statesman 7.4.0 → 12.1.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +7 -0
  3. data/.github/workflows/tests.yml +112 -0
  4. data/.gitignore +65 -15
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +14 -1
  7. data/.rubocop_todo.yml +37 -28
  8. data/.ruby-version +1 -0
  9. data/CHANGELOG.md +262 -41
  10. data/CONTRIBUTING.md +23 -4
  11. data/Gemfile +4 -6
  12. data/README.md +243 -43
  13. data/docs/COMPATIBILITY.md +2 -2
  14. data/lib/generators/statesman/active_record_transition_generator.rb +1 -1
  15. data/lib/generators/statesman/generator_helpers.rb +12 -4
  16. data/lib/statesman/adapters/active_record.rb +84 -55
  17. data/lib/statesman/adapters/active_record_queries.rb +19 -7
  18. data/lib/statesman/adapters/active_record_transition.rb +5 -1
  19. data/lib/statesman/adapters/memory.rb +5 -1
  20. data/lib/statesman/adapters/type_safe_active_record_queries.rb +21 -0
  21. data/lib/statesman/callback.rb +2 -2
  22. data/lib/statesman/config.rb +3 -10
  23. data/lib/statesman/exceptions.rb +13 -7
  24. data/lib/statesman/guard.rb +1 -1
  25. data/lib/statesman/machine.rb +68 -0
  26. data/lib/statesman/version.rb +1 -1
  27. data/lib/statesman.rb +5 -5
  28. data/lib/tasks/statesman.rake +5 -5
  29. data/spec/generators/statesman/active_record_transition_generator_spec.rb +7 -1
  30. data/spec/generators/statesman/migration_generator_spec.rb +5 -1
  31. data/spec/spec_helper.rb +44 -7
  32. data/spec/statesman/adapters/active_record_queries_spec.rb +34 -12
  33. data/spec/statesman/adapters/active_record_spec.rb +176 -51
  34. data/spec/statesman/adapters/active_record_transition_spec.rb +5 -2
  35. data/spec/statesman/adapters/memory_spec.rb +0 -1
  36. data/spec/statesman/adapters/memory_transition_spec.rb +0 -1
  37. data/spec/statesman/adapters/shared_examples.rb +3 -4
  38. data/spec/statesman/adapters/type_safe_active_record_queries_spec.rb +206 -0
  39. data/spec/statesman/callback_spec.rb +0 -2
  40. data/spec/statesman/config_spec.rb +0 -2
  41. data/spec/statesman/exceptions_spec.rb +17 -4
  42. data/spec/statesman/guard_spec.rb +0 -2
  43. data/spec/statesman/machine_spec.rb +252 -15
  44. data/spec/statesman/utils_spec.rb +0 -2
  45. data/spec/support/active_record.rb +156 -24
  46. data/spec/support/exactly_query_databases.rb +35 -0
  47. data/statesman.gemspec +9 -10
  48. metadata +32 -59
  49. data/.circleci/config.yml +0 -187
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :statesman do
4
- desc "Set most_recent to false for old transitions and to true for the "\
4
+ desc "Set most_recent to false for old transitions and to true for the " \
5
5
  "latest one. Safe to re-run"
6
6
  task :backfill_most_recent, [:parent_model_name] => :environment do |_, args|
7
7
  parent_model_name = args.parent_model_name
@@ -21,8 +21,8 @@ namespace :statesman do
21
21
  batch_size = 500
22
22
 
23
23
  parent_class.find_in_batches(batch_size: batch_size) do |models|
24
- ActiveRecord::Base.transaction(requires_new: true) do
25
- if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
24
+ transition_class.transaction(requires_new: true) do
25
+ if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?(transition_class)
26
26
  # Set all transitions' most_recent to FALSE
27
27
  transition_class.where(parent_fk => models.map(&:id)).
28
28
  update_all(most_recent: false, updated_at: updated_at)
@@ -56,8 +56,8 @@ namespace :statesman do
56
56
  end
57
57
 
58
58
  done_models += batch_size
59
- puts "Updated #{transition_class.name.pluralize} for "\
60
- "#{[done_models, total_models].min}/#{total_models} "\
59
+ puts "Updated #{transition_class.name.pluralize} for " \
60
+ "#{[done_models, total_models].min}/#{total_models} " \
61
61
  "#{parent_model_name.pluralize}"
62
62
  end
63
63
  end
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "support/generators_shared_examples"
5
4
  require "generators/statesman/active_record_transition_generator"
6
5
 
7
6
  describe Statesman::ActiveRecordTransitionGenerator, type: :generator do
7
+ before do
8
+ stub_const("Bacon", Class.new(ActiveRecord::Base))
9
+ stub_const("BaconTransition", Class.new(ActiveRecord::Base))
10
+ stub_const("Yummy::Bacon", Class.new(ActiveRecord::Base))
11
+ stub_const("Yummy::BaconTransition", Class.new(ActiveRecord::Base))
12
+ end
13
+
8
14
  it_behaves_like "a generator" do
9
15
  let(:migration_name) { "db/migrate/create_bacon_transitions.rb" }
10
16
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "support/generators_shared_examples"
5
4
  require "generators/statesman/migration_generator"
6
5
 
7
6
  describe Statesman::MigrationGenerator, type: :generator do
7
+ before do
8
+ stub_const("Yummy::Bacon", Class.new(ActiveRecord::Base))
9
+ stub_const("Yummy::BaconTransition", Class.new(ActiveRecord::Base))
10
+ end
11
+
8
12
  it_behaves_like "a generator" do
9
13
  let(:migration_name) { "db/migrate/add_statesman_to_bacon_transitions.rb" }
10
14
  end
data/spec/spec_helper.rb CHANGED
@@ -5,13 +5,14 @@ require "sqlite3"
5
5
  require "mysql2"
6
6
  require "pg"
7
7
  require "active_record"
8
+ require "active_record/database_configurations"
8
9
  # We have to include all of Rails to make rspec-rails work
9
10
  require "rails"
10
11
  require "action_view"
11
12
  require "action_dispatch"
12
13
  require "action_controller"
13
14
  require "rspec/rails"
14
- require "support/active_record"
15
+ require "support/exactly_query_databases"
15
16
  require "rspec/its"
16
17
  require "pry"
17
18
 
@@ -28,10 +29,31 @@ RSpec.configure do |config|
28
29
  if config.exclusion_filter[:active_record]
29
30
  puts "Skipping ActiveRecord tests"
30
31
  else
31
- # Connect to the database for activerecord tests
32
- db_conn_spec = ENV["DATABASE_URL"]
33
- db_conn_spec ||= { adapter: "sqlite3", database: ":memory:" }
34
- ActiveRecord::Base.establish_connection(db_conn_spec)
32
+ current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
33
+
34
+ # We have to parse this to a hash since ActiveRecord::Base.configurations
35
+ # will only consider a single URL config.
36
+ url_config = if ENV["DATABASE_URL"]
37
+ ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.
38
+ new(ENV["DATABASE_URL"]).to_hash.merge({ sslmode: "disable" })
39
+ end
40
+
41
+ db_config = {
42
+ current_env => {
43
+ primary: url_config || {
44
+ adapter: "sqlite3",
45
+ database: "/tmp/statesman.db",
46
+ },
47
+ secondary: url_config || {
48
+ adapter: "sqlite3",
49
+ database: "/tmp/statesman.db",
50
+ },
51
+ },
52
+ }
53
+
54
+ # Connect to the primary database for activerecord tests.
55
+ ActiveRecord::Base.configurations = db_config
56
+ ActiveRecord::Base.establish_connection(:primary)
35
57
 
36
58
  db_adapter = ActiveRecord::Base.connection.adapter_name
37
59
  puts "Running with database adapter '#{db_adapter}'"
@@ -40,7 +62,9 @@ RSpec.configure do |config|
40
62
  ActiveRecord::Migration.verbose = false
41
63
  end
42
64
 
43
- config.before(:each, active_record: true) do
65
+ # Since our primary and secondary connections point to the same database, we don't
66
+ # need to worry about applying these actions to both.
67
+ config.before(:each, :active_record) do
44
68
  tables = %w[
45
69
  my_active_record_models
46
70
  my_active_record_model_transitions
@@ -48,9 +72,12 @@ RSpec.configure do |config|
48
72
  my_namespace_my_active_record_model_transitions
49
73
  other_active_record_models
50
74
  other_active_record_model_transitions
75
+ sti_active_record_models
76
+ sti_active_record_model_transitions
51
77
  ]
52
78
  tables.each do |table_name|
53
79
  sql = "DROP TABLE IF EXISTS #{table_name};"
80
+
54
81
  ActiveRecord::Base.connection.execute(sql)
55
82
  end
56
83
 
@@ -72,6 +99,16 @@ RSpec.configure do |config|
72
99
  OtherActiveRecordModelTransition.reset_column_information
73
100
  end
74
101
 
75
- MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON)
102
+ def prepare_sti_model_table
103
+ CreateStiActiveRecordModelMigration.migrate(:up)
104
+ end
105
+
106
+ def prepare_sti_transitions_table
107
+ CreateStiActiveRecordModelTransitionMigration.migrate(:up)
108
+ StiActiveRecordModelTransition.reset_column_information
109
+ end
76
110
  end
77
111
  end
112
+
113
+ # We have to require this after the databases are configured.
114
+ require "support/active_record"
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
- describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
3
+ describe Statesman::Adapters::ActiveRecordQueries, :active_record do
6
4
  def configure_old(klass, transition_class)
7
5
  klass.define_singleton_method(:transition_class) { transition_class }
8
6
  klass.define_singleton_method(:initial_state) { :initial }
@@ -50,10 +48,11 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
50
48
 
51
49
  shared_examples "testing methods" do
52
50
  before do
53
- if config_type == :old
51
+ case config_type
52
+ when :old
54
53
  configure_old(MyActiveRecordModel, MyActiveRecordModelTransition)
55
54
  configure_old(OtherActiveRecordModel, OtherActiveRecordModelTransition)
56
- elsif config_type == :new
55
+ when :new
57
56
  configure_new(MyActiveRecordModel, MyActiveRecordModelTransition)
58
57
  configure_new(OtherActiveRecordModel, OtherActiveRecordModelTransition)
59
58
  else
@@ -116,8 +115,8 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
116
115
  subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
117
116
 
118
117
  it do
119
- expect(not_in_state).to match_array([initial_state_model,
120
- returned_to_initial_model])
118
+ expect(not_in_state).to contain_exactly(initial_state_model,
119
+ returned_to_initial_model)
121
120
  end
122
121
  end
123
122
 
@@ -125,8 +124,8 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
125
124
  subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
126
125
 
127
126
  it do
128
- expect(not_in_state).to match_array([initial_state_model,
129
- returned_to_initial_model])
127
+ expect(not_in_state).to contain_exactly(initial_state_model,
128
+ returned_to_initial_model)
130
129
  end
131
130
  end
132
131
  end
@@ -154,6 +153,31 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
154
153
  end
155
154
  end
156
155
 
156
+ context "with a custom primary key for the model" do
157
+ before do
158
+ # Switch to using OtherActiveRecordModelTransition, so the existing
159
+ # relation with MyActiveRecordModelTransition doesn't interfere with
160
+ # this spec.
161
+ # Configure the relationship to use a different primary key,
162
+ MyActiveRecordModel.send(:has_many,
163
+ :custom_name,
164
+ class_name: "OtherActiveRecordModelTransition",
165
+ primary_key: :external_id)
166
+
167
+ MyActiveRecordModel.class_eval do
168
+ def self.transition_class
169
+ OtherActiveRecordModelTransition
170
+ end
171
+ end
172
+ end
173
+
174
+ describe ".in_state" do
175
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
176
+
177
+ specify { expect { query }.to_not raise_error }
178
+ end
179
+ end
180
+
157
181
  context "after_commit transactional integrity" do
158
182
  before do
159
183
  MyStateMachine.class_eval do
@@ -176,7 +200,6 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
176
200
  MyActiveRecordModel.create
177
201
  end
178
202
 
179
- # rubocop:disable RSpec/ExampleLength
180
203
  it do
181
204
  expect do
182
205
  ActiveRecord::Base.transaction do
@@ -185,7 +208,6 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
185
208
  end
186
209
  end.to_not change(MyStateMachine, :after_commit_callback_executed)
187
210
  end
188
- # rubocop:enable RSpec/ExampleLength
189
211
  end
190
212
  end
191
213
 
@@ -230,7 +252,7 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
230
252
  end
231
253
 
232
254
  it "does not raise an error" do
233
- expect { check_missing_methods! }.to_not raise_exception(NotImplementedError)
255
+ expect { check_missing_methods! }.to_not raise_exception
234
256
  end
235
257
  end
236
258
 
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "timecop"
5
4
  require "statesman/adapters/shared_examples"
6
5
  require "statesman/exceptions"
7
6
 
8
- describe Statesman::Adapters::ActiveRecord, active_record: true do
7
+ describe Statesman::Adapters::ActiveRecord, :active_record do
9
8
  before do
10
9
  prepare_model_table
11
10
  prepare_transitions_table
12
11
 
13
- MyActiveRecordModelTransition.serialize(:metadata, JSON)
12
+ prepare_sti_model_table
13
+ prepare_sti_transitions_table
14
14
 
15
15
  Statesman.configure do
16
16
  # Rubocop requires described_class to be used, but this block
@@ -23,8 +23,10 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
23
23
 
24
24
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
25
25
 
26
+ let(:model_class) { MyActiveRecordModel }
27
+ let(:transition_class) { MyActiveRecordModelTransition }
26
28
  let(:observer) { double(Statesman::Machine, execute: nil) }
27
- let(:model) { MyActiveRecordModel.create(current_state: :pending) }
29
+ let(:model) { model_class.create(current_state: :pending) }
28
30
 
29
31
  it_behaves_like "an adapter", described_class, MyActiveRecordModelTransition
30
32
 
@@ -33,17 +35,11 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
33
35
  before do
34
36
  metadata_column = double
35
37
  allow(metadata_column).to receive_messages(sql_type: "")
36
- allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
37
- { "metadata" => metadata_column })
38
- if ::ActiveRecord.respond_to?(:gem_version) &&
39
- ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
40
- expect(MyActiveRecordModelTransition).
41
- to receive(:type_for_attribute).with("metadata").
42
- and_return(ActiveRecord::Type::Value.new)
43
- else
44
- expect(MyActiveRecordModelTransition).
45
- to receive_messages(serialized_attributes: {})
46
- end
38
+ allow(MyActiveRecordModelTransition).
39
+ to receive_messages(columns_hash: { "metadata" => metadata_column })
40
+ expect(MyActiveRecordModelTransition).
41
+ to receive(:type_for_attribute).with("metadata").
42
+ and_return(ActiveRecord::Type::Value.new)
47
43
  end
48
44
 
49
45
  it "raises an exception" do
@@ -60,10 +56,10 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
60
56
  allow(metadata_column).to receive_messages(sql_type: "json")
61
57
  allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
62
58
  { "metadata" => metadata_column })
63
- if ::ActiveRecord.respond_to?(:gem_version) &&
64
- ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
65
- serialized_type = ::ActiveRecord::Type::Serialized.new(
66
- "", ::ActiveRecord::Coders::JSON
59
+ if ActiveRecord.respond_to?(:gem_version) &&
60
+ ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
61
+ serialized_type = ActiveRecord::Type::Serialized.new(
62
+ "", ActiveRecord::Coders::JSON
67
63
  )
68
64
  expect(MyActiveRecordModelTransition).
69
65
  to receive(:type_for_attribute).with("metadata").
@@ -88,18 +84,12 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
88
84
  allow(metadata_column).to receive_messages(sql_type: "jsonb")
89
85
  allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
90
86
  { "metadata" => metadata_column })
91
- if ::ActiveRecord.respond_to?(:gem_version) &&
92
- ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
93
- serialized_type = ::ActiveRecord::Type::Serialized.new(
94
- "", ::ActiveRecord::Coders::JSON
95
- )
96
- expect(MyActiveRecordModelTransition).
97
- to receive(:type_for_attribute).with("metadata").
98
- and_return(serialized_type)
99
- else
100
- expect(MyActiveRecordModelTransition).
101
- to receive_messages(serialized_attributes: { "metadata" => "" })
102
- end
87
+ serialized_type = ActiveRecord::Type::Serialized.new(
88
+ "", ActiveRecord::Coders::JSON
89
+ )
90
+ expect(MyActiveRecordModelTransition).
91
+ to receive(:type_for_attribute).with("metadata").
92
+ and_return(serialized_type)
103
93
  end
104
94
 
105
95
  it "raises an exception" do
@@ -112,15 +102,17 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
112
102
  end
113
103
 
114
104
  describe "#create" do
115
- subject { -> { create } }
105
+ subject(:transition) { create }
116
106
 
117
- let!(:adapter) do
118
- described_class.new(MyActiveRecordModelTransition, model, observer)
119
- end
107
+ let!(:adapter) { described_class.new(transition_class, model, observer) }
120
108
  let(:from) { :x }
121
109
  let(:to) { :y }
122
110
  let(:create) { adapter.create(from, to) }
123
111
 
112
+ it "only connects to the primary database" do
113
+ expect { create }.to exactly_query_databases({ primary: [:writing] })
114
+ end
115
+
124
116
  context "when there is a race" do
125
117
  it "raises a TransitionConflictError" do
126
118
  adapter2 = adapter.dup
@@ -128,7 +120,33 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
128
120
  adapter.last
129
121
  adapter2.create(:y, :z)
130
122
  expect { adapter.create(:y, :z) }.
131
- to raise_exception(Statesman::TransitionConflictError)
123
+ to raise_exception(Statesman::TransitionConflictError).
124
+ and exactly_query_databases({ primary: [:writing] })
125
+ end
126
+
127
+ it "does not pollute the state when the transition fails" do
128
+ # this increments the sort_key in the database
129
+ adapter.create(:x, :y)
130
+
131
+ # we then pre-load the transitions for efficiency
132
+ preloaded_model = MyActiveRecordModel.
133
+ includes(:my_active_record_model_transitions).
134
+ find(model.id)
135
+
136
+ adapter2 = described_class.
137
+ new(MyActiveRecordModelTransition, preloaded_model, observer)
138
+
139
+ # Now we generate a race
140
+ adapter.create(:y, :z)
141
+ expect { adapter2.create(:y, :a) }.
142
+ to raise_error(Statesman::TransitionConflictError)
143
+
144
+ # The preloaded adapter should discard the preloaded info
145
+ expect(adapter2.last).to have_attributes(to_state: "z")
146
+ expect(adapter2.history).to contain_exactly(
147
+ have_attributes(to_state: "y"),
148
+ have_attributes(to_state: "z"),
149
+ )
132
150
  end
133
151
  end
134
152
 
@@ -140,27 +158,25 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
140
158
 
141
159
  context "ActiveRecord::RecordNotUnique unrelated to this transition" do
142
160
  let(:error) do
143
- if ::ActiveRecord.respond_to?(:gem_version) &&
144
- ::ActiveRecord.gem_version >= Gem::Version.new("4.0.0")
161
+ if ActiveRecord.respond_to?(:gem_version) &&
162
+ ActiveRecord.gem_version >= Gem::Version.new("4.0.0")
145
163
  ActiveRecord::RecordNotUnique.new("unrelated")
146
164
  else
147
165
  ActiveRecord::RecordNotUnique.new("unrelated", nil)
148
166
  end
149
167
  end
150
168
 
151
- it { is_expected.to raise_exception(ActiveRecord::RecordNotUnique) }
169
+ it { expect { transition }.to raise_exception(ActiveRecord::RecordNotUnique) }
152
170
  end
153
171
 
154
172
  context "other errors" do
155
173
  let(:error) { StandardError }
156
174
 
157
- it { is_expected.to raise_exception(StandardError) }
175
+ it { expect { transition }.to raise_exception(StandardError) }
158
176
  end
159
177
  end
160
178
 
161
179
  describe "updating the most_recent column" do
162
- subject { create }
163
-
164
180
  context "with no previous transition" do
165
181
  its(:most_recent) { is_expected.to eq(true) }
166
182
  end
@@ -277,17 +293,90 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
277
293
  from(true).to be_falsey
278
294
  end
279
295
  end
296
+
297
+ context "when transition uses STI" do
298
+ let(:sti_model) { StiActiveRecordModel.create }
299
+
300
+ let(:adapter_a) do
301
+ described_class.new(
302
+ StiAActiveRecordModelTransition,
303
+ sti_model,
304
+ observer,
305
+ { association_name: :sti_a_active_record_model_transitions },
306
+ )
307
+ end
308
+ let(:adapter_b) do
309
+ described_class.new(
310
+ StiBActiveRecordModelTransition,
311
+ sti_model,
312
+ observer,
313
+ { association_name: :sti_b_active_record_model_transitions },
314
+ )
315
+ end
316
+ let(:create) { adapter_a.create(from, to) }
317
+
318
+ context "with a previous unrelated transition" do
319
+ let!(:transition_b) { adapter_b.create(from, to) }
320
+
321
+ its(:most_recent) { is_expected.to eq(true) }
322
+
323
+ it "doesn't update the previous transition's most_recent flag" do
324
+ expect { create }.
325
+ to_not(change { transition_b.reload.most_recent })
326
+ end
327
+ end
328
+
329
+ context "with previous related and unrelated transitions" do
330
+ let!(:transition_a) { adapter_a.create(from, to) }
331
+ let!(:transition_b) { adapter_b.create(from, to) }
332
+
333
+ its(:most_recent) { is_expected.to eq(true) }
334
+
335
+ it "updates the previous transition's most_recent flag" do
336
+ expect { create }.
337
+ to change { transition_a.reload.most_recent }.
338
+ from(true).to be_falsey
339
+ end
340
+
341
+ it "doesn't update the previous unrelated transition's most_recent flag" do
342
+ expect { create }.
343
+ to_not(change { transition_b.reload.most_recent })
344
+ end
345
+ end
346
+ end
280
347
  end
281
- end
282
348
 
283
- describe "#last" do
284
- let(:adapter) do
285
- described_class.new(MyActiveRecordModelTransition, model, observer)
349
+ context "when using the secondary database" do
350
+ let(:model_class) { SecondaryActiveRecordModel }
351
+ let(:transition_class) { SecondaryActiveRecordModelTransition }
352
+
353
+ it "doesn't connect to the primary database" do
354
+ expect { create }.to exactly_query_databases({ secondary: [:writing] })
355
+ expect(adapter.last.to_state).to eq("y")
356
+ end
357
+
358
+ context "when there is a race" do
359
+ it "raises a TransitionConflictError and uses the correct database" do
360
+ adapter2 = adapter.dup
361
+ adapter2.create(:x, :y)
362
+ adapter.last
363
+ adapter2.create(:y, :z)
364
+
365
+ expect { adapter.create(:y, :z) }.
366
+ to raise_exception(Statesman::TransitionConflictError).
367
+ and exactly_query_databases({ secondary: [:writing] })
368
+ end
369
+ end
286
370
  end
371
+ end
287
372
 
288
- before { adapter.create(:x, :y) }
373
+ describe "#last" do
374
+ let(:transition_class) { MyActiveRecordModelTransition }
375
+ let(:adapter) { described_class.new(transition_class, model, observer) }
289
376
 
290
377
  context "with a previously looked up transition" do
378
+ before { adapter.create(:x, :y) }
379
+
291
380
  before { adapter.last }
292
381
 
293
382
  it "caches the transition" do
@@ -300,8 +389,19 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
300
389
  before { adapter.create(:y, :z, []) }
301
390
 
302
391
  it "retrieves the new transition from the database" do
392
+ expect { adapter.last.to_state }.to exactly_query_databases({ primary: [:writing] })
303
393
  expect(adapter.last.to_state).to eq("z")
304
394
  end
395
+
396
+ context "when using the secondary database" do
397
+ let(:model_class) { SecondaryActiveRecordModel }
398
+ let(:transition_class) { SecondaryActiveRecordModelTransition }
399
+
400
+ it "retrieves the new transition from the database" do
401
+ expect { adapter.last.to_state }.to exactly_query_databases({ secondary: [:writing] })
402
+ expect(adapter.last.to_state).to eq("z")
403
+ end
404
+ end
305
405
  end
306
406
 
307
407
  context "when a new transition has been created elsewhere" do
@@ -353,6 +453,35 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
353
453
  expect(adapter.last.to_state).to eq("y")
354
454
  end
355
455
  end
456
+
457
+ context "without previous transitions" do
458
+ it "does query the database only once" do
459
+ expect(model.my_active_record_model_transitions).
460
+ to receive(:order).once.and_call_original
461
+
462
+ expect(adapter.last).to eq(nil)
463
+ expect(adapter.last).to eq(nil)
464
+ end
465
+ end
466
+ end
467
+
468
+ describe "#reset" do
469
+ it "works with empty cache" do
470
+ expect { model.state_machine.reset }.to_not raise_error
471
+ end
472
+ end
473
+
474
+ it "resets last with #reload" do
475
+ model.save!
476
+ ActiveRecord::Base.transaction do
477
+ model.state_machine.transition_to!(:succeeded)
478
+ # force to cache value in last_transition instance variable
479
+ expect(model.state_machine.current_state).to eq("succeeded")
480
+ raise ActiveRecord::Rollback
481
+ end
482
+ expect(model.state_machine.current_state).to eq("succeeded")
483
+ model.reload
484
+ expect(model.state_machine.current_state).to eq("initial")
356
485
  end
357
486
 
358
487
  context "with a namespaced model" do
@@ -361,10 +490,6 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
361
490
  CreateNamespacedARModelTransitionMigration.migrate(:up)
362
491
  end
363
492
 
364
- before do
365
- MyNamespace::MyActiveRecordModelTransition.serialize(:metadata, JSON)
366
- end
367
-
368
493
  let(:observer) { double(Statesman::Machine, execute: nil) }
369
494
  let(:model) do
370
495
  MyNamespace::MyActiveRecordModel.create(current_state: :pending)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "json"
5
4
 
6
5
  describe Statesman::Adapters::ActiveRecordTransition do
@@ -8,7 +7,11 @@ describe Statesman::Adapters::ActiveRecordTransition do
8
7
 
9
8
  describe "including behaviour" do
10
9
  it "calls Class.serialize" do
11
- expect(transition_class).to receive(:serialize).with(:metadata, JSON).once
10
+ if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.1")
11
+ expect(transition_class).to receive(:serialize).with(:metadata, coder: JSON).once
12
+ else
13
+ expect(transition_class).to receive(:serialize).with(:metadata, JSON).once
14
+ end
12
15
  transition_class.send(:include, described_class)
13
16
  end
14
17
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "statesman/adapters/shared_examples"
5
4
  require "statesman/adapters/memory_transition"
6
5
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
3
  require "statesman/adapters/memory_transition"
5
4
 
6
5
  describe Statesman::Adapters::MemoryTransition do
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  # All adpators must define seven methods:
6
4
  # initialize: Accepts a transition class, parent model and state_attr.
7
5
  # transition_class: Returns the transition class object passed to initialize.
@@ -30,14 +28,14 @@ shared_examples_for "an adapter" do |adapter_class, transition_class, options =
30
28
  end
31
29
 
32
30
  describe "#create" do
33
- subject { -> { create } }
31
+ subject(:transition) { create }
34
32
 
35
33
  let(:from) { :x }
36
34
  let(:to) { :y }
37
35
  let(:there) { :z }
38
36
  let(:create) { adapter.create(from, to) }
39
37
 
40
- it { is_expected.to change(adapter.history, :count).by(1) }
38
+ it { expect { transition }.to change(adapter.history, :count).by(1) }
41
39
 
42
40
  context "the new transition" do
43
41
  subject(:instance) { create }
@@ -128,6 +126,7 @@ shared_examples_for "an adapter" do |adapter_class, transition_class, options =
128
126
 
129
127
  it { is_expected.to be_a(transition_class) }
130
128
  specify { expect(adapter.last.to_state.to_sym).to eq(:z) }
129
+
131
130
  specify do
132
131
  expect(adapter.last(force_reload: true).to_state.to_sym).to eq(:z)
133
132
  end