statesman 0.0.1
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 +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
|