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.
- checksums.yaml +5 -5
- data/.circleci/config.yml +49 -250
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +26 -6
- data/CHANGELOG.md +106 -0
- data/Gemfile +10 -4
- data/Guardfile +2 -0
- data/README.md +78 -48
- data/Rakefile +2 -4
- data/lib/generators/statesman/active_record_transition_generator.rb +2 -0
- data/lib/generators/statesman/generator_helpers.rb +2 -0
- data/lib/generators/statesman/migration_generator.rb +2 -0
- data/lib/statesman.rb +14 -4
- data/lib/statesman/adapters/active_record.rb +259 -37
- data/lib/statesman/adapters/active_record_queries.rb +100 -36
- data/lib/statesman/adapters/active_record_transition.rb +2 -0
- data/lib/statesman/adapters/memory.rb +2 -0
- data/lib/statesman/adapters/memory_transition.rb +2 -0
- data/lib/statesman/callback.rb +2 -0
- data/lib/statesman/config.rb +28 -0
- data/lib/statesman/exceptions.rb +34 -2
- data/lib/statesman/guard.rb +3 -4
- data/lib/statesman/machine.rb +29 -7
- data/lib/statesman/railtie.rb +2 -0
- data/lib/statesman/utils.rb +2 -0
- data/lib/statesman/version.rb +3 -1
- data/lib/tasks/statesman.rake +3 -1
- data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_with_partial_index.rb +2 -0
- data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_without_partial_index.rb +2 -0
- data/spec/fixtures/add_most_recent_to_bacon_transitions.rb +2 -0
- data/spec/generators/statesman/active_record_transition_generator_spec.rb +2 -0
- data/spec/generators/statesman/migration_generator_spec.rb +2 -0
- data/spec/spec_helper.rb +3 -30
- data/spec/statesman/adapters/active_record_queries_spec.rb +167 -91
- data/spec/statesman/adapters/active_record_spec.rb +15 -1
- data/spec/statesman/adapters/active_record_transition_spec.rb +2 -0
- data/spec/statesman/adapters/memory_spec.rb +2 -0
- data/spec/statesman/adapters/memory_transition_spec.rb +2 -0
- data/spec/statesman/adapters/shared_examples.rb +2 -0
- data/spec/statesman/callback_spec.rb +2 -0
- data/spec/statesman/config_spec.rb +2 -0
- data/spec/statesman/exceptions_spec.rb +88 -0
- data/spec/statesman/guard_spec.rb +2 -0
- data/spec/statesman/machine_spec.rb +79 -4
- data/spec/statesman/utils_spec.rb +2 -0
- data/spec/support/active_record.rb +9 -12
- data/spec/support/generators_shared_examples.rb +2 -0
- data/statesman.gemspec +19 -7
- metadata +40 -32
- data/lib/generators/statesman/mongoid_transition_generator.rb +0 -25
- data/lib/generators/statesman/templates/mongoid_transition_model.rb.erb +0 -14
- data/lib/statesman/adapters/mongoid.rb +0 -66
- data/lib/statesman/adapters/mongoid_transition.rb +0 -10
- data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -23
- data/spec/statesman/adapters/mongoid_spec.rb +0 -86
- data/spec/support/mongoid.rb +0 -28
data/CHANGELOG.md
CHANGED
@@ -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
|
-
|
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.
|
17
|
+
gem "test-unit", "~> 3.3" if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("2.2.0")
|
12
18
|
end
|
data/Guardfile
CHANGED
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
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', '~>
|
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
|
-
|
168
|
-
|
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
|
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
|
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
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
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
|
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
|
-
|
357
|
-
:
|
358
|
-
|
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
|
-
|
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/
|
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",
|
data/lib/statesman.rb
CHANGED
@@ -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
|
-
|
66
|
-
|
67
|
-
|
74
|
+
transition = transitions_for_parent.build(
|
75
|
+
default_transition_attributes(to, metadata),
|
76
|
+
)
|
68
77
|
|
69
|
-
|
78
|
+
::ActiveRecord::Base.transaction(requires_new: true) do
|
79
|
+
@observer.execute(:before, from, to, transition)
|
70
80
|
|
71
|
-
|
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
|
-
|
129
|
+
parent_model.send(@association_name)
|
87
130
|
end
|
88
131
|
|
89
|
-
|
90
|
-
|
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
|
-
#
|
93
|
-
#
|
94
|
-
#
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
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
|
-
|
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?(
|
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
|
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
|
134
|
-
|
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
|
-
|
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
|
-
|
142
|
-
Time.now.utc
|
143
|
-
else
|
144
|
-
Time.now
|
145
|
-
end
|
365
|
+
def rolledback!(*); end
|
146
366
|
|
147
|
-
|
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
|