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