statesman 0.7.0 → 0.8.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 +4 -4
- data/.rubocop.yml +8 -2
- data/CHANGELOG.md +6 -0
- data/Guardfile +1 -1
- data/README.md +75 -1
- data/circle.yml +1 -1
- data/lib/statesman/adapters/mongoid.rb +1 -1
- data/lib/statesman/event_transitions.rb +15 -0
- data/lib/statesman/machine.rb +55 -1
- data/lib/statesman/version.rb +1 -1
- data/spec/generators/statesman/active_record_transition_generator_spec.rb +7 -7
- data/spec/generators/statesman/migration_generator_spec.rb +4 -4
- data/spec/generators/statesman/mongoid_transition_generator_spec.rb +6 -6
- data/spec/spec_helper.rb +3 -3
- data/spec/statesman/adapters/active_record_model_spec.rb +6 -6
- data/spec/statesman/adapters/active_record_spec.rb +14 -13
- data/spec/statesman/adapters/active_record_transition_spec.rb +2 -2
- data/spec/statesman/adapters/mongoid_spec.rb +5 -4
- data/spec/statesman/adapters/shared_examples.rb +27 -25
- data/spec/statesman/callback_spec.rb +11 -11
- data/spec/statesman/config_spec.rb +1 -1
- data/spec/statesman/machine_spec.rb +222 -25
- data/spec/support/generators_shared_examples.rb +3 -3
- data/statesman.gemspec +6 -5
- metadata +27 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 22f3c63fa8f737d3fbaef505d2329bd8e0ed1183
|
4
|
+
data.tar.gz: ef478f905d16dc128d8ddeaefbf52ccce0731938
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 810023ee4183eeb404a4ed5cee4d933d45590ab4eaf3a30864fb15181ac8d8de8e9ff0f7ed50a36ecad0e7414d303da750b824cd29d5984d95dfa3095eb93411
|
7
|
+
data.tar.gz: 0a83338cf2ab14661eef2fca42ee08511fc9e27538965e5b1213843492efd8bb24aa7bb442ae4b830917a87dba30dd581e434e5d70538054b08560ad6d41aac1
|
data/.rubocop.yml
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
# For all options see https://github.com/bbatsov/rubocop/tree/master/config
|
2
2
|
|
3
3
|
AllCops:
|
4
|
-
|
4
|
+
Include:
|
5
5
|
- Rakefile
|
6
6
|
- statesman.gemfile
|
7
|
-
|
7
|
+
Exclude:
|
8
8
|
- vendor/**
|
9
9
|
|
10
10
|
StringLiterals:
|
@@ -30,3 +30,9 @@ Encoding:
|
|
30
30
|
|
31
31
|
LineLength:
|
32
32
|
Max: 80
|
33
|
+
|
34
|
+
GuardClause:
|
35
|
+
Enabled: false
|
36
|
+
|
37
|
+
SingleSpaceBeforeFirstArg:
|
38
|
+
Enabled: false
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,9 @@
|
|
1
|
+
## v0.8.0 29 June 2014
|
2
|
+
*Additions*
|
3
|
+
|
4
|
+
- Events. Machines can now define events as a logical grouping of transitions (patch by [@iurimatias](https://github.com/iurimatias))
|
5
|
+
- Retries. Individual transitions can be executed with a retry policy by wrapping the method call in a `Machine.retry_conflicts {}` block (patch by [@greysteil](https://github.com/greysteil))
|
6
|
+
|
1
7
|
## v0.7.0 25 June 2014
|
2
8
|
*Additions*
|
3
9
|
|
data/Guardfile
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
# A sample Guardfile
|
2
2
|
# More info at https://github.com/guard/guard#readme
|
3
3
|
|
4
|
-
guard :rspec,
|
4
|
+
guard :rspec, all_on_start: true, cmd: 'bundle exec rspec --color' do
|
5
5
|
watch(%r{^spec/.+_spec\.rb$})
|
6
6
|
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
7
7
|
watch('spec/spec_helper.rb') { "spec" }
|
data/README.md
CHANGED
@@ -96,6 +96,71 @@ Order.first.state_machine.transition_to!(:cancelled)
|
|
96
96
|
# => true/exception
|
97
97
|
```
|
98
98
|
|
99
|
+
## Events
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
class TaskStateMachine
|
103
|
+
include Statesman::Machine
|
104
|
+
|
105
|
+
state :unstarted, initial: true
|
106
|
+
state :started
|
107
|
+
state :finished
|
108
|
+
state :delivered
|
109
|
+
state :accepted
|
110
|
+
state :rejected
|
111
|
+
|
112
|
+
event :start do
|
113
|
+
transition from: :unstarted, to: :started
|
114
|
+
end
|
115
|
+
|
116
|
+
event :finish do
|
117
|
+
transition from: :started, to: :finished
|
118
|
+
end
|
119
|
+
|
120
|
+
event :deliver do
|
121
|
+
transition from: :finished, to: :delivered
|
122
|
+
transition from: :started, to: :delivered
|
123
|
+
end
|
124
|
+
|
125
|
+
event :accept do
|
126
|
+
transition from: :delivered, to: :accepted
|
127
|
+
end
|
128
|
+
|
129
|
+
event :rejected do
|
130
|
+
transition from: :delivered, to: :rejected
|
131
|
+
end
|
132
|
+
|
133
|
+
event :restart do
|
134
|
+
transition from: :rejected, to: :started
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|
138
|
+
|
139
|
+
class Task < ActiveRecord::Base
|
140
|
+
delegate :current_state, :trigger!, :available_events, to: :state_machine
|
141
|
+
|
142
|
+
def state_machine
|
143
|
+
@state_machine ||= TaskStateMachine.new(self)
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
147
|
+
|
148
|
+
task = Task.new
|
149
|
+
|
150
|
+
task.current_state
|
151
|
+
# => "unstarted"
|
152
|
+
|
153
|
+
task.trigger!(:start)
|
154
|
+
# => true/exception
|
155
|
+
|
156
|
+
task.current_state
|
157
|
+
# => "started"
|
158
|
+
|
159
|
+
task.available_events
|
160
|
+
# => [:finish, :deliver]
|
161
|
+
|
162
|
+
```
|
163
|
+
|
99
164
|
## Persistence
|
100
165
|
|
101
166
|
By default Statesman stores transition history in memory only. It can be
|
@@ -209,6 +274,15 @@ Initialize a new state machine instance. `my_model` is required. If using the
|
|
209
274
|
ActiveRecord adapter `my_model` should have a `has_many` association with
|
210
275
|
`MyTransitionModel`.
|
211
276
|
|
277
|
+
#### `Machine.retry_conflicts`
|
278
|
+
```ruby
|
279
|
+
Machine.retry_conflicts { instance.transition_to(:new_state) }
|
280
|
+
```
|
281
|
+
Automatically retry the given block if a `TransitionConflictError` is raised.
|
282
|
+
If you know you want to retry a transition if it fails due to a race condition
|
283
|
+
call it from within this block. Takes an (optional) argument for the maximum
|
284
|
+
number of retry attempts (defaults to 1).
|
285
|
+
|
212
286
|
## Instance methods
|
213
287
|
|
214
288
|
#### `Machine#current_state`
|
@@ -245,7 +319,7 @@ model and define a `transition_class` method.
|
|
245
319
|
```ruby
|
246
320
|
class Order < ActiveRecord::Base
|
247
321
|
include Statesman::Adapters::ActiveRecordModel
|
248
|
-
|
322
|
+
|
249
323
|
private
|
250
324
|
|
251
325
|
def self.transition_class
|
data/circle.yml
CHANGED
@@ -0,0 +1,15 @@
|
|
1
|
+
module Statesman
|
2
|
+
class EventTransitions
|
3
|
+
attr_reader :machine, :event_name
|
4
|
+
|
5
|
+
def initialize(machine, event_name, &block)
|
6
|
+
@machine = machine
|
7
|
+
@event_name = event_name
|
8
|
+
instance_eval(&block)
|
9
|
+
end
|
10
|
+
|
11
|
+
def transition(options = { from: nil, to: nil })
|
12
|
+
machine.transition(options, event_name)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/statesman/machine.rb
CHANGED
@@ -2,6 +2,7 @@ require_relative "version"
|
|
2
2
|
require_relative "exceptions"
|
3
3
|
require_relative "guard"
|
4
4
|
require_relative "callback"
|
5
|
+
require_relative "event_transitions"
|
5
6
|
require_relative "adapters/memory_transition"
|
6
7
|
|
7
8
|
module Statesman
|
@@ -12,6 +13,18 @@ module Statesman
|
|
12
13
|
base.send(:attr_reader, :object)
|
13
14
|
end
|
14
15
|
|
16
|
+
# Retry any transitions that fail due to a TransitionConflictError
|
17
|
+
def self.retry_conflicts(max_retries = 1)
|
18
|
+
retry_attempt = 0
|
19
|
+
|
20
|
+
begin
|
21
|
+
yield
|
22
|
+
rescue TransitionConflictError
|
23
|
+
retry_attempt += 1
|
24
|
+
retry_attempt <= max_retries ? retry : raise
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
15
28
|
module ClassMethods
|
16
29
|
attr_reader :initial_state
|
17
30
|
|
@@ -19,6 +32,10 @@ module Statesman
|
|
19
32
|
@states ||= []
|
20
33
|
end
|
21
34
|
|
35
|
+
def events
|
36
|
+
@events ||= {}
|
37
|
+
end
|
38
|
+
|
22
39
|
def state(name, options = { initial: false })
|
23
40
|
name = name.to_s
|
24
41
|
if options[:initial]
|
@@ -28,6 +45,10 @@ module Statesman
|
|
28
45
|
states << name
|
29
46
|
end
|
30
47
|
|
48
|
+
def event(name, &block)
|
49
|
+
EventTransitions.new(self, name, &block)
|
50
|
+
end
|
51
|
+
|
31
52
|
def successors
|
32
53
|
@successors ||= {}
|
33
54
|
end
|
@@ -41,7 +62,7 @@ module Statesman
|
|
41
62
|
}
|
42
63
|
end
|
43
64
|
|
44
|
-
def transition(options = { from: nil, to: nil })
|
65
|
+
def transition(options = { from: nil, to: nil }, event = nil)
|
45
66
|
from = to_s_or_nil(options[:from])
|
46
67
|
to = Array(options[:to]).map { |item| to_s_or_nil(item) }
|
47
68
|
|
@@ -50,6 +71,12 @@ module Statesman
|
|
50
71
|
([from] + to).each { |state| validate_state(state) }
|
51
72
|
|
52
73
|
successors[from] += to
|
74
|
+
|
75
|
+
if event
|
76
|
+
events[event] ||= {}
|
77
|
+
events[event][from] ||= []
|
78
|
+
events[event][from] += to
|
79
|
+
end
|
53
80
|
end
|
54
81
|
|
55
82
|
def before_transition(options = { from: nil, to: nil }, &block)
|
@@ -189,6 +216,21 @@ module Statesman
|
|
189
216
|
true
|
190
217
|
end
|
191
218
|
|
219
|
+
def trigger!(event_name, metadata = nil)
|
220
|
+
transitions = self.class.events.fetch(event_name) do
|
221
|
+
raise Statesman::TransitionFailedError,
|
222
|
+
"Event #{event_name} not found"
|
223
|
+
end
|
224
|
+
|
225
|
+
new_state = transitions.fetch(current_state) do
|
226
|
+
raise Statesman::TransitionFailedError,
|
227
|
+
"State #{current_state} not found for Event #{event_name}"
|
228
|
+
end
|
229
|
+
|
230
|
+
transition_to!(new_state.first, metadata)
|
231
|
+
true
|
232
|
+
end
|
233
|
+
|
192
234
|
def execute(phase, initial_state, new_state, transition)
|
193
235
|
callbacks = callbacks_for(phase, from: initial_state, to: new_state)
|
194
236
|
callbacks.each { |cb| cb.call(@object, transition) }
|
@@ -200,6 +242,18 @@ module Statesman
|
|
200
242
|
false
|
201
243
|
end
|
202
244
|
|
245
|
+
def trigger(event_name, metadata = nil)
|
246
|
+
self.trigger!(event_name, metadata)
|
247
|
+
rescue TransitionFailedError, GuardFailedError
|
248
|
+
false
|
249
|
+
end
|
250
|
+
|
251
|
+
def available_events
|
252
|
+
self.class.events.select do |_, transitions|
|
253
|
+
transitions.key?(current_state)
|
254
|
+
end.map(&:first)
|
255
|
+
end
|
256
|
+
|
203
257
|
private
|
204
258
|
|
205
259
|
def adapter_class(transition_class)
|
data/lib/statesman/version.rb
CHANGED
@@ -9,20 +9,20 @@ describe Statesman::ActiveRecordTransitionGenerator, type: :generator do
|
|
9
9
|
end
|
10
10
|
|
11
11
|
describe 'properly adds class names' do
|
12
|
-
before { run_generator %w
|
12
|
+
before { run_generator %w(Yummy::Bacon Yummy::BaconTransition) }
|
13
13
|
subject { file('app/models/yummy/bacon_transition.rb') }
|
14
14
|
|
15
|
-
it {
|
16
|
-
it {
|
17
|
-
it {
|
15
|
+
it { is_expected.to contain(/:bacon_transition/) }
|
16
|
+
it { is_expected.not_to contain(/:yummy\/bacon/) }
|
17
|
+
it { is_expected.to contain(/class_name: 'Yummy::Bacon'/) }
|
18
18
|
end
|
19
19
|
|
20
20
|
describe 'properly formats without class names' do
|
21
|
-
before { run_generator %w
|
21
|
+
before { run_generator %w(Bacon BaconTransition) }
|
22
22
|
subject { file('app/models/bacon_transition.rb') }
|
23
23
|
|
24
|
-
it {
|
25
|
-
it {
|
24
|
+
it { is_expected.not_to contain(/class_name:/) }
|
25
|
+
it { is_expected.to contain(/class BaconTransition/) }
|
26
26
|
end
|
27
27
|
|
28
28
|
end
|
@@ -22,11 +22,11 @@ describe Statesman::MigrationGenerator, type: :generator do
|
|
22
22
|
end
|
23
23
|
|
24
24
|
before do
|
25
|
-
Time.
|
26
|
-
run_generator %w
|
25
|
+
allow(Time).to receive(:now).and_return(mock_time)
|
26
|
+
run_generator %w(Yummy::Bacon Yummy::BaconTransition)
|
27
27
|
end
|
28
28
|
|
29
|
-
it {
|
30
|
-
it {
|
29
|
+
it { is_expected.to contain(/:bacon_transition/) }
|
30
|
+
it { is_expected.not_to contain(/:yummy\/bacon/) }
|
31
31
|
end
|
32
32
|
end
|
@@ -5,19 +5,19 @@ require "generators/statesman/mongoid_transition_generator"
|
|
5
5
|
describe Statesman::MongoidTransitionGenerator, type: :generator do
|
6
6
|
|
7
7
|
describe 'the model contains the correct words' do
|
8
|
-
before { run_generator %w
|
8
|
+
before { run_generator %w(Yummy::Bacon Yummy::BaconTransition) }
|
9
9
|
subject { file('app/models/yummy/bacon_transition.rb') }
|
10
10
|
|
11
|
-
it {
|
12
|
-
it {
|
11
|
+
it { is_expected.not_to contain(/:yummy\/bacon/) }
|
12
|
+
it { is_expected.to contain(/class_name: 'Yummy::Bacon'/) }
|
13
13
|
end
|
14
14
|
|
15
15
|
describe 'the model contains the correct words' do
|
16
|
-
before { run_generator %w
|
16
|
+
before { run_generator %w(Bacon BaconTransition) }
|
17
17
|
subject { file('app/models/bacon_transition.rb') }
|
18
18
|
|
19
|
-
it {
|
20
|
-
it {
|
19
|
+
it { is_expected.not_to contain(/class_name:/) }
|
20
|
+
it { is_expected.not_to contain(/CreateYummy::Bacon/) }
|
21
21
|
end
|
22
22
|
|
23
23
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -3,11 +3,11 @@ require "sqlite3"
|
|
3
3
|
require "active_record"
|
4
4
|
require "support/active_record"
|
5
5
|
require "mongoid"
|
6
|
+
require 'rspec/its'
|
6
7
|
|
7
8
|
RSpec.configure do |config|
|
8
|
-
config.
|
9
|
-
|
10
|
-
end
|
9
|
+
config.raise_errors_for_deprecations!
|
10
|
+
config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true }
|
11
11
|
|
12
12
|
config.order = "random"
|
13
13
|
|
@@ -31,27 +31,27 @@ describe Statesman::Adapters::ActiveRecordModel do
|
|
31
31
|
context "given a single state" do
|
32
32
|
subject { MyActiveRecordModel.in_state(:state_a) }
|
33
33
|
|
34
|
-
it {
|
34
|
+
it { is_expected.to include model }
|
35
35
|
end
|
36
36
|
|
37
37
|
context "given multiple states" do
|
38
38
|
subject { MyActiveRecordModel.in_state(:state_a, :state_b) }
|
39
39
|
|
40
|
-
it {
|
41
|
-
it {
|
40
|
+
it { is_expected.to include model }
|
41
|
+
it { is_expected.to include other_model }
|
42
42
|
end
|
43
43
|
end
|
44
44
|
|
45
45
|
describe ".not_in_state" do
|
46
46
|
context "given a single state" do
|
47
47
|
subject { MyActiveRecordModel.not_in_state(:state_b) }
|
48
|
-
it {
|
49
|
-
it {
|
48
|
+
it { is_expected.to include model }
|
49
|
+
it { is_expected.not_to include other_model }
|
50
50
|
end
|
51
51
|
|
52
52
|
context "given multiple states" do
|
53
53
|
subject { MyActiveRecordModel.not_in_state(:state_a, :state_b) }
|
54
|
-
it {
|
54
|
+
it { is_expected.to eq([]) }
|
55
55
|
end
|
56
56
|
end
|
57
57
|
end
|
@@ -11,7 +11,7 @@ describe Statesman::Adapters::ActiveRecord do
|
|
11
11
|
before { MyActiveRecordModelTransition.serialize(:metadata, JSON) }
|
12
12
|
let(:observer) do
|
13
13
|
result = double(Statesman::Machine)
|
14
|
-
result.
|
14
|
+
allow(result).to receive(:execute)
|
15
15
|
result
|
16
16
|
end
|
17
17
|
let(:model) { MyActiveRecordModel.create(current_state: :pending) }
|
@@ -21,10 +21,11 @@ describe Statesman::Adapters::ActiveRecord do
|
|
21
21
|
context "with unserialized metadata and non json column type" do
|
22
22
|
before do
|
23
23
|
metadata_column = double
|
24
|
-
metadata_column.
|
25
|
-
MyActiveRecordModelTransition.
|
24
|
+
allow(metadata_column).to receive_messages(sql_type: '')
|
25
|
+
allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
|
26
26
|
{ 'metadata' => metadata_column })
|
27
|
-
MyActiveRecordModelTransition
|
27
|
+
allow(MyActiveRecordModelTransition)
|
28
|
+
.to receive_messages(serialized_attributes: {})
|
28
29
|
end
|
29
30
|
|
30
31
|
it "raises an exception" do
|
@@ -38,11 +39,11 @@ describe Statesman::Adapters::ActiveRecord do
|
|
38
39
|
context "with serialized metadata and json column type" do
|
39
40
|
before do
|
40
41
|
metadata_column = double
|
41
|
-
metadata_column.
|
42
|
-
MyActiveRecordModelTransition.
|
42
|
+
allow(metadata_column).to receive_messages(sql_type: 'json')
|
43
|
+
allow(MyActiveRecordModelTransition).to receive_messages(columns_hash:
|
43
44
|
{ 'metadata' => metadata_column })
|
44
|
-
MyActiveRecordModelTransition
|
45
|
-
|
45
|
+
allow(MyActiveRecordModelTransition)
|
46
|
+
.to receive_messages(serialized_attributes: { 'metadata' => '' })
|
46
47
|
end
|
47
48
|
|
48
49
|
it "raises an exception" do
|
@@ -82,12 +83,12 @@ describe Statesman::Adapters::ActiveRecord do
|
|
82
83
|
|
83
84
|
context "ActiveRecord::RecordNotUnique unrelated to this transition" do
|
84
85
|
let(:error) { ActiveRecord::RecordNotUnique.new("unrelated", nil) }
|
85
|
-
it {
|
86
|
+
it { is_expected.to raise_exception(ActiveRecord::RecordNotUnique) }
|
86
87
|
end
|
87
88
|
|
88
89
|
context "other errors" do
|
89
90
|
let(:error) { StandardError }
|
90
|
-
it {
|
91
|
+
it { is_expected.to raise_exception(StandardError) }
|
91
92
|
end
|
92
93
|
end
|
93
94
|
end
|
@@ -107,8 +108,8 @@ describe Statesman::Adapters::ActiveRecord do
|
|
107
108
|
end
|
108
109
|
|
109
110
|
it "caches the transition" do
|
110
|
-
MyActiveRecordModel
|
111
|
-
.
|
111
|
+
expect_any_instance_of(MyActiveRecordModel)
|
112
|
+
.to receive(:my_active_record_model_transitions).never
|
112
113
|
adapter.last
|
113
114
|
end
|
114
115
|
|
@@ -127,7 +128,7 @@ describe Statesman::Adapters::ActiveRecord do
|
|
127
128
|
end
|
128
129
|
|
129
130
|
it "doesn't query the database" do
|
130
|
-
MyActiveRecordModelTransition.
|
131
|
+
expect(MyActiveRecordModelTransition).not_to receive(:connection)
|
131
132
|
expect(adapter.last.to_state).to eq("y")
|
132
133
|
end
|
133
134
|
end
|