statesman 13.2.0 → 13.3.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: 5de6588300b8aa3559f3ef4691e865c8b4571b029029671704aa8249b6fccb48
4
+ data.tar.gz: 2b70040b208dfa076b5633640668f5b964a148cd7d6ea4e1cd7a6671dc29e5cd
5
5
  SHA512:
6
- metadata.gz: 7fc095f7cdfec543802c1e367af567d63e8abea8e57c01bc0b1fc1a6d661ea3b187e0d9ed91457c2eecf109286879ef7bd5bf45e3fa80a44777cb326486f9007
7
- data.tar.gz: e3c342d10b68eccd5d4f145079c70537582f7923cf5cf7a9c0724fb58553476ca3d4e319c993929840e7ee2d2dbfedf1634e40160647429ea7cf194574275b79
6
+ metadata.gz: 33129e2eb00defefabd11b7edcf85efb709eed969c68897fc2b321927e8b7b47a7e4abf474365ab4ed9b9b5916e06937752a312520e205650427992de93adf2e
7
+ data.tar.gz: 0c33cba64756fc17cb293c0efbd3dd752d7741c8fb7469407ce1e5eb51df59a4c3ff9b989e433ed5220716c6c378a97331e7cb4ffb64fe97dbd9521be6dd2877
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@
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.3.0 4th August 2026
9
+
10
+ ### Added
11
+
12
+ - `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.
13
+ - `configure_state_machine` now exposes `transition_class` and `initial_state` as class readers.
14
+
8
15
  ## v13.2.0 29th July 2026
9
16
 
10
17
  ### Added
data/README.md CHANGED
@@ -617,6 +617,63 @@ 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
+
620
677
  ## Frequently Asked Questions
621
678
 
622
679
  ### Storing the state on the model object
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statesman
4
- VERSION = "13.2.0"
4
+ VERSION = "13.3.0"
5
5
  end
data/lib/statesman.rb CHANGED
@@ -16,6 +16,8 @@ module Statesman
16
16
  "statesman/adapters/active_record_queries"
17
17
  autoload :TypeSafeActiveRecordQueries,
18
18
  "statesman/adapters/type_safe_active_record_queries"
19
+ autoload :ConfigureCachedCurrentState,
20
+ "statesman/adapters/configure_cached_current_state"
19
21
  end
20
22
  require "statesman/railtie" if defined?(::Rails::Railtie)
21
23
 
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Statesman::Adapters::ConfigureCachedCurrentState, :active_record do
4
+ before do
5
+ prepare_model_table
6
+ prepare_transitions_table
7
+
8
+ Statesman.configure do
9
+ storage_adapter(Statesman::Adapters::ActiveRecord)
10
+ end
11
+
12
+ MyActiveRecordModel.extend Statesman::Adapters::TypeSafeActiveRecordQueries
13
+ MyActiveRecordModel.send(:include, described_class)
14
+ end
15
+
16
+ after do
17
+ Statesman.configure { storage_adapter(Statesman::Adapters::Memory) }
18
+ MyActiveRecordModel.reset_callbacks(:create)
19
+ end
20
+
21
+ def configure(**args)
22
+ MyActiveRecordModel.configure_state_machine(
23
+ transition_class: MyActiveRecordModelTransition,
24
+ initial_state: :initial,
25
+ **args,
26
+ )
27
+ end
28
+
29
+ describe "including the module without calling configure_cached_current_state" do
30
+ it "does not add any callbacks" do
31
+ configure
32
+
33
+ expect { MyActiveRecordModel.create }.
34
+ to_not change(MyActiveRecordModelTransition, :count)
35
+ end
36
+ end
37
+
38
+ describe ".configure_cached_current_state" do
39
+ it "raises if transition_class was not configured" do
40
+ klass = Class.new
41
+ klass.send(:extend, Statesman::Adapters::TypeSafeActiveRecordQueries)
42
+ klass.send(:include, described_class)
43
+
44
+ expect { klass.configure_cached_current_state }.
45
+ to raise_error(ArgumentError, /transition_class/)
46
+ end
47
+
48
+ it "sets the column to the initial state on create" do
49
+ configure
50
+ MyActiveRecordModel.configure_cached_current_state
51
+
52
+ model = MyActiveRecordModel.create
53
+
54
+ expect(model.cached_current_state).to eq("initial")
55
+ end
56
+
57
+ it "does not override an explicitly-set column value on create" do
58
+ configure
59
+ MyActiveRecordModel.configure_cached_current_state
60
+
61
+ model = MyActiveRecordModel.create(cached_current_state: "succeeded")
62
+
63
+ expect(model.reload.cached_current_state).to eq("succeeded")
64
+ end
65
+
66
+ it "updates the column on every transition" do
67
+ configure
68
+ MyActiveRecordModel.configure_cached_current_state
69
+
70
+ model = MyActiveRecordModel.create
71
+ model.state_machine.transition_to!(:succeeded)
72
+
73
+ expect(model.reload.cached_current_state).to eq("succeeded")
74
+ end
75
+
76
+ it "writes via update_columns, not updating updated_at by default" do
77
+ configure
78
+ MyActiveRecordModel.configure_cached_current_state
79
+
80
+ model = MyActiveRecordModel.create
81
+ original_updated_at = model.updated_at
82
+
83
+ Timecop.travel(1.hour.from_now) do
84
+ model.state_machine.transition_to!(:succeeded)
85
+ end
86
+
87
+ expect(model.reload.updated_at).to be_within(1.second).of(original_updated_at)
88
+ end
89
+
90
+ it "touches updated_at when touch_updated_at is true" do
91
+ configure
92
+ MyActiveRecordModel.configure_cached_current_state(touch_updated_at: true)
93
+
94
+ model = MyActiveRecordModel.create
95
+ original_updated_at = model.updated_at
96
+
97
+ Timecop.travel(1.hour.from_now) do
98
+ model.state_machine.transition_to!(:succeeded)
99
+ end
100
+
101
+ expect(model.reload.updated_at).to be > original_updated_at
102
+ end
103
+
104
+ it "respects a custom column" do
105
+ MyActiveRecordModel.connection.add_column(:my_active_record_models, :cached_state, :string)
106
+ MyActiveRecordModel.reset_column_information
107
+
108
+ configure
109
+ MyActiveRecordModel.configure_cached_current_state(column: :cached_state)
110
+
111
+ model = MyActiveRecordModel.create
112
+ model.state_machine.transition_to!(:succeeded)
113
+
114
+ expect(model.reload.cached_state).to eq("succeeded")
115
+ end
116
+
117
+ it "exposes the configured column name via cached_state_column_name" do
118
+ configure
119
+ MyActiveRecordModel.configure_cached_current_state(column: :cached_state)
120
+
121
+ expect(MyActiveRecordModel.cached_state_column_name).to eq(:cached_state)
122
+ end
123
+
124
+ it "raises at transition time if the configured column doesn't exist" do
125
+ configure
126
+ MyActiveRecordModel.configure_cached_current_state(column: :not_a_real_column)
127
+
128
+ model = MyActiveRecordModel.create
129
+
130
+ expect { model.state_machine.transition_to!(:succeeded) }.
131
+ to raise_error(ArgumentError, /not_a_real_column/)
132
+ end
133
+
134
+ it "does not update the cache for a transition row created directly, bypassing the machine" do
135
+ configure
136
+ MyActiveRecordModel.configure_cached_current_state
137
+
138
+ model = MyActiveRecordModel.create
139
+ model.my_active_record_model_transitions.create!(to_state: "succeeded", sort_key: 1, most_recent: true)
140
+
141
+ expect(model.reload.cached_current_state).to eq("initial")
142
+ end
143
+
144
+ it "runs before the machine's own after_transition callbacks" do
145
+ seen_cached_state = nil
146
+ MyStateMachine.after_transition { |parent, _t| seen_cached_state = parent.cached_current_state }
147
+
148
+ configure
149
+ MyActiveRecordModel.configure_cached_current_state
150
+
151
+ model = MyActiveRecordModel.create
152
+ model.state_machine.transition_to!(:succeeded)
153
+
154
+ expect(seen_cached_state).to eq("succeeded")
155
+ ensure
156
+ MyStateMachine.class_eval { callbacks[:after] = [] }
157
+ end
158
+
159
+ context "with a model driven by more than one machine class (no single state_machine_class)" do
160
+ it "still updates the cache" do
161
+ configure
162
+ MyActiveRecordModel.configure_cached_current_state
163
+
164
+ model = MyActiveRecordModel.create
165
+ # Simulate a different machine class than `state_machine` normally uses - the
166
+ # cache write happens in the shared adapter code, not per Machine subclass, so
167
+ # it doesn't matter which machine class drove the transition.
168
+ other_machine = MyStateMachine.new(model, transition_class: MyActiveRecordModelTransition)
169
+ other_machine.transition_to!(:failed)
170
+
171
+ expect(model.reload.cached_current_state).to eq("failed")
172
+ end
173
+ end
174
+ end
175
+ end
@@ -203,4 +203,16 @@ describe Statesman::Adapters::TypeSafeActiveRecordQueries, :active_record do
203
203
  context "using configuration method" do
204
204
  it_behaves_like "testing methods"
205
205
  end
206
+
207
+ context "transition_class" do
208
+ it "is exposed as a class method" do
209
+ MyActiveRecordModel.send(:extend, described_class)
210
+ MyActiveRecordModel.configure_state_machine(
211
+ transition_class: MyActiveRecordModelTransition,
212
+ initial_state: :initial,
213
+ )
214
+
215
+ expect(MyActiveRecordModel.transition_class).to eq(MyActiveRecordModelTransition)
216
+ end
217
+ end
206
218
  end
@@ -61,6 +61,7 @@ class CreateMyActiveRecordModelMigration < MIGRATION_CLASS
61
61
  def change
62
62
  create_table :my_active_record_models do |t|
63
63
  t.string :current_state
64
+ t.string :cached_current_state
64
65
  t.timestamps null: false
65
66
  end
66
67
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statesman
3
3
  version: !ruby/object:Gem::Version
4
- version: 13.2.0
4
+ version: 13.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless
@@ -43,6 +43,7 @@ files:
43
43
  - lib/statesman/adapters/active_record.rb
44
44
  - lib/statesman/adapters/active_record_queries.rb
45
45
  - lib/statesman/adapters/active_record_transition.rb
46
+ - lib/statesman/adapters/configure_cached_current_state.rb
46
47
  - lib/statesman/adapters/memory.rb
47
48
  - lib/statesman/adapters/memory_transition.rb
48
49
  - lib/statesman/adapters/type_safe_active_record_queries.rb
@@ -64,6 +65,7 @@ files:
64
65
  - spec/statesman/adapters/active_record_queries_spec.rb
65
66
  - spec/statesman/adapters/active_record_spec.rb
66
67
  - spec/statesman/adapters/active_record_transition_spec.rb
68
+ - spec/statesman/adapters/configure_cached_current_state_spec.rb
67
69
  - spec/statesman/adapters/memory_spec.rb
68
70
  - spec/statesman/adapters/memory_transition_spec.rb
69
71
  - spec/statesman/adapters/shared_examples.rb