statesman 3.5.0 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
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