public
Description: Adds support for creating state machines for attributes on any Ruby class
Homepage: http://www.pluginaweek.org
Clone URL: git://github.com/pluginaweek/state_machine.git
sob (author)
Mon Nov 10 13:11:04 -0800 2008
commit  13a0906c1186ce9250b508296d4a860edc097c93
tree    010ea8b41d94a8512b654087bf5c4e53aad6a2dc
parent  6d213798a6088c75c2f882fcef4b67bb2275464a
state_machine / lib / state_machine / machine.rb
f3565041 » obrie 2008-05-04 Completely rewritten from s... 1 require 'state_machine/event'
2
3 module PluginAWeek #:nodoc:
4 module StateMachine
179ce54f » obrie 2008-07-05 Add more descriptive except... 5 # Represents a state machine for a particular attribute. State machines
6 # consist of events (a.k.a. actions) and a set of transitions that define
7 # how the state changes after a particular event is fired.
f3565041 » obrie 2008-05-04 Completely rewritten from s... 8 #
179ce54f » obrie 2008-07-05 Add more descriptive except... 9 # A state machine may not necessarily know all of the possible states for
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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.
f3565041 » obrie 2008-05-04 Completely rewritten from s... 14 #
179ce54f » obrie 2008-07-05 Add more descriptive except... 15 # == Callbacks
16 #
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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
f3565041 » obrie 2008-05-04 Completely rewritten from s... 91 class Machine
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 92 # The class that the machine is defined for
93 attr_reader :owner_class
26d60afa » obrie 2008-07-03 Add PluginAWeek::StateMachi... 94
f3565041 » obrie 2008-05-04 Completely rewritten from s... 95 # The attribute for which the state machine is being defined
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 96 attr_reader :attribute
f3565041 » obrie 2008-05-04 Completely rewritten from s... 97
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 98 # The initial state that the machine will be in when a record is created
f3565041 » obrie 2008-05-04 Completely rewritten from s... 99 attr_reader :initial_state
100
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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
f3565041 » obrie 2008-05-04 Completely rewritten from s... 106
107 # Creates a new state machine for the given attribute
108 #
109 # Configuration options:
179ce54f » obrie 2008-07-05 Add more descriptive except... 110 # * +initial+ - The initial value to set the attribute to. This can be an actual value or a proc, which will be evaluated at runtime.
f3565041 » obrie 2008-05-04 Completely rewritten from s... 111 #
112 # == Scopes
113 #
179ce54f » obrie 2008-07-05 Add more descriptive except... 114 # This will automatically create a named scope called with_#{attribute}
f3565041 » obrie 2008-05-04 Completely rewritten from s... 115 # that will find all records that have the attribute set to a given value.
116 # For example,
117 #
118 # Switch.with_state('on') # => Finds all switches where the state is on
119 # Switch.with_states('on', 'off') # => Finds all switches where the state is either on or off
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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.
179ce54f » obrie 2008-07-05 Add more descriptive except... 123 def initialize(owner_class, attribute = 'state', options = {})
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 124 set_context(owner_class, options)
f3565041 » obrie 2008-05-04 Completely rewritten from s... 125
126 @attribute = attribute.to_s
26d60afa » obrie 2008-07-03 Add PluginAWeek::StateMachi... 127 @states = []
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 128 @events = {}
f3565041 » obrie 2008-05-04 Completely rewritten from s... 129
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 130 add_transition_callbacks
f3565041 » obrie 2008-05-04 Completely rewritten from s... 131 add_named_scopes
132 end
133
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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
f3565041 » obrie 2008-05-04 Completely rewritten from s... 147 end
148
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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
f3565041 » obrie 2008-05-04 Completely rewritten from s... 155 end
156
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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]
164 end
165
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.
f3565041 » obrie 2008-05-04 Completely rewritten from s... 170 #
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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
f3565041 » obrie 2008-05-04 Completely rewritten from s... 197 #
198 # == Instance methods
199 #
200 # The following instance methods are generated when a new event is defined
201 # (the "park" event is used as an example):
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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.
f3565041 » obrie 2008-05-04 Completely rewritten from s... 205 #
206 # == Defining transitions
207 #
208 # +event+ requires a block which allows you to define the possible
209 # transitions that can happen as a result of that event. For example,
210 #
211 # event :park do
212 # transition :to => 'parked', :from => 'idle'
213 # end
214 #
215 # event :first_gear do
216 # transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on?
217 # end
218 #
219 # See PluginAWeek::StateMachine::Event#transition for more information on
220 # the possible options that can be passed in.
221 #
179ce54f » obrie 2008-07-05 Add more descriptive except... 222 # *Note* that this block is executed within the context of the actual event
223 # object. As a result, you will not be able to reference any class methods
224 # on the model without referencing the class itself. For example,
225 #
226 # class Car < ActiveRecord::Base
227 # def self.safe_states
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 228 # %w(parked idling stalled)
179ce54f » obrie 2008-07-05 Add more descriptive except... 229 # end
230 #
231 # state_machine :state do
8d3df6f1 » obrie 2008-07-05 Fix incomplete example in P... 232 # event :park do
233 # transition :to => 'parked', :from => Car.safe_states
234 # end
235 # end
236 # end
179ce54f » obrie 2008-07-05 Add more descriptive except... 237 #
f3565041 » obrie 2008-05-04 Completely rewritten from s... 238 # == Example
239 #
240 # class Car < ActiveRecord::Base
241 # state_machine(:state, :initial => 'parked') do
242 # event :park, :after => :release_seatbelt do
243 # transition :to => 'parked', :from => %w(first_gear reverse)
244 # end
245 # ...
246 # end
247 # end
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 248 def event(name, &block)
f3565041 » obrie 2008-05-04 Completely rewritten from s... 249 name = name.to_s
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 250 event = events[name] ||= Event.new(self, name)
f3565041 » obrie 2008-05-04 Completely rewritten from s... 251 event.instance_eval(&block)
26d60afa » obrie 2008-07-03 Add PluginAWeek::StateMachi... 252
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 253 # Record the states so that the machine can keep a list of all known
254 # states that have been defined
26d60afa » obrie 2008-07-03 Add PluginAWeek::StateMachi... 255 event.transitions.each do |transition|
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 256 @states |= [transition.options[:to]] + Array(transition.options[:from]) + Array(transition.options[:except_from])
257 @states.sort!
26d60afa » obrie 2008-07-03 Add PluginAWeek::StateMachi... 258 end
259
f3565041 » obrie 2008-05-04 Completely rewritten from s... 260 event
261 end
262
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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)
f3565041 » obrie 2008-05-04 Completely rewritten from s... 381 end
382
383 private
b3b009b1 » obrie 2008-06-29 Add a non-bang version of e... 384 # Adds the given callback to the callback chain during a state transition
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 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}") }
b3b009b1 » obrie 2008-06-29 Add a non-bang version of e... 400 end
401
402 # Add named scopes for finding records with a particular value or values
403 # for the attribute
f3565041 » obrie 2008-05-04 Completely rewritten from s... 404 def add_named_scopes
6a94afcc » obrie 2008-09-06 MAJOR REWRITE! Replace all ... Comment 405 [attribute, attribute.pluralize].uniq.each do |name|
13a0906c » sob 2008-11-10 added support for without_#... 406 with_name = "with_#{name}"
407 without_name = "without_#{name}"
408 owner_class.named_scope with_name.to_sym, lambda {|*values| {:conditions => {attribute => values.flatten}}} unless owner_class.respond_to?(with_name)
409 owner_class.named_scope without_name.to_sym, lambda {|*values| {:conditions => ["#{attribute} NOT IN (?)", values.flatten]}} unless owner_class.respond_to?(without_name)
f3565041 » obrie 2008-05-04 Completely rewritten from s... 410 end
411 end
412 end
413 end
414 end