statesmin 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -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,5 @@
1
+ module Statesmin
2
+ class Railtie < ::Rails::Railtie
3
+ railtie_name :statesmin
4
+ end
5
+ 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
@@ -0,0 +1,3 @@
1
+ module Statesmin
2
+ VERSION = "1.0.0"
3
+ end
@@ -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