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.
@@ -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 into event calls and state transitions.
15
- # The order in which these callbacks are invoked is shown below:
16
- # * (1) before_exit (from state)
17
- # * (2) before_enter (to state)
18
- # * (3) before (event)
19
- # * (-) update state
20
- # * (4) after_exit (from state)
21
- # * (5) after_enter (to state)
22
- # * (6) after (event)
23
- #
24
- # == Cancelling callbacks
25
- #
26
- # If a <tt>before_*</tt> callback returns +false+, all the later callbacks
27
- # and associated event are cancelled. If an <tt>after_*</tt> callback returns
28
- # false, all the later callbacks are cancelled. Callbacks are run in the
29
- # order in which they are defined.
30
- #
31
- # Note that if a <tt>before_*</tt> callback fails and the bang version of an
32
- # event was invoked, an exception will be raised instaed of returning false.
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 events that trigger transitions
35
- attr_reader :events
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
- attr_accessor :attribute
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
- # The class that the attribute belongs to
47
- attr_reader :owner_class
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.assert_valid_keys(:initial)
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
- # Gets the initial state of the machine for the given record. The record
75
- # is only used if a dynamic initial state was configured.
76
- def initial_state(record)
77
- @initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state
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
- # Gets the initial state without processing it against a particular record
81
- def initial_state_without_processing
82
- @initial_state
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
- # Defines an event of the system. This can take an optional hash that
86
- # defines callbacks which will be invoked before and after the event is
87
- # invoked on the object.
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
- # Configuration options:
90
- # * +before+ - One or more callbacks that will be invoked before the event has been fired
91
- # * +after+ - One or more callbacks that will be invoked after the event has been fired
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(*args)</tt> - Fires the "park" event, transitioning from the current state to the next valid state. This takes an optional list of arguments which are passed to the event callbacks.
98
- # * <tt>park!(*args)</tt> - Fires the "park" event, transitioning from the current state to the next valid state. This takes an optional list of arguments which are passed to the event callbacks. If the transition cannot happen (for validation, database, etc. reasons), then an error will be raised
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 first_gear)
228
+ # %w(parked idling stalled)
123
229
  # end
124
230
  #
125
231
  # state_machine :state do
126
- # event :park do
127
- # transition :to
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, options = {}, &block)
248
+ def event(name, &block)
140
249
  name = name.to_s
141
- event = events[name] = Event.new(self, name, options)
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 |= ([transition.to_state] + transition.from_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
- # Define state callbacks
153
- %w(before_exit before_enter after_exit after_enter).each do |callback_type|
154
- define_method(callback_type) {|state, callback| add_callback(callback_type, state, callback)}
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 add_callback(type, state, callback)
160
- callback_name = "#{type}_#{attribute}_#{state}"
161
- owner_class.define_callbacks(callback_name)
162
- owner_class.send(callback_name, callback)
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
- unless owner_class.respond_to?("with_#{name}")
170
- name = "with_#{name}"
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 indicates a state change and is described by a condition
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
- # * The starting state(s)
11
- # * The ending state
12
- # * A guard to check if the transition is allowed
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
- # Whether or not this is a loopback transition (i.e. from and to state are the same)
47
- def loopback?(from_state)
48
- from_state == to_state
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 or not this transition can be performed on the given
52
- # record. The transition can be performed if the record's state matches
53
- # one of the states that are valid in this transition.
54
- def can_perform_on?(record)
55
- from_states.empty? || from_states.include?(record.send(machine.attribute)) == @require_match
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 with entering
59
- # and exiting the states. Any additional arguments are passed to the
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>can_perform_on?</tt> before calling
63
- # perform. This will *not* check whether transition should be performed.
64
- def perform(record, *args)
65
- perform_with_optional_bang(record, false, *args)
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 with entering
69
- # and exiting the states. Any errors during validation or saving will be
70
- # raised. If any +before+ callbacks fail, a PluginAWeek::StateMachine::InvalidTransition
71
- # error will be raised.
72
- def perform!(record, *args)
73
- perform_with_optional_bang(record, true, *args) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{event.name} from #{record.send(machine.attribute).inspect} to #{to_state.inspect}")
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
- # Performs the transition
78
- def perform_with_optional_bang(record, bang, *args)
79
- state = record.send(machine.attribute)
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
- # Updates the record's attribute to the state represented by this transition
88
- def update_state(from_state, bang, record)
89
- if loopback?(from_state)
90
- true
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
- record.send("#{machine.attribute}=", to_state)
93
- bang ? record.save! : record.save
94
- end
145
+ true
146
+ end
95
147
  end
96
148
 
97
- def invoke_before_callbacks(from_state, record)
98
- # Start leaving the last state and start entering the next state
99
- loopback?(from_state) || invoke_callbacks(:before_exit, from_state, record) && invoke_callbacks(:before_enter, to_state, record)
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
- def invoke_after_callbacks(from_state, record)
103
- # Start leaving the last state and start entering the next state
104
- unless loopback?(from_state)
105
- invoke_callbacks(:after_exit, from_state, record)
106
- invoke_callbacks(:after_enter, to_state, record)
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
- true
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
- def invoke_callbacks(type, state, record)
113
- kind = "#{type}_#{machine.attribute}_#{state}"
114
- if record.class.respond_to?("#{kind}_callback_chain")
115
- record.run_callbacks(kind) {|result, record| result == false}
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