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.
@@ -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