statesman 4.1.4 → 5.0.0

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