statesman 6.0.0 → 7.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75560d08df4affec82126fce63627c30902c534e0e8fd11dd89fdd1c94f66b6b
4
- data.tar.gz: 3308c1cdff0e0cc32c2e964fac6c7e641bd841384c50d94eb824cae785822c3a
3
+ metadata.gz: 444b828dd17ce5984d99e93e25ff774ccbc621a06d0afeb51dc6d8a4b7cd0bc5
4
+ data.tar.gz: 8e70e4e74a6edc795d66f203619974c1483d8b2a3b02a19e5264ba588e991a37
5
5
  SHA512:
6
- metadata.gz: 7cb81b50c75c21841f729fa91635cc6470397bafdc683b0ccdee1809e99cc2961ab3084d3fcd256b1b062d1362b6a17e2c7d63ea63dad9d6bc7ae8143d982542
7
- data.tar.gz: 3885a1f4459572a92f3c47c6544d5a6eab1e8c38d0adb4206dbde7ad331953090822e3c2930eef695139e96a88df47bf55f4b2eb9df084d5724edab0e97c36ea
6
+ metadata.gz: 9e354a540525c8ab3a2c2f0de4b7c6eb4ecc931f54e1d46dca64f03e910a3f95267f94fb61584444f53e37891ac5a8bd75a2054001eaff118a8a3c5eccad6be3
7
+ data.tar.gz: 1f2266d2181d451621ca355c9c5e8db982548f0028c65af723e3d9164deb43f1516e1550fd11678f99b5243e47302ee5fcd916cc2b0f9fb6f7dc9a4a8edb6218
@@ -12,7 +12,7 @@ references:
12
12
  - type: cache-restore
13
13
  key: statesman-{{ checksum "Gemfile" }}-{{ checksum "~/RAILS_VERSION.txt" }}
14
14
 
15
- - run: gem install bundler -v 1.3
15
+ - run: gem install bundler -v 2.1.4
16
16
 
17
17
  - run: bundle install --path vendor/bundle
18
18
 
@@ -31,11 +31,11 @@ references:
31
31
  path: /tmp/test-results
32
32
 
33
33
  jobs:
34
- build-ruby249-rails-429-mysql:
34
+ build-ruby249-rails-524-mysql:
35
35
  docker:
36
36
  - image: circleci/ruby:2.4.9-node
37
37
  environment:
38
- - RAILS_VERSION=4.2.9
38
+ - RAILS_VERSION=5.2.4
39
39
  - DATABASE_URL=mysql2://root@127.0.0.1/statesman_test
40
40
  - DATABASE_DEPENDENCY_PORT=3306
41
41
  - image: circleci/mysql:5.7.18
@@ -45,23 +45,25 @@ jobs:
45
45
  - MYSQL_PASSWORD=
46
46
  - MYSQL_DATABASE=statesman_test
47
47
  steps: *steps
48
- build-ruby249-rails-429-postgres:
48
+ build-ruby249-rails-524-postgres:
49
49
  docker:
50
50
  - image: circleci/ruby:2.4.9-node
51
51
  environment:
52
- - RAILS_VERSION=4.2.9
52
+ - RAILS_VERSION=5.2.4
53
53
  - DATABASE_URL=postgres://postgres@localhost/statesman_test
54
54
  - DATABASE_DEPENDENCY_PORT=5432
55
55
  - image: circleci/postgres:9.6
56
56
  environment:
57
57
  - POSTGRES_USER=postgres
58
58
  - POSTGRES_DB=statesman_test
59
+ - POSTGRES_PASSWORD=statesman
59
60
  steps: *steps
60
- build-ruby249-rails-505-mysql:
61
+
62
+ build-ruby265-rails-602-mysql:
61
63
  docker:
62
- - image: circleci/ruby:2.4.9-node
64
+ - image: circleci/ruby:2.6.5-node
63
65
  environment:
64
- - RAILS_VERSION=5.0.5
66
+ - RAILS_VERSION=6.0.2
65
67
  - DATABASE_URL=mysql2://root@127.0.0.1/statesman_test
66
68
  - DATABASE_DEPENDENCY_PORT=3306
67
69
  - image: circleci/mysql:5.7.18
@@ -71,23 +73,24 @@ jobs:
71
73
  - MYSQL_PASSWORD=
72
74
  - MYSQL_DATABASE=statesman_test
73
75
  steps: *steps
74
- build-ruby249-rails-505-postgres:
76
+ build-ruby265-rails-602-postgres:
75
77
  docker:
76
- - image: circleci/ruby:2.4.9-node
78
+ - image: circleci/ruby:2.6.5-node
77
79
  environment:
78
- - RAILS_VERSION=5.0.5
80
+ - RAILS_VERSION=6.0.2
79
81
  - DATABASE_URL=postgres://postgres@localhost/statesman_test
80
82
  - DATABASE_DEPENDENCY_PORT=5432
81
83
  - image: circleci/postgres:9.6
82
84
  environment:
83
85
  - POSTGRES_USER=postgres
84
86
  - POSTGRES_DB=statesman_test
87
+ - POSTGRES_PASSWORD=statesman
85
88
  steps: *steps
86
- build-ruby249-rails-513-mysql:
89
+ build-ruby265-rails-master-mysql:
87
90
  docker:
88
- - image: circleci/ruby:2.4.9-node
91
+ - image: circleci/ruby:2.6.5-node
89
92
  environment:
90
- - RAILS_VERSION=5.1.3
93
+ - RAILS_VERSION=master
91
94
  - DATABASE_URL=mysql2://root@127.0.0.1/statesman_test
92
95
  - DATABASE_DEPENDENCY_PORT=3306
93
96
  - image: circleci/mysql:5.7.18
@@ -97,23 +100,26 @@ jobs:
97
100
  - MYSQL_PASSWORD=
98
101
  - MYSQL_DATABASE=statesman_test
99
102
  steps: *steps
100
- build-ruby249-rails-513-postgres:
103
+ build-ruby265-rails-master-postgres:
101
104
  docker:
102
- - image: circleci/ruby:2.4.9-node
105
+ - image: circleci/ruby:2.6.5-node
103
106
  environment:
104
- - RAILS_VERSION=5.1.3
107
+ - RAILS_VERSION=master
105
108
  - DATABASE_URL=postgres://postgres@localhost/statesman_test
109
+ - EXCLUDE_MONGOID=true
106
110
  - DATABASE_DEPENDENCY_PORT=5432
107
111
  - image: circleci/postgres:9.6
108
112
  environment:
109
113
  - POSTGRES_USER=postgres
110
114
  - POSTGRES_DB=statesman_test
115
+ - POSTGRES_PASSWORD=statesman
111
116
  steps: *steps
112
- build-ruby265-rails-600-mysql:
117
+
118
+ build-ruby270-rails-602-mysql:
113
119
  docker:
114
- - image: circleci/ruby:2.6.5-node
120
+ - image: circleci/ruby:2.7.0-node
115
121
  environment:
116
- - RAILS_VERSION=6.0.0
122
+ - RAILS_VERSION=6.0.2
117
123
  - DATABASE_URL=mysql2://root@127.0.0.1/statesman_test
118
124
  - DATABASE_DEPENDENCY_PORT=3306
119
125
  - image: circleci/mysql:5.7.18
@@ -123,21 +129,22 @@ jobs:
123
129
  - MYSQL_PASSWORD=
124
130
  - MYSQL_DATABASE=statesman_test
125
131
  steps: *steps
126
- build-ruby265-rails-600-postgres:
132
+ build-ruby270-rails-602-postgres:
127
133
  docker:
128
- - image: circleci/ruby:2.6.5-node
134
+ - image: circleci/ruby:2.7.0-node
129
135
  environment:
130
- - RAILS_VERSION=6.0.0
136
+ - RAILS_VERSION=6.0.2
131
137
  - DATABASE_URL=postgres://postgres@localhost/statesman_test
132
138
  - DATABASE_DEPENDENCY_PORT=5432
133
139
  - image: circleci/postgres:9.6
134
140
  environment:
135
141
  - POSTGRES_USER=postgres
136
142
  - POSTGRES_DB=statesman_test
143
+ - POSTGRES_PASSWORD=statesman
137
144
  steps: *steps
138
- build-ruby265-rails-master-mysql:
145
+ build-ruby270-rails-master-mysql:
139
146
  docker:
140
- - image: circleci/ruby:2.6.5-node
147
+ - image: circleci/ruby:2.7.0-node
141
148
  environment:
142
149
  - RAILS_VERSION=master
143
150
  - DATABASE_URL=mysql2://root@127.0.0.1/statesman_test
@@ -149,9 +156,9 @@ jobs:
149
156
  - MYSQL_PASSWORD=
150
157
  - MYSQL_DATABASE=statesman_test
151
158
  steps: *steps
152
- build-ruby265-rails-master-postgres:
159
+ build-ruby270-rails-master-postgres:
153
160
  docker:
154
- - image: circleci/ruby:2.6.5-node
161
+ - image: circleci/ruby:2.7.0-node
155
162
  environment:
156
163
  - RAILS_VERSION=master
157
164
  - DATABASE_URL=postgres://postgres@localhost/statesman_test
@@ -161,48 +168,20 @@ jobs:
161
168
  environment:
162
169
  - POSTGRES_USER=postgres
163
170
  - POSTGRES_DB=statesman_test
171
+ - POSTGRES_PASSWORD=statesman
164
172
  steps: *steps
165
- build-ruby249-rails-523-mysql:
166
- docker:
167
- - image: circleci/ruby:2.4.9-node
168
- environment:
169
- - RAILS_VERSION=5.2.3
170
- - DATABASE_URL=mysql2://root@127.0.0.1/statesman_test
171
- - DATABASE_DEPENDENCY_PORT=3306
172
- - image: circleci/mysql:5.7.18
173
- environment:
174
- - MYSQL_ALLOW_EMPTY_PASSWORD=true
175
- - MYSQL_USER=root
176
- - MYSQL_PASSWORD=
177
- - MYSQL_DATABASE=statesman_test
178
- steps: *steps
179
- build-ruby249-rails-523-postgres:
180
- docker:
181
- - image: circleci/ruby:2.4.9-node
182
- environment:
183
- - RAILS_VERSION=5.2.3
184
- - DATABASE_URL=postgres://postgres@localhost/statesman_test
185
- - DATABASE_DEPENDENCY_PORT=5432
186
- - image: circleci/postgres:9.6
187
- environment:
188
- - POSTGRES_USER=postgres
189
- - POSTGRES_DB=statesman_test
190
- steps: *steps
191
-
192
173
 
193
174
  workflows:
194
175
  version: 2
195
176
  tests:
196
177
  jobs:
197
- - build-ruby249-rails-429-mysql
198
- - build-ruby249-rails-429-postgres
199
- - build-ruby249-rails-505-mysql
200
- - build-ruby249-rails-505-postgres
201
- - build-ruby249-rails-513-mysql
202
- - build-ruby249-rails-513-postgres
203
- - build-ruby249-rails-523-mysql
204
- - build-ruby249-rails-523-postgres
205
- - build-ruby265-rails-600-mysql
206
- - build-ruby265-rails-600-postgres
178
+ - build-ruby249-rails-524-mysql
179
+ - build-ruby249-rails-524-postgres
180
+ - build-ruby265-rails-602-mysql
181
+ - build-ruby265-rails-602-postgres
207
182
  - build-ruby265-rails-master-mysql
208
183
  - build-ruby265-rails-master-postgres
184
+ - build-ruby270-rails-602-mysql
185
+ - build-ruby270-rails-602-postgres
186
+ - build-ruby270-rails-master-mysql
187
+ - build-ruby270-rails-master-postgres
@@ -1,3 +1,19 @@
1
+ ## v7.1.0, 10th Feb 2020
2
+
3
+ - Fix `to_s` on `TransitionFailedError` & `GuardFailedError`. `.message` and
4
+ `.to_s` diverged when `from` and `to` accessors where added in v4.1.3
5
+
6
+ ## v7.0.1, 8th Jan 2020
7
+
8
+ - Fix deprecation warning with Ruby 2.7 [#386](https://github.com/gocardless/statesman/pull/386)
9
+
10
+ ## v7.0.0, 8th Jan 2020
11
+
12
+ **Breaking changes**
13
+
14
+ - Drop official support for Rails 4.2, 5.0 and 5.1, following our [compatibility
15
+ policy](https://github.com/gocardless/statesman/blob/master/docs/COMPATIBILITY.md).
16
+
1
17
  ## v6.0.0, 20th December 2019
2
18
 
3
19
  **Breaking changes**
@@ -5,7 +21,6 @@
5
21
  - Drop official support for Ruby 2.2 and 2.3 following our [compatibility
6
22
  policy](https://github.com/gocardless/statesman/blob/master/docs/COMPATIBILITY.md).
7
23
 
8
-
9
24
  ## v5.2.0, 17th December 2019
10
25
 
11
26
  - Issue `most_recent_transition_join` query as a single-line string [#381](https://github.com/gocardless/statesman/pull/381)
data/Gemfile CHANGED
@@ -14,5 +14,5 @@ end
14
14
 
15
15
  group :development do
16
16
  # test/unit is no longer bundled with Ruby 2.2, but required by Rails
17
- gem "test-unit", "~> 3.0" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.2.0")
17
+ gem "test-unit", "~> 3.3" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.2.0")
18
18
  end
data/README.md CHANGED
@@ -30,7 +30,7 @@ protection.
30
30
  To get started, just add Statesman to your `Gemfile`, and then run `bundle`:
31
31
 
32
32
  ```ruby
33
- gem 'statesman', '~> 5.2.0'
33
+ gem 'statesman', '~> 7.1.0'
34
34
  ```
35
35
 
36
36
  ## Usage
@@ -499,4 +499,4 @@ end
499
499
 
500
500
  ---
501
501
 
502
- GoCardless ♥ open source. If you do too, come [join us](https://gocardless.com/about/jobs/software-engineer).
502
+ GoCardless ♥ open source. If you do too, come [join us](https://gocardless.com/about/careers/).
@@ -20,14 +20,25 @@ module Statesman
20
20
  # Example:
21
21
  # Statesman.configure do
22
22
  # storage_adapter Statesman::ActiveRecordAdapter
23
+ # enable_mysql_gaplock_protection
23
24
  # end
24
25
  #
25
26
  def self.configure(&block)
26
- config = Config.new(block)
27
+ @config = Config.new(block)
27
28
  @storage_adapter = config.adapter_class
28
29
  end
29
30
 
30
31
  def self.storage_adapter
31
32
  @storage_adapter || Adapters::Memory
32
33
  end
34
+
35
+ def self.mysql_gaplock_protection?
36
+ return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
37
+
38
+ @mysql_gaplock_protection = config.mysql_gaplock_protection?
39
+ end
40
+
41
+ def self.config
42
+ @config ||= Config.new
43
+ end
33
44
  end
@@ -5,9 +5,6 @@ require_relative "../exceptions"
5
5
  module Statesman
6
6
  module Adapters
7
7
  class ActiveRecord
8
- attr_reader :transition_class
9
- attr_reader :parent_model
10
-
11
8
  JSON_COLUMN_TYPES = %w[json jsonb].freeze
12
9
 
13
10
  def self.database_supports_partial_indexes?
@@ -19,6 +16,10 @@ module Statesman
19
16
  end
20
17
  end
21
18
 
19
+ def self.adapter_name
20
+ ::ActiveRecord::Base.connection.adapter_name.downcase
21
+ end
22
+
22
23
  def initialize(transition_class, parent_model, observer, options = {})
23
24
  serialized = serialized?(transition_class)
24
25
  column_type = transition_class.columns_hash["metadata"].sql_type
@@ -29,12 +30,15 @@ module Statesman
29
30
  end
30
31
 
31
32
  @transition_class = transition_class
33
+ @transition_table = transition_class.arel_table
32
34
  @parent_model = parent_model
33
35
  @observer = observer
34
36
  @association_name =
35
37
  options[:association_name] || @transition_class.table_name
36
38
  end
37
39
 
40
+ attr_reader :transition_class, :transition_table, :parent_model
41
+
38
42
  def create(from, to, metadata = {})
39
43
  create_transition(from.to_s, to.to_s, metadata)
40
44
  rescue ::ActiveRecord::RecordNotUnique => e
@@ -65,19 +69,36 @@ module Statesman
65
69
 
66
70
  private
67
71
 
72
+ # rubocop:disable Metrics/MethodLength
68
73
  def create_transition(from, to, metadata)
69
- transition_attributes = { to_state: to,
70
- sort_key: next_sort_key,
71
- metadata: metadata }
72
-
73
- transition_attributes[:most_recent] = true
74
-
75
- transition = transitions_for_parent.build(transition_attributes)
74
+ transition = transitions_for_parent.build(
75
+ default_transition_attributes(to, metadata),
76
+ )
76
77
 
77
78
  ::ActiveRecord::Base.transaction(requires_new: true) do
78
79
  @observer.execute(:before, from, to, transition)
79
- unset_old_most_recent
80
- transition.save!
80
+
81
+ if mysql_gaplock_protection?
82
+ # We save the transition first with most_recent falsy, then mark most_recent
83
+ # true after to avoid letting MySQL acquire a next-key lock which can cause
84
+ # deadlocks.
85
+ #
86
+ # To avoid an additional query, we manually adjust the most_recent attribute
87
+ # on our transition assuming that update_most_recents will have set it to true
88
+
89
+ transition.save!
90
+
91
+ unless update_most_recents(transition.id).positive?
92
+ raise ActiveRecord::Rollback, "failed to update most_recent"
93
+ end
94
+
95
+ transition.assign_attributes(most_recent: true)
96
+ else
97
+ update_most_recents
98
+ transition.assign_attributes(most_recent: true)
99
+ transition.save!
100
+ end
101
+
81
102
  @last_transition = transition
82
103
  @observer.execute(:after, from, to, transition)
83
104
  add_after_commit_callback(from, to, transition)
@@ -85,6 +106,16 @@ module Statesman
85
106
 
86
107
  transition
87
108
  end
109
+ # rubocop:enable Metrics/MethodLength
110
+
111
+ def default_transition_attributes(to, metadata)
112
+ {
113
+ to_state: to,
114
+ sort_key: next_sort_key,
115
+ metadata: metadata,
116
+ most_recent: not_most_recent_value(db_cast: false),
117
+ }
118
+ end
88
119
 
89
120
  def add_after_commit_callback(from, to, transition)
90
121
  ::ActiveRecord::Base.connection.add_transaction_record(
@@ -98,20 +129,92 @@ module Statesman
98
129
  parent_model.send(@association_name)
99
130
  end
100
131
 
101
- def unset_old_most_recent
102
- most_recent = transitions_for_parent.where(most_recent: true)
132
+ # Sets the given transition most_recent = t while unsetting the most_recent of any
133
+ # previous transitions.
134
+ def update_most_recents(most_recent_id = nil)
135
+ update = build_arel_manager(::Arel::UpdateManager)
136
+ update.table(transition_table)
137
+ update.where(most_recent_transitions(most_recent_id))
138
+ update.set(build_most_recents_update_all_values(most_recent_id))
103
139
 
104
- # Check whether the `most_recent` column allows null values. If it
105
- # doesn't, set old records to `false`, otherwise, set them to `NULL`.
106
- #
107
- # Some conditioning here is required to support databases that don't
108
- # support partial indexes. By doing the conditioning on the column,
109
- # rather than Rails' opinion of whether the database supports partial
110
- # indexes, we're robust to DBs later adding support for partial indexes.
111
- if transition_class.columns_hash["most_recent"].null == false
112
- most_recent.update_all(with_updated_timestamp(most_recent: false))
140
+ # MySQL will validate index constraints across the intermediate result of an
141
+ # update. This means we must order our update to deactivate the previous
142
+ # most_recent before setting the new row to be true.
143
+ update.order(transition_table[:most_recent].desc) if mysql_gaplock_protection?
144
+
145
+ ::ActiveRecord::Base.connection.update(update.to_sql)
146
+ end
147
+
148
+ def most_recent_transitions(most_recent_id = nil)
149
+ if most_recent_id
150
+ transitions_of_parent.and(
151
+ transition_table[:id].eq(most_recent_id).or(
152
+ transition_table[:most_recent].eq(true),
153
+ ),
154
+ )
155
+ else
156
+ transitions_of_parent.and(transition_table[:most_recent].eq(true))
157
+ end
158
+ end
159
+
160
+ def transitions_of_parent
161
+ transition_table[parent_join_foreign_key.to_sym].eq(parent_model.id)
162
+ end
163
+
164
+ # Generates update_all Arel values that will touch the updated timestamp (if valid
165
+ # for this model) and set most_recent to true only for the transition with a
166
+ # matching most_recent ID.
167
+ #
168
+ # This is quite nasty, but combines two updates (set all most_recent = f, set
169
+ # current most_recent = t) into one, which helps improve transition performance
170
+ # especially when database latency is significant.
171
+ #
172
+ # The SQL this can help produce looks like:
173
+ #
174
+ # update transitions
175
+ # set most_recent = (case when id = 'PA123' then TRUE else FALSE end)
176
+ # , updated_at = '...'
177
+ # ...
178
+ #
179
+ def build_most_recents_update_all_values(most_recent_id = nil)
180
+ [
181
+ [
182
+ transition_table[:most_recent],
183
+ Arel::Nodes::SqlLiteral.new(most_recent_value(most_recent_id)),
184
+ ],
185
+ ].tap do |values|
186
+ # Only if we support the updated at timestamps should we add this column to the
187
+ # update
188
+ updated_column, updated_at = updated_column_and_timestamp
189
+
190
+ if updated_column
191
+ values << [
192
+ transition_table[updated_column.to_sym],
193
+ updated_at,
194
+ ]
195
+ end
196
+ end
197
+ end
198
+
199
+ def most_recent_value(most_recent_id)
200
+ if most_recent_id
201
+ Arel::Nodes::Case.new.
202
+ when(transition_table[:id].eq(most_recent_id)).then(db_true).
203
+ else(not_most_recent_value).to_sql
113
204
  else
114
- most_recent.update_all(with_updated_timestamp(most_recent: nil))
205
+ Arel::Nodes::SqlLiteral.new(not_most_recent_value)
206
+ end
207
+ end
208
+
209
+ # Provide a wrapper for constructing an update manager which handles a breaking API
210
+ # change in Arel as we move into Rails >6.0.
211
+ #
212
+ # https://github.com/rails/rails/commit/7508284800f67b4611c767bff9eae7045674b66f
213
+ def build_arel_manager(manager)
214
+ if manager.instance_method(:initialize).arity.zero?
215
+ manager.new
216
+ else
217
+ manager.new(::ActiveRecord::Base)
115
218
  end
116
219
  end
117
220
 
@@ -170,7 +273,8 @@ module Statesman
170
273
  end
171
274
  end
172
275
 
173
- def with_updated_timestamp(params)
276
+ # updated_column_and_timestamp should return [column_name, value]
277
+ def updated_column_and_timestamp
174
278
  # TODO: Once we've set expectations that transition classes should conform to
175
279
  # the interface of Adapters::ActiveRecordTransition as a breaking change in the
176
280
  # next major version, we can stop calling `#respond_to?` first and instead
@@ -184,21 +288,57 @@ module Statesman
184
288
  ActiveRecordTransition::DEFAULT_UPDATED_TIMESTAMP_COLUMN
185
289
  end
186
290
 
187
- return params if column.nil?
291
+ # No updated timestamp column, don't return anything
292
+ return nil if column.nil?
293
+
294
+ [
295
+ column, ::ActiveRecord::Base.default_timezone == :utc ? Time.now.utc : Time.now
296
+ ]
297
+ end
298
+
299
+ def mysql_gaplock_protection?
300
+ Statesman.mysql_gaplock_protection?
301
+ end
302
+
303
+ def db_true
304
+ value = ::ActiveRecord::Base.connection.type_cast(
305
+ true,
306
+ transition_class.columns_hash["most_recent"],
307
+ )
308
+ ::ActiveRecord::Base.connection.quote(value)
309
+ end
188
310
 
189
- timestamp = if ::ActiveRecord::Base.default_timezone == :utc
190
- Time.now.utc
191
- else
192
- Time.now
193
- end
311
+ def db_false
312
+ value = ::ActiveRecord::Base.connection.type_cast(
313
+ false,
314
+ transition_class.columns_hash["most_recent"],
315
+ )
316
+ ::ActiveRecord::Base.connection.quote(value)
317
+ end
318
+
319
+ def db_null
320
+ Arel::Nodes::SqlLiteral.new("NULL")
321
+ end
322
+
323
+ # Check whether the `most_recent` column allows null values. If it doesn't, set old
324
+ # records to `false`, otherwise, set them to `NULL`.
325
+ #
326
+ # Some conditioning here is required to support databases that don't support partial
327
+ # indexes. By doing the conditioning on the column, rather than Rails' opinion of
328
+ # whether the database supports partial indexes, we're robust to DBs later adding
329
+ # support for partial indexes.
330
+ def not_most_recent_value(db_cast: true)
331
+ if transition_class.columns_hash["most_recent"].null == false
332
+ return db_cast ? db_false : false
333
+ end
194
334
 
195
- params.merge(column => timestamp)
335
+ db_cast ? db_null : nil
196
336
  end
197
337
  end
198
338
 
199
339
  class ActiveRecordAfterCommitWrap
200
- def initialize
201
- @callback = Proc.new
340
+ def initialize(&block)
341
+ @callback = block
202
342
  @connection = ::ActiveRecord::Base.connection
203
343
  end
204
344
 
@@ -14,5 +14,31 @@ module Statesman
14
14
  def storage_adapter(adapter_class)
15
15
  @adapter_class = adapter_class
16
16
  end
17
+
18
+ def mysql_gaplock_protection?
19
+ return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
20
+
21
+ # If our adapter class suggests we're using mysql, enable gaplock protection by
22
+ # default.
23
+ enable_mysql_gaplock_protection if mysql_adapter?(adapter_class)
24
+ @mysql_gaplock_protection
25
+ end
26
+
27
+ def enable_mysql_gaplock_protection
28
+ @mysql_gaplock_protection = true
29
+ end
30
+
31
+ private
32
+
33
+ def mysql_adapter?(adapter_class)
34
+ adapter_name = adapter_name(adapter_class)
35
+ return false unless adapter_name
36
+
37
+ adapter_name.start_with?("mysql")
38
+ end
39
+
40
+ def adapter_name(adapter_class)
41
+ adapter_class.respond_to?(:adapter_name) && adapter_class&.adapter_name
42
+ end
17
43
  end
18
44
  end
@@ -4,16 +4,21 @@ module Statesman
4
4
  class InvalidStateError < StandardError; end
5
5
  class InvalidTransitionError < StandardError; end
6
6
  class InvalidCallbackError < StandardError; end
7
+ class TransitionConflictError < StandardError; end
8
+ class MissingTransitionAssociation < StandardError; end
7
9
 
8
10
  class TransitionFailedError < StandardError
9
11
  def initialize(from, to)
10
12
  @from = from
11
13
  @to = to
14
+ super(_message)
12
15
  end
13
16
 
14
17
  attr_reader :from, :to
15
18
 
16
- def message
19
+ private
20
+
21
+ def _message
17
22
  "Cannot transition from '#{from}' to '#{to}'"
18
23
  end
19
24
  end
@@ -22,18 +27,18 @@ module Statesman
22
27
  def initialize(from, to)
23
28
  @from = from
24
29
  @to = to
30
+ super(_message)
25
31
  end
26
32
 
27
33
  attr_reader :from, :to
28
34
 
29
- def message
35
+ private
36
+
37
+ def _message
30
38
  "Guard on transition from: '#{from}' to '#{to}' returned false"
31
39
  end
32
40
  end
33
41
 
34
- class TransitionConflictError < StandardError; end
35
- class MissingTransitionAssociation < StandardError; end
36
-
37
42
  class UnserializedMetadataError < StandardError
38
43
  def initialize(transition_class_name)
39
44
  super(_message(transition_class_name))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statesman
4
- VERSION = "6.0.0"
4
+ VERSION = "7.3.0"
5
5
  end
@@ -20,7 +20,9 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
20
20
  prepare_other_model_table
21
21
  prepare_other_transitions_table
22
22
 
23
- Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }
23
+ Statesman.configure do
24
+ storage_adapter(Statesman::Adapters::ActiveRecord)
25
+ end
24
26
  end
25
27
 
26
28
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
@@ -9,9 +9,19 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
9
9
  before do
10
10
  prepare_model_table
11
11
  prepare_transitions_table
12
+
13
+ MyActiveRecordModelTransition.serialize(:metadata, JSON)
14
+
15
+ Statesman.configure do
16
+ # Rubocop requires described_class to be used, but this block
17
+ # is instance_eval'd and described_class won't be defined
18
+ # rubocop:disable RSpec/DescribedClass
19
+ storage_adapter(Statesman::Adapters::ActiveRecord)
20
+ # rubocop:enable RSpec/DescribedClass
21
+ end
12
22
  end
13
23
 
14
- before { MyActiveRecordModelTransition.serialize(:metadata, JSON) }
24
+ after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
15
25
 
16
26
  let(:observer) { double(Statesman::Machine, execute: nil) }
17
27
  let(:model) { MyActiveRecordModel.create(current_state: :pending) }
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe Statesman do
6
+ describe "InvalidStateError" do
7
+ subject(:error) { Statesman::InvalidStateError.new }
8
+
9
+ its(:message) { is_expected.to eq("Statesman::InvalidStateError") }
10
+ its "string matches its message" do
11
+ expect(error.to_s).to eq(error.message)
12
+ end
13
+ end
14
+
15
+ describe "InvalidTransitionError" do
16
+ subject(:error) { Statesman::InvalidTransitionError.new }
17
+
18
+ its(:message) { is_expected.to eq("Statesman::InvalidTransitionError") }
19
+ its "string matches its message" do
20
+ expect(error.to_s).to eq(error.message)
21
+ end
22
+ end
23
+
24
+ describe "InvalidCallbackError" do
25
+ subject(:error) { Statesman::InvalidTransitionError.new }
26
+
27
+ its(:message) { is_expected.to eq("Statesman::InvalidTransitionError") }
28
+ its "string matches its message" do
29
+ expect(error.to_s).to eq(error.message)
30
+ end
31
+ end
32
+
33
+ describe "TransitionConflictError" do
34
+ subject(:error) { Statesman::TransitionConflictError.new }
35
+
36
+ its(:message) { is_expected.to eq("Statesman::TransitionConflictError") }
37
+ its "string matches its message" do
38
+ expect(error.to_s).to eq(error.message)
39
+ end
40
+ end
41
+
42
+ describe "MissingTransitionAssociation" do
43
+ subject(:error) { Statesman::MissingTransitionAssociation.new }
44
+
45
+ its(:message) { is_expected.to eq("Statesman::MissingTransitionAssociation") }
46
+ its "string matches its message" do
47
+ expect(error.to_s).to eq(error.message)
48
+ end
49
+ end
50
+
51
+ describe "TransitionFailedError" do
52
+ subject(:error) { Statesman::TransitionFailedError.new("from", "to") }
53
+
54
+ its(:message) { is_expected.to eq("Cannot transition from 'from' to 'to'") }
55
+ its "string matches its message" do
56
+ expect(error.to_s).to eq(error.message)
57
+ end
58
+ end
59
+
60
+ describe "GuardFailedError" do
61
+ subject(:error) { Statesman::GuardFailedError.new("from", "to") }
62
+
63
+ its(:message) do
64
+ is_expected.to eq("Guard on transition from: 'from' to 'to' returned false")
65
+ end
66
+ its "string matches its message" do
67
+ expect(error.to_s).to eq(error.message)
68
+ end
69
+ end
70
+
71
+ describe "UnserializedMetadataError" do
72
+ subject(:error) { Statesman::UnserializedMetadataError.new("foo") }
73
+
74
+ its(:message) { is_expected.to match(/foo#metadata is not serialized/) }
75
+ its "string matches its message" do
76
+ expect(error.to_s).to eq(error.message)
77
+ end
78
+ end
79
+
80
+ describe "IncompatibleSerializationError" do
81
+ subject(:error) { Statesman::IncompatibleSerializationError.new("foo") }
82
+
83
+ its(:message) { is_expected.to match(/foo#metadata column type cannot be json/) }
84
+ its "string matches its message" do
85
+ expect(error.to_s).to eq(error.message)
86
+ end
87
+ end
88
+ end
@@ -22,17 +22,17 @@ Gem::Specification.new do |spec|
22
22
  spec.required_ruby_version = ">= 2.2"
23
23
 
24
24
  spec.add_development_dependency "ammeter", "~> 1.1"
25
- spec.add_development_dependency "bundler", "~> 1.3"
25
+ spec.add_development_dependency "bundler", "~> 2.1.4"
26
26
  spec.add_development_dependency "gc_ruboconfig", "~> 2.3.9"
27
27
  spec.add_development_dependency "mysql2", ">= 0.4", "< 0.6"
28
- spec.add_development_dependency "pg", "~> 0.18"
28
+ spec.add_development_dependency "pg", ">= 0.18", "<= 1.3"
29
29
  spec.add_development_dependency "pry"
30
- spec.add_development_dependency "rails", ">= 3.2"
30
+ spec.add_development_dependency "rails", ">= 5.2"
31
31
  spec.add_development_dependency "rake", "~> 13.0.0"
32
32
  spec.add_development_dependency "rspec", "~> 3.1"
33
33
  spec.add_development_dependency "rspec-its", "~> 1.1"
34
34
  spec.add_development_dependency "rspec-rails", "~> 3.1"
35
35
  spec.add_development_dependency "rspec_junit_formatter", "~> 0.4.0"
36
- spec.add_development_dependency "sqlite3", "~> 1.3.6"
36
+ spec.add_development_dependency "sqlite3", "~> 1.4.2"
37
37
  spec.add_development_dependency "timecop", "~> 0.9.1"
38
38
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statesman
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 7.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-12-19 00:00:00.000000000 Z
11
+ date: 2020-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ammeter
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '1.3'
33
+ version: 2.1.4
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '1.3'
40
+ version: 2.1.4
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: gc_ruboconfig
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -76,16 +76,22 @@ dependencies:
76
76
  name: pg
77
77
  requirement: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - "~>"
79
+ - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.18'
82
+ - - "<="
83
+ - !ruby/object:Gem::Version
84
+ version: '1.3'
82
85
  type: :development
83
86
  prerelease: false
84
87
  version_requirements: !ruby/object:Gem::Requirement
85
88
  requirements:
86
- - - "~>"
89
+ - - ">="
87
90
  - !ruby/object:Gem::Version
88
91
  version: '0.18'
92
+ - - "<="
93
+ - !ruby/object:Gem::Version
94
+ version: '1.3'
89
95
  - !ruby/object:Gem::Dependency
90
96
  name: pry
91
97
  requirement: !ruby/object:Gem::Requirement
@@ -106,14 +112,14 @@ dependencies:
106
112
  requirements:
107
113
  - - ">="
108
114
  - !ruby/object:Gem::Version
109
- version: '3.2'
115
+ version: '5.2'
110
116
  type: :development
111
117
  prerelease: false
112
118
  version_requirements: !ruby/object:Gem::Requirement
113
119
  requirements:
114
120
  - - ">="
115
121
  - !ruby/object:Gem::Version
116
- version: '3.2'
122
+ version: '5.2'
117
123
  - !ruby/object:Gem::Dependency
118
124
  name: rake
119
125
  requirement: !ruby/object:Gem::Requirement
@@ -190,14 +196,14 @@ dependencies:
190
196
  requirements:
191
197
  - - "~>"
192
198
  - !ruby/object:Gem::Version
193
- version: 1.3.6
199
+ version: 1.4.2
194
200
  type: :development
195
201
  prerelease: false
196
202
  version_requirements: !ruby/object:Gem::Requirement
197
203
  requirements:
198
204
  - - "~>"
199
205
  - !ruby/object:Gem::Version
200
- version: 1.3.6
206
+ version: 1.4.2
201
207
  - !ruby/object:Gem::Dependency
202
208
  name: timecop
203
209
  requirement: !ruby/object:Gem::Requirement
@@ -266,6 +272,7 @@ files:
266
272
  - spec/statesman/adapters/shared_examples.rb
267
273
  - spec/statesman/callback_spec.rb
268
274
  - spec/statesman/config_spec.rb
275
+ - spec/statesman/exceptions_spec.rb
269
276
  - spec/statesman/guard_spec.rb
270
277
  - spec/statesman/machine_spec.rb
271
278
  - spec/statesman/utils_spec.rb
@@ -276,7 +283,7 @@ homepage: https://github.com/gocardless/statesman
276
283
  licenses:
277
284
  - MIT
278
285
  metadata: {}
279
- post_install_message:
286
+ post_install_message:
280
287
  rdoc_options: []
281
288
  require_paths:
282
289
  - lib
@@ -292,7 +299,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
292
299
  version: '0'
293
300
  requirements: []
294
301
  rubygems_version: 3.1.1
295
- signing_key:
302
+ signing_key:
296
303
  specification_version: 4
297
304
  summary: A statesman-like state machine library
298
305
  test_files:
@@ -310,6 +317,7 @@ test_files:
310
317
  - spec/statesman/adapters/shared_examples.rb
311
318
  - spec/statesman/callback_spec.rb
312
319
  - spec/statesman/config_spec.rb
320
+ - spec/statesman/exceptions_spec.rb
313
321
  - spec/statesman/guard_spec.rb
314
322
  - spec/statesman/machine_spec.rb
315
323
  - spec/statesman/utils_spec.rb