statesman 3.5.0 → 7.4.0

Sign up to get free protection for your applications and to get access to all the features.
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