statesmin 1.0.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 +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
|