statesman 6.0.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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