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,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe Statesman::BulkTransition, :active_record do
6
+ before do
7
+ # The shared MyActiveRecordModel fixture is wired via the older
8
+ # `include ActiveRecordQueries[...]` form, which doesn't define `.transition_class`.
9
+ # BulkTransition relies on the newer `configure_state_machine` convention (the same
10
+ # one `configure_cached_current_state` requires), so configure it here once.
11
+ unless MyActiveRecordModel.respond_to?(:transition_class)
12
+ MyActiveRecordModel.extend(Statesman::Adapters::TypeSafeActiveRecordQueries)
13
+ MyActiveRecordModel.configure_state_machine(
14
+ transition_class: MyActiveRecordModelTransition, initial_state: :initial,
15
+ )
16
+ end
17
+
18
+ unless MyActiveRecordModel.respond_to?(:cached_state_column_name)
19
+ MyActiveRecordModel.include(Statesman::Adapters::ConfigureCachedCurrentState)
20
+ MyActiveRecordModel.configure_cached_current_state
21
+ end
22
+
23
+ prepare_model_table
24
+ prepare_transitions_table
25
+
26
+ # Other specs redefine MyActiveRecordModel.transition_class without restoring it, so
27
+ # pin it here to stay independent of spec ordering.
28
+ allow(MyActiveRecordModel).to receive(:transition_class).
29
+ and_return(MyActiveRecordModelTransition)
30
+
31
+ # Add a free business column (not one of AR's "magic" created_on/updated_on/etc.
32
+ # timestamp columns, which insert_all auto-populates regardless of row content) so
33
+ # attributes_to_copy has something meaningful to carry forward in this spec.
34
+ ActiveRecord::Base.connection.execute(
35
+ "ALTER TABLE my_active_record_model_transitions ADD COLUMN extra_data text",
36
+ )
37
+ MyActiveRecordModelTransition.reset_column_information
38
+ end
39
+
40
+ let(:machine_class) { MyStateMachine }
41
+
42
+ # How a non-current row's most_recent is stored: NULL when the column is nullable
43
+ # (needed for the unique index on databases without partial indexes, e.g. MySQL),
44
+ # false otherwise - same as the single-transition path.
45
+ def not_most_recent_value
46
+ MyActiveRecordModelTransition.columns_hash["most_recent"].null ? nil : false
47
+ end
48
+
49
+ def create_model(initial_state: "initial", extra_data: nil)
50
+ model = MyActiveRecordModel.create!
51
+ model.my_active_record_model_transitions.create!(
52
+ to_state: initial_state, sort_key: 10, most_recent: true, metadata: {},
53
+ extra_data: extra_data
54
+ )
55
+ model
56
+ end
57
+
58
+ def last_transition_for(id)
59
+ MyActiveRecordModel.find(id).my_active_record_model_transitions.order(:sort_key).last
60
+ end
61
+
62
+ describe "#call!" do
63
+ it "transitions only the parents currently in the from state" do
64
+ in_initial = Array.new(3) { create_model.id }
65
+ in_succeeded = create_model(initial_state: "succeeded").id
66
+
67
+ bulk = described_class.new(
68
+ model_class: MyActiveRecordModel, machine_class: machine_class,
69
+ from: :initial, to: :succeeded
70
+ )
71
+ transitioned = bulk.call!(in_initial + [in_succeeded], metadata: { "foo" => "bar" })
72
+
73
+ expect(transitioned).to match_array(in_initial)
74
+ end
75
+
76
+ it "raises TransitionConflictError and rolls the whole batch back on a conflicting row" do
77
+ ok = create_model.id
78
+ conflicted = create_model.id
79
+ # An out-of-band row occupying the sort_key the new transition would take.
80
+ MyActiveRecordModelTransition.create!(
81
+ my_active_record_model_id: conflicted, to_state: "failed", sort_key: 20,
82
+ most_recent: not_most_recent_value, metadata: {}
83
+ )
84
+ bulk = described_class.new(
85
+ model_class: MyActiveRecordModel, machine_class: machine_class,
86
+ from: :initial, to: :succeeded
87
+ )
88
+
89
+ expect { bulk.call!([ok, conflicted]) }.to raise_error(Statesman::TransitionConflictError)
90
+
91
+ expect(MyActiveRecordModelTransition.where(my_active_record_model_id: [ok, conflicted], most_recent: true).
92
+ pluck(:to_state)).to eq(%w[initial initial])
93
+ end
94
+
95
+ it "marks flipped rows as not current the same way the single path does" do
96
+ ids = Array.new(2) { create_model.id }
97
+ bulk = described_class.new(
98
+ model_class: MyActiveRecordModel, machine_class: machine_class,
99
+ from: :initial, to: :succeeded
100
+ )
101
+ bulk.call!(ids)
102
+
103
+ old_rows = MyActiveRecordModelTransition.where(my_active_record_model_id: ids, to_state: "initial")
104
+
105
+ expect(old_rows.map(&:most_recent)).to all(eq(not_most_recent_value))
106
+ expect(MyActiveRecordModelTransition.where(my_active_record_model_id: ids, most_recent: true).count).
107
+ to eq(ids.size)
108
+ end
109
+
110
+ it "applies the metadata and most_recent flag to each transitioned parent" do
111
+ in_initial = Array.new(3) { create_model.id }
112
+ bulk = described_class.new(
113
+ model_class: MyActiveRecordModel, machine_class: machine_class,
114
+ from: :initial, to: :succeeded
115
+ )
116
+ bulk.call!(in_initial, metadata: { "foo" => "bar" })
117
+
118
+ MyActiveRecordModel.where(id: in_initial).each do |model|
119
+ last = model.my_active_record_model_transitions.order(:sort_key).last
120
+ expect(last.to_state).to eq("succeeded")
121
+ expect(last.most_recent).to be(true)
122
+ expect(last.metadata).to eq({ "foo" => "bar" })
123
+ end
124
+ end
125
+
126
+ it "leaves parents not currently in the from state untouched" do
127
+ in_succeeded = create_model(initial_state: "succeeded").id
128
+ bulk = described_class.new(
129
+ model_class: MyActiveRecordModel, machine_class: machine_class,
130
+ from: :initial, to: :succeeded
131
+ )
132
+ bulk.call!([in_succeeded], metadata: { "foo" => "bar" })
133
+
134
+ expect(last_transition_for(in_succeeded).sort_key).to eq(10)
135
+ end
136
+
137
+ it "is idempotent - re-running skips parents no longer in the from state" do
138
+ ids = Array.new(2) { create_model.id }
139
+ bulk = described_class.new(
140
+ model_class: MyActiveRecordModel, machine_class: machine_class,
141
+ from: :initial, to: :succeeded
142
+ )
143
+
144
+ first_run = bulk.call!(ids)
145
+ second_run = bulk.call!(ids)
146
+
147
+ expect(first_run).to match_array(ids)
148
+ expect(second_run).to eq([])
149
+ end
150
+
151
+ it "flips the old most_recent row off and sets sort_key = old + 10" do
152
+ id = create_model.id
153
+ bulk = described_class.new(
154
+ model_class: MyActiveRecordModel, machine_class: machine_class,
155
+ from: :initial, to: :succeeded
156
+ )
157
+ bulk.call!([id])
158
+
159
+ transitions = MyActiveRecordModel.find(id).
160
+ my_active_record_model_transitions.order(:sort_key)
161
+ expect(transitions.map(&:most_recent)).to eq([not_most_recent_value, true])
162
+ expect(transitions.map(&:sort_key)).to eq([10, 20])
163
+ end
164
+
165
+ it "carries forward only explicitly requested attributes_to_copy" do
166
+ id = create_model(extra_data: "keep-me").id
167
+ bulk = described_class.new(
168
+ model_class: MyActiveRecordModel, machine_class: machine_class,
169
+ from: :initial, to: :succeeded
170
+ )
171
+ bulk.call!([id], attributes_to_copy: ["extra_data"])
172
+
173
+ expect(last_transition_for(id).extra_data).to eq("keep-me")
174
+ end
175
+
176
+ it "does not carry forward attributes that weren't requested" do
177
+ id = create_model(extra_data: "should-not-persist").id
178
+ bulk = described_class.new(
179
+ model_class: MyActiveRecordModel, machine_class: machine_class,
180
+ from: :initial, to: :succeeded
181
+ )
182
+ bulk.call!([id]) # no attributes_to_copy
183
+
184
+ expect(last_transition_for(id).extra_data).to be_nil
185
+ end
186
+
187
+ describe "flip query RETURNING clause" do
188
+ def flip_sql_for(bulk, id, **options)
189
+ statements = []
190
+ subscriber = ->(*, payload) do
191
+ statements << payload[:sql] if /\AUPDATE\s+"?my_active_record_model_transitions/i.match?(payload[:sql])
192
+ end
193
+ ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
194
+ bulk.call!([id], **options)
195
+ end
196
+ statements.find { |sql| sql.include?(" RETURNING ") }
197
+ end
198
+
199
+ let(:bulk) do
200
+ described_class.new(
201
+ model_class: MyActiveRecordModel, machine_class: machine_class,
202
+ from: :initial, to: :succeeded
203
+ )
204
+ end
205
+
206
+ it "returns only the carried columns, not *" do
207
+ skip "RETURNING is PostgreSQL-only" unless postgres? # rubocop:disable RSpec/Pending
208
+ id = create_model(extra_data: "x").id
209
+ sql = flip_sql_for(bulk, id, attributes_to_copy: ["extra_data"])
210
+ returning = sql[/RETURNING (.*)\z/m, 1]
211
+
212
+ expect(sql).to_not include("RETURNING *")
213
+ expect(returning.split(", ")).to contain_exactly(
214
+ '"my_active_record_model_id"', '"sort_key"', '"extra_data"'
215
+ )
216
+ end
217
+
218
+ it "does not return columns that were not requested" do
219
+ skip "RETURNING is PostgreSQL-only" unless postgres? # rubocop:disable RSpec/Pending
220
+ id = create_model(extra_data: "x").id
221
+ sql = flip_sql_for(bulk, id, attributes_to_copy: [])
222
+
223
+ expect(sql[/RETURNING (.*)\z/m, 1]).to_not include("extra_data")
224
+ end
225
+ end
226
+
227
+ it "raises ValidationError for unknown attributes_to_copy columns" do
228
+ id = create_model.id
229
+ bulk = described_class.new(
230
+ model_class: MyActiveRecordModel, machine_class: machine_class,
231
+ from: :initial, to: :succeeded
232
+ )
233
+
234
+ expect do
235
+ bulk.call!([id], attributes_to_copy: ["not_a_real_column"])
236
+ end.to raise_error(Statesman::BulkTransition::ValidationError, /not_a_real_column/)
237
+ end
238
+
239
+ it "raises InvalidTransitionError for a from/to pair the machine doesn't allow" do
240
+ expect do
241
+ described_class.new(
242
+ model_class: MyActiveRecordModel, machine_class: machine_class,
243
+ from: :succeeded, to: :succeeded
244
+ )
245
+ end.to raise_error(Statesman::InvalidTransitionError)
246
+ end
247
+
248
+ it "runs the block inside the transaction and rolls back everything if it raises" do
249
+ id = create_model.id
250
+ bulk = described_class.new(
251
+ model_class: MyActiveRecordModel, machine_class: machine_class,
252
+ from: :initial, to: :succeeded
253
+ )
254
+
255
+ expect do
256
+ bulk.call!([id]) { |_rows| raise "boom" }
257
+ end.to raise_error("boom")
258
+
259
+ last = last_transition_for(id)
260
+ expect(last.to_state).to eq("initial")
261
+ expect(last.most_recent).to be(true)
262
+ end
263
+
264
+ it "matches per-parent overrides by the string form of the id" do
265
+ id = create_model(extra_data: "from-previous-row").id
266
+ bulk = described_class.new(
267
+ model_class: MyActiveRecordModel, machine_class: machine_class,
268
+ from: :initial, to: :succeeded
269
+ )
270
+
271
+ bulk.call!(
272
+ [id],
273
+ metadata_per_id: { id.to_s => { "origin" => "api" } },
274
+ attributes_to_copy: ["extra_data"],
275
+ attributes_per_id: { id.to_s => { extra_data: "looked-up-value" } },
276
+ )
277
+
278
+ expect(last_transition_for(id).metadata).to eq({ "origin" => "api" })
279
+ expect(last_transition_for(id).extra_data).to eq("looked-up-value")
280
+ end
281
+
282
+ it "raises ValidationError for attributes_per_id columns not in attributes_to_copy" do
283
+ id = create_model.id
284
+ bulk = described_class.new(
285
+ model_class: MyActiveRecordModel, machine_class: machine_class,
286
+ from: :initial, to: :succeeded
287
+ )
288
+
289
+ expect do
290
+ bulk.call!([id], attributes_per_id: { id => { "extra_data" => "x" } })
291
+ end.to raise_error(Statesman::BulkTransition::ValidationError, /extra_data/)
292
+ end
293
+
294
+ it "yields a relation over the inserted transitions to the block" do
295
+ id = create_model.id
296
+ bulk = described_class.new(
297
+ model_class: MyActiveRecordModel, machine_class: machine_class,
298
+ from: :initial, to: :succeeded
299
+ )
300
+
301
+ yielded = nil
302
+ bulk.call!([id]) { |transitions| yielded = transitions.pluck(:my_active_record_model_id, :to_state) }
303
+
304
+ expect(yielded).to eq([[id, "succeeded"]])
305
+ end
306
+
307
+ it "invokes a callback object's #call via normal method dispatch, not bare yield" do
308
+ id = create_model.id
309
+ bulk = described_class.new(
310
+ model_class: MyActiveRecordModel, machine_class: machine_class,
311
+ from: :initial, to: :succeeded
312
+ )
313
+ callback = ->(_rows) {}
314
+
315
+ expect(callback).to receive(:call).once.and_call_original
316
+
317
+ bulk.call!([id], &callback)
318
+ end
319
+
320
+ it "returns an empty array and does nothing for an empty id list" do
321
+ bulk = described_class.new(
322
+ model_class: MyActiveRecordModel, machine_class: machine_class,
323
+ from: :initial, to: :succeeded
324
+ )
325
+ expect(bulk.call!([])).to eq([])
326
+ end
327
+
328
+ it "overrides metadata per-parent via metadata_per_id, falling back to the uniform metadata" do
329
+ overridden_id = create_model.id
330
+ default_id = create_model.id
331
+ bulk = described_class.new(
332
+ model_class: MyActiveRecordModel, machine_class: machine_class,
333
+ from: :initial, to: :succeeded
334
+ )
335
+
336
+ bulk.call!(
337
+ [overridden_id, default_id],
338
+ metadata: { "origin" => "gocardless" },
339
+ metadata_per_id: { overridden_id => { "origin" => "api" } },
340
+ )
341
+
342
+ expect(last_transition_for(overridden_id).metadata).to eq({ "origin" => "api" })
343
+ expect(last_transition_for(default_id).metadata).to eq({ "origin" => "gocardless" })
344
+ end
345
+
346
+ it "overrides a copied attribute per-parent via attributes_per_id" do
347
+ overridden_id = create_model(extra_data: "from-previous-row").id
348
+ default_id = create_model(extra_data: "from-previous-row").id
349
+ bulk = described_class.new(
350
+ model_class: MyActiveRecordModel, machine_class: machine_class,
351
+ from: :initial, to: :succeeded
352
+ )
353
+
354
+ bulk.call!(
355
+ [overridden_id, default_id],
356
+ attributes_to_copy: ["extra_data"],
357
+ attributes_per_id: { overridden_id => { "extra_data" => "looked-up-value" } },
358
+ )
359
+
360
+ expect(last_transition_for(overridden_id).extra_data).to eq("looked-up-value")
361
+ expect(last_transition_for(default_id).extra_data).to eq("from-previous-row")
362
+ end
363
+
364
+ it "yields only the new transitions, with their final most_recent state" do
365
+ id = create_model.id
366
+ bulk = described_class.new(
367
+ model_class: MyActiveRecordModel, machine_class: machine_class,
368
+ from: :initial, to: :succeeded
369
+ )
370
+
371
+ yielded = nil
372
+ bulk.call!([id]) { |transitions| yielded = transitions.pluck(:to_state, :most_recent) }
373
+
374
+ expect(yielded).to eq([["succeeded", true]])
375
+ end
376
+ end
377
+
378
+ describe "cached_current_state integration" do
379
+ it "bulk-updates the cached column for every transitioned parent" do
380
+ ids = Array.new(2) { create_model.id }
381
+ bulk = described_class.new(
382
+ model_class: MyActiveRecordModel, machine_class: machine_class,
383
+ from: :initial, to: :succeeded
384
+ )
385
+ bulk.call!(ids)
386
+
387
+ MyActiveRecordModel.where(id: ids).each do |model|
388
+ expect(model.cached_current_state).to eq("succeeded")
389
+ end
390
+ end
391
+ end
392
+ 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
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "testcontainers/mysql"
4
+ require "testcontainers/postgres"
5
+
6
+ # Starts a throwaway database in Docker so the suite can run against PostgreSQL or
7
+ # MySQL locally without a pre-provisioned server.
8
+ module DatabaseContainer
9
+ DEFAULT_IMAGES = {
10
+ "postgres" => "postgres:17",
11
+ "mysql" => "mysql:8.4",
12
+ }.freeze
13
+
14
+ def self.start(db, image: nil)
15
+ image ||= DEFAULT_IMAGES.fetch(db) do
16
+ raise ArgumentError, "Unsupported DB '#{db}', expected one of: " \
17
+ "#{DEFAULT_IMAGES.keys.join(', ')}"
18
+ end
19
+
20
+ container = build(db, image)
21
+ container.start
22
+ at_exit { container.remove(force: true) }
23
+
24
+ database_url(container)
25
+ end
26
+
27
+ def self.build(db, image)
28
+ case db
29
+ when "postgres" then Testcontainers::PostgresContainer.new(image)
30
+ when "mysql" then Testcontainers::MysqlContainer.new(image)
31
+ end
32
+ end
33
+
34
+ def self.database_url(container)
35
+ if container.is_a?(Testcontainers::MysqlContainer)
36
+ container.database_url(protocol: "mysql2")
37
+ else
38
+ container.database_url
39
+ end
40
+ end
41
+ 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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless
@@ -25,6 +25,7 @@ files:
25
25
  - ".rubocop.yml"
26
26
  - ".rubocop_todo.yml"
27
27
  - ".ruby-version"
28
+ - ".tool-versions"
28
29
  - CHANGELOG.md
29
30
  - CONTRIBUTING.md
30
31
  - Gemfile
@@ -43,9 +44,11 @@ files:
43
44
  - lib/statesman/adapters/active_record.rb
44
45
  - lib/statesman/adapters/active_record_queries.rb
45
46
  - lib/statesman/adapters/active_record_transition.rb
47
+ - lib/statesman/adapters/configure_cached_current_state.rb
46
48
  - lib/statesman/adapters/memory.rb
47
49
  - lib/statesman/adapters/memory_transition.rb
48
50
  - lib/statesman/adapters/type_safe_active_record_queries.rb
51
+ - lib/statesman/bulk_transition.rb
49
52
  - lib/statesman/callback.rb
50
53
  - lib/statesman/config.rb
51
54
  - lib/statesman/exceptions.rb
@@ -64,10 +67,12 @@ files:
64
67
  - spec/statesman/adapters/active_record_queries_spec.rb
65
68
  - spec/statesman/adapters/active_record_spec.rb
66
69
  - spec/statesman/adapters/active_record_transition_spec.rb
70
+ - spec/statesman/adapters/configure_cached_current_state_spec.rb
67
71
  - spec/statesman/adapters/memory_spec.rb
68
72
  - spec/statesman/adapters/memory_transition_spec.rb
69
73
  - spec/statesman/adapters/shared_examples.rb
70
74
  - spec/statesman/adapters/type_safe_active_record_queries_spec.rb
75
+ - spec/statesman/bulk_transition_spec.rb
71
76
  - spec/statesman/callback_spec.rb
72
77
  - spec/statesman/config_spec.rb
73
78
  - spec/statesman/exceptions_spec.rb
@@ -75,6 +80,7 @@ files:
75
80
  - spec/statesman/machine_spec.rb
76
81
  - spec/statesman/utils_spec.rb
77
82
  - spec/support/active_record.rb
83
+ - spec/support/database_container.rb
78
84
  - spec/support/exactly_query_databases.rb
79
85
  - spec/support/generators_shared_examples.rb
80
86
  - statesman.gemspec