statesman 3.5.0 → 6.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 (55) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +45 -225
  3. data/.rubocop.yml +1 -1
  4. data/.rubocop_todo.yml +26 -6
  5. data/CHANGELOG.md +69 -0
  6. data/Gemfile +9 -3
  7. data/Guardfile +2 -0
  8. data/README.md +77 -47
  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/adapters/active_record.rb +88 -6
  14. data/lib/statesman/adapters/active_record_queries.rb +100 -36
  15. data/lib/statesman/adapters/active_record_transition.rb +2 -0
  16. data/lib/statesman/adapters/memory.rb +2 -0
  17. data/lib/statesman/adapters/memory_transition.rb +2 -0
  18. data/lib/statesman/callback.rb +2 -0
  19. data/lib/statesman/config.rb +2 -0
  20. data/lib/statesman/exceptions.rb +29 -2
  21. data/lib/statesman/guard.rb +3 -4
  22. data/lib/statesman/machine.rb +29 -7
  23. data/lib/statesman/railtie.rb +2 -0
  24. data/lib/statesman/utils.rb +2 -0
  25. data/lib/statesman/version.rb +3 -1
  26. data/lib/statesman.rb +2 -3
  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 +165 -91
  35. data/spec/statesman/adapters/active_record_spec.rb +4 -0
  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/guard_spec.rb +2 -0
  43. data/spec/statesman/machine_spec.rb +79 -4
  44. data/spec/statesman/utils_spec.rb +2 -0
  45. data/spec/support/active_record.rb +9 -12
  46. data/spec/support/generators_shared_examples.rb +2 -0
  47. data/statesman.gemspec +5 -3
  48. metadata +17 -22
  49. data/lib/generators/statesman/mongoid_transition_generator.rb +0 -25
  50. data/lib/generators/statesman/templates/mongoid_transition_model.rb.erb +0 -14
  51. data/lib/statesman/adapters/mongoid.rb +0 -66
  52. data/lib/statesman/adapters/mongoid_transition.rb +0 -10
  53. data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -23
  54. data/spec/statesman/adapters/mongoid_spec.rb +0 -86
  55. data/spec/support/mongoid.rb +0 -28
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "version"
2
4
  require_relative "exceptions"
3
5
  require_relative "guard"
@@ -46,10 +48,12 @@ module Statesman
46
48
 
47
49
  def callbacks
48
50
  @callbacks ||= {
49
- before: [],
50
- after: [],
51
+ before: [],
52
+ after: [],
53
+ after_transition_failure: [],
54
+ after_guard_failure: [],
51
55
  after_commit: [],
52
- guards: [],
56
+ guards: [],
53
57
  }
54
58
  end
55
59
 
@@ -83,6 +87,16 @@ module Statesman
83
87
  from: options[:from], to: options[:to], &block)
84
88
  end
85
89
 
90
+ def after_transition_failure(options = {}, &block)
91
+ add_callback(callback_type: :after_transition_failure, callback_class: Callback,
92
+ from: options[:from], to: options[:to], &block)
93
+ end
94
+
95
+ def after_guard_failure(options = {}, &block)
96
+ add_callback(callback_type: :after_guard_failure, callback_class: Callback,
97
+ from: options[:from], to: options[:to], &block)
98
+ end
99
+
86
100
  def validate_callback_condition(options = { from: nil, to: nil })
87
101
  from = to_s_or_nil(options[:from])
88
102
  to = array_to_s_or_nil(options[:to])
@@ -219,6 +233,17 @@ module Statesman
219
233
  @storage_adapter.create(initial_state, new_state, metadata)
220
234
 
221
235
  true
236
+ rescue TransitionFailedError => e
237
+ execute_on_failure(:after_transition_failure, initial_state, new_state, e)
238
+ raise
239
+ rescue GuardFailedError => e
240
+ execute_on_failure(:after_guard_failure, initial_state, new_state, e)
241
+ raise
242
+ end
243
+
244
+ def execute_on_failure(phase, initial_state, new_state, exception)
245
+ callbacks = callbacks_for(phase, from: initial_state, to: new_state)
246
+ callbacks.each { |cb| cb.call(@object, exception) }
222
247
  end
223
248
 
224
249
  def execute(phase, initial_state, new_state, transition)
@@ -265,10 +290,7 @@ module Statesman
265
290
  to = to_s_or_nil(options[:to])
266
291
 
267
292
  successors = self.class.successors[from] || []
268
- unless successors.include?(to)
269
- raise TransitionFailedError,
270
- "Cannot transition from '#{from}' to '#{to}'"
271
- end
293
+ raise TransitionFailedError.new(from, to) unless successors.include?(to)
272
294
 
273
295
  # Call all guards, they raise exceptions if they fail
274
296
  guards_for(from: from, to: to).each do |guard|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  class Railtie < ::Rails::Railtie
3
5
  railtie_name :statesman
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  module Utils
3
5
  def self.rails_major_version
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
- VERSION = "3.5.0".freeze
4
+ VERSION = "6.0.0"
3
5
  end
data/lib/statesman.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  autoload :Config, "statesman/config"
3
5
  autoload :Machine, "statesman/machine"
@@ -12,9 +14,6 @@ module Statesman
12
14
  "statesman/adapters/active_record_transition"
13
15
  autoload :ActiveRecordQueries,
14
16
  "statesman/adapters/active_record_queries"
15
- autoload :Mongoid, "statesman/adapters/mongoid"
16
- autoload :MongoidTransition,
17
- "statesman/adapters/mongoid_transition"
18
17
  end
19
18
  require "statesman/railtie" if defined?(::Rails::Railtie)
20
19
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  namespace :statesman do
2
4
  desc "Set most_recent to false for old transitions and to true for the "\
3
5
  "latest one. Safe to re-run"
@@ -19,7 +21,7 @@ namespace :statesman do
19
21
  batch_size = 500
20
22
 
21
23
  parent_class.find_in_batches(batch_size: batch_size) do |models|
22
- ActiveRecord::Base.transaction do
24
+ ActiveRecord::Base.transaction(requires_new: true) do
23
25
  if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
24
26
  # Set all transitions' most_recent to FALSE
25
27
  transition_class.where(parent_fk => models.map(&:id)).
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AddConstraintsToMostRecentForBaconTransitions < ActiveRecord::Migration
2
4
  disable_ddl_transaction!
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AddConstraintsToMostRecentForBaconTransitions < ActiveRecord::Migration
2
4
  disable_ddl_transaction!
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AddMostRecentToBaconTransitions < ActiveRecord::Migration
2
4
  def up
3
5
  add_column :bacon_transitions, :most_recent, :boolean, null: true
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "support/generators_shared_examples"
3
5
  require "generators/statesman/active_record_transition_generator"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "support/generators_shared_examples"
3
5
  require "generators/statesman/migration_generator"
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "statesman"
2
4
  require "sqlite3"
3
5
  require "mysql2"
@@ -20,36 +22,7 @@ RSpec.configure do |config|
20
22
  config.order = "random"
21
23
 
22
24
  def connection_failure
23
- if defined?(Moped)
24
- Moped::Errors::ConnectionFailure
25
- else
26
- Mongo::Error::NoServerAvailable
27
- end
28
- end
29
-
30
- if config.exclusion_filter[:mongo]
31
- puts "Skipping Mongo tests"
32
- else
33
- require "mongoid"
34
-
35
- # Try a mongo connection at the start of the suite and raise if it fails
36
- begin
37
- Mongoid.configure do |mongo_config|
38
- if defined?(Moped)
39
- mongo_config.connect_to("statesman_test")
40
- mongo_config.sessions["default"]["options"]["max_retries"] = 2
41
- else
42
- mongo_config.connect_to("statesman_test", server_selection_timeout: 2)
43
- end
44
- end
45
- # Attempting a mongo operation will trigger 2 retries then throw an
46
- # exception if mongo is not running.
47
- Mongoid.purge!
48
- rescue connection_failure => error
49
- puts "The spec suite requires MongoDB to be installed and running locally"
50
- puts "Mongo dependent specs can be filtered with rspec --tag '~mongo'"
51
- raise(error)
52
- end
25
+ Moped::Errors::ConnectionFailure if defined?(Moped)
53
26
  end
54
27
 
55
28
  if config.exclusion_filter[:active_record]
@@ -1,6 +1,19 @@
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
@@ -8,32 +21,6 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
8
21
  prepare_other_transitions_table
9
22
 
10
23
  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
21
- 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
24
  end
38
25
 
39
26
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
@@ -59,105 +46,164 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
59
46
  model
60
47
  end
61
48
 
62
- describe ".in_state" do
63
- context "given a single state" do
64
- subject { MyActiveRecordModel.in_state(:succeeded) }
49
+ shared_examples "testing methods" do
50
+ before do
51
+ if config_type == :old
52
+ configure_old(MyActiveRecordModel, MyActiveRecordModelTransition)
53
+ configure_old(OtherActiveRecordModel, OtherActiveRecordModelTransition)
54
+ elsif config_type == :new
55
+ configure_new(MyActiveRecordModel, MyActiveRecordModelTransition)
56
+ configure_new(OtherActiveRecordModel, OtherActiveRecordModelTransition)
57
+ else
58
+ raise "Unknown config type #{config_type}"
59
+ end
65
60
 
66
- it { is_expected.to include model }
67
- it { is_expected.to_not include other_model }
61
+ MyActiveRecordModel.send(:has_one, :other_active_record_model)
62
+ OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
68
63
  end
69
64
 
70
- context "given multiple states" do
71
- subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
65
+ describe ".in_state" do
66
+ context "given a single state" do
67
+ subject { MyActiveRecordModel.in_state(:succeeded) }
68
+
69
+ it { is_expected.to include model }
70
+ it { is_expected.to_not include other_model }
71
+ end
72
72
 
73
- it { is_expected.to include model }
74
- it { is_expected.to include other_model }
75
- end
73
+ context "given multiple states" do
74
+ subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
76
75
 
77
- context "given the initial state" do
78
- subject { MyActiveRecordModel.in_state(:initial) }
76
+ it { is_expected.to include model }
77
+ it { is_expected.to include other_model }
78
+ end
79
79
 
80
- it { is_expected.to include initial_state_model }
81
- it { is_expected.to include returned_to_initial_model }
82
- end
80
+ context "given the initial state" do
81
+ subject { MyActiveRecordModel.in_state(:initial) }
83
82
 
84
- context "given an array of states" do
85
- subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
83
+ it { is_expected.to include initial_state_model }
84
+ it { is_expected.to include returned_to_initial_model }
85
+ end
86
86
 
87
- it { is_expected.to include model }
88
- it { is_expected.to include other_model }
89
- end
87
+ context "given an array of states" do
88
+ subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
90
89
 
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))
90
+ it { is_expected.to include model }
91
+ it { is_expected.to include other_model }
96
92
  end
97
93
 
98
- it { is_expected.to be_empty }
94
+ context "merging two queries" do
95
+ subject do
96
+ MyActiveRecordModel.in_state(:succeeded).
97
+ joins(:other_active_record_model).
98
+ merge(OtherActiveRecordModel.in_state(:initial))
99
+ end
100
+
101
+ it { is_expected.to be_empty }
102
+ end
99
103
  end
100
- end
101
104
 
102
- describe ".not_in_state" do
103
- context "given a single state" do
104
- subject { MyActiveRecordModel.not_in_state(:failed) }
105
+ describe ".not_in_state" do
106
+ context "given a single state" do
107
+ subject { MyActiveRecordModel.not_in_state(:failed) }
105
108
 
106
- it { is_expected.to include model }
107
- it { is_expected.to_not include other_model }
108
- end
109
+ it { is_expected.to include model }
110
+ it { is_expected.to_not include other_model }
111
+ end
109
112
 
110
- context "given multiple states" do
111
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
113
+ context "given multiple states" do
114
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
112
115
 
113
- it do
114
- expect(not_in_state).to match_array([initial_state_model,
115
- returned_to_initial_model])
116
+ it do
117
+ expect(not_in_state).to match_array([initial_state_model,
118
+ returned_to_initial_model])
119
+ end
116
120
  end
117
- end
118
121
 
119
- context "given an array of states" do
120
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
122
+ context "given an array of states" do
123
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
121
124
 
122
- it do
123
- expect(not_in_state).to match_array([initial_state_model,
124
- returned_to_initial_model])
125
+ it do
126
+ expect(not_in_state).to match_array([initial_state_model,
127
+ returned_to_initial_model])
128
+ end
125
129
  end
126
130
  end
127
- end
128
131
 
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
132
+ context "with a custom name for the transition association" do
133
+ before do
134
+ # Switch to using OtherActiveRecordModelTransition, so the existing
135
+ # relation with MyActiveRecordModelTransition doesn't interfere with
136
+ # this spec.
137
+ MyActiveRecordModel.send(:has_many,
138
+ :custom_name,
139
+ class_name: "OtherActiveRecordModelTransition")
140
+
141
+ MyActiveRecordModel.class_eval do
142
+ def self.transition_class
143
+ OtherActiveRecordModelTransition
144
+ end
141
145
  end
142
146
  end
147
+
148
+ describe ".in_state" do
149
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
150
+
151
+ specify { expect { query }.to_not raise_error }
152
+ end
143
153
  end
144
154
 
145
- describe ".in_state" do
146
- subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
155
+ context "after_commit transactional integrity" do
156
+ before do
157
+ MyStateMachine.class_eval do
158
+ cattr_accessor(:after_commit_callback_executed) { false }
147
159
 
148
- specify { expect { query }.to_not raise_error }
160
+ after_transition(from: :initial, to: :succeeded, after_commit: true) do
161
+ # This leaks state in a testable way if transactional integrity is broken.
162
+ MyStateMachine.after_commit_callback_executed = true
163
+ end
164
+ end
165
+ end
166
+
167
+ after do
168
+ MyStateMachine.class_eval do
169
+ callbacks[:after_commit] = []
170
+ end
171
+ end
172
+
173
+ let!(:model) do
174
+ MyActiveRecordModel.create
175
+ end
176
+
177
+ # rubocop:disable RSpec/ExampleLength
178
+ it do
179
+ expect do
180
+ ActiveRecord::Base.transaction do
181
+ model.state_machine.transition_to!(:succeeded)
182
+ raise ActiveRecord::Rollback
183
+ end
184
+ end.to_not change(MyStateMachine, :after_commit_callback_executed)
185
+ end
186
+ # rubocop:enable RSpec/ExampleLength
149
187
  end
150
188
  end
151
189
 
190
+ context "using old configuration method" do
191
+ let(:config_type) { :old }
192
+
193
+ include_examples "testing methods"
194
+ end
195
+
196
+ context "using new configuration method" do
197
+ let(:config_type) { :new }
198
+
199
+ include_examples "testing methods"
200
+ end
201
+
152
202
  context "with no association with the transition class" do
153
203
  before do
154
204
  class UnknownModelTransition < OtherActiveRecordModelTransition; end
155
205
 
156
- MyActiveRecordModel.class_eval do
157
- def self.transition_class
158
- UnknownModelTransition
159
- end
160
- end
206
+ configure_old(MyActiveRecordModel, UnknownModelTransition)
161
207
  end
162
208
 
163
209
  describe ".in_state" do
@@ -168,4 +214,32 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
168
214
  end
169
215
  end
170
216
  end
217
+
218
+ describe "check_missing_methods!" do
219
+ subject(:check_missing_methods!) { described_class.check_missing_methods!(base) }
220
+
221
+ context "when base has no missing methods" do
222
+ let(:base) do
223
+ Class.new do
224
+ def self.transition_class; end
225
+
226
+ def self.initial_state; end
227
+ end
228
+ end
229
+
230
+ it "does not raise an error" do
231
+ expect { check_missing_methods! }.to_not raise_exception(NotImplementedError)
232
+ end
233
+ end
234
+
235
+ context "when base has missing methods" do
236
+ let(:base) do
237
+ Class.new
238
+ end
239
+
240
+ it "raises an error" do
241
+ expect { check_missing_methods! }.to raise_exception(NotImplementedError)
242
+ end
243
+ end
244
+ end
171
245
  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"
@@ -227,6 +229,7 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
227
229
  it "still has the old state" do
228
230
  allow(observer).to receive(:execute) do |phase|
229
231
  next unless phase == :before
232
+
230
233
  expect(
231
234
  model.transitions.where(most_recent: true).first.to_state,
232
235
  ).to eq("y")
@@ -240,6 +243,7 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
240
243
  it "still has the old state" do
241
244
  allow(observer).to receive(:execute) do |phase|
242
245
  next unless phase == :after
246
+
243
247
  expect(
244
248
  model.transitions.where(most_recent: true).first.to_state,
245
249
  ).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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
 
3
5
  describe Statesman::Guard do