state_machine 0.2.1 → 0.3.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.
- data/CHANGELOG.rdoc +15 -0
- data/README.rdoc +64 -14
- data/Rakefile +1 -1
- data/lib/state_machine.rb +25 -28
- data/lib/state_machine/event.rb +47 -111
- data/lib/state_machine/machine.rb +302 -67
- data/lib/state_machine/transition.rb +173 -68
- data/test/app_root/app/models/auto_shop.rb +2 -2
- data/test/app_root/app/models/switch_observer.rb +20 -0
- data/test/app_root/app/models/vehicle.rb +14 -7
- data/test/app_root/config/environment.rb +7 -0
- data/test/factory.rb +6 -0
- data/test/functional/state_machine_test.rb +53 -1
- data/test/unit/event_test.rb +43 -188
- data/test/unit/machine_test.rb +154 -30
- data/test/unit/state_machine_test.rb +39 -29
- data/test/unit/transition_test.rb +347 -218
- metadata +6 -3
@@ -7,44 +7,102 @@ module PluginAWeek #:nodoc:
|
|
7
7
|
# how the state changes after a particular event is fired.
|
8
8
|
#
|
9
9
|
# A state machine may not necessarily know all of the possible states for
|
10
|
-
# an object since they can be any arbitrary value.
|
10
|
+
# an object since they can be any arbitrary value. As a result, anything
|
11
|
+
# that relies on a list of all possible states should keep in mind that if
|
12
|
+
# a state has not been referenced *anywhere* in the state machine definition,
|
13
|
+
# then it will *not* be a known state.
|
11
14
|
#
|
12
15
|
# == Callbacks
|
13
16
|
#
|
14
|
-
# Callbacks are supported for hooking
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
#
|
21
|
-
#
|
22
|
-
#
|
23
|
-
#
|
24
|
-
#
|
25
|
-
#
|
26
|
-
#
|
27
|
-
# and
|
28
|
-
#
|
29
|
-
#
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
17
|
+
# Callbacks are supported for hooking before and after every possible
|
18
|
+
# transition in the machine. Each callback is invoked in the order in which
|
19
|
+
# it was defined. See PluginAWeek::StateMachine::Machine#before_transition
|
20
|
+
# and PluginAWeek::StateMachine::Machine#after_transition for documentation
|
21
|
+
# on how to define new callbacks.
|
22
|
+
#
|
23
|
+
# === Cancelling callbacks
|
24
|
+
#
|
25
|
+
# If a +before+ callback returns +false+, all the later callbacks and
|
26
|
+
# associated transition are cancelled. If an +after+ callback returns false,
|
27
|
+
# the later callbacks are cancelled, but the transition is still successful.
|
28
|
+
# This is the same behavior as exposed by ActiveRecord's callback support.
|
29
|
+
#
|
30
|
+
# *Note* that if a +before+ callback fails and the bang version of an event
|
31
|
+
# was invoked, an exception will be raised instead of returning false.
|
32
|
+
#
|
33
|
+
# == Observers
|
34
|
+
#
|
35
|
+
# ActiveRecord observers can also hook into state machines in addition to
|
36
|
+
# the conventional before_save, after_save, etc. behaviors. The following
|
37
|
+
# types of behaviors can be observed:
|
38
|
+
# * events (e.g. before_park/after_park, before_ignite/after_ignite)
|
39
|
+
# * transitions (before_transition/after_transition)
|
40
|
+
#
|
41
|
+
# Each method takes a set of parameters that provides additional information
|
42
|
+
# about the transition that caused the observer to be notified. Below are
|
43
|
+
# examples of defining observers for the following state machine:
|
44
|
+
#
|
45
|
+
# class Vehicle < ActiveRecord::Base
|
46
|
+
# state_machine do
|
47
|
+
# event :park do
|
48
|
+
# transition :to => 'parked', :from => 'idling'
|
49
|
+
# end
|
50
|
+
# ...
|
51
|
+
# end
|
52
|
+
# ...
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# Event behaviors:
|
56
|
+
#
|
57
|
+
# class VehicleObserver < ActiveRecord::Observer
|
58
|
+
# def before_park(vehicle, from_state, to_state)
|
59
|
+
# logger.info "Vehicle #{vehicle.id} instructed to park... state is: #{from_state}, state will be: #{to_state}"
|
60
|
+
# end
|
61
|
+
#
|
62
|
+
# def after_park(vehicle, from_state, to_state)
|
63
|
+
# logger.info "Vehicle #{vehicle.id} instructed to park... state was: #{from_state}, state is: #{to_state}"
|
64
|
+
# end
|
65
|
+
# end
|
66
|
+
#
|
67
|
+
# Transition behaviors:
|
68
|
+
#
|
69
|
+
# class VehicleObserver < ActiveRecord::Observer
|
70
|
+
# def before_transition(vehicle, attribute, event, from_state, to_state)
|
71
|
+
# logger.info "Vehicle #{vehicle.id} instructed to #{event}... #{attribute} is: #{from_state}, #{attribute} will be: #{to_state}"
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# def after_transition(vehicle, attribute, event, from_state, to_state)
|
75
|
+
# logger.info "Vehicle #{vehicle.id} instructed to #{event}... #{attribute} was: #{from_state}, #{attribute} is: #{to_state}"
|
76
|
+
# end
|
77
|
+
# end
|
78
|
+
#
|
79
|
+
# One common callback is to record transitions for all models in the system
|
80
|
+
# for audit/debugging purposes. Below is an example of an observer that can
|
81
|
+
# easily automate this process for all models:
|
82
|
+
#
|
83
|
+
# class StateMachineObserver < ActiveRecord::Observer
|
84
|
+
# observe Vehicle, Switch, AutoShop
|
85
|
+
#
|
86
|
+
# def before_transition(record, attribute, event, from_state, to_state)
|
87
|
+
# transition = StateTransition.build(:record => record, :attribute => attribute, :event => event, :from_state => from_state, :to_state => to_state)
|
88
|
+
# transition.save # Will cancel rollback/cancel transition if this fails
|
89
|
+
# end
|
90
|
+
# end
|
33
91
|
class Machine
|
34
|
-
# The
|
35
|
-
attr_reader :
|
36
|
-
|
37
|
-
# A list of the states defined in the transitions of all of the events
|
38
|
-
attr_reader :states
|
92
|
+
# The class that the machine is defined for
|
93
|
+
attr_reader :owner_class
|
39
94
|
|
40
95
|
# The attribute for which the state machine is being defined
|
41
|
-
|
96
|
+
attr_reader :attribute
|
42
97
|
|
43
|
-
# The initial state that the machine will be in
|
98
|
+
# The initial state that the machine will be in when a record is created
|
44
99
|
attr_reader :initial_state
|
45
100
|
|
46
|
-
#
|
47
|
-
attr_reader :
|
101
|
+
# A list of the states defined in the transitions of all of the events
|
102
|
+
attr_reader :states
|
103
|
+
|
104
|
+
# The events that trigger transitions
|
105
|
+
attr_reader :events
|
48
106
|
|
49
107
|
# Creates a new state machine for the given attribute
|
50
108
|
#
|
@@ -59,43 +117,91 @@ module PluginAWeek #:nodoc:
|
|
59
117
|
#
|
60
118
|
# Switch.with_state('on') # => Finds all switches where the state is on
|
61
119
|
# Switch.with_states('on', 'off') # => Finds all switches where the state is either on or off
|
120
|
+
#
|
121
|
+
# *Note* that if class methods already exist with those names (i.e. "with_state"
|
122
|
+
# or "with_states"), then a scope will not be defined for that name.
|
62
123
|
def initialize(owner_class, attribute = 'state', options = {})
|
63
|
-
options
|
124
|
+
set_context(owner_class, options)
|
64
125
|
|
65
|
-
@owner_class = owner_class
|
66
126
|
@attribute = attribute.to_s
|
67
|
-
@initial_state = options[:initial]
|
68
|
-
@events = {}
|
69
127
|
@states = []
|
128
|
+
@events = {}
|
70
129
|
|
130
|
+
add_transition_callbacks
|
71
131
|
add_named_scopes
|
72
132
|
end
|
73
133
|
|
74
|
-
#
|
75
|
-
#
|
76
|
-
|
77
|
-
|
134
|
+
# Creates a copy of this machine in addition to copies of each associated
|
135
|
+
# event, so that the list of transitions for each event don't conflict
|
136
|
+
# with different machines
|
137
|
+
def initialize_copy(orig) #:nodoc:
|
138
|
+
super
|
139
|
+
|
140
|
+
@states = @states.dup
|
141
|
+
@events = @events.inject({}) do |events, (name, event)|
|
142
|
+
event = event.dup
|
143
|
+
event.machine = self
|
144
|
+
events[name] = event
|
145
|
+
events
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Creates a copy of this machine within the context of the given class.
|
150
|
+
# This should be used for inheritance support of state machines.
|
151
|
+
def within_context(owner_class, options = {}) #:nodoc:
|
152
|
+
machine = dup
|
153
|
+
machine.set_context(owner_class, options)
|
154
|
+
machine
|
78
155
|
end
|
79
156
|
|
80
|
-
#
|
81
|
-
|
82
|
-
|
157
|
+
# Changes the context of this machine to the given class so that new
|
158
|
+
# events and transitions are created in the proper context.
|
159
|
+
def set_context(owner_class, options = {}) #:nodoc:
|
160
|
+
options.assert_valid_keys(:initial)
|
161
|
+
|
162
|
+
@owner_class = owner_class
|
163
|
+
@initial_state = options[:initial] if options[:initial]
|
83
164
|
end
|
84
165
|
|
85
|
-
#
|
86
|
-
#
|
87
|
-
#
|
166
|
+
# Gets the initial state of the machine for the given record. If a record
|
167
|
+
# is specified a and a dynamic initial state was configured for the machine,
|
168
|
+
# then that record will be passed into the proc to help determine the actual
|
169
|
+
# value of the initial state.
|
88
170
|
#
|
89
|
-
#
|
90
|
-
#
|
91
|
-
#
|
171
|
+
# == Examples
|
172
|
+
#
|
173
|
+
# With normal initial state:
|
174
|
+
#
|
175
|
+
# class Vehicle < ActiveRecord::Base
|
176
|
+
# state_machine :initial => 'parked' do
|
177
|
+
# ...
|
178
|
+
# end
|
179
|
+
# end
|
180
|
+
#
|
181
|
+
# Vehicle.state_machines['state'].initial_state(@vehicle) # => "parked"
|
182
|
+
#
|
183
|
+
# With dynamic initial state:
|
184
|
+
#
|
185
|
+
# class Vehicle < ActiveRecord::Base
|
186
|
+
# state_machine :initial => lambda {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do
|
187
|
+
# ...
|
188
|
+
# end
|
189
|
+
# end
|
190
|
+
#
|
191
|
+
# Vehicle.state_machines['state'].initial_state(@vehicle) # => "idling"
|
192
|
+
def initial_state(record)
|
193
|
+
@initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state
|
194
|
+
end
|
195
|
+
|
196
|
+
# Defines an event of the system
|
92
197
|
#
|
93
198
|
# == Instance methods
|
94
199
|
#
|
95
200
|
# The following instance methods are generated when a new event is defined
|
96
201
|
# (the "park" event is used as an example):
|
97
|
-
# * <tt>park
|
98
|
-
# * <tt>park
|
202
|
+
# * <tt>park</tt> - Fires the "park" event, transitioning from the current state to the next valid state.
|
203
|
+
# * <tt>park!</tt> - Fires the "park" event, transitioning from the current state to the next valid state. If the transition cannot happen (for validation, database, etc. reasons), then an error will be raised.
|
204
|
+
# * <tt>can_park?</tt> - Checks whether the "park" event can be fired given the current state of the record.
|
99
205
|
#
|
100
206
|
# == Defining transitions
|
101
207
|
#
|
@@ -119,12 +225,15 @@ module PluginAWeek #:nodoc:
|
|
119
225
|
#
|
120
226
|
# class Car < ActiveRecord::Base
|
121
227
|
# def self.safe_states
|
122
|
-
# %w(parked idling
|
228
|
+
# %w(parked idling stalled)
|
123
229
|
# end
|
124
230
|
#
|
125
231
|
# state_machine :state do
|
126
|
-
#
|
127
|
-
#
|
232
|
+
# event :park do
|
233
|
+
# transition :to => 'parked', :from => Car.safe_states
|
234
|
+
# end
|
235
|
+
# end
|
236
|
+
# end
|
128
237
|
#
|
129
238
|
# == Example
|
130
239
|
#
|
@@ -136,40 +245,166 @@ module PluginAWeek #:nodoc:
|
|
136
245
|
# ...
|
137
246
|
# end
|
138
247
|
# end
|
139
|
-
def event(name,
|
248
|
+
def event(name, &block)
|
140
249
|
name = name.to_s
|
141
|
-
event = events[name]
|
250
|
+
event = events[name] ||= Event.new(self, name)
|
142
251
|
event.instance_eval(&block)
|
143
252
|
|
144
|
-
# Record the states
|
253
|
+
# Record the states so that the machine can keep a list of all known
|
254
|
+
# states that have been defined
|
145
255
|
event.transitions.each do |transition|
|
146
|
-
@states |=
|
256
|
+
@states |= [transition.options[:to]] + Array(transition.options[:from]) + Array(transition.options[:except_from])
|
257
|
+
@states.sort!
|
147
258
|
end
|
148
259
|
|
149
260
|
event
|
150
261
|
end
|
151
262
|
|
152
|
-
#
|
153
|
-
|
154
|
-
|
263
|
+
# Creates a callback that will be invoked *before* a transition has been
|
264
|
+
# performed, so long as the given configuration options match the transition.
|
265
|
+
# Each part of the transition (to state, from state, and event) must match
|
266
|
+
# in order for the callback to get invoked.
|
267
|
+
#
|
268
|
+
# Configuration options:
|
269
|
+
# * +to+ - One or more states being transitioned to. If none are specified, then all states will match.
|
270
|
+
# * +from+ - One or more states being transitioned from. If none are specified, then all states will match.
|
271
|
+
# * +on+ - One or more events that fired the transition. If none are specified, then all events will match.
|
272
|
+
# * +except_to+ - One more states *not* being transitioned to
|
273
|
+
# * +except_from+ - One or more states *not* being transitioned from
|
274
|
+
# * +except_on+ - One or more events that *did not* fire the transition
|
275
|
+
# * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string.
|
276
|
+
# * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value.
|
277
|
+
# * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value.
|
278
|
+
#
|
279
|
+
# The +except+ group of options (+except_to+, +exception_from+, and
|
280
|
+
# +except_on+) acts as the +unless+ equivalent of their counterparts (+to+,
|
281
|
+
# +from+, and +on+, respectively)
|
282
|
+
#
|
283
|
+
# == The callback
|
284
|
+
#
|
285
|
+
# When defining additional configuration options, callbacks must be defined
|
286
|
+
# in the :do option like so:
|
287
|
+
#
|
288
|
+
# class Vehicle < ActiveRecord::Base
|
289
|
+
# state_machine do
|
290
|
+
# before_transition :to => 'parked', :do => :set_alarm
|
291
|
+
# ...
|
292
|
+
# end
|
293
|
+
# end
|
294
|
+
#
|
295
|
+
# == Examples
|
296
|
+
#
|
297
|
+
# Below is an example of a model with one state machine and various types
|
298
|
+
# of +before+ transitions defined for it:
|
299
|
+
#
|
300
|
+
# class Vehicle < ActiveRecord::Base
|
301
|
+
# state_machine do
|
302
|
+
# # Before all transitions
|
303
|
+
# before_transition :update_dashboard
|
304
|
+
#
|
305
|
+
# # Before specific transition:
|
306
|
+
# before_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt
|
307
|
+
#
|
308
|
+
# # With conditional callback:
|
309
|
+
# before_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on?
|
310
|
+
#
|
311
|
+
# # Using :except counterparts:
|
312
|
+
# before_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard
|
313
|
+
# ...
|
314
|
+
# end
|
315
|
+
# end
|
316
|
+
#
|
317
|
+
# As can be seen, any number of transitions can be created using various
|
318
|
+
# combinations of configuration options.
|
319
|
+
def before_transition(options = {})
|
320
|
+
add_transition_callback(:before, options)
|
321
|
+
end
|
322
|
+
|
323
|
+
# Creates a callback that will be invoked *after* a transition has been
|
324
|
+
# performed, so long as the given configuration options match the transition.
|
325
|
+
# Each part of the transition (to state, from state, and event) must match
|
326
|
+
# in order for the callback to get invoked.
|
327
|
+
#
|
328
|
+
# Configuration options:
|
329
|
+
# * +to+ - One or more states being transitioned to. If none are specified, then all states will match.
|
330
|
+
# * +from+ - One or more states being transitioned from. If none are specified, then all states will match.
|
331
|
+
# * +on+ - One or more events that fired the transition. If none are specified, then all events will match.
|
332
|
+
# * +except_to+ - One more states *not* being transitioned to
|
333
|
+
# * +except_from+ - One or more states *not* being transitioned from
|
334
|
+
# * +except_on+ - One or more events that *did not* fire the transition
|
335
|
+
# * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string.
|
336
|
+
# * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value.
|
337
|
+
# * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value.
|
338
|
+
#
|
339
|
+
# The +except+ group of options (+except_to+, +exception_from+, and
|
340
|
+
# +except_on+) acts as the +unless+ equivalent of their counterparts (+to+,
|
341
|
+
# +from+, and +on+, respectively)
|
342
|
+
#
|
343
|
+
# == The callback
|
344
|
+
#
|
345
|
+
# When defining additional configuration options, callbacks must be defined
|
346
|
+
# in the :do option like so:
|
347
|
+
#
|
348
|
+
# class Vehicle < ActiveRecord::Base
|
349
|
+
# state_machine do
|
350
|
+
# after_transition :to => 'parked', :do => :set_alarm
|
351
|
+
# ...
|
352
|
+
# end
|
353
|
+
# end
|
354
|
+
#
|
355
|
+
# == Examples
|
356
|
+
#
|
357
|
+
# Below is an example of a model with one state machine and various types
|
358
|
+
# of +after+ transitions defined for it:
|
359
|
+
#
|
360
|
+
# class Vehicle < ActiveRecord::Base
|
361
|
+
# state_machine do
|
362
|
+
# # After all transitions
|
363
|
+
# after_transition :update_dashboard
|
364
|
+
#
|
365
|
+
# # After specific transition:
|
366
|
+
# after_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt
|
367
|
+
#
|
368
|
+
# # With conditional callback:
|
369
|
+
# after_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on?
|
370
|
+
#
|
371
|
+
# # Using :except counterparts:
|
372
|
+
# after_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard
|
373
|
+
# ...
|
374
|
+
# end
|
375
|
+
# end
|
376
|
+
#
|
377
|
+
# As can be seen, any number of transitions can be created using various
|
378
|
+
# combinations of configuration options.
|
379
|
+
def after_transition(options = {})
|
380
|
+
add_transition_callback(:after, options)
|
155
381
|
end
|
156
382
|
|
157
383
|
private
|
158
384
|
# Adds the given callback to the callback chain during a state transition
|
159
|
-
def
|
160
|
-
|
161
|
-
|
162
|
-
|
385
|
+
def add_transition_callback(type, options)
|
386
|
+
options = {:do => options} unless options.is_a?(Hash)
|
387
|
+
options.assert_valid_keys(:to, :from, :on, :except_to, :except_from, :except_on, :do, :if, :unless)
|
388
|
+
|
389
|
+
# The actual callback (defined in the :do option) must be defined
|
390
|
+
raise ArgumentError, ':do callback must be specified' unless options[:do]
|
391
|
+
|
392
|
+
# Create the callback
|
393
|
+
owner_class.send("#{type}_transition_#{attribute}", options.delete(:do), options)
|
394
|
+
end
|
395
|
+
|
396
|
+
# Add before/after callbacks for when the attribute transitions to a
|
397
|
+
# different value
|
398
|
+
def add_transition_callbacks
|
399
|
+
%w(before after).each {|type| owner_class.define_callbacks("#{type}_transition_#{attribute}") }
|
163
400
|
end
|
164
401
|
|
165
402
|
# Add named scopes for finding records with a particular value or values
|
166
403
|
# for the attribute
|
167
404
|
def add_named_scopes
|
168
|
-
[attribute, attribute.pluralize].each do |name|
|
169
|
-
|
170
|
-
|
171
|
-
owner_class.named_scope name, Proc.new {|*values| {:conditions => {attribute => values.flatten}}}
|
172
|
-
end
|
405
|
+
[attribute, attribute.pluralize].uniq.each do |name|
|
406
|
+
name = "with_#{name}"
|
407
|
+
owner_class.named_scope name.to_sym, lambda {|*values| {:conditions => {attribute => values.flatten}}} unless owner_class.respond_to?(name)
|
173
408
|
end
|
174
409
|
end
|
175
410
|
end
|
@@ -4,22 +4,19 @@ module PluginAWeek #:nodoc:
|
|
4
4
|
class InvalidTransition < StandardError
|
5
5
|
end
|
6
6
|
|
7
|
-
# A transition
|
7
|
+
# A transition represents a state change and is described by a condition
|
8
8
|
# that would need to be fulfilled to enable the transition. Transitions
|
9
9
|
# consist of:
|
10
|
-
# *
|
11
|
-
# *
|
12
|
-
# *
|
10
|
+
# * An event
|
11
|
+
# * One or more starting states
|
12
|
+
# * An ending state
|
13
13
|
class Transition
|
14
|
-
# The state to which the transition is being made
|
15
|
-
attr_reader :to_state
|
16
|
-
|
17
|
-
# The states from which the transition can be made
|
18
|
-
attr_reader :from_states
|
19
|
-
|
20
14
|
# The event that caused the transition
|
21
15
|
attr_reader :event
|
22
16
|
|
17
|
+
# The configuration for this transition
|
18
|
+
attr_reader :options
|
19
|
+
|
23
20
|
delegate :machine,
|
24
21
|
:to => :event
|
25
22
|
|
@@ -29,93 +26,201 @@ module PluginAWeek #:nodoc:
|
|
29
26
|
# * +to+ - The state being transitioned to
|
30
27
|
# * +from+ - One or more states being transitioned from. Default is nil (can transition from any state)
|
31
28
|
# * +except_from+ - One or more states that *can't* be transitioned from.
|
29
|
+
# * +if+ - Specifies a method, proc or string to call to determine if the transition should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value.
|
30
|
+
# * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value.
|
32
31
|
def initialize(event, options) #:nodoc:
|
33
32
|
@event = event
|
33
|
+
@options = options
|
34
|
+
@options.symbolize_keys!
|
34
35
|
|
35
|
-
options.assert_valid_keys(:to, :from, :except_from)
|
36
|
+
options.assert_valid_keys(:to, :from, :except_from, :if, :unless)
|
36
37
|
raise ArgumentError, ':to state must be specified' unless options.include?(:to)
|
37
|
-
|
38
|
-
# Get the states involved in the transition
|
39
|
-
@to_state = options[:to]
|
40
|
-
@from_states = Array(options[:from] || options[:except_from])
|
41
|
-
|
42
|
-
# Should we be matching the from states?
|
43
|
-
@require_match = !options[:from].nil?
|
44
38
|
end
|
45
39
|
|
46
|
-
#
|
47
|
-
|
48
|
-
|
40
|
+
# Determines whether the given query options match the machine state that
|
41
|
+
# this transition describes. Since transitions have no way of telling
|
42
|
+
# what the *current* from state is in this context (may be called before
|
43
|
+
# or after a transition has occurred), it must be provided.
|
44
|
+
#
|
45
|
+
# Query options:
|
46
|
+
# * +to+ - One or more states being transitioned to. If none are specified, then this will always match.
|
47
|
+
# * +from+ - One or more states being transitioned from. If none are specified, then this will always match.
|
48
|
+
# * +on+ - One or more events that fired the transition. If none are specified, then this will always match.
|
49
|
+
# * +except_to+ - One more states *not* being transitioned to
|
50
|
+
# * +except_from+ - One or more states *not* being transitioned from
|
51
|
+
# * +except_on+ - One or more events that *did not* fire the transition.
|
52
|
+
#
|
53
|
+
# *Note* that if the given from state is not an actual valid state for this
|
54
|
+
# transition, then an ArgumentError will be raised.
|
55
|
+
#
|
56
|
+
# == Examples
|
57
|
+
#
|
58
|
+
# event = PluginAWeek::StateMachine::Event.new(machine, 'ignite')
|
59
|
+
# transition = PluginAWeek::StateMachine::Transition.new(event, :to => 'idling', :from => 'parked')
|
60
|
+
#
|
61
|
+
# # Successful
|
62
|
+
# transition.matches?('parked') # => true
|
63
|
+
# transition.matches?('parked', :from => 'parked') # => true
|
64
|
+
# transition.matches?('parked', :to => 'idling') # => true
|
65
|
+
# transition.matches?('parked', :on => 'ignite') # => true
|
66
|
+
# transition.matches?('parked', :from => 'parked', :to => 'idling') # => true
|
67
|
+
# transition.matches?('parked', :from => 'parked', :to => 'idling', :on => 'ignite') # => true
|
68
|
+
#
|
69
|
+
# # Unsuccessful
|
70
|
+
# transition.matches?('idling') # => ArgumentError: "idling" is not a valid from state for transition
|
71
|
+
# transition.matches?('parked', :from => 'idling') # => false
|
72
|
+
# transition.matches?('parked', :to => 'first_gear') # => false
|
73
|
+
# transition.matches?('parked', :on => 'park') # => false
|
74
|
+
# transition.matches?('parked', :from => 'parked', :to => 'first_gear') # => false
|
75
|
+
# transition.matches?('parked', :from => 'parked', :to => 'idling', :on => 'park') # => false
|
76
|
+
def matches?(from_state, query = {})
|
77
|
+
raise ArgumentError, "\"#{from_state}\" is not a valid from state for transition" unless valid_from_state?(from_state)
|
78
|
+
|
79
|
+
# Ensure that from state, to state, and event match the query
|
80
|
+
query.blank? ||
|
81
|
+
find_match(from_state, query[:from], query[:except_from]) &&
|
82
|
+
find_match(@options[:to], query[:to], query[:except_to]) &&
|
83
|
+
find_match(event.name, query[:on], query[:except_on])
|
49
84
|
end
|
50
85
|
|
51
|
-
# Determines whether
|
52
|
-
#
|
53
|
-
#
|
54
|
-
|
55
|
-
|
86
|
+
# Determines whether this transition can be performed on the given record.
|
87
|
+
# This checks two things:
|
88
|
+
# 1. Does the from state match what's configured for this transition
|
89
|
+
# 2. If so, do the conditional :if/:unless options for the transition
|
90
|
+
# allow the transition to be performed?
|
91
|
+
#
|
92
|
+
# If both of those two checks pass, then this transition can be performed
|
93
|
+
# by subsequently calling +perform+/<tt>perform!</tt>
|
94
|
+
def can_perform?(record)
|
95
|
+
if valid_from_state?(record.send(machine.attribute))
|
96
|
+
# Verify that the conditional evaluates to true for the record
|
97
|
+
if @options[:if]
|
98
|
+
evaluate_method(@options[:if], record)
|
99
|
+
elsif @options[:unless]
|
100
|
+
!evaluate_method(@options[:unless], record)
|
101
|
+
else
|
102
|
+
true
|
103
|
+
end
|
104
|
+
else
|
105
|
+
false
|
106
|
+
end
|
56
107
|
end
|
57
108
|
|
58
|
-
# Runs the actual transition and any callbacks associated
|
59
|
-
#
|
60
|
-
# callbacks.
|
109
|
+
# Runs the actual transition and any before/after callbacks associated
|
110
|
+
# with the transition. Additional arguments are passed to the callbacks.
|
61
111
|
#
|
62
|
-
# *Note* that the caller should check <tt>
|
63
|
-
#
|
64
|
-
def perform(record
|
65
|
-
|
112
|
+
# *Note* that the caller should check <tt>matches?</tt> before being
|
113
|
+
# called. This will *not* check whether transition should be performed.
|
114
|
+
def perform(record)
|
115
|
+
run(record, false)
|
66
116
|
end
|
67
117
|
|
68
|
-
# Runs the actual transition and any callbacks associated
|
69
|
-
#
|
70
|
-
#
|
71
|
-
#
|
72
|
-
|
73
|
-
|
118
|
+
# Runs the actual transition and any before/after callbacks associated
|
119
|
+
# with the transition. Additional arguments are passed to the callbacks.
|
120
|
+
#
|
121
|
+
# Any errors during validation or saving will be raised. If any +before+
|
122
|
+
# callbacks fail, a PluginAWeek::StateMachine::InvalidTransition error
|
123
|
+
# will be raised.
|
124
|
+
def perform!(record)
|
125
|
+
run(record, true) || raise(PluginAWeek::StateMachine::InvalidTransition, "Could not transition via :#{event.name} from #{record.send(machine.attribute).inspect} to #{@options[:to].inspect}")
|
74
126
|
end
|
75
127
|
|
76
128
|
private
|
77
|
-
#
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
return false if invoke_before_callbacks(state, record) == false
|
82
|
-
result = update_state(state, bang, record)
|
83
|
-
invoke_after_callbacks(state, record)
|
84
|
-
result
|
129
|
+
# Determines whether the given from state matches what was configured
|
130
|
+
# for this transition
|
131
|
+
def valid_from_state?(from_state)
|
132
|
+
find_match(from_state, @options[:from], @options[:except_from])
|
85
133
|
end
|
86
134
|
|
87
|
-
#
|
88
|
-
|
89
|
-
|
90
|
-
|
135
|
+
# Attempts to find the given value in either a whitelist of values or
|
136
|
+
# a blacklist of values. The whitelist will always be used first if it
|
137
|
+
# is specified. If neither lists are specified, then this will always
|
138
|
+
# find a match.
|
139
|
+
def find_match(value, whitelist, blacklist)
|
140
|
+
if whitelist
|
141
|
+
Array(whitelist).include?(value)
|
142
|
+
elsif blacklist
|
143
|
+
!Array(blacklist).include?(value)
|
91
144
|
else
|
92
|
-
|
93
|
-
|
94
|
-
end
|
145
|
+
true
|
146
|
+
end
|
95
147
|
end
|
96
148
|
|
97
|
-
|
98
|
-
|
99
|
-
|
149
|
+
# Evaluates a method for conditionally determining whether this
|
150
|
+
# transition is allowed to be performed on the given record. This is
|
151
|
+
# copied from ActiveSupport::Calllbacks::Callback since it has not been
|
152
|
+
# extracted into a separate, reusable method.
|
153
|
+
def evaluate_method(method, record)
|
154
|
+
case method
|
155
|
+
when Symbol
|
156
|
+
record.send(method)
|
157
|
+
when String
|
158
|
+
eval(method, record.instance_eval {binding})
|
159
|
+
when Proc, Method
|
160
|
+
method.call(record)
|
161
|
+
else
|
162
|
+
raise ArgumentError, 'Transition conditionals must be a symbol denoting the method to call, a string to be evaluated, or a block to be invoked'
|
163
|
+
end
|
100
164
|
end
|
101
165
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
end
|
166
|
+
# Performs the actual transition, invoking before/after callbacks in the
|
167
|
+
# process. If either the before callbacks fail or the actual save fails,
|
168
|
+
# then this transition will fail.
|
169
|
+
def run(record, bang)
|
170
|
+
from_state = record.send(machine.attribute)
|
108
171
|
|
109
|
-
|
172
|
+
# Stop the transition if any before callbacks fail
|
173
|
+
return false if invoke_callbacks(record, :before, from_state) == false
|
174
|
+
result = update_state(record, bang)
|
175
|
+
|
176
|
+
# Always invoke after callbacks regardless of whether the update failed
|
177
|
+
invoke_callbacks(record, :after, from_state)
|
178
|
+
|
179
|
+
result
|
110
180
|
end
|
111
181
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
182
|
+
# Updates the record's attribute to the state represented by this
|
183
|
+
# transition. Even if the transition is a loopback, the record will
|
184
|
+
# still be saved.
|
185
|
+
def update_state(record, bang)
|
186
|
+
record.send("#{machine.attribute}=", @options[:to])
|
187
|
+
bang ? record.save! : record.save
|
188
|
+
end
|
189
|
+
|
190
|
+
# Runs the callbacks of the given type for this transition
|
191
|
+
def invoke_callbacks(record, type, from_state)
|
192
|
+
# Transition callback
|
193
|
+
kind = "#{type}_transition_#{machine.attribute}"
|
194
|
+
|
195
|
+
result = if record.class.respond_to?("#{kind}_callback_chain")
|
196
|
+
record.class.send("#{kind}_callback_chain").all? do |callback|
|
197
|
+
# false indicates that the remaining callbacks should be skipped
|
198
|
+
!matches?(from_state, callback.options) || callback.call(record) != false
|
199
|
+
end
|
116
200
|
else
|
201
|
+
# No callbacks defined for attribute: always successful
|
117
202
|
true
|
118
203
|
end
|
204
|
+
|
205
|
+
# Notify observers
|
206
|
+
notify("#{type}_#{event.name}", record, from_state, @options[:to])
|
207
|
+
notify("#{type}_transition", record, machine.attribute, event.name, from_state, @options[:to])
|
208
|
+
|
209
|
+
result
|
210
|
+
end
|
211
|
+
|
212
|
+
# Sends a notification to all observers of the record's class
|
213
|
+
def notify(method, record, *args)
|
214
|
+
# This technique of notifying observers is much less than ideal.
|
215
|
+
# Unfortunately, ActiveRecord only allows the record to be passed into
|
216
|
+
# Observer methods. As a result, it's not possible to pass in the
|
217
|
+
# from state, to state, and other contextual information for the
|
218
|
+
# transition.
|
219
|
+
record.class.class_eval do
|
220
|
+
@observer_peers.dup.each do |observer|
|
221
|
+
observer.send(method, record, *args) if observer.respond_to?(method)
|
222
|
+
end if defined?(@observer_peers)
|
223
|
+
end
|
119
224
|
end
|
120
225
|
end
|
121
226
|
end
|