statesman 3.5.0 → 7.4.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.
Files changed (56) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +49 -250
  3. data/.rubocop.yml +1 -1
  4. data/.rubocop_todo.yml +26 -6
  5. data/CHANGELOG.md +106 -0
  6. data/Gemfile +10 -4
  7. data/Guardfile +2 -0
  8. data/README.md +78 -48
  9. data/Rakefile +2 -4
  10. data/lib/generators/statesman/active_record_transition_generator.rb +2 -0
  11. data/lib/generators/statesman/generator_helpers.rb +2 -0
  12. data/lib/generators/statesman/migration_generator.rb +2 -0
  13. data/lib/statesman.rb +14 -4
  14. data/lib/statesman/adapters/active_record.rb +259 -37
  15. data/lib/statesman/adapters/active_record_queries.rb +100 -36
  16. data/lib/statesman/adapters/active_record_transition.rb +2 -0
  17. data/lib/statesman/adapters/memory.rb +2 -0
  18. data/lib/statesman/adapters/memory_transition.rb +2 -0
  19. data/lib/statesman/callback.rb +2 -0
  20. data/lib/statesman/config.rb +28 -0
  21. data/lib/statesman/exceptions.rb +34 -2
  22. data/lib/statesman/guard.rb +3 -4
  23. data/lib/statesman/machine.rb +29 -7
  24. data/lib/statesman/railtie.rb +2 -0
  25. data/lib/statesman/utils.rb +2 -0
  26. data/lib/statesman/version.rb +3 -1
  27. data/lib/tasks/statesman.rake +3 -1
  28. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_with_partial_index.rb +2 -0
  29. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_without_partial_index.rb +2 -0
  30. data/spec/fixtures/add_most_recent_to_bacon_transitions.rb +2 -0
  31. data/spec/generators/statesman/active_record_transition_generator_spec.rb +2 -0
  32. data/spec/generators/statesman/migration_generator_spec.rb +2 -0
  33. data/spec/spec_helper.rb +3 -30
  34. data/spec/statesman/adapters/active_record_queries_spec.rb +167 -91
  35. data/spec/statesman/adapters/active_record_spec.rb +15 -1
  36. data/spec/statesman/adapters/active_record_transition_spec.rb +2 -0
  37. data/spec/statesman/adapters/memory_spec.rb +2 -0
  38. data/spec/statesman/adapters/memory_transition_spec.rb +2 -0
  39. data/spec/statesman/adapters/shared_examples.rb +2 -0
  40. data/spec/statesman/callback_spec.rb +2 -0
  41. data/spec/statesman/config_spec.rb +2 -0
  42. data/spec/statesman/exceptions_spec.rb +88 -0
  43. data/spec/statesman/guard_spec.rb +2 -0
  44. data/spec/statesman/machine_spec.rb +79 -4
  45. data/spec/statesman/utils_spec.rb +2 -0
  46. data/spec/support/active_record.rb +9 -12
  47. data/spec/support/generators_shared_examples.rb +2 -0
  48. data/statesman.gemspec +19 -7
  49. metadata +40 -32
  50. data/lib/generators/statesman/mongoid_transition_generator.rb +0 -25
  51. data/lib/generators/statesman/templates/mongoid_transition_model.rb.erb +0 -14
  52. data/lib/statesman/adapters/mongoid.rb +0 -66
  53. data/lib/statesman/adapters/mongoid_transition.rb +0 -10
  54. data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -23
  55. data/spec/statesman/adapters/mongoid_spec.rb +0 -86
  56. data/spec/support/mongoid.rb +0 -28
@@ -1,3 +1,109 @@
1
+ ## v7.4.0 26th August 2020
2
+
3
+ ### Added
4
+
5
+ - [Gem Metadata](https://guides.rubygems.org/specification-reference/#metadata)
6
+ to make finding changes between releases even easier.
7
+
8
+ ## v7.3.0, 24th August 2020
9
+
10
+ ### Changed
11
+
12
+ - Use correct Arel for null [#409](https://github.com/gocardless/statesman/pull/#409)
13
+
14
+ ## v7.2.0, 19th May 2020
15
+
16
+ ### Changed
17
+
18
+ - Set non-empty password for postgres tests [#398](https://github.com/gocardless/statesman/pull/#398)
19
+ - Handle transitions differently for MySQL [#399](https://github.com/gocardless/statesman/pull/#399)
20
+ - pg requirement from >= 0.18, <= 1.1 to >= 0.18, <= 1.3 [#400](https://github.com/gocardless/statesman/pull/#400)
21
+ - Lazily enable mysql gaplock protection [#402](https://github.com/gocardless/statesman/pull/#402)
22
+
23
+ ## v7.1.0, 10th Feb 2020
24
+
25
+ - Fix `to_s` on `TransitionFailedError` & `GuardFailedError`. `.message` and
26
+ `.to_s` diverged when `from` and `to` accessors where added in v4.1.3
27
+
28
+ ## v7.0.1, 8th Jan 2020
29
+
30
+ - Fix deprecation warning with Ruby 2.7 [#386](https://github.com/gocardless/statesman/pull/386)
31
+
32
+ ## v7.0.0, 8th Jan 2020
33
+
34
+ **Breaking changes**
35
+
36
+ - Drop official support for Rails 4.2, 5.0 and 5.1, following our [compatibility
37
+ policy](https://github.com/gocardless/statesman/blob/master/docs/COMPATIBILITY.md).
38
+
39
+ ## v6.0.0, 20th December 2019
40
+
41
+ **Breaking changes**
42
+
43
+ - Drop official support for Ruby 2.2 and 2.3 following our [compatibility
44
+ policy](https://github.com/gocardless/statesman/blob/master/docs/COMPATIBILITY.md).
45
+
46
+ ## v5.2.0, 17th December 2019
47
+
48
+ - Issue `most_recent_transition_join` query as a single-line string [#381](https://github.com/gocardless/statesman/pull/381)
49
+
50
+ ## v5.1.0, 22th November 2019
51
+
52
+ - Correct `Statesman::Adapters::ActiveRecordQueries` error text [@Bramjetten](https://github.com/gocardless/statesman/pull/376)
53
+ - Removes duplicate `map` call [Isaac Seymour](https://github.com/gocardless/statesman/pull/362)
54
+ - Update changelog with instructions of how to use `ActiveRecordQueries` added
55
+ in v5.0.0
56
+ - Pass exception into `after_transition_failure` and `after_guard_failure` callbacks [@credric-cordenier](https://github.com/gocardless/statesman/pull/378)
57
+
58
+ ## v5.0.0, 11th November 2019
59
+
60
+ - Adds new syntax and restrictions to ActiveRecordQueries [PR#358](https://github.com/gocardless/statesman/pull/358). With the introduction of this, defining `self.transition_class` or `self.initial_state` is deprecated and will be removed in the next major release.
61
+ Change
62
+ ```ruby
63
+ include Statesman::Adapters::ActiveRecordQueries
64
+ def self.initial_state
65
+ :initial
66
+ end
67
+ def self.transition_class
68
+ MyTransition
69
+ end
70
+ ```
71
+ to
72
+ ```ruby
73
+ include Statesman::Adapters::ActiveRecordQueries[
74
+ initial_state: :inital,
75
+ transition_class: MyTransition
76
+ ]
77
+ ```
78
+
79
+ ## v4.1.4, 11th November 2019
80
+
81
+ - Reverts the breaking changes from [PR#358](https://github.com/gocardless/statesman/pull/358) & `v4.1.3` that where included in the last minor release. If you have changed your code to work with these changes `v5.0.0` will be a copy of `v4.1.3` with a bugfix applied.
82
+
83
+ ## v4.1.3, 6th November 2019
84
+
85
+ - Add accessible from / to state attributes on the `TransitionFailedError` to avoid parsing strings [@ahjmorton](https://github.com/gocardless/statesman/pull/367)
86
+ - Add `after_transition_failure` mechanism [@credric-cordenier](https://github.com/gocardless/statesman/pull/366)
87
+
88
+ ## v4.1.2, 17th August 2019
89
+
90
+ - Add support for Rails 6 [@greysteil](https://github.com/gocardless/statesman/pull/360)
91
+
92
+ ## v4.1.1, 6th July 2019
93
+
94
+ - Fix statesman index detection for indexes that start t-z [@hmarr](https://github.com/gocardless/statesman/pull/354)
95
+ - Correct access of metadata via `state_machine` [@glenpike](https://github.com/gocardless/statesman/pull/349)
96
+
97
+ ## v4.1.0, 10 April 2019
98
+
99
+ - Add better support for mysql (and others) in `transition_conflict_error?` [@greysteil](https://github.com/greysteil) (https://github.com/gocardless/statesman/pull/342)
100
+
101
+ ## v4.0.0, 22 February 2019
102
+
103
+ - Forces Statesman to use a new transactions with `requires_new: true` (https://github.com/gocardless/statesman/pull/249)
104
+ - Fixes an issue with `after_commit` transition blocks that where being
105
+ executed even if the transaction rolled back. ([patch](https://github.com/gocardless/statesman/pull/338) by [@matid](https://github.com/matid))
106
+
1
107
  ## v3.5.0, 2 November 2018
2
108
 
3
109
  - Expose `most_recent_transition_join` - ActiveRecords `or` requires that both
data/Gemfile CHANGED
@@ -1,12 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  gemspec
4
6
 
5
- gem "rails", "~> #{ENV['RAILS_VERSION']}" if ENV["RAILS_VERSION"]
7
+ # rubocop:disable Bundler/DuplicatedGem
8
+ if ENV['RAILS_VERSION'] == 'master'
9
+ gem "rails", git: "https://github.com/rails/rails"
10
+ elsif ENV['RAILS_VERSION']
11
+ gem "rails", "~> #{ENV['RAILS_VERSION']}"
12
+ end
13
+ # rubocop:enable Bundler/DuplicatedGem
6
14
 
7
15
  group :development do
8
- gem "mongoid", ">= 3.1" unless ENV["EXCLUDE_MONGOID"]
9
-
10
16
  # test/unit is no longer bundled with Ruby 2.2, but required by Rails
11
- 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")
12
18
  end
data/Guardfile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # A sample Guardfile
2
4
  # More info at https://github.com/guard/guard#readme
3
5
 
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Statesman](http://f.cl.ly/items/410n2A0S3l1W0i3i0o2K/statesman.png)
1
+ <p align="center"><img src="http://f.cl.ly/items/410n2A0S3l1W0i3i0o2K/statesman.png" alt="Statesman"></p>
2
2
 
3
3
  A statesmanlike state machine library.
4
4
 
@@ -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', '~> 3.4.1'
33
+ gem 'statesman', '~> 7.1.0'
34
34
  ```
35
35
 
36
36
  ## Usage
@@ -76,22 +76,16 @@ Then, link it to your model:
76
76
 
77
77
  ```ruby
78
78
  class Order < ActiveRecord::Base
79
- include Statesman::Adapters::ActiveRecordQueries
80
-
81
79
  has_many :order_transitions, autosave: false
82
80
 
81
+ include Statesman::Adapters::ActiveRecordQueries[
82
+ transition_class: OrderTransition,
83
+ initial_state: :pending
84
+ ]
85
+
83
86
  def state_machine
84
87
  @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
85
88
  end
86
-
87
- def self.transition_class
88
- OrderTransition
89
- end
90
-
91
- def self.initial_state
92
- :pending
93
- end
94
- private_class_method :initial_state
95
89
  end
96
90
  ```
97
91
 
@@ -164,8 +158,9 @@ class Order < ActiveRecord::Base
164
158
  end
165
159
 
166
160
  # Optionally delegate some methods
167
- delegate :can_transition_to?, :transition_to!, :transition_to, :current_state,
168
- to: :state_machine
161
+
162
+ delegate :can_transition_to?, :current_state, :history, :last_transition,
163
+ :transition_to!, :transition_to, :in_state?, to: :state_machine
169
164
  end
170
165
  ```
171
166
  #### Using PostgreSQL JSON column
@@ -199,13 +194,11 @@ or 5. To do that
199
194
  ```ruby
200
195
  Statesman.configure do
201
196
  storage_adapter(Statesman::Adapters::ActiveRecord)
202
- # ...or
203
- storage_adapter(Statesman::Adapters::Mongoid)
204
197
  end
205
198
  ```
206
199
  Statesman defaults to storing transitions in memory. If you're using rails, you
207
200
  can instead configure it to persist transitions to the database by using the
208
- ActiveRecord or Mongoid adapter.
201
+ ActiveRecord adapter.
209
202
 
210
203
  Statesman will fallback to memory unless you specify a transition_class when instantiating your state machine. This allows you to only persist transitions on certain state machines in your app.
211
204
 
@@ -234,7 +227,8 @@ end
234
227
  ```
235
228
  Define a guard. `to` and `from` parameters are optional, a nil parameter means
236
229
  guard all transitions. The passed block should evaluate to a boolean and must
237
- be idempotent as it could be called many times.
230
+ be idempotent as it could be called many times. The guard will pass when it
231
+ evaluates to a truthy value and fail when it evaluates to a falsey value (`nil` or `false`).
238
232
 
239
233
  #### `Machine.before_transition`
240
234
  ```ruby
@@ -261,6 +255,35 @@ after the transition.
261
255
  If you specify `after_commit: true`, the callback will be executed once the
262
256
  transition has been committed to the database.
263
257
 
258
+ #### `Machine.after_transition_failure`
259
+ ```ruby
260
+ Machine.after_transition_failure(from: :some_state, to: :another_state) do |object, exception|
261
+ Logger.info("transition to #{exception.to} failed for #{object.id}")
262
+ end
263
+ ```
264
+ Define a callback to run if `Statesman::TransitionFailedError` is raised
265
+ during the execution of transition callbacks. `to` and `from`
266
+ parameters are optional, a nil parameter means run after all transitions.
267
+ The model object, and exception are passed as arguments to the callback.
268
+ This is executed outside of the transaction wrapping other callbacks.
269
+ If using `transition!` the exception is re-raised after these callbacks are
270
+ executed.
271
+
272
+ #### `Machine.after_guard_failure`
273
+ ```ruby
274
+ Machine.after_guard_failure(from: :some_state, to: :another_state) do |object, exception|
275
+ Logger.info("guard failed during transition to #{exception.to} for #{object.id}")
276
+ end
277
+ ```
278
+ Define a callback to run if `Statesman::GuardFailedError` is raised
279
+ during the execution of guard callbacks. `to` and `from`
280
+ parameters are optional, a nil parameter means run after all transitions.
281
+ The model object, and exception are passed as arguments to the callback.
282
+ This is executed outside of the transaction wrapping other callbacks.
283
+ If using `transition!` the exception is re-raised after these callbacks are
284
+ executed.
285
+
286
+
264
287
  #### `Machine.new`
265
288
  ```ruby
266
289
  my_machine = Machine.new(my_model, transition_class: MyTransitionModel)
@@ -328,43 +351,34 @@ callback code throws an exception, it will not be caught.)
328
351
 
329
352
  A mixin is provided for the ActiveRecord adapter which adds scopes to easily
330
353
  find all models currently in (or not in) a given state. Include it into your
331
- model and define `transition_class` and `initial_state` class methods:
354
+ model and passing in `transition_class` and `initial_state` as options.
355
+
356
+ In 4.1.2 and below, these two options had to be defined as methods on the model,
357
+ but 5.0.0 and above allow this style of configuration as well.
358
+ The old method pollutes the model with extra class methods, and is deprecated,
359
+ to be removed in 6.0.0.
332
360
 
333
361
  ```ruby
334
362
  class Order < ActiveRecord::Base
335
- include Statesman::Adapters::ActiveRecordQueries
336
-
337
- def self.transition_class
338
- OrderTransition
339
- end
340
- private_class_method :transition_class
341
-
342
- def self.initial_state
343
- OrderStateMachine.initial_state
344
- end
345
- private_class_method :initial_state
363
+ has_many :order_transitions, autosave: false
364
+ include Statesman::Adapters::ActiveRecordQueries[
365
+ transition_class: OrderTransition,
366
+ initial_state: OrderStateMachine.initial_state
367
+ ]
346
368
  end
347
369
  ```
348
370
 
349
371
  If the transition class-name differs from the association name, you will also
350
- need to define a corresponding `transition_name` class method:
372
+ need to pass `transition_name` as an option:
351
373
 
352
374
  ```ruby
353
375
  class Order < ActiveRecord::Base
354
376
  has_many :transitions, class_name: "OrderTransition", autosave: false
355
-
356
- def self.transition_name
357
- :transitions
358
- end
359
-
360
- def self.transition_class
361
- OrderTransition
362
- end
363
-
364
- def self.initial_state
365
- OrderStateMachine.initial_state
366
- end
367
- private_class_method :initial_state
377
+ include Statesman::Adapters::ActiveRecordQueries[
378
+ transition_class: OrderTransition,
379
+ initial_state: OrderStateMachine.initial_state,
380
+ transition_name: :transitions
381
+ ]
368
382
  end
369
383
  ```
370
384
 
@@ -375,7 +389,7 @@ Returns all models currently in any of the supplied states.
375
389
  Returns all models not currently in any of the supplied states.
376
390
 
377
391
 
378
- ### `Model.most_recent_transition_join`
392
+ #### `Model.most_recent_transition_join`
379
393
  This joins the model to its most recent transition whatever that may be.
380
394
  We expose this method to ease use of ActiveRecord's `or` e.g
381
395
 
@@ -406,7 +420,7 @@ You could also use a calculated column or view in your database.
406
420
  Given a field `foo` that was stored in the metadata, you can access it like so:
407
421
 
408
422
  ```ruby
409
- model_instance.last_transition.metadata["foo"]
423
+ model_instance.state_machine.last_transition.metadata["foo"]
410
424
  ```
411
425
 
412
426
  #### Events
@@ -425,6 +439,22 @@ class OrderStateMachine
425
439
  end
426
440
  ```
427
441
 
442
+ #### Deleting records.
443
+
444
+ If you need to delete the Parent model regularly you will need to change
445
+ either the association deletion behaviour or add a `DELETE CASCADE` condition
446
+ to foreign key in your database.
447
+
448
+ E.g
449
+ ```
450
+ has_many :order_transitions, autosave: false, dependent: :destroy
451
+ ```
452
+ or when migrating the transition model
453
+ ```
454
+ add_foreign_key :order_transitions, :orders, on_delete: :cascade
455
+ ```
456
+
457
+
428
458
  ## Testing Statesman Implementations
429
459
 
430
460
  This answer was abstracted from [this issue](https://github.com/gocardless/statesman/issues/77).
@@ -469,4 +499,4 @@ end
469
499
 
470
500
  ---
471
501
 
472
- 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/).
data/Rakefile CHANGED
@@ -1,13 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler/gem_tasks"
2
4
  require "rspec/core/rake_task"
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec) do |task|
5
7
  task.rspec_opts = []
6
8
 
7
- if ENV["EXCLUDE_MONGOID"]
8
- task.rspec_opts += ["--tag ~mongo", "--exclude-pattern **/*mongo*"]
9
- end
10
-
11
9
  if ENV["CIRCLECI"]
12
10
  task.rspec_opts += ["--format RspecJunitFormatter",
13
11
  "--out /tmp/test-results/rspec.xml",
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "rails/generators"
2
4
  require "generators/statesman/generator_helpers"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  module GeneratorHelpers
3
5
  def class_name_option
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "rails/generators"
2
4
  require "generators/statesman/generator_helpers"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  autoload :Config, "statesman/config"
3
5
  autoload :Machine, "statesman/machine"
@@ -12,23 +14,31 @@ module Statesman
12
14
  "statesman/adapters/active_record_transition"
13
15
  autoload :ActiveRecordQueries,
14
16
  "statesman/adapters/active_record_queries"
15
- autoload :Mongoid, "statesman/adapters/mongoid"
16
- autoload :MongoidTransition,
17
- "statesman/adapters/mongoid_transition"
18
17
  end
19
18
  require "statesman/railtie" if defined?(::Rails::Railtie)
20
19
 
21
20
  # Example:
22
21
  # Statesman.configure do
23
22
  # storage_adapter Statesman::ActiveRecordAdapter
23
+ # enable_mysql_gaplock_protection
24
24
  # end
25
25
  #
26
26
  def self.configure(&block)
27
- config = Config.new(block)
27
+ @config = Config.new(block)
28
28
  @storage_adapter = config.adapter_class
29
29
  end
30
30
 
31
31
  def self.storage_adapter
32
32
  @storage_adapter || Adapters::Memory
33
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
34
44
  end
@@ -1,11 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "../exceptions"
2
4
 
3
5
  module Statesman
4
6
  module Adapters
5
7
  class ActiveRecord
6
- attr_reader :transition_class
7
- attr_reader :parent_model
8
-
9
8
  JSON_COLUMN_TYPES = %w[json jsonb].freeze
10
9
 
11
10
  def self.database_supports_partial_indexes?
@@ -17,6 +16,10 @@ module Statesman
17
16
  end
18
17
  end
19
18
 
19
+ def self.adapter_name
20
+ ::ActiveRecord::Base.connection.adapter_name.downcase
21
+ end
22
+
20
23
  def initialize(transition_class, parent_model, observer, options = {})
21
24
  serialized = serialized?(transition_class)
22
25
  column_type = transition_class.columns_hash["metadata"].sql_type
@@ -25,17 +28,22 @@ module Statesman
25
28
  elsif serialized && JSON_COLUMN_TYPES.include?(column_type)
26
29
  raise IncompatibleSerializationError, transition_class.name
27
30
  end
31
+
28
32
  @transition_class = transition_class
33
+ @transition_table = transition_class.arel_table
29
34
  @parent_model = parent_model
30
35
  @observer = observer
31
36
  @association_name =
32
37
  options[:association_name] || @transition_class.table_name
33
38
  end
34
39
 
40
+ attr_reader :transition_class, :transition_table, :parent_model
41
+
35
42
  def create(from, to, metadata = {})
36
43
  create_transition(from.to_s, to.to_s, metadata)
37
44
  rescue ::ActiveRecord::RecordNotUnique => e
38
45
  raise TransitionConflictError, e.message if transition_conflict_error? e
46
+
39
47
  raise
40
48
  ensure
41
49
  @last_transition = nil
@@ -61,45 +69,152 @@ module Statesman
61
69
 
62
70
  private
63
71
 
72
+ # rubocop:disable Metrics/MethodLength
64
73
  def create_transition(from, to, metadata)
65
- transition_attributes = { to_state: to,
66
- sort_key: next_sort_key,
67
- metadata: metadata }
74
+ transition = transitions_for_parent.build(
75
+ default_transition_attributes(to, metadata),
76
+ )
68
77
 
69
- transition_attributes[:most_recent] = true
78
+ ::ActiveRecord::Base.transaction(requires_new: true) do
79
+ @observer.execute(:before, from, to, transition)
70
80
 
71
- transition = transitions_for_parent.build(transition_attributes)
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
72
101
 
73
- ::ActiveRecord::Base.transaction do
74
- @observer.execute(:before, from, to, transition)
75
- unset_old_most_recent
76
- transition.save!
77
102
  @last_transition = transition
78
103
  @observer.execute(:after, from, to, transition)
104
+ add_after_commit_callback(from, to, transition)
79
105
  end
80
- @observer.execute(:after_commit, from, to, transition)
81
106
 
82
107
  transition
83
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
119
+
120
+ def add_after_commit_callback(from, to, transition)
121
+ ::ActiveRecord::Base.connection.add_transaction_record(
122
+ ActiveRecordAfterCommitWrap.new do
123
+ @observer.execute(:after_commit, from, to, transition)
124
+ end,
125
+ )
126
+ end
84
127
 
85
128
  def transitions_for_parent
86
- @parent_model.send(@association_name)
129
+ parent_model.send(@association_name)
87
130
  end
88
131
 
89
- def unset_old_most_recent
90
- 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))
91
139
 
92
- # Check whether the `most_recent` column allows null values. If it
93
- # doesn't, set old records to `false`, otherwise, set them to `NULL`.
94
- #
95
- # Some conditioning here is required to support databases that don't
96
- # support partial indexes. By doing the conditioning on the column,
97
- # rather than Rails' opinion of whether the database supports partial
98
- # indexes, we're robust to DBs later adding support for partial indexes.
99
- if transition_class.columns_hash["most_recent"].null == false
100
- 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
101
204
  else
102
- 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)
103
218
  end
104
219
  end
105
220
 
@@ -118,11 +233,48 @@ module Statesman
118
233
  end
119
234
 
120
235
  def transition_conflict_error?(err)
121
- err.message.include?(@transition_class.table_name) &&
236
+ return true if unique_indexes.any? { |i| err.message.include?(i.name) }
237
+
238
+ err.message.include?(transition_class.table_name) &&
122
239
  (err.message.include?("sort_key") || err.message.include?("most_recent"))
123
240
  end
124
241
 
125
- def with_updated_timestamp(params)
242
+ def unique_indexes
243
+ ::ActiveRecord::Base.connection.
244
+ indexes(transition_class.table_name).
245
+ select do |index|
246
+ next unless index.unique
247
+
248
+ # We care about the columns used in the index, but not necessarily
249
+ # the order, which is why we sort both sides of the comparison here
250
+ index.columns.sort == [parent_join_foreign_key, "sort_key"].sort ||
251
+ index.columns.sort == [parent_join_foreign_key, "most_recent"].sort
252
+ end
253
+ end
254
+
255
+ def parent_join_foreign_key
256
+ association =
257
+ parent_model.class.
258
+ reflect_on_all_associations(:has_many).
259
+ find { |r| r.name.to_s == @association_name.to_s }
260
+
261
+ association_join_primary_key(association)
262
+ end
263
+
264
+ def association_join_primary_key(association)
265
+ if association.respond_to?(:join_primary_key)
266
+ association.join_primary_key
267
+ elsif association.method(:join_keys).arity.zero?
268
+ # Support for Rails 5.1
269
+ association.join_keys.key
270
+ else
271
+ # Support for Rails < 5.1
272
+ association.join_keys(transition_class).key
273
+ end
274
+ end
275
+
276
+ # updated_column_and_timestamp should return [column_name, value]
277
+ def updated_column_and_timestamp
126
278
  # TODO: Once we've set expectations that transition classes should conform to
127
279
  # the interface of Adapters::ActiveRecordTransition as a breaking change in the
128
280
  # next major version, we can stop calling `#respond_to?` first and instead
@@ -130,21 +282,91 @@ module Statesman
130
282
  #
131
283
  # At the moment, most transition classes will include the module, but not all,
132
284
  # not least because it doesn't work with PostgreSQL JSON columns for metadata.
133
- column = if @transition_class.respond_to?(:updated_timestamp_column)
134
- @transition_class.updated_timestamp_column
285
+ column = if transition_class.respond_to?(:updated_timestamp_column)
286
+ transition_class.updated_timestamp_column
135
287
  else
136
288
  ActiveRecordTransition::DEFAULT_UPDATED_TIMESTAMP_COLUMN
137
289
  end
138
290
 
139
- 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
310
+
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
334
+
335
+ db_cast ? db_null : nil
336
+ end
337
+ end
338
+
339
+ class ActiveRecordAfterCommitWrap
340
+ def initialize(&block)
341
+ @callback = block
342
+ @connection = ::ActiveRecord::Base.connection
343
+ end
344
+
345
+ def self.trigger_transactional_callbacks?
346
+ true
347
+ end
348
+
349
+ def trigger_transactional_callbacks?
350
+ true
351
+ end
352
+
353
+ # rubocop: disable Naming/PredicateName
354
+ def has_transactional_callbacks?
355
+ true
356
+ end
357
+ # rubocop: enable Naming/PredicateName
358
+
359
+ def committed!(*)
360
+ @callback.call
361
+ end
362
+
363
+ def before_committed!(*); end
140
364
 
141
- timestamp = if ::ActiveRecord::Base.default_timezone == :utc
142
- Time.now.utc
143
- else
144
- Time.now
145
- end
365
+ def rolledback!(*); end
146
366
 
147
- params.merge(column => timestamp)
367
+ # Required for +transaction(requires_new: true)+
368
+ def add_to_transaction(*)
369
+ @connection.add_transaction_record(self)
148
370
  end
149
371
  end
150
372
  end