statesman 9.0.0 → 13.0.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/devcontainer.json +31 -0
  3. data/.devcontainer/docker-compose.yml +39 -0
  4. data/.github/workflows/tests.yml +130 -0
  5. data/.gitignore +65 -15
  6. data/.rspec +2 -0
  7. data/.rubocop.yml +11 -1
  8. data/.rubocop_todo.yml +23 -38
  9. data/.ruby-version +1 -1
  10. data/CHANGELOG.md +229 -43
  11. data/CONTRIBUTING.md +14 -13
  12. data/Gemfile +18 -3
  13. data/README.md +203 -74
  14. data/docs/COMPATIBILITY.md +3 -3
  15. data/lib/generators/statesman/active_record_transition_generator.rb +1 -1
  16. data/lib/generators/statesman/generator_helpers.rb +2 -2
  17. data/lib/statesman/adapters/active_record.rb +69 -52
  18. data/lib/statesman/adapters/active_record_queries.rb +15 -7
  19. data/lib/statesman/adapters/active_record_transition.rb +5 -1
  20. data/lib/statesman/adapters/memory.rb +1 -1
  21. data/lib/statesman/adapters/type_safe_active_record_queries.rb +21 -0
  22. data/lib/statesman/callback.rb +2 -2
  23. data/lib/statesman/config.rb +3 -10
  24. data/lib/statesman/exceptions.rb +9 -7
  25. data/lib/statesman/guard.rb +1 -1
  26. data/lib/statesman/machine.rb +60 -0
  27. data/lib/statesman/version.rb +1 -1
  28. data/lib/statesman.rb +5 -5
  29. data/lib/tasks/statesman.rake +5 -5
  30. data/spec/generators/statesman/active_record_transition_generator_spec.rb +7 -1
  31. data/spec/generators/statesman/migration_generator_spec.rb +5 -1
  32. data/spec/spec_helper.rb +44 -7
  33. data/spec/statesman/adapters/active_record_queries_spec.rb +8 -10
  34. data/spec/statesman/adapters/active_record_spec.rb +144 -55
  35. data/spec/statesman/adapters/active_record_transition_spec.rb +5 -2
  36. data/spec/statesman/adapters/memory_spec.rb +0 -1
  37. data/spec/statesman/adapters/memory_transition_spec.rb +0 -1
  38. data/spec/statesman/adapters/shared_examples.rb +6 -7
  39. data/spec/statesman/adapters/type_safe_active_record_queries_spec.rb +206 -0
  40. data/spec/statesman/callback_spec.rb +0 -2
  41. data/spec/statesman/config_spec.rb +0 -2
  42. data/spec/statesman/exceptions_spec.rb +8 -4
  43. data/spec/statesman/guard_spec.rb +0 -2
  44. data/spec/statesman/machine_spec.rb +231 -19
  45. data/spec/statesman/utils_spec.rb +0 -2
  46. data/spec/support/active_record.rb +156 -29
  47. data/spec/support/exactly_query_databases.rb +35 -0
  48. data/statesman.gemspec +2 -17
  49. metadata +14 -238
  50. data/.circleci/config.yml +0 -127
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe Statesman::Adapters::TypeSafeActiveRecordQueries, :active_record do
4
+ def configure(klass, transition_class)
5
+ klass.send(:extend, described_class)
6
+ klass.configure_state_machine(
7
+ transition_class: transition_class,
8
+ initial_state: :initial,
9
+ )
10
+ end
11
+
12
+ before do
13
+ prepare_model_table
14
+ prepare_transitions_table
15
+ prepare_other_model_table
16
+ prepare_other_transitions_table
17
+
18
+ Statesman.configure do
19
+ storage_adapter(Statesman::Adapters::ActiveRecord)
20
+ end
21
+ end
22
+
23
+ after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
24
+
25
+ let!(:model) do
26
+ model = MyActiveRecordModel.create
27
+ model.state_machine.transition_to(:succeeded)
28
+ model
29
+ end
30
+
31
+ let!(:other_model) do
32
+ model = MyActiveRecordModel.create
33
+ model.state_machine.transition_to(:failed)
34
+ model
35
+ end
36
+
37
+ let!(:initial_state_model) { MyActiveRecordModel.create }
38
+
39
+ let!(:returned_to_initial_model) do
40
+ model = MyActiveRecordModel.create
41
+ model.state_machine.transition_to(:failed)
42
+ model.state_machine.transition_to(:initial)
43
+ model
44
+ end
45
+
46
+ shared_examples "testing methods" do
47
+ before do
48
+ configure(MyActiveRecordModel, MyActiveRecordModelTransition)
49
+ configure(OtherActiveRecordModel, OtherActiveRecordModelTransition)
50
+
51
+ MyActiveRecordModel.send(:has_one, :other_active_record_model)
52
+ OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
53
+ end
54
+
55
+ describe ".in_state" do
56
+ context "given a single state" do
57
+ subject { MyActiveRecordModel.in_state(:succeeded) }
58
+
59
+ it { is_expected.to include model }
60
+ it { is_expected.to_not include other_model }
61
+ end
62
+
63
+ context "given multiple states" do
64
+ subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
65
+
66
+ it { is_expected.to include model }
67
+ it { is_expected.to include other_model }
68
+ end
69
+
70
+ context "given the initial state" do
71
+ subject { MyActiveRecordModel.in_state(:initial) }
72
+
73
+ it { is_expected.to include initial_state_model }
74
+ it { is_expected.to include returned_to_initial_model }
75
+ end
76
+
77
+ context "given an array of states" do
78
+ subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
79
+
80
+ it { is_expected.to include model }
81
+ it { is_expected.to include other_model }
82
+ end
83
+
84
+ context "merging two queries" do
85
+ subject do
86
+ MyActiveRecordModel.in_state(:succeeded).
87
+ joins(:other_active_record_model).
88
+ merge(OtherActiveRecordModel.in_state(:initial))
89
+ end
90
+
91
+ it { is_expected.to be_empty }
92
+ end
93
+ end
94
+
95
+ describe ".not_in_state" do
96
+ context "given a single state" do
97
+ subject { MyActiveRecordModel.not_in_state(:failed) }
98
+
99
+ it { is_expected.to include model }
100
+ it { is_expected.to_not include other_model }
101
+ end
102
+
103
+ context "given multiple states" do
104
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
105
+
106
+ it do
107
+ expect(not_in_state).to contain_exactly(initial_state_model,
108
+ returned_to_initial_model)
109
+ end
110
+ end
111
+
112
+ context "given an array of states" do
113
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
114
+
115
+ it do
116
+ expect(not_in_state).to contain_exactly(initial_state_model,
117
+ returned_to_initial_model)
118
+ end
119
+ end
120
+ end
121
+
122
+ context "with a custom name for the transition association" do
123
+ before do
124
+ # Switch to using OtherActiveRecordModelTransition, so the existing
125
+ # relation with MyActiveRecordModelTransition doesn't interfere with
126
+ # this spec.
127
+ MyActiveRecordModel.send(:has_many,
128
+ :custom_name,
129
+ class_name: "OtherActiveRecordModelTransition")
130
+
131
+ MyActiveRecordModel.class_eval do
132
+ def self.transition_class
133
+ OtherActiveRecordModelTransition
134
+ end
135
+ end
136
+ end
137
+
138
+ describe ".in_state" do
139
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
140
+
141
+ specify { expect { query }.to_not raise_error }
142
+ end
143
+ end
144
+
145
+ context "with a custom primary key for the model" do
146
+ before do
147
+ # Switch to using OtherActiveRecordModelTransition, so the existing
148
+ # relation with MyActiveRecordModelTransition doesn't interfere with
149
+ # this spec.
150
+ # Configure the relationship to use a different primary key,
151
+ MyActiveRecordModel.send(:has_many,
152
+ :custom_name,
153
+ class_name: "OtherActiveRecordModelTransition",
154
+ primary_key: :external_id)
155
+
156
+ MyActiveRecordModel.class_eval do
157
+ def self.transition_class
158
+ OtherActiveRecordModelTransition
159
+ end
160
+ end
161
+ end
162
+
163
+ describe ".in_state" do
164
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
165
+
166
+ specify { expect { query }.to_not raise_error }
167
+ end
168
+ end
169
+
170
+ context "after_commit transactional integrity" do
171
+ before do
172
+ MyStateMachine.class_eval do
173
+ cattr_accessor(:after_commit_callback_executed) { false }
174
+
175
+ after_transition(from: :initial, to: :succeeded, after_commit: true) do
176
+ # This leaks state in a testable way if transactional integrity is broken.
177
+ MyStateMachine.after_commit_callback_executed = true
178
+ end
179
+ end
180
+ end
181
+
182
+ after do
183
+ MyStateMachine.class_eval do
184
+ callbacks[:after_commit] = []
185
+ end
186
+ end
187
+
188
+ let!(:model) do
189
+ MyActiveRecordModel.create
190
+ end
191
+
192
+ it do
193
+ expect do
194
+ ActiveRecord::Base.transaction do
195
+ model.state_machine.transition_to!(:succeeded)
196
+ raise ActiveRecord::Rollback
197
+ end
198
+ end.to_not change(MyStateMachine, :after_commit_callback_executed)
199
+ end
200
+ end
201
+ end
202
+
203
+ context "using configuration method" do
204
+ it_behaves_like "testing methods"
205
+ end
206
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Callback do
6
4
  let(:cb_lambda) { -> {} }
7
5
  let(:callback) do
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Config do
6
4
  let(:instance) { described_class.new }
7
5
 
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
- describe Statesman do
3
+ describe "Exceptions" do
6
4
  describe "InvalidStateError" do
7
5
  subject(:error) { Statesman::InvalidStateError.new }
8
6
 
@@ -64,12 +62,18 @@ describe Statesman do
64
62
  end
65
63
 
66
64
  describe "GuardFailedError" do
67
- subject(:error) { Statesman::GuardFailedError.new("from", "to") }
65
+ subject(:error) { Statesman::GuardFailedError.new("from", "to", callback) }
66
+
67
+ let(:callback) { -> { "hello" } }
68
68
 
69
69
  its(:message) do
70
70
  is_expected.to eq("Guard on transition from: 'from' to 'to' returned false")
71
71
  end
72
72
 
73
+ its(:backtrace) do
74
+ is_expected.to eq([callback.source_location.join(":")])
75
+ end
76
+
73
77
  its "string matches its message" do
74
78
  expect(error.to_s).to eq(error.message)
75
79
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Guard do
6
4
  let(:callback) { -> {} }
7
5
  let(:guard) { described_class.new(from: nil, to: nil, callback: callback) }
@@ -1,15 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Machine do
6
4
  let(:machine) { Class.new { include Statesman::Machine } }
7
5
  let(:my_model) { Class.new { attr_accessor :current_state }.new }
8
6
 
9
7
  describe ".state" do
10
- before { machine.state(:x) }
11
-
12
- before { machine.state(:y) }
8
+ before do
9
+ machine.state(:x)
10
+ machine.state(:y)
11
+ end
13
12
 
14
13
  specify { expect(machine.states).to eq(%w[x y]) }
15
14
 
@@ -27,6 +26,112 @@ describe Statesman::Machine do
27
26
  end
28
27
  end
29
28
 
29
+ describe ".remove_state" do
30
+ subject(:remove_state) { machine.remove_state(:x) }
31
+
32
+ before do
33
+ machine.class_eval do
34
+ state :x
35
+ state :y
36
+ state :z
37
+ end
38
+ end
39
+
40
+ it "removes the state" do
41
+ expect { remove_state }.
42
+ to change(machine, :states).
43
+ from(match_array(%w[x y z])).
44
+ to(%w[y z])
45
+ end
46
+
47
+ context "with a transition from the removed state" do
48
+ before { machine.transition from: :x, to: :y }
49
+
50
+ it "removes the transition" do
51
+ expect { remove_state }.
52
+ to change(machine, :successors).
53
+ from({ "x" => ["y"] }).
54
+ to({})
55
+ end
56
+
57
+ context "with multiple transitions" do
58
+ before { machine.transition from: :x, to: :z }
59
+
60
+ it "removes all transitions" do
61
+ expect { remove_state }.
62
+ to change(machine, :successors).
63
+ from({ "x" => %w[y z] }).
64
+ to({})
65
+ end
66
+ end
67
+ end
68
+
69
+ context "with a transition to the removed state" do
70
+ before { machine.transition from: :y, to: :x }
71
+
72
+ it "removes the transition" do
73
+ expect { remove_state }.
74
+ to change(machine, :successors).
75
+ from({ "y" => ["x"] }).
76
+ to({})
77
+ end
78
+
79
+ context "with multiple transitions" do
80
+ before { machine.transition from: :z, to: :x }
81
+
82
+ it "removes all transitions" do
83
+ expect { remove_state }.
84
+ to change(machine, :successors).
85
+ from({ "y" => ["x"], "z" => ["x"] }).
86
+ to({})
87
+ end
88
+ end
89
+ end
90
+
91
+ context "with a callback from the removed state" do
92
+ before do
93
+ machine.class_eval do
94
+ transition from: :x, to: :y
95
+ transition from: :x, to: :z
96
+ guard_transition(from: :x) { return false }
97
+ guard_transition(from: :x, to: :z) { return true }
98
+ end
99
+ end
100
+
101
+ let(:guards) do
102
+ [having_attributes(from: "x", to: []), having_attributes(from: "x", to: ["z"])]
103
+ end
104
+
105
+ it "removes the guard" do
106
+ expect { remove_state }.
107
+ to change(machine, :callbacks).
108
+ from(a_hash_including(guards: match_array(guards))).
109
+ to(a_hash_including(guards: []))
110
+ end
111
+ end
112
+
113
+ context "with a callback to the removed state" do
114
+ before do
115
+ machine.class_eval do
116
+ transition from: :y, to: :x
117
+ guard_transition(to: :x) { return false }
118
+ guard_transition(from: :y, to: :x) { return true }
119
+ end
120
+ end
121
+
122
+ let(:guards) do
123
+ [having_attributes(from: nil, to: ["x"]), having_attributes(from: "y", to: ["x"])]
124
+ end
125
+
126
+ it "removes the guard" do
127
+ expect { remove_state }.
128
+ to change(machine, :callbacks).
129
+ from(a_hash_including(guards: match_array(guards))).
130
+ to(a_hash_including(guards: []))
131
+ end
132
+ end
133
+ end
134
+
30
135
  describe ".retry_conflicts" do
31
136
  subject(:transition_state) do
32
137
  described_class.retry_conflicts(retry_attempts) do
@@ -170,6 +275,42 @@ describe Statesman::Machine do
170
275
  end
171
276
  end
172
277
 
278
+ describe ".remove_transitions" do
279
+ before do
280
+ machine.class_eval do
281
+ state :x
282
+ state :y
283
+ state :z
284
+ transition from: :x, to: :y
285
+ transition from: :x, to: :z
286
+ transition from: :y, to: :z
287
+ end
288
+ end
289
+
290
+ let(:initial_successors) { { "x" => %w[y z], "y" => ["z"] } }
291
+
292
+ it "removes the correct transitions when given a from state" do
293
+ expect { machine.remove_transitions(from: :x) }.
294
+ to change(machine, :successors).
295
+ from(initial_successors).
296
+ to({ "y" => ["z"] })
297
+ end
298
+
299
+ it "removes the correct transitions when given a to state" do
300
+ expect { machine.remove_transitions(to: :z) }.
301
+ to change(machine, :successors).
302
+ from(initial_successors).
303
+ to({ "x" => ["y"] })
304
+ end
305
+
306
+ it "removes the correct transitions when given a from and to state" do
307
+ expect { machine.remove_transitions(from: :x, to: :z) }.
308
+ to change(machine, :successors).
309
+ from(initial_successors).
310
+ to({ "x" => ["y"], "y" => ["z"] })
311
+ end
312
+ end
313
+
173
314
  describe ".validate_callback_condition" do
174
315
  before do
175
316
  machine.class_eval do
@@ -338,12 +479,83 @@ describe Statesman::Machine do
338
479
  it_behaves_like "a callback store", :after_guard_failure, :after_guard_failure
339
480
  end
340
481
 
482
+ shared_examples "initial transition is not created" do
483
+ it "doesn't call .create on storage adapter" do
484
+ expect_any_instance_of(Statesman.storage_adapter).to_not receive(:create)
485
+ machine.new(my_model, options)
486
+ end
487
+ end
488
+
489
+ shared_examples "initial transition is created" do
490
+ it "calls .create on storage adapter" do
491
+ expect_any_instance_of(Statesman.storage_adapter).to receive(:create).with(nil, "x")
492
+ machine.new(my_model, options)
493
+ end
494
+
495
+ it "creates a new transition object" do
496
+ instance = machine.new(my_model, options)
497
+
498
+ expect(instance.history.count).to eq(1)
499
+ expect(instance.history.first.to_state).to eq("x")
500
+ end
501
+ end
502
+
341
503
  describe "#initialize" do
342
504
  it "accepts an object to manipulate" do
343
505
  machine_instance = machine.new(my_model)
344
506
  expect(machine_instance.object).to be(my_model)
345
507
  end
346
508
 
509
+ context "initial_transition is not provided" do
510
+ let(:options) { {} }
511
+
512
+ it_behaves_like "initial transition is not created"
513
+ end
514
+
515
+ context "initial_transition is provided" do
516
+ context "initial_transition is true" do
517
+ let(:options) do
518
+ { initial_transition: true,
519
+ transition_class: Statesman::Adapters::MemoryTransition }
520
+ end
521
+
522
+ context "history is empty" do
523
+ context "initial state is defined" do
524
+ before { machine.state(:x, initial: true) }
525
+
526
+ it_behaves_like "initial transition is created"
527
+ end
528
+
529
+ context "initial state is not defined" do
530
+ it_behaves_like "initial transition is not created"
531
+ end
532
+ end
533
+
534
+ context "history is not empty" do
535
+ before do
536
+ allow_any_instance_of(Statesman.storage_adapter).to receive(:history).
537
+ and_return([{}])
538
+ end
539
+
540
+ context "initial state is defined" do
541
+ before { machine.state(:x, initial: true) }
542
+
543
+ it_behaves_like "initial transition is not created"
544
+ end
545
+
546
+ context "initial state is not defined" do
547
+ it_behaves_like "initial transition is not created"
548
+ end
549
+ end
550
+ end
551
+
552
+ context "initial_transition is false" do
553
+ let(:options) { { initial_transition: false } }
554
+
555
+ it_behaves_like "initial transition is not created"
556
+ end
557
+ end
558
+
347
559
  context "transition class" do
348
560
  it "sets a default" do
349
561
  expect(Statesman.storage_adapter).to receive(:new).once.
@@ -399,9 +611,10 @@ describe Statesman::Machine do
399
611
  end
400
612
 
401
613
  context "with multiple transitions" do
402
- before { instance.transition_to!(:y) }
403
-
404
- before { instance.transition_to!(:z) }
614
+ before do
615
+ instance.transition_to!(:y)
616
+ instance.transition_to!(:z)
617
+ end
405
618
 
406
619
  it { is_expected.to eq("z") }
407
620
  end
@@ -416,12 +629,11 @@ describe Statesman::Machine do
416
629
  state :y
417
630
  transition from: :x, to: :y
418
631
  end
632
+ instance.transition_to!(:y)
419
633
  end
420
634
 
421
635
  let(:instance) { machine.new(my_model) }
422
636
 
423
- before { instance.transition_to!(:y) }
424
-
425
637
  context "when machine is in given state" do
426
638
  let(:state) { "y" }
427
639
 
@@ -784,7 +996,7 @@ describe Statesman::Machine do
784
996
  let(:instance) { machine.new(my_model) }
785
997
  let(:metadata) { { some: :metadata } }
786
998
 
787
- context "when it is succesful" do
999
+ context "when it is successful" do
788
1000
  before do
789
1001
  expect(instance).to receive(:transition_to!).once.
790
1002
  with(:some_state, metadata).and_return(:some_state)
@@ -793,10 +1005,10 @@ describe Statesman::Machine do
793
1005
  it { is_expected.to be(:some_state) }
794
1006
  end
795
1007
 
796
- context "when it is unsuccesful" do
1008
+ context "when it is unsuccessful" do
797
1009
  before do
798
1010
  allow(instance).to receive(:transition_to!).
799
- and_raise(Statesman::GuardFailedError.new(:x, :some_state))
1011
+ and_raise(Statesman::GuardFailedError.new(:x, :some_state, nil))
800
1012
  end
801
1013
 
802
1014
  it { is_expected.to be_falsey }
@@ -834,20 +1046,20 @@ describe Statesman::Machine do
834
1046
  end
835
1047
 
836
1048
  context "with defined callbacks" do
837
- let(:callback_1) { -> { "Hi" } }
838
- let(:callback_2) { -> { "Bye" } }
1049
+ let(:callback_one) { -> { "Hi" } }
1050
+ let(:callback_two) { -> { "Bye" } }
839
1051
 
840
1052
  before do
841
- machine.send(definer, from: :x, to: :y, &callback_1)
842
- machine.send(definer, from: :y, to: :z, &callback_2)
1053
+ machine.send(definer, from: :x, to: :y, &callback_one)
1054
+ machine.send(definer, from: :y, to: :z, &callback_two)
843
1055
  end
844
1056
 
845
1057
  it "contains the relevant callback" do
846
- expect(callbacks.map(&:callback)).to include(callback_1)
1058
+ expect(callbacks.map(&:callback)).to include(callback_one)
847
1059
  end
848
1060
 
849
1061
  it "does not contain the irrelevant callback" do
850
- expect(callbacks.map(&:callback)).to_not include(callback_2)
1062
+ expect(callbacks.map(&:callback)).to_not include(callback_two)
851
1063
  end
852
1064
  end
853
1065
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "spec_helper"
4
-
5
3
  describe Statesman::Utils do
6
4
  describe ".rails_major_version" do
7
5
  subject { described_class.rails_major_version }