statesmin 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.rubocop.yml +48 -0
- data/.travis.yml +15 -0
- data/CONTRIBUTING.md +28 -0
- data/Gemfile +3 -0
- data/Guardfile +14 -0
- data/LICENSE.txt +22 -0
- data/README.md +556 -0
- data/Rakefile +6 -0
- data/lib/statesmin.rb +8 -0
- data/lib/statesmin/callback.rb +52 -0
- data/lib/statesmin/exceptions.rb +21 -0
- data/lib/statesmin/guard.rb +13 -0
- data/lib/statesmin/machine.rb +276 -0
- data/lib/statesmin/railtie.rb +5 -0
- data/lib/statesmin/transition_helper.rb +41 -0
- data/lib/statesmin/version.rb +3 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/statesmin/callback_spec.rb +120 -0
- data/spec/statesmin/guard_spec.rb +22 -0
- data/spec/statesmin/machine_spec.rb +704 -0
- data/spec/statesmin/transition_helper_spec.rb +170 -0
- data/statesmin.gemspec +26 -0
- metadata +142 -0
data/Rakefile
ADDED
data/lib/statesmin.rb
ADDED
@@ -0,0 +1,8 @@
|
|
1
|
+
module Statesmin
|
2
|
+
autoload :Callback, 'statesmin/callback'
|
3
|
+
autoload :Guard, 'statesmin/guard'
|
4
|
+
autoload :Machine, 'statesmin/machine'
|
5
|
+
autoload :TransitionHelper, 'statesmin/transition_helper'
|
6
|
+
autoload :Version, 'statesmin/version'
|
7
|
+
require 'statesmin/railtie' if defined?(::Rails::Railtie)
|
8
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require_relative "exceptions"
|
2
|
+
|
3
|
+
module Statesmin
|
4
|
+
class Callback
|
5
|
+
attr_reader :from
|
6
|
+
attr_reader :to
|
7
|
+
attr_reader :callback
|
8
|
+
|
9
|
+
def initialize(options = { from: nil, to: nil, callback: nil })
|
10
|
+
unless options[:callback].respond_to?(:call)
|
11
|
+
raise InvalidCallbackError, "No callback passed"
|
12
|
+
end
|
13
|
+
|
14
|
+
@from = options[:from]
|
15
|
+
@to = Array(options[:to])
|
16
|
+
@callback = options[:callback]
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(*args)
|
20
|
+
callback.call(*args)
|
21
|
+
end
|
22
|
+
|
23
|
+
def applies_to?(options = { from: nil, to: nil })
|
24
|
+
matches(options[:from], options[:to])
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def matches(from, to)
|
30
|
+
matches_all_transitions ||
|
31
|
+
matches_to_state(from, to) ||
|
32
|
+
matches_from_state(from, to) ||
|
33
|
+
matches_both_states(from, to)
|
34
|
+
end
|
35
|
+
|
36
|
+
def matches_all_transitions
|
37
|
+
from.nil? && to.empty?
|
38
|
+
end
|
39
|
+
|
40
|
+
def matches_from_state(from, to)
|
41
|
+
(from == self.from && (to.nil? || self.to.empty?))
|
42
|
+
end
|
43
|
+
|
44
|
+
def matches_to_state(from, to)
|
45
|
+
((from.nil? || self.from.nil?) && self.to.include?(to))
|
46
|
+
end
|
47
|
+
|
48
|
+
def matches_both_states(from, to)
|
49
|
+
from == self.from && self.to.include?(to)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Statesmin
|
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 TransitionConflictError < StandardError; end
|
8
|
+
|
9
|
+
class NotImplementedError < StandardError
|
10
|
+
def initialize(method_name, transition_class_name)
|
11
|
+
super(_message(method_name, transition_class_name))
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def _message(method_name, transition_class_name)
|
17
|
+
"'#{method_name}' method is not defined in '#{transition_class_name}'." \
|
18
|
+
"Either define this method or do not include 'TransitionHelper'."
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require_relative "callback"
|
2
|
+
require_relative "exceptions"
|
3
|
+
|
4
|
+
module Statesmin
|
5
|
+
class Guard < Callback
|
6
|
+
def call(*args)
|
7
|
+
unless super(*args)
|
8
|
+
raise GuardFailedError,
|
9
|
+
"Guard on transition from: '#{from}' to '#{to}' returned false"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,276 @@
|
|
1
|
+
require_relative "version"
|
2
|
+
require_relative "exceptions"
|
3
|
+
require_relative "guard"
|
4
|
+
require_relative "callback"
|
5
|
+
|
6
|
+
module Statesmin
|
7
|
+
# The main module, that should be `extend`ed in to state machine classes.
|
8
|
+
module Machine
|
9
|
+
def self.included(base)
|
10
|
+
base.extend(ClassMethods)
|
11
|
+
base.send(:attr_reader, :object)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Retry any transitions that fail due to a TransitionConflictError
|
15
|
+
def self.retry_conflicts(max_retries = 1)
|
16
|
+
retry_attempt = 0
|
17
|
+
|
18
|
+
begin
|
19
|
+
yield
|
20
|
+
rescue TransitionConflictError
|
21
|
+
retry_attempt += 1
|
22
|
+
retry_attempt <= max_retries ? retry : raise
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module ClassMethods
|
27
|
+
attr_reader :initial_state
|
28
|
+
|
29
|
+
def states
|
30
|
+
@states ||= []
|
31
|
+
end
|
32
|
+
|
33
|
+
def state(name, options = { initial: false })
|
34
|
+
name = name.to_s
|
35
|
+
if options[:initial]
|
36
|
+
validate_initial_state(name)
|
37
|
+
@initial_state = name
|
38
|
+
end
|
39
|
+
states << name
|
40
|
+
end
|
41
|
+
|
42
|
+
def successors
|
43
|
+
@successors ||= {}
|
44
|
+
end
|
45
|
+
|
46
|
+
def callbacks
|
47
|
+
@callbacks ||= {
|
48
|
+
before: [],
|
49
|
+
after: [],
|
50
|
+
after_commit: [],
|
51
|
+
guards: []
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
def transition(from: nil, to: nil)
|
56
|
+
from = to_s_or_nil(from)
|
57
|
+
to = array_to_s_or_nil(to)
|
58
|
+
|
59
|
+
raise InvalidStateError, "No to states provided." if to.empty?
|
60
|
+
|
61
|
+
successors[from] ||= []
|
62
|
+
|
63
|
+
([from] + to).each { |state| validate_state(state) }
|
64
|
+
|
65
|
+
successors[from] += to
|
66
|
+
end
|
67
|
+
|
68
|
+
def before_transition(options = {}, &block)
|
69
|
+
add_callback(callback_type: :before, callback_class: Callback,
|
70
|
+
from: options[:from], to: options[:to], &block)
|
71
|
+
end
|
72
|
+
|
73
|
+
def guard_transition(options = {}, &block)
|
74
|
+
add_callback(callback_type: :guards, callback_class: Guard,
|
75
|
+
from: options[:from], to: options[:to], &block)
|
76
|
+
end
|
77
|
+
|
78
|
+
def after_transition(options = { after_commit: false }, &block)
|
79
|
+
callback_type = options[:after_commit] ? :after_commit : :after
|
80
|
+
|
81
|
+
add_callback(callback_type: callback_type, callback_class: Callback,
|
82
|
+
from: options[:from], to: options[:to], &block)
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate_callback_condition(options = { from: nil, to: nil })
|
86
|
+
from = to_s_or_nil(options[:from])
|
87
|
+
to = array_to_s_or_nil(options[:to])
|
88
|
+
|
89
|
+
([from] + to).compact.each { |state| validate_state(state) }
|
90
|
+
return if from.nil? && to.empty?
|
91
|
+
|
92
|
+
validate_not_from_terminal_state(from)
|
93
|
+
to.each { |state| validate_not_to_initial_state(state) }
|
94
|
+
|
95
|
+
return if from.nil? || to.empty?
|
96
|
+
|
97
|
+
to.each { |state| validate_from_and_to_state(from, state) }
|
98
|
+
end
|
99
|
+
|
100
|
+
# Check that the 'from' state is not terminal
|
101
|
+
def validate_not_from_terminal_state(from)
|
102
|
+
unless from.nil? || successors.keys.include?(from)
|
103
|
+
raise InvalidTransitionError,
|
104
|
+
"Cannot transition away from terminal state '#{from}'"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Check that the 'to' state is not initial
|
109
|
+
def validate_not_to_initial_state(to)
|
110
|
+
unless to.nil? || successors.values.flatten.include?(to)
|
111
|
+
raise InvalidTransitionError,
|
112
|
+
"Cannot transition to initial state '#{to}'"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Check that the transition is valid when 'from' and 'to' are given
|
117
|
+
def validate_from_and_to_state(from, to)
|
118
|
+
unless successors.fetch(from, []).include?(to)
|
119
|
+
raise InvalidTransitionError,
|
120
|
+
"Cannot transition from '#{from}' to '#{to}'"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def validate_state(state)
|
125
|
+
unless states.include?(state.to_s)
|
126
|
+
raise InvalidStateError, "Invalid state '#{state}'"
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
def add_callback(callback_type: nil, callback_class: nil,
|
133
|
+
from: nil, to: nil, &block)
|
134
|
+
validate_callback_type_and_class(callback_type, callback_class)
|
135
|
+
|
136
|
+
from = to_s_or_nil(from)
|
137
|
+
to = array_to_s_or_nil(to)
|
138
|
+
|
139
|
+
validate_callback_condition(from: from, to: to)
|
140
|
+
|
141
|
+
callbacks[callback_type] <<
|
142
|
+
callback_class.new(from: from, to: to, callback: block)
|
143
|
+
end
|
144
|
+
|
145
|
+
def validate_callback_type_and_class(callback_type, callback_class)
|
146
|
+
if callback_type.nil?
|
147
|
+
raise ArgumentError.new("missing keyword: callback_type")
|
148
|
+
end
|
149
|
+
if callback_class.nil?
|
150
|
+
raise ArgumentError.new("missing keyword: callback_class")
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def validate_initial_state(state)
|
155
|
+
unless initial_state.nil?
|
156
|
+
raise InvalidStateError, "Cannot set initial state to '#{state}', " \
|
157
|
+
"already defined as #{initial_state}."
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def to_s_or_nil(input)
|
162
|
+
input.nil? ? input : input.to_s
|
163
|
+
end
|
164
|
+
|
165
|
+
def array_to_s_or_nil(input)
|
166
|
+
Array(input).map { |item| to_s_or_nil(item) }
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
attr_reader :current_state
|
171
|
+
|
172
|
+
def initialize(object, options = {})
|
173
|
+
@object = object
|
174
|
+
|
175
|
+
if (new_state = options[:state] && options[:state].to_s)
|
176
|
+
self.class.validate_state(new_state)
|
177
|
+
@current_state = new_state
|
178
|
+
else
|
179
|
+
@current_state = self.class.initial_state
|
180
|
+
end
|
181
|
+
send(:after_initialize) if respond_to? :after_initialize
|
182
|
+
end
|
183
|
+
|
184
|
+
def in_state?(*states)
|
185
|
+
states.flatten.any? { |state| current_state == state.to_s }
|
186
|
+
end
|
187
|
+
|
188
|
+
def allowed_transitions
|
189
|
+
successors_for(current_state).select do |state|
|
190
|
+
can_transition_to?(state)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def can_transition_to?(new_state, metadata = {})
|
195
|
+
validate_transition(from: current_state,
|
196
|
+
to: new_state,
|
197
|
+
metadata: metadata)
|
198
|
+
true
|
199
|
+
rescue TransitionFailedError, GuardFailedError
|
200
|
+
false
|
201
|
+
end
|
202
|
+
|
203
|
+
def transition_to!(new_state, metadata = {}, &block)
|
204
|
+
initial_state = current_state
|
205
|
+
new_state = new_state.to_s
|
206
|
+
|
207
|
+
validate_transition(from: initial_state,
|
208
|
+
to: new_state,
|
209
|
+
metadata: metadata)
|
210
|
+
|
211
|
+
block_value = block.call(new_state, metadata) if block_given?
|
212
|
+
@current_state = new_state
|
213
|
+
|
214
|
+
block_value || true
|
215
|
+
end
|
216
|
+
|
217
|
+
def execute(phase, initial_state, new_state, transition)
|
218
|
+
callbacks = callbacks_for(phase, from: initial_state, to: new_state)
|
219
|
+
callbacks.each { |cb| cb.call(@object, transition) }
|
220
|
+
end
|
221
|
+
|
222
|
+
def transition_to(new_state, metadata = {}, &block)
|
223
|
+
self.transition_to!(new_state, metadata, &block)
|
224
|
+
rescue TransitionFailedError, GuardFailedError
|
225
|
+
false
|
226
|
+
end
|
227
|
+
|
228
|
+
private
|
229
|
+
|
230
|
+
def adapter_class(transition_class)
|
231
|
+
if transition_class == Statesmin::Adapters::MemoryTransition
|
232
|
+
Adapters::Memory
|
233
|
+
else
|
234
|
+
Statesmin.storage_adapter
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
def successors_for(from)
|
239
|
+
self.class.successors[from] || []
|
240
|
+
end
|
241
|
+
|
242
|
+
def guards_for(options = { from: nil, to: nil })
|
243
|
+
select_callbacks_for(self.class.callbacks[:guards], options)
|
244
|
+
end
|
245
|
+
|
246
|
+
def callbacks_for(phase, options = { from: nil, to: nil })
|
247
|
+
select_callbacks_for(self.class.callbacks[phase], options)
|
248
|
+
end
|
249
|
+
|
250
|
+
def select_callbacks_for(callbacks, options = { from: nil, to: nil })
|
251
|
+
from = to_s_or_nil(options[:from])
|
252
|
+
to = to_s_or_nil(options[:to])
|
253
|
+
callbacks.select { |callback| callback.applies_to?(from: from, to: to) }
|
254
|
+
end
|
255
|
+
|
256
|
+
def validate_transition(options = { from: nil, to: nil, metadata: nil })
|
257
|
+
from = to_s_or_nil(options[:from])
|
258
|
+
to = to_s_or_nil(options[:to])
|
259
|
+
|
260
|
+
successors = self.class.successors[from] || []
|
261
|
+
unless successors.include?(to)
|
262
|
+
raise TransitionFailedError,
|
263
|
+
"Cannot transition from '#{from}' to '#{to}'"
|
264
|
+
end
|
265
|
+
|
266
|
+
# Call all guards, they raise exceptions if they fail
|
267
|
+
guards_for(from: from, to: to).each do |guard|
|
268
|
+
guard.call(@object, options[:metadata])
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def to_s_or_nil(input)
|
273
|
+
input.nil? ? input : input.to_s
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require_relative "exceptions"
|
2
|
+
|
3
|
+
module Statesmin
|
4
|
+
module TransitionHelper
|
5
|
+
# Methods to delegate to `state_machine`
|
6
|
+
DELEGATED_METHODS = [:allowed_transitions, :can_transition_to?,
|
7
|
+
:current_state, :in_state?].freeze
|
8
|
+
|
9
|
+
# Delegate the methods
|
10
|
+
DELEGATED_METHODS.each do |method_name|
|
11
|
+
module_eval <<-RUBY, __FILE__, __LINE__ + 1
|
12
|
+
def #{method_name}(*args)
|
13
|
+
state_machine.#{method_name}(*args)
|
14
|
+
end
|
15
|
+
RUBY
|
16
|
+
end
|
17
|
+
|
18
|
+
def transition_to!(next_state, data = {})
|
19
|
+
raise_transition_not_defined_error unless respond_to?(:transition)
|
20
|
+
state_machine.transition_to!(next_state, data) do
|
21
|
+
transition(next_state, data)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def transition_to(next_state, data = {})
|
26
|
+
transition_to!(next_state, data)
|
27
|
+
rescue Statesmin::TransitionFailedError, Statesmin::GuardFailedError
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def state_machine
|
34
|
+
raise Statesmin::NotImplementedError.new('state_machine', self.class.name)
|
35
|
+
end
|
36
|
+
|
37
|
+
def raise_transition_not_defined_error
|
38
|
+
raise Statesmin::NotImplementedError.new('transition', self.class.name)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require "statesmin"
|
2
|
+
require "rspec/its"
|
3
|
+
|
4
|
+
RSpec.configure do |config|
|
5
|
+
config.raise_errors_for_deprecations!
|
6
|
+
config.mock_with(:rspec) { |mocks| mocks.verify_partial_doubles = true }
|
7
|
+
|
8
|
+
config.default_formatter = 'doc'
|
9
|
+
config.color = true
|
10
|
+
config.order = "random"
|
11
|
+
end
|