statesman 3.5.0 → 7.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.
Files changed (56) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +49 -250
  3. data/.rubocop.yml +1 -1
  4. data/.rubocop_todo.yml +26 -6
  5. data/CHANGELOG.md +106 -0
  6. data/Gemfile +10 -4
  7. data/Guardfile +2 -0
  8. data/README.md +78 -48
  9. data/Rakefile +2 -4
  10. data/lib/generators/statesman/active_record_transition_generator.rb +2 -0
  11. data/lib/generators/statesman/generator_helpers.rb +2 -0
  12. data/lib/generators/statesman/migration_generator.rb +2 -0
  13. data/lib/statesman.rb +14 -4
  14. data/lib/statesman/adapters/active_record.rb +259 -37
  15. data/lib/statesman/adapters/active_record_queries.rb +100 -36
  16. data/lib/statesman/adapters/active_record_transition.rb +2 -0
  17. data/lib/statesman/adapters/memory.rb +2 -0
  18. data/lib/statesman/adapters/memory_transition.rb +2 -0
  19. data/lib/statesman/callback.rb +2 -0
  20. data/lib/statesman/config.rb +28 -0
  21. data/lib/statesman/exceptions.rb +34 -2
  22. data/lib/statesman/guard.rb +3 -4
  23. data/lib/statesman/machine.rb +29 -7
  24. data/lib/statesman/railtie.rb +2 -0
  25. data/lib/statesman/utils.rb +2 -0
  26. data/lib/statesman/version.rb +3 -1
  27. data/lib/tasks/statesman.rake +3 -1
  28. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_with_partial_index.rb +2 -0
  29. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_without_partial_index.rb +2 -0
  30. data/spec/fixtures/add_most_recent_to_bacon_transitions.rb +2 -0
  31. data/spec/generators/statesman/active_record_transition_generator_spec.rb +2 -0
  32. data/spec/generators/statesman/migration_generator_spec.rb +2 -0
  33. data/spec/spec_helper.rb +3 -30
  34. data/spec/statesman/adapters/active_record_queries_spec.rb +167 -91
  35. data/spec/statesman/adapters/active_record_spec.rb +15 -1
  36. data/spec/statesman/adapters/active_record_transition_spec.rb +2 -0
  37. data/spec/statesman/adapters/memory_spec.rb +2 -0
  38. data/spec/statesman/adapters/memory_transition_spec.rb +2 -0
  39. data/spec/statesman/adapters/shared_examples.rb +2 -0
  40. data/spec/statesman/callback_spec.rb +2 -0
  41. data/spec/statesman/config_spec.rb +2 -0
  42. data/spec/statesman/exceptions_spec.rb +88 -0
  43. data/spec/statesman/guard_spec.rb +2 -0
  44. data/spec/statesman/machine_spec.rb +79 -4
  45. data/spec/statesman/utils_spec.rb +2 -0
  46. data/spec/support/active_record.rb +9 -12
  47. data/spec/support/generators_shared_examples.rb +2 -0
  48. data/statesman.gemspec +19 -7
  49. metadata +40 -32
  50. data/lib/generators/statesman/mongoid_transition_generator.rb +0 -25
  51. data/lib/generators/statesman/templates/mongoid_transition_model.rb.erb +0 -14
  52. data/lib/statesman/adapters/mongoid.rb +0 -66
  53. data/lib/statesman/adapters/mongoid_transition.rb +0 -10
  54. data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -23
  55. data/spec/statesman/adapters/mongoid_spec.rb +0 -86
  56. data/spec/support/mongoid.rb +0 -28
@@ -1,39 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
 
3
5
  describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
6
+ def configure_old(klass, transition_class)
7
+ klass.define_singleton_method(:transition_class) { transition_class }
8
+ klass.define_singleton_method(:initial_state) { :initial }
9
+ klass.send(:include, described_class)
10
+ end
11
+
12
+ def configure_new(klass, transition_class)
13
+ klass.send(:include, described_class[transition_class: transition_class,
14
+ initial_state: :initial])
15
+ end
16
+
4
17
  before do
5
18
  prepare_model_table
6
19
  prepare_transitions_table
7
20
  prepare_other_model_table
8
21
  prepare_other_transitions_table
9
22
 
10
- Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }
11
-
12
- MyActiveRecordModel.send(:include, Statesman::Adapters::ActiveRecordQueries)
13
- MyActiveRecordModel.class_eval do
14
- def self.transition_class
15
- MyActiveRecordModelTransition
16
- end
17
-
18
- def self.initial_state
19
- :initial
20
- end
23
+ Statesman.configure do
24
+ storage_adapter(Statesman::Adapters::ActiveRecord)
21
25
  end
22
-
23
- OtherActiveRecordModel.send(:include,
24
- Statesman::Adapters::ActiveRecordQueries)
25
- OtherActiveRecordModel.class_eval do
26
- def self.transition_class
27
- OtherActiveRecordModelTransition
28
- end
29
-
30
- def self.initial_state
31
- :initial
32
- end
33
- end
34
-
35
- MyActiveRecordModel.send(:has_one, :other_active_record_model)
36
- OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
37
26
  end
38
27
 
39
28
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
@@ -59,105 +48,164 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
59
48
  model
60
49
  end
61
50
 
62
- describe ".in_state" do
63
- context "given a single state" do
64
- subject { MyActiveRecordModel.in_state(:succeeded) }
51
+ shared_examples "testing methods" do
52
+ before do
53
+ if config_type == :old
54
+ configure_old(MyActiveRecordModel, MyActiveRecordModelTransition)
55
+ configure_old(OtherActiveRecordModel, OtherActiveRecordModelTransition)
56
+ elsif config_type == :new
57
+ configure_new(MyActiveRecordModel, MyActiveRecordModelTransition)
58
+ configure_new(OtherActiveRecordModel, OtherActiveRecordModelTransition)
59
+ else
60
+ raise "Unknown config type #{config_type}"
61
+ end
65
62
 
66
- it { is_expected.to include model }
67
- it { is_expected.to_not include other_model }
63
+ MyActiveRecordModel.send(:has_one, :other_active_record_model)
64
+ OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
68
65
  end
69
66
 
70
- context "given multiple states" do
71
- subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
67
+ describe ".in_state" do
68
+ context "given a single state" do
69
+ subject { MyActiveRecordModel.in_state(:succeeded) }
72
70
 
73
- it { is_expected.to include model }
74
- it { is_expected.to include other_model }
75
- end
71
+ it { is_expected.to include model }
72
+ it { is_expected.to_not include other_model }
73
+ end
76
74
 
77
- context "given the initial state" do
78
- subject { MyActiveRecordModel.in_state(:initial) }
75
+ context "given multiple states" do
76
+ subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
79
77
 
80
- it { is_expected.to include initial_state_model }
81
- it { is_expected.to include returned_to_initial_model }
82
- end
78
+ it { is_expected.to include model }
79
+ it { is_expected.to include other_model }
80
+ end
83
81
 
84
- context "given an array of states" do
85
- subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
82
+ context "given the initial state" do
83
+ subject { MyActiveRecordModel.in_state(:initial) }
86
84
 
87
- it { is_expected.to include model }
88
- it { is_expected.to include other_model }
89
- end
85
+ it { is_expected.to include initial_state_model }
86
+ it { is_expected.to include returned_to_initial_model }
87
+ end
90
88
 
91
- context "merging two queries" do
92
- subject do
93
- MyActiveRecordModel.in_state(:succeeded).
94
- joins(:other_active_record_model).
95
- merge(OtherActiveRecordModel.in_state(:initial))
89
+ context "given an array of states" do
90
+ subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
91
+
92
+ it { is_expected.to include model }
93
+ it { is_expected.to include other_model }
96
94
  end
97
95
 
98
- it { is_expected.to be_empty }
96
+ context "merging two queries" do
97
+ subject do
98
+ MyActiveRecordModel.in_state(:succeeded).
99
+ joins(:other_active_record_model).
100
+ merge(OtherActiveRecordModel.in_state(:initial))
101
+ end
102
+
103
+ it { is_expected.to be_empty }
104
+ end
99
105
  end
100
- end
101
106
 
102
- describe ".not_in_state" do
103
- context "given a single state" do
104
- subject { MyActiveRecordModel.not_in_state(:failed) }
107
+ describe ".not_in_state" do
108
+ context "given a single state" do
109
+ subject { MyActiveRecordModel.not_in_state(:failed) }
105
110
 
106
- it { is_expected.to include model }
107
- it { is_expected.to_not include other_model }
108
- end
111
+ it { is_expected.to include model }
112
+ it { is_expected.to_not include other_model }
113
+ end
109
114
 
110
- context "given multiple states" do
111
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
115
+ context "given multiple states" do
116
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
112
117
 
113
- it do
114
- expect(not_in_state).to match_array([initial_state_model,
115
- returned_to_initial_model])
118
+ it do
119
+ expect(not_in_state).to match_array([initial_state_model,
120
+ returned_to_initial_model])
121
+ end
116
122
  end
117
- end
118
123
 
119
- context "given an array of states" do
120
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
124
+ context "given an array of states" do
125
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
121
126
 
122
- it do
123
- expect(not_in_state).to match_array([initial_state_model,
124
- returned_to_initial_model])
127
+ it do
128
+ expect(not_in_state).to match_array([initial_state_model,
129
+ returned_to_initial_model])
130
+ end
125
131
  end
126
132
  end
127
- end
128
133
 
129
- context "with a custom name for the transition association" do
130
- before do
131
- # Switch to using OtherActiveRecordModelTransition, so the existing
132
- # relation with MyActiveRecordModelTransition doesn't interfere with
133
- # this spec.
134
- MyActiveRecordModel.send(:has_many,
135
- :custom_name,
136
- class_name: "OtherActiveRecordModelTransition")
137
-
138
- MyActiveRecordModel.class_eval do
139
- def self.transition_class
140
- OtherActiveRecordModelTransition
134
+ context "with a custom name for the transition association" do
135
+ before do
136
+ # Switch to using OtherActiveRecordModelTransition, so the existing
137
+ # relation with MyActiveRecordModelTransition doesn't interfere with
138
+ # this spec.
139
+ MyActiveRecordModel.send(:has_many,
140
+ :custom_name,
141
+ class_name: "OtherActiveRecordModelTransition")
142
+
143
+ MyActiveRecordModel.class_eval do
144
+ def self.transition_class
145
+ OtherActiveRecordModelTransition
146
+ end
141
147
  end
142
148
  end
149
+
150
+ describe ".in_state" do
151
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
152
+
153
+ specify { expect { query }.to_not raise_error }
154
+ end
143
155
  end
144
156
 
145
- describe ".in_state" do
146
- subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
157
+ context "after_commit transactional integrity" do
158
+ before do
159
+ MyStateMachine.class_eval do
160
+ cattr_accessor(:after_commit_callback_executed) { false }
147
161
 
148
- specify { expect { query }.to_not raise_error }
162
+ after_transition(from: :initial, to: :succeeded, after_commit: true) do
163
+ # This leaks state in a testable way if transactional integrity is broken.
164
+ MyStateMachine.after_commit_callback_executed = true
165
+ end
166
+ end
167
+ end
168
+
169
+ after do
170
+ MyStateMachine.class_eval do
171
+ callbacks[:after_commit] = []
172
+ end
173
+ end
174
+
175
+ let!(:model) do
176
+ MyActiveRecordModel.create
177
+ end
178
+
179
+ # rubocop:disable RSpec/ExampleLength
180
+ it do
181
+ expect do
182
+ ActiveRecord::Base.transaction do
183
+ model.state_machine.transition_to!(:succeeded)
184
+ raise ActiveRecord::Rollback
185
+ end
186
+ end.to_not change(MyStateMachine, :after_commit_callback_executed)
187
+ end
188
+ # rubocop:enable RSpec/ExampleLength
149
189
  end
150
190
  end
151
191
 
192
+ context "using old configuration method" do
193
+ let(:config_type) { :old }
194
+
195
+ include_examples "testing methods"
196
+ end
197
+
198
+ context "using new configuration method" do
199
+ let(:config_type) { :new }
200
+
201
+ include_examples "testing methods"
202
+ end
203
+
152
204
  context "with no association with the transition class" do
153
205
  before do
154
206
  class UnknownModelTransition < OtherActiveRecordModelTransition; end
155
207
 
156
- MyActiveRecordModel.class_eval do
157
- def self.transition_class
158
- UnknownModelTransition
159
- end
160
- end
208
+ configure_old(MyActiveRecordModel, UnknownModelTransition)
161
209
  end
162
210
 
163
211
  describe ".in_state" do
@@ -168,4 +216,32 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
168
216
  end
169
217
  end
170
218
  end
219
+
220
+ describe "check_missing_methods!" do
221
+ subject(:check_missing_methods!) { described_class.check_missing_methods!(base) }
222
+
223
+ context "when base has no missing methods" do
224
+ let(:base) do
225
+ Class.new do
226
+ def self.transition_class; end
227
+
228
+ def self.initial_state; end
229
+ end
230
+ end
231
+
232
+ it "does not raise an error" do
233
+ expect { check_missing_methods! }.to_not raise_exception(NotImplementedError)
234
+ end
235
+ end
236
+
237
+ context "when base has missing methods" do
238
+ let(:base) do
239
+ Class.new
240
+ end
241
+
242
+ it "raises an error" do
243
+ expect { check_missing_methods! }.to raise_exception(NotImplementedError)
244
+ end
245
+ end
246
+ end
171
247
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "timecop"
3
5
  require "statesman/adapters/shared_examples"
@@ -7,9 +9,19 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
7
9
  before do
8
10
  prepare_model_table
9
11
  prepare_transitions_table
12
+
13
+ MyActiveRecordModelTransition.serialize(:metadata, JSON)
14
+
15
+ Statesman.configure do
16
+ # Rubocop requires described_class to be used, but this block
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
21
+ end
10
22
  end
11
23
 
12
- before { MyActiveRecordModelTransition.serialize(:metadata, JSON) }
24
+ after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
13
25
 
14
26
  let(:observer) { double(Statesman::Machine, execute: nil) }
15
27
  let(:model) { MyActiveRecordModel.create(current_state: :pending) }
@@ -227,6 +239,7 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
227
239
  it "still has the old state" do
228
240
  allow(observer).to receive(:execute) do |phase|
229
241
  next unless phase == :before
242
+
230
243
  expect(
231
244
  model.transitions.where(most_recent: true).first.to_state,
232
245
  ).to eq("y")
@@ -240,6 +253,7 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
240
253
  it "still has the old state" do
241
254
  allow(observer).to receive(:execute) do |phase|
242
255
  next unless phase == :after
256
+
243
257
  expect(
244
258
  model.transitions.where(most_recent: true).first.to_state,
245
259
  ).to eq("z")
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "json"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "statesman/adapters/shared_examples"
3
5
  require "statesman/adapters/memory_transition"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "statesman/adapters/memory_transition"
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
 
3
5
  # All adpators must define seven methods:
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
 
3
5
  describe Statesman::Callback do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
 
3
5
  describe Statesman::Config do
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe Statesman do
6
+ describe "InvalidStateError" do
7
+ subject(:error) { Statesman::InvalidStateError.new }
8
+
9
+ its(:message) { is_expected.to eq("Statesman::InvalidStateError") }
10
+ its "string matches its message" do
11
+ expect(error.to_s).to eq(error.message)
12
+ end
13
+ end
14
+
15
+ describe "InvalidTransitionError" do
16
+ subject(:error) { Statesman::InvalidTransitionError.new }
17
+
18
+ its(:message) { is_expected.to eq("Statesman::InvalidTransitionError") }
19
+ its "string matches its message" do
20
+ expect(error.to_s).to eq(error.message)
21
+ end
22
+ end
23
+
24
+ describe "InvalidCallbackError" do
25
+ subject(:error) { Statesman::InvalidTransitionError.new }
26
+
27
+ its(:message) { is_expected.to eq("Statesman::InvalidTransitionError") }
28
+ its "string matches its message" do
29
+ expect(error.to_s).to eq(error.message)
30
+ end
31
+ end
32
+
33
+ describe "TransitionConflictError" do
34
+ subject(:error) { Statesman::TransitionConflictError.new }
35
+
36
+ its(:message) { is_expected.to eq("Statesman::TransitionConflictError") }
37
+ its "string matches its message" do
38
+ expect(error.to_s).to eq(error.message)
39
+ end
40
+ end
41
+
42
+ describe "MissingTransitionAssociation" do
43
+ subject(:error) { Statesman::MissingTransitionAssociation.new }
44
+
45
+ its(:message) { is_expected.to eq("Statesman::MissingTransitionAssociation") }
46
+ its "string matches its message" do
47
+ expect(error.to_s).to eq(error.message)
48
+ end
49
+ end
50
+
51
+ describe "TransitionFailedError" do
52
+ subject(:error) { Statesman::TransitionFailedError.new("from", "to") }
53
+
54
+ its(:message) { is_expected.to eq("Cannot transition from 'from' to 'to'") }
55
+ its "string matches its message" do
56
+ expect(error.to_s).to eq(error.message)
57
+ end
58
+ end
59
+
60
+ describe "GuardFailedError" do
61
+ subject(:error) { Statesman::GuardFailedError.new("from", "to") }
62
+
63
+ its(:message) do
64
+ is_expected.to eq("Guard on transition from: 'from' to 'to' returned false")
65
+ end
66
+ its "string matches its message" do
67
+ expect(error.to_s).to eq(error.message)
68
+ end
69
+ end
70
+
71
+ describe "UnserializedMetadataError" do
72
+ subject(:error) { Statesman::UnserializedMetadataError.new("foo") }
73
+
74
+ its(:message) { is_expected.to match(/foo#metadata is not serialized/) }
75
+ its "string matches its message" do
76
+ expect(error.to_s).to eq(error.message)
77
+ end
78
+ end
79
+
80
+ describe "IncompatibleSerializationError" do
81
+ subject(:error) { Statesman::IncompatibleSerializationError.new("foo") }
82
+
83
+ its(:message) { is_expected.to match(/foo#metadata column type cannot be json/) }
84
+ its "string matches its message" do
85
+ expect(error.to_s).to eq(error.message)
86
+ end
87
+ end
88
+ end