statesman 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rubocop.yml +20 -0
- data/Gemfile +4 -0
- data/Guardfile +14 -0
- data/LICENSE.txt +22 -0
- data/README.md +193 -0
- data/Rakefile +1 -0
- data/circle.yml +9 -0
- data/lib/generators/statesman/migration_generator.rb +35 -0
- data/lib/generators/statesman/templates/create_migration.rb.erb +13 -0
- data/lib/generators/statesman/templates/transition_model.rb.erb +4 -0
- data/lib/generators/statesman/templates/update_migration.rb.erb +11 -0
- data/lib/generators/statesman/transition_generator.rb +39 -0
- data/lib/statesman.rb +24 -0
- data/lib/statesman/adapters/active_record.rb +53 -0
- data/lib/statesman/adapters/memory.rb +39 -0
- data/lib/statesman/callback.rb +34 -0
- data/lib/statesman/config.rb +23 -0
- data/lib/statesman/exceptions.rb +8 -0
- data/lib/statesman/guard.rb +15 -0
- data/lib/statesman/machine.rb +231 -0
- data/lib/statesman/transition.rb +15 -0
- data/lib/statesman/version.rb +3 -0
- data/spec/spec_helper.rb +33 -0
- data/spec/statesman/adapters/active_record_spec.rb +50 -0
- data/spec/statesman/adapters/memory_spec.rb +7 -0
- data/spec/statesman/adapters/shared_examples.rb +111 -0
- data/spec/statesman/callback_spec.rb +119 -0
- data/spec/statesman/config_spec.rb +34 -0
- data/spec/statesman/guard_spec.rb +28 -0
- data/spec/statesman/machine_spec.rb +459 -0
- data/spec/statesman/transition_spec.rb +20 -0
- data/spec/support/active_record.rb +35 -0
- data/statesman.gemspec +29 -0
- metadata +201 -0
@@ -0,0 +1,39 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module Statesman
|
4
|
+
module Adapters
|
5
|
+
class Memory
|
6
|
+
attr_reader :transition_class
|
7
|
+
attr_reader :history
|
8
|
+
attr_reader :parent_model
|
9
|
+
|
10
|
+
# We only accept mode as a parameter to maintain a consistent interface
|
11
|
+
# with other adapters which require it.
|
12
|
+
def initialize(transition_class, parent_model)
|
13
|
+
@history = []
|
14
|
+
@transition_class = transition_class
|
15
|
+
@parent_model = parent_model
|
16
|
+
end
|
17
|
+
|
18
|
+
def create(to, before_cbs, after_cbs, metadata = {})
|
19
|
+
transition = transition_class.new(to, next_sort_key, metadata)
|
20
|
+
|
21
|
+
before_cbs.each { |cb| cb.call(@parent_model, transition) }
|
22
|
+
@history << transition
|
23
|
+
after_cbs.each { |cb| cb.call(@parent_model, transition) }
|
24
|
+
|
25
|
+
transition
|
26
|
+
end
|
27
|
+
|
28
|
+
def last
|
29
|
+
@history.sort_by(&:sort_key).last
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def next_sort_key
|
35
|
+
(last && last.sort_key + 10) || 0
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require "statesman/exceptions"
|
2
|
+
|
3
|
+
module Statesman
|
4
|
+
class Callback
|
5
|
+
attr_reader :from
|
6
|
+
attr_reader :to
|
7
|
+
attr_reader :callback
|
8
|
+
|
9
|
+
def initialize(from: nil, to: nil, callback: nil)
|
10
|
+
unless callback.respond_to?(:call)
|
11
|
+
raise InvalidCallbackError, "No callback passed"
|
12
|
+
end
|
13
|
+
|
14
|
+
@from = from
|
15
|
+
@to = to
|
16
|
+
@callback = callback
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(*args)
|
20
|
+
callback.call(*args)
|
21
|
+
end
|
22
|
+
|
23
|
+
def applies_to?(from: nil, to: nil)
|
24
|
+
# rubocop:disable RedundantSelf
|
25
|
+
(self.from.nil? && self.to.nil?) ||
|
26
|
+
(from.nil? && to == self.to) ||
|
27
|
+
(self.from.nil? && to == self.to) ||
|
28
|
+
(from == self.from && to.nil?) ||
|
29
|
+
(from == self.from && self.to.nil?) ||
|
30
|
+
(from == self.from && to == self.to)
|
31
|
+
# rubocop:enable RedundantSelf
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require "json"
|
2
|
+
require "statesman/exceptions"
|
3
|
+
|
4
|
+
module Statesman
|
5
|
+
class Config
|
6
|
+
attr_reader :adapter_class
|
7
|
+
|
8
|
+
def initialize(block = nil)
|
9
|
+
instance_eval(&block) unless block.nil?
|
10
|
+
end
|
11
|
+
|
12
|
+
# rubocop:disable TrivialAccessors
|
13
|
+
def storage_adapter(adapter_class)
|
14
|
+
@adapter_class = adapter_class
|
15
|
+
end
|
16
|
+
# rubocop:enable TrivialAccessors
|
17
|
+
|
18
|
+
def transition_class(*args)
|
19
|
+
args.each { |klass| klass.serialize(:metadata, JSON) }
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
module Statesman
|
2
|
+
class InvalidStateError < StandardError; end
|
3
|
+
class InvalidTransitionError < StandardError; end
|
4
|
+
class InvalidCallbackError < StandardError; end
|
5
|
+
class GuardFailedError < StandardError; end
|
6
|
+
class TransitionFailedError < StandardError; end
|
7
|
+
class UnserializedMetadataError < StandardError; end
|
8
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require "statesman/callback"
|
2
|
+
require "statesman/exceptions"
|
3
|
+
|
4
|
+
module Statesman
|
5
|
+
class Guard < Callback
|
6
|
+
|
7
|
+
def call(*args)
|
8
|
+
unless super(*args)
|
9
|
+
raise GuardFailedError,
|
10
|
+
"Guard on transition from: '#{from}' to '#{to}' returned false"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,231 @@
|
|
1
|
+
require "statesman/version"
|
2
|
+
require "statesman/exceptions"
|
3
|
+
require "statesman/guard"
|
4
|
+
require "statesman/callback"
|
5
|
+
require "statesman/transition"
|
6
|
+
|
7
|
+
module Statesman
|
8
|
+
# The main module, that should be `extend`ed in to state machine classes.
|
9
|
+
module Machine
|
10
|
+
def self.included(base)
|
11
|
+
base.extend(ClassMethods)
|
12
|
+
base.send(:attr_reader, :object)
|
13
|
+
end
|
14
|
+
|
15
|
+
module ClassMethods
|
16
|
+
attr_reader :initial_state
|
17
|
+
|
18
|
+
def states
|
19
|
+
@states ||= []
|
20
|
+
end
|
21
|
+
|
22
|
+
def state(name, initial: false)
|
23
|
+
name = name.to_s
|
24
|
+
if initial
|
25
|
+
validate_initial_state(name)
|
26
|
+
@initial_state = name
|
27
|
+
end
|
28
|
+
states << name
|
29
|
+
end
|
30
|
+
|
31
|
+
def successors
|
32
|
+
@successors ||= {}
|
33
|
+
end
|
34
|
+
|
35
|
+
def before_callbacks
|
36
|
+
@before_callbacks ||= []
|
37
|
+
end
|
38
|
+
|
39
|
+
def after_callbacks
|
40
|
+
@after_callbacks ||= []
|
41
|
+
end
|
42
|
+
|
43
|
+
def guards
|
44
|
+
@guards ||= []
|
45
|
+
end
|
46
|
+
|
47
|
+
def transition(from: nil, to: nil)
|
48
|
+
from = to_s_or_nil(from)
|
49
|
+
to = Array(to).map { |item| to_s_or_nil(item) }
|
50
|
+
|
51
|
+
successors[from] ||= []
|
52
|
+
|
53
|
+
([from] + to).each { |state| validate_state(state) }
|
54
|
+
|
55
|
+
successors[from] += to
|
56
|
+
end
|
57
|
+
|
58
|
+
def before_transition(from: nil, to: nil, &block)
|
59
|
+
from = to_s_or_nil(from)
|
60
|
+
to = to_s_or_nil(to)
|
61
|
+
|
62
|
+
validate_callback_condition(from: from, to: to)
|
63
|
+
before_callbacks << Callback.new(from: from, to: to, callback: block)
|
64
|
+
end
|
65
|
+
|
66
|
+
def after_transition(from: nil, to: nil, &block)
|
67
|
+
from = to_s_or_nil(from)
|
68
|
+
to = to_s_or_nil(to)
|
69
|
+
|
70
|
+
validate_callback_condition(from: from, to: to)
|
71
|
+
after_callbacks << Callback.new(from: from, to: to, callback: block)
|
72
|
+
end
|
73
|
+
|
74
|
+
def guard_transition(from: nil, to: nil, &block)
|
75
|
+
from = to_s_or_nil(from)
|
76
|
+
to = to_s_or_nil(to)
|
77
|
+
|
78
|
+
validate_callback_condition(from: from, to: to)
|
79
|
+
guards << Guard.new(from: from, to: to, callback: block)
|
80
|
+
end
|
81
|
+
|
82
|
+
def validate_callback_condition(from: nil, to: nil)
|
83
|
+
from = to_s_or_nil(from)
|
84
|
+
to = to_s_or_nil(to)
|
85
|
+
|
86
|
+
[from, to].compact.each { |state| validate_state(state) }
|
87
|
+
return if from.nil? && to.nil?
|
88
|
+
|
89
|
+
validate_not_from_terminal_state(from)
|
90
|
+
validate_not_to_initial_state(to)
|
91
|
+
|
92
|
+
return if from.nil? || to.nil?
|
93
|
+
|
94
|
+
validate_from_and_to_state(from, to)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Check that the 'from' state is not terminal
|
98
|
+
def validate_not_from_terminal_state(from)
|
99
|
+
unless from.nil? || successors.keys.include?(from)
|
100
|
+
raise InvalidTransitionError,
|
101
|
+
"Cannont transition away from terminal state '#{from}'"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Check that the 'to' state is not initial
|
106
|
+
def validate_not_to_initial_state(to)
|
107
|
+
unless to.nil? || successors.values.flatten.include?(to)
|
108
|
+
raise InvalidTransitionError,
|
109
|
+
"Cannont transition to initial state '#{to}'"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Check that the transition is valid when 'from' and 'to' are given
|
114
|
+
def validate_from_and_to_state(from, to)
|
115
|
+
unless successors.fetch(from, []).include?(to)
|
116
|
+
raise InvalidTransitionError,
|
117
|
+
"Cannot transition from '#{from}' to '#{to}'"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def validate_state(state)
|
124
|
+
unless states.include?(state.to_s)
|
125
|
+
raise InvalidStateError, "Invalid state '#{state}'"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def validate_initial_state(state)
|
130
|
+
unless initial_state.nil?
|
131
|
+
raise InvalidStateError, "Cannot set initial state to '#{state}', " +
|
132
|
+
"already defined as #{initial_state}."
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def to_s_or_nil(input)
|
137
|
+
input.nil? ? input : input.to_s
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def initialize(object, transition_class: Statesman::Transition)
|
142
|
+
@object = object
|
143
|
+
@storage_adapter = Statesman.storage_adapter.new(transition_class,
|
144
|
+
object)
|
145
|
+
end
|
146
|
+
|
147
|
+
def current_state
|
148
|
+
last_action = last_transition
|
149
|
+
last_action ? last_action.to_state : self.class.initial_state
|
150
|
+
end
|
151
|
+
|
152
|
+
def last_transition
|
153
|
+
@storage_adapter.last
|
154
|
+
end
|
155
|
+
|
156
|
+
def can_transition_to?(new_state, metadata = nil)
|
157
|
+
validate_transition(from: current_state,
|
158
|
+
to: new_state,
|
159
|
+
metadata: metadata)
|
160
|
+
true
|
161
|
+
rescue TransitionFailedError, GuardFailedError
|
162
|
+
false
|
163
|
+
end
|
164
|
+
|
165
|
+
def history
|
166
|
+
@storage_adapter.history
|
167
|
+
end
|
168
|
+
|
169
|
+
def transition_to!(new_state, metadata = nil)
|
170
|
+
initial_state = current_state
|
171
|
+
new_state = new_state.to_s
|
172
|
+
|
173
|
+
validate_transition(from: initial_state,
|
174
|
+
to: new_state,
|
175
|
+
metadata: metadata)
|
176
|
+
|
177
|
+
before_cbs = before_callbacks_for(from: initial_state, to: new_state)
|
178
|
+
after_cbs = after_callbacks_for(from: initial_state, to: new_state)
|
179
|
+
|
180
|
+
@storage_adapter.create(new_state, before_cbs, after_cbs, metadata)
|
181
|
+
|
182
|
+
true
|
183
|
+
end
|
184
|
+
|
185
|
+
def transition_to(new_state, metadata = nil)
|
186
|
+
self.transition_to!(new_state, metadata)
|
187
|
+
rescue
|
188
|
+
false
|
189
|
+
end
|
190
|
+
|
191
|
+
private
|
192
|
+
|
193
|
+
def guards_for(from: nil, to: nil)
|
194
|
+
select_callbacks_for(self.class.guards, from: from, to: to)
|
195
|
+
end
|
196
|
+
|
197
|
+
def before_callbacks_for(from: nil, to: nil)
|
198
|
+
select_callbacks_for(self.class.before_callbacks, from: from, to: to)
|
199
|
+
end
|
200
|
+
|
201
|
+
def after_callbacks_for(from: nil, to: nil)
|
202
|
+
select_callbacks_for(self.class.after_callbacks, from: from, to: to)
|
203
|
+
end
|
204
|
+
|
205
|
+
def select_callbacks_for(callbacks, from: nil, to: nil)
|
206
|
+
from = to_s_or_nil(from)
|
207
|
+
to = to_s_or_nil(to)
|
208
|
+
callbacks.select { |callback| callback.applies_to?(from: from, to: to) }
|
209
|
+
end
|
210
|
+
|
211
|
+
def validate_transition(from: nil, to: nil, metadata: nil)
|
212
|
+
from = to_s_or_nil(from)
|
213
|
+
to = to_s_or_nil(to)
|
214
|
+
|
215
|
+
# Call all guards, they raise exceptions if they fail
|
216
|
+
guards_for(from: from, to: to).each do |guard|
|
217
|
+
guard.call(@object, last_transition, metadata)
|
218
|
+
end
|
219
|
+
|
220
|
+
successors = self.class.successors[from] || []
|
221
|
+
unless successors.include?(to)
|
222
|
+
raise TransitionFailedError,
|
223
|
+
"Cannot transition from '#{from}' to '#{to}'"
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def to_s_or_nil(input)
|
228
|
+
input.nil? ? input : input.to_s
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Statesman
|
2
|
+
class Transition
|
3
|
+
attr_accessor :created_at
|
4
|
+
attr_accessor :to_state
|
5
|
+
attr_accessor :sort_key
|
6
|
+
attr_accessor :metadata
|
7
|
+
|
8
|
+
def initialize(to, sort_key, metadata = nil)
|
9
|
+
@created_at = Time.now
|
10
|
+
@to_state = to
|
11
|
+
@sort_key = sort_key
|
12
|
+
@metadata = metadata
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require "statesman"
|
2
|
+
require "sqlite3"
|
3
|
+
require "active_record"
|
4
|
+
require "support/active_record"
|
5
|
+
|
6
|
+
RSpec.configure do |config|
|
7
|
+
config.expect_with :rspec do |c|
|
8
|
+
c.syntax = :expect
|
9
|
+
end
|
10
|
+
|
11
|
+
config.order = 'random'
|
12
|
+
|
13
|
+
config.before(:each) do
|
14
|
+
# Connect to & cleanup test database
|
15
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3",
|
16
|
+
database: DB.to_s)
|
17
|
+
|
18
|
+
%w(my_models my_model_transitions).each do |table_name|
|
19
|
+
sql = "DROP TABLE IF EXISTS #{table_name};"
|
20
|
+
ActiveRecord::Base.connection.execute(sql)
|
21
|
+
end
|
22
|
+
|
23
|
+
def prepare_model_table
|
24
|
+
silence_stream(STDOUT) { CreateMyModelMigration.migrate(:up) }
|
25
|
+
end
|
26
|
+
|
27
|
+
def prepare_transitions_table
|
28
|
+
silence_stream(STDOUT) { CreateMyModelTransitionMigration.migrate(:up) }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
config.after(:each) { DB.delete if DB.exist? }
|
33
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "statesman/adapters/shared_examples"
|
3
|
+
require "statesman/exceptions"
|
4
|
+
|
5
|
+
describe Statesman::Adapters::ActiveRecord do
|
6
|
+
before do
|
7
|
+
prepare_model_table
|
8
|
+
prepare_transitions_table
|
9
|
+
end
|
10
|
+
|
11
|
+
before { MyModelTransition.serialize(:metadata, JSON) }
|
12
|
+
|
13
|
+
let(:model) { MyModel.create(current_state: :pending) }
|
14
|
+
it_behaves_like "an adapter", described_class, MyModelTransition
|
15
|
+
|
16
|
+
describe "#initialize" do
|
17
|
+
context "with unserialized metadata" do
|
18
|
+
before { MyModelTransition.stub(serialized_attributes: {}) }
|
19
|
+
|
20
|
+
it "raises an exception if metadata is not serialized" do
|
21
|
+
expect do
|
22
|
+
described_class.new(MyModelTransition, MyModel)
|
23
|
+
end.to raise_exception(Statesman::UnserializedMetadataError)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe "#last" do
|
29
|
+
let(:adapter) { described_class.new(MyModelTransition, model) }
|
30
|
+
|
31
|
+
context "with a previously looked up transition" do
|
32
|
+
before do
|
33
|
+
adapter.create(:y, [], [])
|
34
|
+
adapter.last
|
35
|
+
end
|
36
|
+
|
37
|
+
it "caches the transition" do
|
38
|
+
MyModel.any_instance.should_receive(:my_model_transitions).never
|
39
|
+
adapter.last
|
40
|
+
end
|
41
|
+
|
42
|
+
context "and a new transition" do
|
43
|
+
before { adapter.create(:z, [], []) }
|
44
|
+
it "retrieves the new transition from the database" do
|
45
|
+
expect(adapter.last.to_state).to eq("z")
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|