statesman 4.1.4 → 5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7445e509f7b9bafc381522bfbdbedac811430da81375bb767472f92386218140
4
- data.tar.gz: 176143a22965d91c30957bfa7744089947e4269046fac188b89550d737b7cbb8
3
+ metadata.gz: 5f79354b244926e7be655b02df501cfed437ab2da6e869ec8db35097bb5ef372
4
+ data.tar.gz: 2b6ad54a68b6bca463c00c9e09a83d2d2d9d47d984fad212da77b0b0fd8a18a9
5
5
  SHA512:
6
- metadata.gz: afec82cb24e537055105b34d1468f55b4fc220d7dffe49627910fdc7be7e61fbeb044b2e36f53bb111a9c7aeab23cc77b7828c0ac8b3bba167ac461246becc40
7
- data.tar.gz: d98cf49966c4bb635b98798b44ecab0e5ad77714c794580eeb13939cbff7ab7dfd308de8041e6b95a0a1b56e5e364813896ade88b50422a87ecb00b7294745c0
6
+ metadata.gz: e66962ac0dd871627bdf6c53c5282e140bd109c3fcd0ba2ebd25504c490e3ddf2050132aabdf143c35aff09330e229afe8ec57c7d7fc43552bcff8140f8f0fa0
7
+ data.tar.gz: 9e3fa636071bcd530b177e80bf1014071d38601da2076e5258ffed22f41eec388255db2b03b79710130624c5fb6c60009af511b76d478be718f5fc69a6f0e933
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## v5.0.0, 11th November 2019
2
+
3
+ - Adds new syntax and restrictions to ActiveRecordQueries [PR#358](https://github.com/gocardless/statesman/pull/358). With the introduction of this, defining `self.transition_class` or `self.initial_state` is deprecated and will be removed in the next major release.
4
+
1
5
  ## v4.1.4, 11th November 2019
2
6
 
3
7
  - Reverts the breaking changes from [PR#358](https://github.com/gocardless/statesman/pull/358) & `v4.1.3` that where included in the last minor release. If you have changed your code to work with these changes `v5.0.0` will be a copy of `v4.1.3` with a bugfix applied.
data/README.md CHANGED
@@ -30,7 +30,7 @@ protection.
30
30
  To get started, just add Statesman to your `Gemfile`, and then run `bundle`:
31
31
 
32
32
  ```ruby
33
- gem 'statesman', '~> 4.1.4'
33
+ gem 'statesman', '~> 5.0.0'
34
34
  ```
35
35
 
36
36
  ## Usage
@@ -76,22 +76,16 @@ Then, link it to your model:
76
76
 
77
77
  ```ruby
78
78
  class Order < ActiveRecord::Base
79
- include Statesman::Adapters::ActiveRecordQueries
80
-
81
79
  has_many :order_transitions, autosave: false
82
80
 
81
+ include Statesman::Adapters::ActiveRecordQueries[
82
+ transition_class: OrderTransition,
83
+ initial_state: :pending
84
+ ]
85
+
83
86
  def state_machine
84
87
  @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
85
88
  end
86
-
87
- def self.transition_class
88
- OrderTransition
89
- end
90
-
91
- def self.initial_state
92
- :pending
93
- end
94
- private_class_method :initial_state
95
89
  end
96
90
  ```
97
91
 
@@ -357,43 +351,34 @@ callback code throws an exception, it will not be caught.)
357
351
 
358
352
  A mixin is provided for the ActiveRecord adapter which adds scopes to easily
359
353
  find all models currently in (or not in) a given state. Include it into your
360
- model and define `transition_class` and `initial_state` class methods:
354
+ model and passing in `transition_class` and `initial_state` as options.
355
+
356
+ In 4.1.2 and below, these two options had to be defined as methods on the model,
357
+ but 5.0.0 and above allow this style of configuration as well.
358
+ The old method pollutes the model with extra class methods, and is deprecated,
359
+ to be removed in 6.0.0.
361
360
 
362
361
  ```ruby
363
362
  class Order < ActiveRecord::Base
364
- include Statesman::Adapters::ActiveRecordQueries
365
-
366
- def self.transition_class
367
- OrderTransition
368
- end
369
- private_class_method :transition_class
370
-
371
- def self.initial_state
372
- OrderStateMachine.initial_state
373
- end
374
- private_class_method :initial_state
363
+ has_many :order_transitions, autosave: false
364
+ include Statesman::Adapters::ActiveRecordQueries[
365
+ transition_class: OrderTransition,
366
+ initial_state: OrderStateMachine.initial_state
367
+ ]
375
368
  end
376
369
  ```
377
370
 
378
371
  If the transition class-name differs from the association name, you will also
379
- need to define a corresponding `transition_name` class method:
372
+ need to pass `transition_name` as an option:
380
373
 
381
374
  ```ruby
382
375
  class Order < ActiveRecord::Base
383
376
  has_many :transitions, class_name: "OrderTransition", autosave: false
384
-
385
- def self.transition_name
386
- :transitions
387
- end
388
-
389
- def self.transition_class
390
- OrderTransition
391
- end
392
-
393
- def self.initial_state
394
- OrderStateMachine.initial_state
395
- end
396
- private_class_method :initial_state
377
+ include Statesman::Adapters::ActiveRecordQueries[
378
+ transition_class: OrderTransition,
379
+ initial_state: OrderStateMachine.initial_state,
380
+ transition_name: :transitions
381
+ ]
397
382
  end
398
383
  ```
399
384
 
@@ -1,51 +1,122 @@
1
1
  module Statesman
2
2
  module Adapters
3
3
  module ActiveRecordQueries
4
+ def self.check_missing_methods!(base)
5
+ missing_methods = %i[transition_class initial_state].
6
+ reject { |m| base.respond_to?(m) }
7
+ return if missing_methods.none?
8
+
9
+ raise NotImplementedError,
10
+ "#{missing_methods.join(', ')} method(s) should be defined on " \
11
+ "the model. Alternatively, use the new form of `extend " \
12
+ "Statesman::Adapters::ActiveRecordQueries[" \
13
+ "transition_class: MyTransition, " \
14
+ "initial_state: :some_state]`"
15
+ end
16
+
4
17
  def self.included(base)
5
- base.extend(ClassMethods)
18
+ check_missing_methods!(base)
19
+
20
+ base.include(
21
+ ClassMethods.new(
22
+ transition_class: base.transition_class,
23
+ initial_state: base.initial_state,
24
+ most_recent_transition_alias: base.try(:most_recent_transition_alias),
25
+ transition_name: base.try(:transition_name),
26
+ ),
27
+ )
6
28
  end
7
29
 
8
- module ClassMethods
9
- def in_state(*states)
10
- states = states.flatten.map(&:to_s)
30
+ def self.[](**args)
31
+ ClassMethods.new(**args)
32
+ end
33
+
34
+ class ClassMethods < Module
35
+ def initialize(**args)
36
+ @args = args
37
+ end
38
+
39
+ def included(base)
40
+ ensure_inheritance(base)
41
+
42
+ query_builder = QueryBuilder.new(base, **@args)
43
+
44
+ base.define_singleton_method(:most_recent_transition_join) do
45
+ query_builder.most_recent_transition_join
46
+ end
47
+
48
+ define_in_state(base, query_builder)
49
+ define_not_in_state(base, query_builder)
50
+ end
11
51
 
12
- joins(most_recent_transition_join).
13
- where(states_where(most_recent_transition_alias, states), states)
52
+ private
53
+
54
+ def ensure_inheritance(base)
55
+ klass = self
56
+ existing_inherited = base.method(:inherited)
57
+ base.define_singleton_method(:inherited) do |subclass|
58
+ existing_inherited.call(subclass)
59
+ subclass.send(:include, klass)
60
+ end
14
61
  end
15
62
 
16
- def not_in_state(*states)
17
- states = states.flatten.map(&:to_s)
63
+ def define_in_state(base, query_builder)
64
+ base.define_singleton_method(:in_state) do |*states|
65
+ states = states.flatten.map(&:to_s)
18
66
 
19
- joins(most_recent_transition_join).
20
- where("NOT (#{states_where(most_recent_transition_alias, states)})",
21
- states)
67
+ joins(most_recent_transition_join).
68
+ where(query_builder.states_where(states), states)
69
+ end
70
+ end
71
+
72
+ def define_not_in_state(base, query_builder)
73
+ base.define_singleton_method(:not_in_state) do |*states|
74
+ states = states.flatten.map(&:to_s)
75
+
76
+ joins(most_recent_transition_join).
77
+ where("NOT (#{query_builder.states_where(states)})", states)
78
+ end
79
+ end
80
+ end
81
+
82
+ class QueryBuilder
83
+ def initialize(model, transition_class:, initial_state:,
84
+ most_recent_transition_alias: nil,
85
+ transition_name: nil)
86
+ @model = model
87
+ @transition_class = transition_class
88
+ @initial_state = initial_state
89
+ @most_recent_transition_alias = most_recent_transition_alias
90
+ @transition_name = transition_name
91
+ end
92
+
93
+ def states_where(states)
94
+ if initial_state.to_s.in?(states.map(&:to_s))
95
+ "#{most_recent_transition_alias}.to_state IN (?) OR " \
96
+ "#{most_recent_transition_alias}.to_state IS NULL"
97
+ else
98
+ "#{most_recent_transition_alias}.to_state IN (?) AND " \
99
+ "#{most_recent_transition_alias}.to_state IS NOT NULL"
100
+ end
22
101
  end
23
102
 
24
103
  def most_recent_transition_join
25
104
  "LEFT OUTER JOIN #{model_table} AS #{most_recent_transition_alias}
26
- ON #{table_name}.id =
105
+ ON #{model.table_name}.id =
27
106
  #{most_recent_transition_alias}.#{model_foreign_key}
28
107
  AND #{most_recent_transition_alias}.most_recent = #{db_true}"
29
108
  end
30
109
 
31
110
  private
32
111
 
33
- def transition_class
34
- raise NotImplementedError, "A transition_class method should be " \
35
- "defined on the model"
36
- end
37
-
38
- def initial_state
39
- raise NotImplementedError, "An initial_state method should be " \
40
- "defined on the model"
41
- end
112
+ attr_reader :model, :transition_class, :initial_state
42
113
 
43
114
  def transition_name
44
- transition_class.table_name.to_sym
115
+ @transition_name || transition_class.table_name.to_sym
45
116
  end
46
117
 
47
118
  def transition_reflection
48
- reflect_on_all_associations(:has_many).each do |value|
119
+ model.reflect_on_all_associations(:has_many).each do |value|
49
120
  return value if value.klass == transition_class
50
121
  end
51
122
 
@@ -62,18 +133,9 @@ module Statesman
62
133
  transition_reflection.table_name
63
134
  end
64
135
 
65
- def states_where(temporary_table_name, states)
66
- if initial_state.to_s.in?(states.map(&:to_s))
67
- "#{temporary_table_name}.to_state IN (?) OR " \
68
- "#{temporary_table_name}.to_state IS NULL"
69
- else
70
- "#{temporary_table_name}.to_state IN (?) AND " \
71
- "#{temporary_table_name}.to_state IS NOT NULL"
72
- end
73
- end
74
-
75
136
  def most_recent_transition_alias
76
- "most_recent_#{transition_name.to_s.singularize}"
137
+ @most_recent_transition_alias ||
138
+ "most_recent_#{transition_name.to_s.singularize}"
77
139
  end
78
140
 
79
141
  def db_true
@@ -1,3 +1,3 @@
1
1
  module Statesman
2
- VERSION = "4.1.4".freeze
2
+ VERSION = "5.0.0".freeze
3
3
  end
@@ -1,6 +1,17 @@
1
1
  require "spec_helper"
2
2
 
3
3
  describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
4
+ def configure_old(klass, transition_class)
5
+ klass.define_singleton_method(:transition_class) { transition_class }
6
+ klass.define_singleton_method(:initial_state) { :initial }
7
+ klass.send(:include, described_class)
8
+ end
9
+
10
+ def configure_new(klass, transition_class)
11
+ klass.send(:include, described_class[transition_class: transition_class,
12
+ initial_state: :initial])
13
+ end
14
+
4
15
  before do
5
16
  prepare_model_table
6
17
  prepare_transitions_table
@@ -8,32 +19,6 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
8
19
  prepare_other_transitions_table
9
20
 
10
21
  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
22
  end
38
23
 
39
24
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
@@ -59,105 +44,164 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
59
44
  model
60
45
  end
61
46
 
62
- describe ".in_state" do
63
- context "given a single state" do
64
- subject { MyActiveRecordModel.in_state(:succeeded) }
47
+ shared_examples "testing methods" do
48
+ before do
49
+ if config_type == :old
50
+ configure_old(MyActiveRecordModel, MyActiveRecordModelTransition)
51
+ configure_old(OtherActiveRecordModel, OtherActiveRecordModelTransition)
52
+ elsif config_type == :new
53
+ configure_new(MyActiveRecordModel, MyActiveRecordModelTransition)
54
+ configure_new(OtherActiveRecordModel, OtherActiveRecordModelTransition)
55
+ else
56
+ raise "Unknown config type #{config_type}"
57
+ end
65
58
 
66
- it { is_expected.to include model }
67
- it { is_expected.to_not include other_model }
59
+ MyActiveRecordModel.send(:has_one, :other_active_record_model)
60
+ OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
68
61
  end
69
62
 
70
- context "given multiple states" do
71
- subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
63
+ describe ".in_state" do
64
+ context "given a single state" do
65
+ subject { MyActiveRecordModel.in_state(:succeeded) }
72
66
 
73
- it { is_expected.to include model }
74
- it { is_expected.to include other_model }
75
- end
67
+ it { is_expected.to include model }
68
+ it { is_expected.to_not include other_model }
69
+ end
76
70
 
77
- context "given the initial state" do
78
- subject { MyActiveRecordModel.in_state(:initial) }
71
+ context "given multiple states" do
72
+ subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
79
73
 
80
- it { is_expected.to include initial_state_model }
81
- it { is_expected.to include returned_to_initial_model }
82
- end
74
+ it { is_expected.to include model }
75
+ it { is_expected.to include other_model }
76
+ end
83
77
 
84
- context "given an array of states" do
85
- subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
78
+ context "given the initial state" do
79
+ subject { MyActiveRecordModel.in_state(:initial) }
86
80
 
87
- it { is_expected.to include model }
88
- it { is_expected.to include other_model }
89
- end
81
+ it { is_expected.to include initial_state_model }
82
+ it { is_expected.to include returned_to_initial_model }
83
+ end
84
+
85
+ context "given an array of states" do
86
+ subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
90
87
 
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))
88
+ it { is_expected.to include model }
89
+ it { is_expected.to include other_model }
96
90
  end
97
91
 
98
- it { is_expected.to be_empty }
92
+ context "merging two queries" do
93
+ subject do
94
+ MyActiveRecordModel.in_state(:succeeded).
95
+ joins(:other_active_record_model).
96
+ merge(OtherActiveRecordModel.in_state(:initial))
97
+ end
98
+
99
+ it { is_expected.to be_empty }
100
+ end
99
101
  end
100
- end
101
102
 
102
- describe ".not_in_state" do
103
- context "given a single state" do
104
- subject { MyActiveRecordModel.not_in_state(:failed) }
103
+ describe ".not_in_state" do
104
+ context "given a single state" do
105
+ subject { MyActiveRecordModel.not_in_state(:failed) }
105
106
 
106
- it { is_expected.to include model }
107
- it { is_expected.to_not include other_model }
108
- end
107
+ it { is_expected.to include model }
108
+ it { is_expected.to_not include other_model }
109
+ end
109
110
 
110
- context "given multiple states" do
111
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
111
+ context "given multiple states" do
112
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
112
113
 
113
- it do
114
- expect(not_in_state).to match_array([initial_state_model,
115
- returned_to_initial_model])
114
+ it do
115
+ expect(not_in_state).to match_array([initial_state_model,
116
+ returned_to_initial_model])
117
+ end
116
118
  end
117
- end
118
119
 
119
- context "given an array of states" do
120
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
120
+ context "given an array of states" do
121
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
121
122
 
122
- it do
123
- expect(not_in_state).to match_array([initial_state_model,
124
- returned_to_initial_model])
123
+ it do
124
+ expect(not_in_state).to match_array([initial_state_model,
125
+ returned_to_initial_model])
126
+ end
125
127
  end
126
128
  end
127
- end
128
129
 
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
130
+ context "with a custom name for the transition association" do
131
+ before do
132
+ # Switch to using OtherActiveRecordModelTransition, so the existing
133
+ # relation with MyActiveRecordModelTransition doesn't interfere with
134
+ # this spec.
135
+ MyActiveRecordModel.send(:has_many,
136
+ :custom_name,
137
+ class_name: "OtherActiveRecordModelTransition")
138
+
139
+ MyActiveRecordModel.class_eval do
140
+ def self.transition_class
141
+ OtherActiveRecordModelTransition
142
+ end
141
143
  end
142
144
  end
145
+
146
+ describe ".in_state" do
147
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
148
+
149
+ specify { expect { query }.to_not raise_error }
150
+ end
143
151
  end
144
152
 
145
- describe ".in_state" do
146
- subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
153
+ context "after_commit transactional integrity" do
154
+ before do
155
+ MyStateMachine.class_eval do
156
+ cattr_accessor(:after_commit_callback_executed) { false }
157
+
158
+ after_transition(from: :initial, to: :succeeded, after_commit: true) do
159
+ # This leaks state in a testable way if transactional integrity is broken.
160
+ MyStateMachine.after_commit_callback_executed = true
161
+ end
162
+ end
163
+ end
164
+
165
+ after do
166
+ MyStateMachine.class_eval do
167
+ callbacks[:after_commit] = []
168
+ end
169
+ end
170
+
171
+ let!(:model) do
172
+ MyActiveRecordModel.create
173
+ end
147
174
 
148
- specify { expect { query }.to_not raise_error }
175
+ # rubocop:disable RSpec/ExampleLength
176
+ it do
177
+ expect do
178
+ ActiveRecord::Base.transaction do
179
+ model.state_machine.transition_to!(:succeeded)
180
+ raise ActiveRecord::Rollback
181
+ end
182
+ end.to_not change(MyStateMachine, :after_commit_callback_executed)
183
+ end
184
+ # rubocop:enable RSpec/ExampleLength
149
185
  end
150
186
  end
151
187
 
188
+ context "using old configuration method" do
189
+ let(:config_type) { :old }
190
+
191
+ include_examples "testing methods"
192
+ end
193
+
194
+ context "using new configuration method" do
195
+ let(:config_type) { :new }
196
+
197
+ include_examples "testing methods"
198
+ end
199
+
152
200
  context "with no association with the transition class" do
153
201
  before do
154
202
  class UnknownModelTransition < OtherActiveRecordModelTransition; end
155
203
 
156
- MyActiveRecordModel.class_eval do
157
- def self.transition_class
158
- UnknownModelTransition
159
- end
160
- end
204
+ configure_old(MyActiveRecordModel, UnknownModelTransition)
161
205
  end
162
206
 
163
207
  describe ".in_state" do
@@ -169,37 +213,31 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
169
213
  end
170
214
  end
171
215
 
172
- context "after_commit transactional integrity" do
173
- before do
174
- MyStateMachine.class_eval do
175
- cattr_accessor(:after_commit_callback_executed) { false }
216
+ describe "check_missing_methods!" do
217
+ subject(:check_missing_methods!) { described_class.check_missing_methods!(base) }
176
218
 
177
- after_transition(from: :initial, to: :succeeded, after_commit: true) do
178
- # This leaks state in a testable way if transactional integrity is broken.
179
- MyStateMachine.after_commit_callback_executed = true
219
+ context "when base has no missing methods" do
220
+ let(:base) do
221
+ Class.new do
222
+ def self.transition_class; end
223
+
224
+ def self.initial_state; end
180
225
  end
181
226
  end
182
- end
183
227
 
184
- after do
185
- MyStateMachine.class_eval do
186
- callbacks[:after_commit] = []
228
+ it "does not raise an error" do
229
+ expect { check_missing_methods! }.to_not raise_exception(NotImplementedError)
187
230
  end
188
231
  end
189
232
 
190
- let!(:model) do
191
- MyActiveRecordModel.create
192
- end
233
+ context "when base has missing methods" do
234
+ let(:base) do
235
+ Class.new
236
+ end
193
237
 
194
- # rubocop:disable RSpec/ExampleLength
195
- it do
196
- expect do
197
- ActiveRecord::Base.transaction do
198
- model.state_machine.transition_to!(:succeeded)
199
- raise ActiveRecord::Rollback
200
- end
201
- end.to_not change(MyStateMachine, :after_commit_callback_executed)
238
+ it "raises an error" do
239
+ expect { check_missing_methods! }.to raise_exception(NotImplementedError)
240
+ end
202
241
  end
203
- # rubocop:enable RSpec/ExampleLength
204
242
  end
205
243
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statesman
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.4
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GoCardless