statesman 13.2.0 → 13.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 +4 -4
- data/.github/workflows/tests.yml +96 -36
- data/.tool-versions +1 -0
- data/CHANGELOG.md +13 -0
- data/CONTRIBUTING.md +20 -0
- data/Gemfile +5 -0
- data/README.md +117 -0
- data/Rakefile +32 -1
- data/lib/statesman/adapters/active_record.rb +26 -0
- data/lib/statesman/adapters/active_record_queries.rb +20 -8
- data/lib/statesman/adapters/configure_cached_current_state.rb +58 -0
- data/lib/statesman/adapters/type_safe_active_record_queries.rb +3 -0
- data/lib/statesman/bulk_transition.rb +321 -0
- data/lib/statesman/version.rb +1 -1
- data/lib/statesman.rb +3 -0
- data/spec/spec_helper.rb +9 -2
- data/spec/statesman/adapters/active_record_spec.rb +1 -3
- data/spec/statesman/adapters/configure_cached_current_state_spec.rb +175 -0
- data/spec/statesman/adapters/type_safe_active_record_queries_spec.rb +12 -0
- data/spec/statesman/bulk_transition_spec.rb +392 -0
- data/spec/support/active_record.rb +1 -0
- data/spec/support/database_container.rb +41 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 706f8fde91c1454f1ab6a7082c1317341eb5855f4c4c418e73bca0246b677a0e
|
|
4
|
+
data.tar.gz: 6cc6d3c4c43fc38f751dd361db4a7dd622b02b4ca07afd94315bef7dc433f23d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fbcf95c6f1bfdbb867768072d5cfe47d0476d26c9f37ad038eb7563ee541cf03dd4a2e66c77fe808926732e9f51a6746be77892b171148756d07e24e3e11b368
|
|
7
|
+
data.tar.gz: b70bb3d87a2f6923a4f42b1d2fda278caae324d2339731a2abdd501ac76f4a43fd70a9128a528ae55e1e05173d944b6d328b2ebeedc6c56a8a128e0d7b48371d
|
data/.github/workflows/tests.yml
CHANGED
|
@@ -21,7 +21,52 @@ jobs:
|
|
|
21
21
|
bundler-cache: true
|
|
22
22
|
- run: bundle exec rubocop --extra-details --display-style-guide --parallel --force-exclusion
|
|
23
23
|
|
|
24
|
+
bundle:
|
|
25
|
+
strategy:
|
|
26
|
+
fail-fast: false
|
|
27
|
+
matrix:
|
|
28
|
+
ruby-version: ["3.3", "3.4", "4.0"]
|
|
29
|
+
rails-version:
|
|
30
|
+
- "7.2"
|
|
31
|
+
- "8.0"
|
|
32
|
+
- "8.1"
|
|
33
|
+
- "main"
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
env:
|
|
36
|
+
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
37
|
+
BUNDLE_PATH: vendor/bundle
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v5
|
|
40
|
+
- name: Set up Ruby
|
|
41
|
+
uses: ruby/setup-ruby@v1
|
|
42
|
+
with:
|
|
43
|
+
ruby-version: "${{ matrix.ruby-version }}"
|
|
44
|
+
- name: Resolve gems
|
|
45
|
+
run: bundle lock
|
|
46
|
+
- name: Restore gems
|
|
47
|
+
id: cache
|
|
48
|
+
uses: actions/cache/restore@v6
|
|
49
|
+
with:
|
|
50
|
+
path: vendor/bundle
|
|
51
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-${{ hashFiles('Gemfile.lock') }}
|
|
52
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
53
|
+
- name: Install gems
|
|
54
|
+
if: steps.cache.outputs.cache-hit != 'true'
|
|
55
|
+
# The lockfile is stored inside the cached path so downstream jobs, which can't
|
|
56
|
+
# compute this key, can restore the most recent entry and use the same gems.
|
|
57
|
+
run: |
|
|
58
|
+
bundle install --jobs 4
|
|
59
|
+
bundle clean
|
|
60
|
+
cp Gemfile.lock vendor/bundle/Gemfile.lock
|
|
61
|
+
- name: Save gems
|
|
62
|
+
if: steps.cache.outputs.cache-hit != 'true'
|
|
63
|
+
uses: actions/cache/save@v6
|
|
64
|
+
with:
|
|
65
|
+
path: vendor/bundle
|
|
66
|
+
key: ${{ steps.cache.outputs.cache-primary-key }}
|
|
67
|
+
|
|
24
68
|
sqlite:
|
|
69
|
+
needs: bundle
|
|
25
70
|
strategy:
|
|
26
71
|
fail-fast: false
|
|
27
72
|
matrix:
|
|
@@ -37,18 +82,33 @@ jobs:
|
|
|
37
82
|
runs-on: ubuntu-latest
|
|
38
83
|
env:
|
|
39
84
|
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
85
|
+
BUNDLE_PATH: vendor/bundle
|
|
40
86
|
steps:
|
|
41
87
|
- uses: actions/checkout@v5
|
|
42
88
|
- name: Set up Ruby
|
|
43
89
|
uses: ruby/setup-ruby@v1
|
|
44
90
|
with:
|
|
45
|
-
bundler-cache: true
|
|
46
91
|
ruby-version: "${{ matrix.ruby-version }}"
|
|
92
|
+
# Restore-only: the bundle job owns saving, so matrix jobs never race to write
|
|
93
|
+
# the same cache entry. The most recent entry for this prefix is the one the
|
|
94
|
+
# bundle job just resolved.
|
|
95
|
+
- name: Restore gems
|
|
96
|
+
uses: actions/cache/restore@v6
|
|
97
|
+
with:
|
|
98
|
+
path: vendor/bundle
|
|
99
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
100
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
101
|
+
fail-on-cache-miss: true
|
|
102
|
+
- name: Install gems
|
|
103
|
+
run: |
|
|
104
|
+
cp vendor/bundle/Gemfile.lock Gemfile.lock
|
|
105
|
+
bundle install --local
|
|
47
106
|
- name: Run specs
|
|
48
107
|
run: |
|
|
49
108
|
bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
|
|
50
109
|
|
|
51
110
|
postgres:
|
|
111
|
+
needs: bundle
|
|
52
112
|
strategy:
|
|
53
113
|
fail-fast: false
|
|
54
114
|
matrix:
|
|
@@ -63,36 +123,37 @@ jobs:
|
|
|
63
123
|
- ruby-version: "3.2"
|
|
64
124
|
rails-version: "main"
|
|
65
125
|
runs-on: ubuntu-latest
|
|
66
|
-
services:
|
|
67
|
-
postgres:
|
|
68
|
-
image: postgres:${{ matrix.postgres-version }}
|
|
69
|
-
env:
|
|
70
|
-
POSTGRES_USER: postgres
|
|
71
|
-
POSTGRES_DB: statesman_test
|
|
72
|
-
POSTGRES_PASSWORD: statesman
|
|
73
|
-
ports:
|
|
74
|
-
- 5432:5432
|
|
75
|
-
options: >-
|
|
76
|
-
--health-cmd pg_isready
|
|
77
|
-
--health-interval 10s
|
|
78
|
-
--health-timeout 5s
|
|
79
|
-
--health-retries 10
|
|
80
126
|
env:
|
|
81
|
-
|
|
82
|
-
|
|
127
|
+
DB: postgres
|
|
128
|
+
DB_IMAGE: postgres:${{ matrix.postgres-version }}
|
|
83
129
|
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
130
|
+
BUNDLE_PATH: vendor/bundle
|
|
84
131
|
steps:
|
|
85
132
|
- uses: actions/checkout@v5
|
|
86
133
|
- name: Set up Ruby
|
|
87
134
|
uses: ruby/setup-ruby@v1
|
|
88
135
|
with:
|
|
89
|
-
bundler-cache: true
|
|
90
136
|
ruby-version: "${{ matrix.ruby-version }}"
|
|
137
|
+
# Restore-only: the bundle job owns saving, so matrix jobs never race to write
|
|
138
|
+
# the same cache entry. The most recent entry for this prefix is the one the
|
|
139
|
+
# bundle job just resolved.
|
|
140
|
+
- name: Restore gems
|
|
141
|
+
uses: actions/cache/restore@v6
|
|
142
|
+
with:
|
|
143
|
+
path: vendor/bundle
|
|
144
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
145
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
146
|
+
fail-on-cache-miss: true
|
|
147
|
+
- name: Install gems
|
|
148
|
+
run: |
|
|
149
|
+
cp vendor/bundle/Gemfile.lock Gemfile.lock
|
|
150
|
+
bundle install --local
|
|
91
151
|
- name: Run specs
|
|
92
152
|
run: |
|
|
93
153
|
bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
|
|
94
154
|
|
|
95
155
|
mysql:
|
|
156
|
+
needs: bundle
|
|
96
157
|
strategy:
|
|
97
158
|
fail-fast: false
|
|
98
159
|
matrix:
|
|
@@ -108,32 +169,31 @@ jobs:
|
|
|
108
169
|
rails-version: "main"
|
|
109
170
|
|
|
110
171
|
runs-on: ubuntu-latest
|
|
111
|
-
services:
|
|
112
|
-
mysql:
|
|
113
|
-
image: mysql:${{ matrix.mysql-version }}
|
|
114
|
-
env:
|
|
115
|
-
MYSQL_ROOT_PASSWORD: password
|
|
116
|
-
MYSQL_USER: foobar
|
|
117
|
-
MYSQL_PASSWORD: password
|
|
118
|
-
MYSQL_DATABASE: statesman_test
|
|
119
|
-
ports:
|
|
120
|
-
- "3306:3306"
|
|
121
|
-
options: >-
|
|
122
|
-
--health-cmd "mysqladmin ping"
|
|
123
|
-
--health-interval 10s
|
|
124
|
-
--health-timeout 5s
|
|
125
|
-
--health-retries 5
|
|
126
172
|
env:
|
|
127
|
-
|
|
128
|
-
|
|
173
|
+
DB: mysql
|
|
174
|
+
DB_IMAGE: mysql:${{ matrix.mysql-version }}
|
|
129
175
|
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
176
|
+
BUNDLE_PATH: vendor/bundle
|
|
130
177
|
steps:
|
|
131
178
|
- uses: actions/checkout@v5
|
|
132
179
|
- name: Set up Ruby
|
|
133
180
|
uses: ruby/setup-ruby@v1
|
|
134
181
|
with:
|
|
135
|
-
bundler-cache: true
|
|
136
182
|
ruby-version: "${{ matrix.ruby-version }}"
|
|
183
|
+
# Restore-only: the bundle job owns saving, so matrix jobs never race to write
|
|
184
|
+
# the same cache entry. The most recent entry for this prefix is the one the
|
|
185
|
+
# bundle job just resolved.
|
|
186
|
+
- name: Restore gems
|
|
187
|
+
uses: actions/cache/restore@v6
|
|
188
|
+
with:
|
|
189
|
+
path: vendor/bundle
|
|
190
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
191
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
192
|
+
fail-on-cache-miss: true
|
|
193
|
+
- name: Install gems
|
|
194
|
+
run: |
|
|
195
|
+
cp vendor/bundle/Gemfile.lock Gemfile.lock
|
|
196
|
+
bundle install --local
|
|
137
197
|
- name: Run specs
|
|
138
198
|
run: |
|
|
139
199
|
bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 3.4.5
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## v13.4.0 14th October 2026
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Statesman::BulkTransition`, which transitions many parents from one state to another in a single transaction with a fixed number of queries (a flip of the current `most_recent` rows, one `insert_all!`, and one cached-current-state update if configured). Works with the `ActiveRecord` adapter on PostgreSQL, MySQL (including `mysql_gaplock_protection`) and SQLite. Like `transition_to!`, it flips the previous transition and updates the cached current state, but it does not run guards, `before`/`after` callbacks or `ActiveRecord` validations/callbacks on the transition model. Supports per-parent overrides via `metadata_per_id` and `attributes_per_id`, and an optional block that is called inside the transaction with a lazy `ActiveRecord::Relation` over the newly inserted transitions. Parents not in the `from` state are skipped, so a batch is safe to re-run. Assumes a non-STI transition table and a single-column parent primary key.
|
|
13
|
+
|
|
14
|
+
## v13.3.0 4th August 2026
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `Statesman::Adapters::ConfigureCachedCurrentState`, a model mixin to maintain a denormalised current-state column on the parent model without a separate join or query, via `configure_cached_current_state`. The write happens inside the `ActiveRecord` storage adapter itself (shared by every `Machine` subclass and every adapter instance for a model), so it also works for models driven by several different machine classes chosen dynamically (e.g. per scheme), and fires reliably under `mysql_gaplock_protection`. Exposes the configured column via `Model.cached_state_column_name` for generic tooling. Only updates via real `transition_to!` calls - a transition row created directly, bypassing the machine, does not update the cache. The initial-state seed on create only applies if the column is nil, so a record explicitly created into a given state (e.g. a test factory) keeps that value.
|
|
19
|
+
- `configure_state_machine` now exposes `transition_class` and `initial_state` as class readers.
|
|
20
|
+
|
|
8
21
|
## v13.2.0 29th July 2026
|
|
9
22
|
|
|
10
23
|
### Added
|
data/CONTRIBUTING.md
CHANGED
|
@@ -11,6 +11,26 @@ ways you can help make this project better!
|
|
|
11
11
|
- No need to add to the changelog, we will take care of updating it as we make
|
|
12
12
|
releases.
|
|
13
13
|
|
|
14
|
+
## Running tests
|
|
15
|
+
|
|
16
|
+
`bundle exec rake` runs the suite against SQLite, PostgreSQL and MySQL in
|
|
17
|
+
parallel. PostgreSQL and MySQL are started as throwaway containers in Docker via
|
|
18
|
+
[Testcontainers](https://github.com/testcontainers/testcontainers-ruby).
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle exec rake # all databases, in parallel
|
|
22
|
+
bundle exec rake spec:postgres # a single database
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`bundle exec rspec` runs against SQLite by default. Set `DB` to use a container,
|
|
26
|
+
or point `DATABASE_URL` at an existing database:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
DB=postgres bundle exec rspec # postgres:17
|
|
30
|
+
DB=mysql bundle exec rspec # mysql:8.4
|
|
31
|
+
DB=mysql DB_IMAGE=mysql:9.4 bundle exec rspec spec/statesman/machine_spec.rb
|
|
32
|
+
```
|
|
33
|
+
|
|
14
34
|
## Style
|
|
15
35
|
|
|
16
36
|
We use [Rubocop](https://github.com/bbatsov/rubocop) to help maintain a
|
data/Gemfile
CHANGED
|
@@ -12,6 +12,9 @@ else
|
|
|
12
12
|
gem "rails", "~> 8.0"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# ActiveSupport 7.2 passes `quirks_mode:` to JSON, which json 3 no longer accepts.
|
|
16
|
+
gem "json", "< 3" if ENV['RAILS_VERSION'] == '7.2'
|
|
17
|
+
|
|
15
18
|
group :development, :test do
|
|
16
19
|
gem "ammeter", "~> 1.1"
|
|
17
20
|
gem "gc_ruboconfig", "~> 6.0.0"
|
|
@@ -24,6 +27,8 @@ group :development, :test do
|
|
|
24
27
|
gem "rspec-its", "~> 2.0"
|
|
25
28
|
gem "rspec-rails", "~> 8.0"
|
|
26
29
|
gem "sqlite3", "~> 2.9.0"
|
|
30
|
+
gem "testcontainers-mysql", "~> 0.2.0"
|
|
31
|
+
gem "testcontainers-postgres", "~> 0.2.0"
|
|
27
32
|
gem "test-unit", "~> 3.3"
|
|
28
33
|
gem "timecop", "~> 0.9.1"
|
|
29
34
|
end
|
data/README.md
CHANGED
|
@@ -617,6 +617,123 @@ Model.in_state(:state_1).or(
|
|
|
617
617
|
)
|
|
618
618
|
```
|
|
619
619
|
|
|
620
|
+
### Caching the current state
|
|
621
|
+
|
|
622
|
+
If you find yourself frequently needing the current state without wanting to
|
|
623
|
+
join or query the transitions table,
|
|
624
|
+
`Statesman::Adapters::ConfigureCachedCurrentState` can maintain a
|
|
625
|
+
denormalised column on the parent model for you:
|
|
626
|
+
|
|
627
|
+
```ruby
|
|
628
|
+
class MyModel < ActiveRecord::Base
|
|
629
|
+
extend Statesman::Adapters::TypeSafeActiveRecordQueries
|
|
630
|
+
include Statesman::Adapters::ConfigureCachedCurrentState
|
|
631
|
+
|
|
632
|
+
configure_state_machine transition_class: MyModelTransition,
|
|
633
|
+
initial_state: :initial
|
|
634
|
+
configure_cached_current_state
|
|
635
|
+
end
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
This sets the column (`cached_current_state` by default) to the initial state
|
|
639
|
+
when a record is created — unless it's already been explicitly set (e.g. a
|
|
640
|
+
test factory building a record straight into a given state), in which case
|
|
641
|
+
that value is left alone — and updates it via `update_columns` whenever
|
|
642
|
+
`transition_to!` successfully persists a new `most_recent` transition.
|
|
643
|
+
Including the module by itself does nothing — the write is only wired up once
|
|
644
|
+
`configure_cached_current_state` is called.
|
|
645
|
+
|
|
646
|
+
The write happens inside the `ActiveRecord` storage adapter itself, right
|
|
647
|
+
after a transition is persisted as the new `most_recent` row, rather than via
|
|
648
|
+
an ActiveRecord callback on `transition_class`. That single code path is
|
|
649
|
+
shared by every `Machine` subclass and by every adapter instance for a model,
|
|
650
|
+
so it fires reliably even under Statesman's `mysql_gaplock_protection` config
|
|
651
|
+
— where the `most_recent` flip happens via a raw SQL `UPDATE` that bypasses
|
|
652
|
+
ActiveRecord callbacks entirely.
|
|
653
|
+
|
|
654
|
+
One consequence: a transition row created directly on `transition_class`,
|
|
655
|
+
bypassing the machine (e.g. `parent.transitions.create!(...)` in a test or
|
|
656
|
+
backfill), does not update the cache — only real transitions performed
|
|
657
|
+
through `transition_to!` do.
|
|
658
|
+
|
|
659
|
+
Two further options are available:
|
|
660
|
+
|
|
661
|
+
- `column:` — use a column name other than `cached_current_state`.
|
|
662
|
+
- `touch_updated_at:` — also bump `updated_at` on the parent when the cached
|
|
663
|
+
column changes (defaults to `false`).
|
|
664
|
+
|
|
665
|
+
Because the write happens in the shared adapter code rather than against a
|
|
666
|
+
specific `Machine` subclass, this also works for models driven by several
|
|
667
|
+
different machine classes chosen dynamically (e.g. per scheme). It always
|
|
668
|
+
runs before any of a machine's own `after_transition` callbacks, since it
|
|
669
|
+
happens as part of persisting the transition, before Statesman invokes its
|
|
670
|
+
own callbacks.
|
|
671
|
+
|
|
672
|
+
`configure_cached_current_state` also defines `Model.cached_state_column_name`,
|
|
673
|
+
returning the configured column, for generic tooling that needs to introspect
|
|
674
|
+
or repair the cached column (e.g. a cache-repair rake task working across
|
|
675
|
+
several models).
|
|
676
|
+
|
|
677
|
+
## Bulk transitions
|
|
678
|
+
|
|
679
|
+
`Statesman::BulkTransition` moves many parents from one state to another in a
|
|
680
|
+
single transaction, with a fixed number of queries however many parents there
|
|
681
|
+
are, instead of the several queries per record that `transition_to!` costs. It
|
|
682
|
+
works with the `ActiveRecord` adapter on PostgreSQL, MySQL and SQLite.
|
|
683
|
+
|
|
684
|
+
```ruby
|
|
685
|
+
bulk = Statesman::BulkTransition.new(
|
|
686
|
+
model_class: Order,
|
|
687
|
+
machine_class: OrderStateMachine,
|
|
688
|
+
from: :pending,
|
|
689
|
+
to: :confirmed,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
transitioned_ids = bulk.call!(order_ids, metadata: { source: "import" })
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
`call!` returns the ids of the parents that were actually transitioned. Parents
|
|
696
|
+
that aren't currently in the `from` state are silently skipped, so a batch is
|
|
697
|
+
safe to re-run.
|
|
698
|
+
|
|
699
|
+
It performs the same mechanical write as `transition_to!` — flipping the
|
|
700
|
+
current `most_recent` transition, inserting the new one, and updating the
|
|
701
|
+
parent's cached current state if you've called `configure_cached_current_state`
|
|
702
|
+
— but it deliberately **does not run** the machine's guards, its
|
|
703
|
+
`before_transition`/`after_transition` callbacks, or any validations and
|
|
704
|
+
callbacks on the transition model. If you need equivalent side effects,
|
|
705
|
+
reproduce them in the block (below).
|
|
706
|
+
|
|
707
|
+
Options:
|
|
708
|
+
|
|
709
|
+
- `metadata:` — applied to every new transition. `metadata_per_id:` overrides it
|
|
710
|
+
for individual parents, keyed by parent id (`1` and `"1"` match the same
|
|
711
|
+
parent).
|
|
712
|
+
- `attributes_to_copy:` — extra transition columns to carry forward from the
|
|
713
|
+
previous transition onto the new one. The parent foreign key and `sort_key`
|
|
714
|
+
are always carried. There's no default, so you have to choose explicitly.
|
|
715
|
+
`attributes_per_id:` overrides those carried values for individual parents,
|
|
716
|
+
and may only name columns listed in `attributes_to_copy` (anything else raises
|
|
717
|
+
`Statesman::BulkTransition::ValidationError`).
|
|
718
|
+
|
|
719
|
+
If you pass a block, it is called inside the same transaction with an
|
|
720
|
+
`ActiveRecord::Relation` over the newly inserted transitions. The relation is
|
|
721
|
+
lazy, so the block decides what to read or write:
|
|
722
|
+
|
|
723
|
+
```ruby
|
|
724
|
+
bulk.call!(order_ids) do |transitions|
|
|
725
|
+
rows = transitions.pluck(:order_id, :to_state)
|
|
726
|
+
AuditLog.insert_all(rows.map { |id, state| { order_id: id, state: state } })
|
|
727
|
+
end
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
An exception raised in the block rolls the entire batch back. If a row
|
|
731
|
+
conflicts with the unique index on the transition table, `call!` raises
|
|
732
|
+
`Statesman::TransitionConflictError` and nothing is committed.
|
|
733
|
+
|
|
734
|
+
Two assumptions to be aware of: the transition table must not be an STI table,
|
|
735
|
+
and the parent must have a single-column primary key.
|
|
736
|
+
|
|
620
737
|
## Frequently Asked Questions
|
|
621
738
|
|
|
622
739
|
### Storing the state on the model object
|
data/Rakefile
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
+
require "open3"
|
|
4
5
|
require "rspec/core/rake_task"
|
|
5
6
|
|
|
6
7
|
RSpec::Core::RakeTask.new(:spec) do |task|
|
|
@@ -13,4 +14,34 @@ RSpec::Core::RakeTask.new(:spec) do |task|
|
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
DATABASES = %w[sqlite postgres mysql].freeze
|
|
18
|
+
RSPEC_COMMAND = %w[bundle exec rspec].freeze
|
|
19
|
+
|
|
20
|
+
# DB_IMAGE and DATABASE_URL are cleared so each run gets its own database rather than all of them
|
|
21
|
+
# sharing whichever one the caller's environment points at.
|
|
22
|
+
database_env = ->(db) { { "DB" => (db unless db == "sqlite"), "DB_IMAGE" => nil, "DATABASE_URL" => nil } }
|
|
23
|
+
|
|
24
|
+
namespace :spec do
|
|
25
|
+
DATABASES.each do |db|
|
|
26
|
+
desc "Run specs against #{db}"
|
|
27
|
+
task(db) { sh(database_env.call(db), *RSPEC_COMMAND) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Run specs against all databases in parallel"
|
|
31
|
+
task :all do
|
|
32
|
+
output_lock = Mutex.new
|
|
33
|
+
|
|
34
|
+
results = DATABASES.map do |db|
|
|
35
|
+
Thread.new do
|
|
36
|
+
output, status = Open3.capture2e(database_env.call(db), *RSPEC_COMMAND)
|
|
37
|
+
output_lock.synchronize { puts "==> #{db}", output }
|
|
38
|
+
[db, status.success?]
|
|
39
|
+
end
|
|
40
|
+
end.map(&:value)
|
|
41
|
+
|
|
42
|
+
failed = results.reject { |_, success| success }.map(&:first)
|
|
43
|
+
abort "Specs failed for: #{failed.join(', ')}" if failed.any?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
task default: "spec:all"
|
|
@@ -108,6 +108,8 @@ module Statesman
|
|
|
108
108
|
transition.save!
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
maintain_cached_current_state(transition)
|
|
112
|
+
|
|
111
113
|
@last_transition = transition
|
|
112
114
|
@observer.execute(:after, from, to, transition)
|
|
113
115
|
add_after_commit_callback(from, to, transition)
|
|
@@ -116,6 +118,30 @@ module Statesman
|
|
|
116
118
|
transition
|
|
117
119
|
end
|
|
118
120
|
|
|
121
|
+
# Writes the cached current state column configured via
|
|
122
|
+
# Statesman::Adapters::ConfigureCachedCurrentState#configure_cached_current_state,
|
|
123
|
+
# if the parent model opted in. Done here, rather than via an ActiveRecord
|
|
124
|
+
# callback on transition_class, so it fires reliably even under
|
|
125
|
+
# mysql_gaplock_protection - where most_recent is flipped to true via a raw SQL
|
|
126
|
+
# UPDATE that bypasses ActiveRecord callbacks entirely (see above). This method
|
|
127
|
+
# always runs after most_recent has been set on `transition` (in both branches
|
|
128
|
+
# above), and before the machine's own after_transition callbacks are invoked.
|
|
129
|
+
def maintain_cached_current_state(transition)
|
|
130
|
+
model_class = parent_model.class
|
|
131
|
+
return unless model_class.respond_to?(:cached_state_column_name)
|
|
132
|
+
|
|
133
|
+
column = model_class.cached_state_column_name
|
|
134
|
+
unless model_class.column_names.include?(column.to_s)
|
|
135
|
+
raise ArgumentError,
|
|
136
|
+
"cache_current_state_column: #{column.inspect} is not a column " \
|
|
137
|
+
"on #{model_class.name}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
attributes = { column => transition.to_state }
|
|
141
|
+
attributes[:updated_at] = Time.current if model_class.cached_current_state_touch_updated_at?
|
|
142
|
+
parent_model.update_columns(attributes)
|
|
143
|
+
end
|
|
144
|
+
|
|
119
145
|
def default_transition_attributes(from, to, metadata)
|
|
120
146
|
attributes = {
|
|
121
147
|
to_state: to,
|
|
@@ -33,6 +33,18 @@ module Statesman
|
|
|
33
33
|
ClassMethods.new(**args)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Finds the has_many association on model that targets transition_class -
|
|
37
|
+
# matching by klass rather than by name, since callers (e.g.
|
|
38
|
+
# ConfigureCachedCurrentState) may not know what the association was named.
|
|
39
|
+
def self.transition_reflection_for(model, transition_class)
|
|
40
|
+
model.reflect_on_all_associations(:has_many).find do |reflection|
|
|
41
|
+
reflection.klass == transition_class
|
|
42
|
+
end || raise(
|
|
43
|
+
MissingTransitionAssociation,
|
|
44
|
+
"Could not find has_many association between #{model} and #{transition_class}.",
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
36
48
|
class ClassMethods < Module
|
|
37
49
|
def initialize(**args)
|
|
38
50
|
@args = args
|
|
@@ -41,7 +53,13 @@ module Statesman
|
|
|
41
53
|
def included(base)
|
|
42
54
|
ensure_inheritance(base) if base.respond_to?(:subclasses) && base.subclasses.any?
|
|
43
55
|
|
|
44
|
-
query_builder = QueryBuilder.new(
|
|
56
|
+
query_builder = QueryBuilder.new(
|
|
57
|
+
base,
|
|
58
|
+
transition_class: @args[:transition_class],
|
|
59
|
+
initial_state: @args[:initial_state],
|
|
60
|
+
most_recent_transition_alias: @args[:most_recent_transition_alias],
|
|
61
|
+
transition_name: @args[:transition_name],
|
|
62
|
+
)
|
|
45
63
|
|
|
46
64
|
base.define_singleton_method(:most_recent_transition_join) do
|
|
47
65
|
query_builder.most_recent_transition_join
|
|
@@ -126,13 +144,7 @@ module Statesman
|
|
|
126
144
|
end
|
|
127
145
|
|
|
128
146
|
def transition_reflection
|
|
129
|
-
|
|
130
|
-
return value if value.klass == transition_class
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
raise MissingTransitionAssociation,
|
|
134
|
-
"Could not find has_many association between #{self.class} " \
|
|
135
|
-
"and #{transition_class}."
|
|
147
|
+
ActiveRecordQueries.transition_reflection_for(model, transition_class)
|
|
136
148
|
end
|
|
137
149
|
|
|
138
150
|
def model_primary_key
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statesman
|
|
4
|
+
module Adapters
|
|
5
|
+
# Include on an ActiveRecord model configured with
|
|
6
|
+
# Statesman::Adapters::TypeSafeActiveRecordQueries#configure_state_machine (only
|
|
7
|
+
# transition_class: is required) to cache the current state on a column on the
|
|
8
|
+
# model. Including this module does nothing on its own - the model must also call
|
|
9
|
+
# `configure_cached_current_state` to wire up the write, which also exposes the
|
|
10
|
+
# configured column name via `cached_state_column_name`, for generic tooling that
|
|
11
|
+
# needs to introspect or repair the cached column (e.g. a cache-repair rake task).
|
|
12
|
+
#
|
|
13
|
+
# The write itself happens inside Statesman::Adapters::ActiveRecord#create_transition
|
|
14
|
+
# (see `maintain_cached_current_state` there), not via a callback registered by this
|
|
15
|
+
# module - that single code path is shared by every Machine subclass and every
|
|
16
|
+
# storage adapter instance for a model, so this works even when a model is driven by
|
|
17
|
+
# several different machine classes (e.g. chosen dynamically per scheme), and it
|
|
18
|
+
# fires reliably under Statesman's mysql_gaplock_protection config, where the DB-level
|
|
19
|
+
# most_recent flip happens via a raw SQL UPDATE that bypasses ActiveRecord callbacks
|
|
20
|
+
# entirely.
|
|
21
|
+
#
|
|
22
|
+
# Caveat: because the write only happens as part of `transition_to!` (via the
|
|
23
|
+
# adapter), a transition row created directly on transition_class - bypassing the
|
|
24
|
+
# machine entirely, e.g. `parent.transitions.create!(...)` - does not update the
|
|
25
|
+
# cache. Only real transitions performed through the machine do.
|
|
26
|
+
#
|
|
27
|
+
# The initial-state seed on create only applies when the column is nil - if it's
|
|
28
|
+
# already been explicitly set (e.g. a test factory building a record straight into a
|
|
29
|
+
# given state, without a real transition history), that value is left alone.
|
|
30
|
+
module ConfigureCachedCurrentState
|
|
31
|
+
def self.included(base)
|
|
32
|
+
base.extend(ClassMethods)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module ClassMethods
|
|
36
|
+
def configure_cached_current_state(column: :cached_current_state, touch_updated_at: false)
|
|
37
|
+
unless respond_to?(:transition_class) && transition_class
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"transition_class: must be configured via configure_state_machine " \
|
|
40
|
+
"before calling configure_cached_current_state"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
initial = initial_state
|
|
44
|
+
|
|
45
|
+
define_singleton_method(:cached_state_column_name) { column }
|
|
46
|
+
define_singleton_method(:cached_current_state_touch_updated_at?) { touch_updated_at }
|
|
47
|
+
|
|
48
|
+
before_create do
|
|
49
|
+
next unless respond_to?(:"#{column}=")
|
|
50
|
+
next unless send(column).nil?
|
|
51
|
+
|
|
52
|
+
send(:"#{column}=", initial.to_s)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|