state_machine 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|