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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffe7ee5f3aa4787fccd4c325c42bd892dc475edaaabc45a4a67155ebe41d41c1
4
- data.tar.gz: 1794e00cf99c34e3d84a680f409a92d2430d0ed4964d3145f7f0539d7c54ec09
3
+ metadata.gz: 706f8fde91c1454f1ab6a7082c1317341eb5855f4c4c418e73bca0246b677a0e
4
+ data.tar.gz: 6cc6d3c4c43fc38f751dd361db4a7dd622b02b4ca07afd94315bef7dc433f23d
5
5
  SHA512:
6
- metadata.gz: 7fc095f7cdfec543802c1e367af567d63e8abea8e57c01bc0b1fc1a6d661ea3b187e0d9ed91457c2eecf109286879ef7bd5bf45e3fa80a44777cb326486f9007
7
- data.tar.gz: e3c342d10b68eccd5d4f145079c70537582f7923cf5cf7a9c0724fb58553476ca3d4e319c993929840e7ee2d2dbfedf1634e40160647429ea7cf194574275b79
6
+ metadata.gz: fbcf95c6f1bfdbb867768072d5cfe47d0476d26c9f37ad038eb7563ee541cf03dd4a2e66c77fe808926732e9f51a6746be77892b171148756d07e24e3e11b368
7
+ data.tar.gz: b70bb3d87a2f6923a4f42b1d2fda278caae324d2339731a2abdd501ac76f4a43fd70a9128a528ae55e1e05173d944b6d328b2ebeedc6c56a8a128e0d7b48371d
@@ -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
- DATABASE_URL: postgres://postgres:statesman@localhost/statesman_test
82
- DATABASE_DEPENDENCY_PORT: "5432"
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
- DATABASE_URL: mysql2://foobar:password@127.0.0.1/statesman_test
128
- DATABASE_DEPENDENCY_PORT: "3306"
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
- task default: :spec
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(base, **@args)
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
- model.reflect_on_all_associations(:has_many).each do |value|
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
@@ -15,6 +15,9 @@ module Statesman
15
15
  transition_name: try(:transition_name),
16
16
  ),
17
17
  )
18
+
19
+ define_singleton_method(:initial_state) { initial_state }
20
+ define_singleton_method(:transition_class) { transition_class }
18
21
  end
19
22
  end
20
23
  end