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.
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statesman
4
+ # Bulk, race-safe state transitions for ActiveRecord (PostgreSQL, MySQL, SQLite).
5
+ #
6
+ # Statesman's Machine#transition_to! handles one parent at a time: a flip UPDATE on
7
+ # the current `most_recent` transition, an INSERT for the new one, and (if the model
8
+ # called configure_cached_current_state) an UPDATE on the parent's cached column -
9
+ # each inside its own transaction, with guards/callbacks firing per record.
10
+ #
11
+ # BulkTransition performs the same mechanical write for many parents in a single
12
+ # transaction: one `UPDATE ... RETURNING` to flip+lock the old rows (on adapters without
13
+ # RETURNING: a `SELECT ... FOR UPDATE` then an `UPDATE`), one `insert_all!` for the new
14
+ # ones, and one bulk cached_current_state UPDATE if configured. This trades
15
+ # per-record guards/callbacks/AR-validations for an order-of-magnitude fewer round
16
+ # trips on large batches.
17
+ #
18
+ # Deliberately NOT run: Machine guards, before/after/after_commit callbacks, and AR
19
+ # validations/callbacks on the transition model. Callers that need equivalent side
20
+ # effects must reproduce them, set-based, in the block passed to #call!.
21
+ #
22
+ # Like the single-transition path, "not current" is written as NULL when the
23
+ # `most_recent` column is nullable (databases without partial indexes, e.g. MySQL, need
24
+ # this for the unique (parent, most_recent) index) and false otherwise. On MySQL the
25
+ # new rows are inserted "not current" and then marked current in a separate UPDATE, to
26
+ # avoid the next-key locks an INSERT of `most_recent = true` takes (see
27
+ # Statesman::Adapters::ActiveRecord#create_transition).
28
+ #
29
+ # Assumption (documented, not enforced): the transition table is not an STI table.
30
+ #
31
+ # A later version may add guard/callback execution (falling back to a plain per-item
32
+ # #transition_to! loop where needed) - this is the proven,
33
+ # independent mechanical core that such a version would sit on top of.
34
+ class BulkTransition
35
+ class ValidationError < StandardError; end
36
+
37
+ SORT_KEY_INCREMENT = 10
38
+
39
+ def initialize(model_class:, machine_class:, from:, to:)
40
+ @model_class = model_class
41
+ @from = from.to_s
42
+ @to = to.to_s
43
+ @transition_class = model_class.transition_class
44
+
45
+ machine_class.validate_from_and_to_state(@from, @to)
46
+ end
47
+
48
+ # @param parent_ids [Array<String, Integer>] primary keys of the parents to
49
+ # transition. Parents not currently in `from` state are silently skipped - this
50
+ # is what makes the batch idempotent and safe to re-run.
51
+ # @param metadata [Hash] applied uniformly to every new transition row in the batch,
52
+ # unless overridden per-parent by metadata_per_id.
53
+ # @param attributes_to_copy [Array<String, Symbol>] extra transition-row columns to
54
+ # carry forward from the flipped row onto the new one, beyond the parent FK and
55
+ # sort_key, which are always carried. No default: the caller must explicitly audit
56
+ # which mutable columns their own after_transition callbacks depend on.
57
+ # @param metadata_per_id [Hash{String, Integer => Hash}, nil] per-parent metadata
58
+ # overrides, keyed by parent id (matched by its string form, so 1 and "1" are the
59
+ # same parent), merged onto (and taking precedence over) the uniform `metadata` for
60
+ # that parent only. Parents absent from this hash just get the uniform `metadata`.
61
+ # @param attributes_per_id [Hash{String, Integer => Hash}, nil] per-parent overrides,
62
+ # keyed like metadata_per_id, for attributes_to_copy columns only (anything else
63
+ # raises ValidationError), merged onto (and taking precedence over) the value
64
+ # carried forward from that parent's own flipped row - for a value that can't be
65
+ # derived from the previous transition (e.g. a value looked up from an
66
+ # association the caller already has loaded).
67
+ # @param block [Proc, nil] optional, called with an ActiveRecord::Relation over the
68
+ # newly inserted transitions, inside the same transaction, after rows are flipped
69
+ # and inserted (and after the cached_current_state bulk update, if any). The
70
+ # relation is lazy: the block decides what to read (e.g. `pluck`) or write. An
71
+ # exception here rolls back the entire batch - nothing is committed.
72
+ # @return [Array] parent ids that were actually transitioned.
73
+ def call!(
74
+ parent_ids, metadata: {}, attributes_to_copy: [],
75
+ metadata_per_id: nil, attributes_per_id: nil, &block
76
+ )
77
+ return [] if parent_ids.empty?
78
+
79
+ attributes_to_copy = attributes_to_copy.to_set(&:to_s)
80
+ validate_attribute_names!(attributes_to_copy)
81
+ metadata_per_id = normalize_per_id(metadata_per_id)
82
+ attributes_per_id = normalize_per_id(attributes_per_id)
83
+ validate_attributes_per_id!(attributes_per_id, attributes_to_copy)
84
+
85
+ now = Time.current
86
+ transitioned_ids = []
87
+
88
+ ActiveRecord::Base.transaction(requires_new: true) do
89
+ flipped_rows = flip_most_recent!(parent_ids, now, attributes_to_copy)
90
+ next if flipped_rows.empty?
91
+
92
+ primary_keys, transitioned_ids = insert_new_transitions!(
93
+ flipped_rows, metadata, attributes_to_copy, now, metadata_per_id, attributes_per_id
94
+ ).transpose
95
+ inserted = transition_class.where(transition_class.primary_key => primary_keys)
96
+
97
+ update_cached_current_state!(transitioned_ids)
98
+
99
+ block&.call(inserted)
100
+ end
101
+
102
+ transitioned_ids
103
+ end
104
+
105
+ private
106
+
107
+ attr_reader :model_class, :from, :to, :transition_class
108
+
109
+ def mysql_gaplock_protection?
110
+ Statesman.mysql_gaplock_protection?(transition_class.connection)
111
+ end
112
+
113
+ # Matches the single-transition path: NULL when the column is nullable (needed for a
114
+ # unique (parent, most_recent) index on databases without partial indexes), else false.
115
+ def not_most_recent_value
116
+ transition_class.columns_hash["most_recent"].null == false ? false : nil
117
+ end
118
+
119
+ # PostgreSQL can flip and return rows in one `UPDATE ... RETURNING` statement and
120
+ # recover inserted keys from `INSERT ... RETURNING`. Other adapters (MySQL, SQLite)
121
+ # take a portable path built from `SELECT ... FOR UPDATE` + `UPDATE` by primary key.
122
+ def returning_supported?
123
+ transition_class.connection.adapter_name.casecmp("postgresql").zero?
124
+ end
125
+
126
+ def validate_attribute_names!(attributes_to_copy)
127
+ unknown = attributes_to_copy - transition_class.column_names
128
+ return if unknown.empty?
129
+
130
+ raise ValidationError, "Unknown #{transition_class.name} column(s): #{unknown.join(', ')}"
131
+ end
132
+
133
+ # Overrides are matched against ids read back from the database, whose type (Integer,
134
+ # String UUID) the caller needn't know - so both sides are compared as strings. Inner
135
+ # keys become strings too, because the rows being inserted are string-keyed.
136
+ def normalize_per_id(per_id)
137
+ per_id&.to_h { |id, overrides| [id.to_s, overrides.transform_keys(&:to_s)] }
138
+ end
139
+
140
+ # Every row in one insert_all! must have the same keys, and an override for a column
141
+ # that isn't carried forward would add a key to only some rows.
142
+ def validate_attributes_per_id!(attributes_per_id, attributes_to_copy)
143
+ return if attributes_per_id.nil?
144
+
145
+ unknown = attributes_per_id.values.flat_map(&:keys).to_set - attributes_to_copy
146
+ return if unknown.empty?
147
+
148
+ raise ValidationError, "attributes_per_id column(s) not in attributes_to_copy: #{unknown.to_a.join(', ')}"
149
+ end
150
+
151
+ def parent_reflection
152
+ @parent_reflection ||= Statesman::Adapters::ActiveRecordQueries.
153
+ transition_reflection_for(model_class, transition_class)
154
+ end
155
+
156
+ def parent_foreign_key
157
+ @parent_foreign_key ||= parent_reflection.foreign_key
158
+ end
159
+
160
+ def parent_primary_key
161
+ @parent_primary_key ||= parent_reflection.active_record_primary_key
162
+ end
163
+
164
+ # Flips the `from`-state transitions to not-most-recent, returning the flipped
165
+ # rows (including their pre-flip sort_key) via RETURNING. Under Postgres Read
166
+ # Committed, a concurrent writer that commits a transition on one of these parents
167
+ # between this statement starting and acquiring its row lock causes the WHERE
168
+ # clause to re-evaluate against the latest committed row on wake - so that parent
169
+ # is silently excluded here, never corrupted.
170
+ def flip_most_recent!(parent_ids, now, attributes_to_copy)
171
+ if returning_supported?
172
+ flip_with_returning!(parent_ids, now, attributes_to_copy)
173
+ else
174
+ flip_with_lock!(parent_ids, now, attributes_to_copy)
175
+ end
176
+ end
177
+
178
+ def flip_with_returning!(parent_ids, now, attributes_to_copy)
179
+ # Not `RETURNING *`: only the carried columns are used afterwards, and a wider
180
+ # result set is costly on large batches.
181
+ returning = carried_columns(attributes_to_copy).
182
+ map { |column| transition_class.connection.quote_column_name(column) }.
183
+ join(", ")
184
+
185
+ # Arel has no RETURNING support, so that one clause is appended to the compiled
186
+ # statement; everything else (quoting, binds, IN list) is built by Arel.
187
+ sql = "#{flip_update_manager(parent_ids, now).to_sql} RETURNING #{returning}"
188
+
189
+ transition_class.connection.exec_query(sql).to_a
190
+ end
191
+
192
+ def flip_update_manager(parent_ids, now)
193
+ table = transition_class.arel_table
194
+
195
+ Arel::UpdateManager.new.tap do |update|
196
+ update.table(table)
197
+ update.set([[table[:most_recent], not_most_recent_value], [table[:updated_at], now]])
198
+ update.where(
199
+ table[:to_state].eq(from).
200
+ and(table[:most_recent].eq(true)).
201
+ and(table[parent_foreign_key].in(parent_ids)),
202
+ )
203
+ end
204
+ end
205
+
206
+ # Portable equivalent of the above: lock the matching rows, then flip them by primary
207
+ # key. The locking read always sees the latest committed row (MySQL REPEATABLE READ
208
+ # included), so a parent that a concurrent writer already moved on is excluded. SQLite
209
+ # has no row locks, but serialises writers at the database level.
210
+ def flip_with_lock!(parent_ids, now, attributes_to_copy)
211
+ columns = carried_columns(attributes_to_copy)
212
+ primary_key = transition_class.primary_key
213
+
214
+ locked = transition_class.
215
+ where(to_state: from, most_recent: true, parent_foreign_key => parent_ids).
216
+ lock.
217
+ pluck(primary_key, *columns)
218
+ return [] if locked.empty?
219
+
220
+ transition_class.where(primary_key => locked.map(&:first)).
221
+ update_all(most_recent: not_most_recent_value, updated_at: now)
222
+
223
+ locked.map { |_pk, *values| columns.zip(values).to_h }
224
+ end
225
+
226
+ def carried_columns(attributes_to_copy)
227
+ Set[parent_foreign_key, "sort_key"] | attributes_to_copy
228
+ end
229
+
230
+ def insert_new_transitions!(
231
+ flipped_rows, metadata, attributes_to_copy, now, metadata_per_id, attributes_per_id
232
+ )
233
+ carried = carried_columns(attributes_to_copy)
234
+ include_from_state = transition_class.has_attribute?(:from_state)
235
+ most_recent = mysql_gaplock_protection? ? not_most_recent_value : true
236
+
237
+ rows = flipped_rows.map do |row|
238
+ id = row[parent_foreign_key]
239
+ row_metadata = merge_per_id(metadata, metadata_per_id, id)
240
+ row_overrides = merge_per_id({}, attributes_per_id, id)
241
+
242
+ new_row = row.slice(*carried).merge(row_overrides).merge(
243
+ "to_state" => to,
244
+ "metadata" => row_metadata,
245
+ "most_recent" => most_recent,
246
+ "sort_key" => row["sort_key"].to_i + SORT_KEY_INCREMENT,
247
+ "created_at" => now,
248
+ "updated_at" => now,
249
+ )
250
+ new_row["from_state"] = from if include_from_state
251
+ new_row
252
+ end
253
+
254
+ insert_rows!(rows)
255
+ rescue ActiveRecord::RecordNotUnique => e
256
+ # One conflicting row aborts the whole batch - this is an accepted trade-off,
257
+ # not a silent gap: every real writer (Statesman's own single-transition path, and
258
+ # this class) follows the flip-then-insert convention, so a collision here means
259
+ # something outside that convention wrote a most_recent row directly.
260
+ raise TransitionConflictError, e.message
261
+ end
262
+
263
+ # Inserts the rows and returns [primary key, parent foreign key] pairs for them, so the
264
+ # caller can both build a relation and know which parents moved without another query.
265
+ # Under MySQL gap-lock protection the rows are also marked most recent here, so
266
+ # whatever reads them afterwards sees the final state.
267
+ def insert_rows!(rows)
268
+ inserted = insert_and_fetch_keys!(rows)
269
+ mark_most_recent!(inserted.map(&:first)) if mysql_gaplock_protection?
270
+ inserted
271
+ end
272
+
273
+ def insert_and_fetch_keys!(rows)
274
+ if returning_supported?
275
+ return transition_class.insert_all!(rows, returning: [transition_class.primary_key, parent_foreign_key]).rows
276
+ end
277
+
278
+ transition_class.insert_all!(rows)
279
+ fetch_inserted_keys(rows)
280
+ end
281
+
282
+ # Finds the rows just inserted by (parent, sort_key): the unique index Statesman
283
+ # requires on those columns makes that pair unambiguous, whatever state the rows are in.
284
+ def fetch_inserted_keys(inserted)
285
+ sort_keys = inserted.map { |row| row["sort_key"] }.uniq
286
+ expected = inserted.to_set { |row| [row[parent_foreign_key], row["sort_key"]] }
287
+
288
+ transition_class.
289
+ where(parent_foreign_key => inserted.map { |row| row[parent_foreign_key] }, sort_key: sort_keys).
290
+ pluck(transition_class.primary_key, parent_foreign_key, "sort_key").
291
+ select { |_pk, fk, sort_key| expected.include?([fk, sort_key]) }.
292
+ map { |pk, fk, _sort_key| [pk, fk] }
293
+ end
294
+
295
+ # Second half of the MySQL gap-lock protection: the old rows were already flipped, so
296
+ # this only ever sets one row per parent to true and cannot trip the unique index.
297
+ def mark_most_recent!(primary_keys)
298
+ transition_class.where(transition_class.primary_key => primary_keys).
299
+ update_all(most_recent: true)
300
+ end
301
+
302
+ # Merges a per-parent override hash (see normalize_per_id) onto a base hash, for one
303
+ # parent only. The override, where present, wins key-by-key.
304
+ def merge_per_id(base, per_id, id)
305
+ return base if per_id.nil? || !per_id.key?(id.to_s)
306
+
307
+ base.merge(per_id.fetch(id.to_s))
308
+ end
309
+
310
+ def update_cached_current_state!(transitioned_ids)
311
+ return if transitioned_ids.empty?
312
+ return unless model_class.respond_to?(:cached_state_column_name)
313
+
314
+ column = model_class.cached_state_column_name
315
+ attrs = { column => to }
316
+ attrs[:updated_at] = Time.current if model_class.cached_current_state_touch_updated_at?
317
+
318
+ model_class.where(parent_primary_key => transitioned_ids).update_all(attrs)
319
+ end
320
+ end
321
+ 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.4.0"
5
5
  end
data/lib/statesman.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Statesman
4
4
  autoload :Config, "statesman/config"
5
5
  autoload :Machine, "statesman/machine"
6
+ autoload :BulkTransition, "statesman/bulk_transition"
6
7
  autoload :Callback, "statesman/callback"
7
8
  autoload :Guard, "statesman/guard"
8
9
  autoload :Utils, "statesman/utils"
@@ -16,6 +17,8 @@ module Statesman
16
17
  "statesman/adapters/active_record_queries"
17
18
  autoload :TypeSafeActiveRecordQueries,
18
19
  "statesman/adapters/type_safe_active_record_queries"
20
+ autoload :ConfigureCachedCurrentState,
21
+ "statesman/adapters/configure_cached_current_state"
19
22
  end
20
23
  require "statesman/railtie" if defined?(::Rails::Railtie)
21
24
 
data/spec/spec_helper.rb CHANGED
@@ -32,11 +32,18 @@ RSpec.configure do |config|
32
32
  else
33
33
  current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
34
34
 
35
+ database_url = ENV.fetch("DATABASE_URL") do
36
+ if (db = ENV["DB"].presence)
37
+ require "support/database_container"
38
+ DatabaseContainer.start(db, image: ENV["DB_IMAGE"].presence)
39
+ end
40
+ end
41
+
35
42
  # We have to parse this to a hash since ActiveRecord::Base.configurations
36
43
  # will only consider a single URL config.
37
- url_config = if ENV["DATABASE_URL"]
44
+ url_config = if database_url
38
45
  ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.
39
- new(ENV["DATABASE_URL"]).to_hash.merge({ sslmode: "disable" })
46
+ new(database_url).to_hash.merge({ sslmode: "disable" })
40
47
  end
41
48
 
42
49
  db_config = {
@@ -15,9 +15,7 @@ describe Statesman::Adapters::ActiveRecord, :active_record do
15
15
  Statesman.configure do
16
16
  # Rubocop requires described_class to be used, but this block
17
17
  # is instance_eval'd and described_class won't be defined
18
- # rubocop:disable RSpec/DescribedClass
19
- storage_adapter(Statesman::Adapters::ActiveRecord)
20
- # rubocop:enable RSpec/DescribedClass
18
+ storage_adapter(Statesman::Adapters::ActiveRecord) # rubocop:disable RSpec/DescribedClass
21
19
  end
22
20
  end
23
21
 
@@ -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