state_machine 0.4.3 → 0.5.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.
Files changed (48) hide show
  1. data/CHANGELOG.rdoc +17 -0
  2. data/LICENSE +1 -1
  3. data/README.rdoc +54 -84
  4. data/Rakefile +1 -1
  5. data/examples/Car_state.png +0 -0
  6. data/examples/Vehicle_state.png +0 -0
  7. data/examples/auto_shop.rb +11 -0
  8. data/examples/car.rb +19 -0
  9. data/examples/traffic_light.rb +9 -0
  10. data/examples/vehicle.rb +35 -0
  11. data/lib/state_machine.rb +65 -52
  12. data/lib/state_machine/assertions.rb +1 -1
  13. data/lib/state_machine/callback.rb +13 -9
  14. data/lib/state_machine/eval_helpers.rb +4 -3
  15. data/lib/state_machine/event.rb +51 -33
  16. data/lib/state_machine/extensions.rb +2 -2
  17. data/lib/state_machine/guard.rb +47 -41
  18. data/lib/state_machine/integrations.rb +67 -0
  19. data/lib/state_machine/integrations/active_record.rb +62 -36
  20. data/lib/state_machine/integrations/active_record/observer.rb +41 -0
  21. data/lib/state_machine/integrations/data_mapper.rb +23 -37
  22. data/lib/state_machine/integrations/data_mapper/observer.rb +23 -9
  23. data/lib/state_machine/integrations/sequel.rb +23 -24
  24. data/lib/state_machine/machine.rb +380 -277
  25. data/lib/state_machine/node_collection.rb +142 -0
  26. data/lib/state_machine/state.rb +114 -69
  27. data/lib/state_machine/state_collection.rb +38 -0
  28. data/lib/state_machine/transition.rb +36 -17
  29. data/test/active_record.log +2940 -85664
  30. data/test/functional/state_machine_test.rb +49 -53
  31. data/test/sequel.log +747 -11990
  32. data/test/unit/assertions_test.rb +2 -1
  33. data/test/unit/callback_test.rb +14 -12
  34. data/test/unit/eval_helpers_test.rb +25 -6
  35. data/test/unit/event_test.rb +144 -124
  36. data/test/unit/guard_test.rb +118 -140
  37. data/test/unit/integrations/active_record_test.rb +102 -68
  38. data/test/unit/integrations/data_mapper_test.rb +48 -37
  39. data/test/unit/integrations/sequel_test.rb +34 -25
  40. data/test/unit/integrations_test.rb +42 -0
  41. data/test/unit/machine_test.rb +460 -531
  42. data/test/unit/node_collection_test.rb +208 -0
  43. data/test/unit/state_collection_test.rb +167 -0
  44. data/test/unit/state_machine_test.rb +1 -1
  45. data/test/unit/state_test.rb +223 -200
  46. data/test/unit/transition_test.rb +81 -46
  47. metadata +17 -3
  48. data/test/data_mapper.log +0 -30860
@@ -1,66 +1,21 @@
1
1
  require 'state_machine/extensions'
2
+ require 'state_machine/assertions'
3
+ require 'state_machine/integrations'
4
+
2
5
  require 'state_machine/state'
3
6
  require 'state_machine/event'
4
7
  require 'state_machine/callback'
5
- require 'state_machine/assertions'
6
-
7
- # Load each available integration
8
- Dir["#{File.dirname(__FILE__)}/integrations/*.rb"].sort.each do |path|
9
- require "state_machine/integrations/#{File.basename(path)}"
10
- end
8
+ require 'state_machine/node_collection'
9
+ require 'state_machine/state_collection'
11
10
 
12
11
  module StateMachine
13
12
  # Represents a state machine for a particular attribute. State machines
14
13
  # consist of states, events and a set of transitions that define how the state
15
14
  # changes after a particular event is fired.
16
15
  #
17
- # A state machine may not necessarily know all of the possible states for
18
- # an object since they can be any arbitrary value. As a result, anything
19
- # that relies on a list of all possible states should keep in mind that if
20
- # a state has not been referenced *anywhere* in the state machine definition,
21
- # then it will *not* be a known state unless the +other_states+ helper is used.
22
- #
23
- # == State values
24
- #
25
- # While strings are the most common object type used for setting values on
26
- # the state of the machine, there are no restrictions on what can be used.
27
- # This means that symbols, integers, dates/times, etc. can all be used.
28
- #
29
- # With string states:
30
- #
31
- # class Vehicle
32
- # state_machine :initial => 'parked' do
33
- # event :ignite do
34
- # transition :to => 'idling', :from => 'parked'
35
- # end
36
- # end
37
- # end
38
- #
39
- # With symbolic states:
40
- #
41
- # class Vehicle
42
- # state_machine :initial => :parked do
43
- # event :ignite do
44
- # transition :to => :idling, :from => :parked
45
- # end
46
- # end
47
- # end
48
- #
49
- # With time states:
50
- #
51
- # class Switch
52
- # state_machine :activated_at
53
- # before_transition :to => nil, :do => lambda {...}
54
- #
55
- # event :activate do
56
- # transition :to => lambda {Time.now}
57
- # end
58
- #
59
- # event :deactivate do
60
- # transition :to => nil
61
- # end
62
- # end
63
- # end
16
+ # A state machine will not know all of the possible states for an object unless
17
+ # they are referenced *somewhere* in the state machine definition. As a result,
18
+ # any unused states should be defined with the +other_states+ or +state+ helper.
64
19
  #
65
20
  # == Callbacks
66
21
  #
@@ -88,8 +43,8 @@ module StateMachine
88
43
  # example,
89
44
  #
90
45
  # class Vehicle
91
- # state_machine, :initial => 'parked' do
92
- # before_transition :to => 'idling', :do => lambda {|vehicle| throw :halt}
46
+ # state_machine, :initial => :parked do
47
+ # before_transition :to => :idling, :do => lambda {|vehicle| throw :halt}
93
48
  # ...
94
49
  # end
95
50
  # end
@@ -109,7 +64,7 @@ module StateMachine
109
64
  # class Vehicle
110
65
  # state_machine do
111
66
  # event :park do
112
- # transition :to => 'parked', :from => 'idling'
67
+ # transition :to => :parked, :from => :idling
113
68
  # end
114
69
  # ...
115
70
  # end
@@ -188,23 +143,28 @@ module StateMachine
188
143
  # Attempts to find or create a state machine for the given class. For
189
144
  # example,
190
145
  #
191
- # StateMachine::Machine.find_or_create(Switch)
192
- # StateMachine::Machine.find_or_create(Switch, :initial => 'off')
193
- # StateMachine::Machine.find_or_create(Switch, 'status')
194
- # StateMachine::Machine.find_or_create(Switch, 'status', :initial => 'off')
146
+ # StateMachine::Machine.find_or_create(Vehicle)
147
+ # StateMachine::Machine.find_or_create(Vehicle, :initial => :parked)
148
+ # StateMachine::Machine.find_or_create(Vehicle, :status)
149
+ # StateMachine::Machine.find_or_create(Vehicle, :status, :initial => :parked)
195
150
  #
196
151
  # If a machine of the given name already exists in one of the class's
197
152
  # superclasses, then a copy of that machine will be created and stored
198
153
  # in the new owner class (the original will remain unchanged).
199
154
  def find_or_create(owner_class, *args, &block)
200
155
  options = args.last.is_a?(Hash) ? args.pop : {}
201
- attribute = (args.first || 'state').to_s
156
+ attribute = args.first || :state
202
157
 
203
158
  # Attempts to find an existing machine
204
159
  if owner_class.respond_to?(:state_machines) && machine = owner_class.state_machines[attribute]
205
- machine = machine.within_context(owner_class, options) unless machine.owner_class == owner_class
160
+ # Create a copy of the state machine if it's being created by a subclass
161
+ unless machine.owner_class == owner_class
162
+ machine = machine.clone
163
+ machine.initial_state = options[:initial] if options.include?(:initial)
164
+ machine.owner_class = owner_class
165
+ end
206
166
 
207
- # Evaluate caller block for DSL
167
+ # Evaluate DSL caller block
208
168
  machine.instance_eval(&block) if block_given?
209
169
  else
210
170
  # No existing machine: create a new one
@@ -218,10 +178,11 @@ module StateMachine
218
178
  # The given classes must be a comma-delimited string of class names.
219
179
  #
220
180
  # Configuration options:
221
- # * +file+ - A comma-delimited string of files to load that contain the state machine definitions to draw
222
- # * +path+ - The path to write the graph file to
223
- # * +format+ - The image format to generate the graph in
224
- # * +font+ - The name of the font to draw state names in
181
+ # * <tt>:file</tt> - A comma-delimited string of files to load that
182
+ # contain the state machine definitions to draw
183
+ # * <tt>:path</tt> - The path to write the graph file to
184
+ # * <tt>:format</tt> - The image format to generate the graph in
185
+ # * <tt>:font</tt> - The name of the font to draw state names in
225
186
  def draw(class_names, options = {})
226
187
  raise ArgumentError, 'At least one class must be specified' unless class_names && class_names.split(',').any?
227
188
 
@@ -246,35 +207,29 @@ module StateMachine
246
207
  end
247
208
 
248
209
  # The class that the machine is defined in
249
- attr_reader :owner_class
210
+ attr_accessor :owner_class
250
211
 
251
212
  # The attribute for which the machine is being defined
252
213
  attr_reader :attribute
253
214
 
254
- # The initial state that the machine will be in when an object is created
255
- attr_reader :initial_state
256
-
257
- # The events that trigger transitions
258
- #
259
- # Maps "name" => StateMachine::Event
215
+ # The events that trigger transitions. These are sorted, by default, in the
216
+ # order in which they were defined.
260
217
  attr_reader :events
261
218
 
262
- # Tracks the order in which events were defined. This is used to determine
263
- # in what order events are drawn on GraphViz visualizations.
264
- attr_reader :events_order
265
-
266
219
  # A list of all of the states known to this state machine. This will pull
267
- # state values from the following sources:
220
+ # states from the following sources:
268
221
  # * Initial state
269
222
  # * State behaviors
270
- # * Event transitions (:to, :from, :except_to, and :except_from options)
223
+ # * Event transitions (:to, :from, and :except_from options)
271
224
  # * Transition callbacks (:to, :from, :except_to, and :except_from options)
272
225
  # * Unreferenced states (using +other_states+ helper)
273
226
  #
274
- # Maps value => StateMachine::State
227
+ # These are sorted, by default, in the order in which they were referenced.
275
228
  attr_reader :states
276
229
 
277
230
  # The callbacks to invoke before/after a transition is performed
231
+ #
232
+ # Maps :before => callbacks and :after => callbacks
278
233
  attr_reader :callbacks
279
234
 
280
235
  # The action to invoke when an object transitions
@@ -291,87 +246,55 @@ module StateMachine
291
246
  assert_valid_keys(options, :initial, :action, :plural, :namespace, :integration)
292
247
 
293
248
  # Set machine configuration
294
- @attribute = (args.first || 'state').to_s
295
- @events = {}
296
- @events_order = []
297
- @states = {}
249
+ @attribute = args.first || :state
250
+ @events = NodeCollection.new
251
+ @states = StateCollection.new
298
252
  @callbacks = {:before => [], :after => []}
299
- @action = options[:action]
300
253
  @namespace = options[:namespace]
254
+ self.owner_class = owner_class
255
+ self.initial_state = options[:initial]
301
256
 
302
- # Add class-/instance-level methods to the owner class for state initialization
303
- owner_class.class_eval do
304
- extend StateMachine::ClassMethods
305
- include StateMachine::InstanceMethods
306
- end unless owner_class.included_modules.include?(StateMachine::InstanceMethods)
307
-
308
- # Initialize the class context of the machine
309
- set_context(owner_class, :initial => options[:initial], :integration => options[:integration], &block)
257
+ # Find an integration that matches this machine's owner class
258
+ if integration = options[:integration] ? StateMachine::Integrations.find(options[:integration]) : StateMachine::Integrations.match(owner_class)
259
+ extend integration
260
+ end
310
261
 
311
262
  # Set integration-specific configurations
312
- @action ||= default_action unless options.include?(:action)
263
+ @action = options.include?(:action) ? options[:action] : default_action
313
264
  define_attribute_accessor
314
265
  define_scopes(options[:plural])
315
266
 
316
267
  # Call after hook for integration-specific extensions
317
268
  after_initialize
318
269
 
319
- # Evaluate caller block for DSL
270
+ # Evaluate DSL caller block
320
271
  instance_eval(&block) if block_given?
321
272
  end
322
273
 
323
274
  # Creates a copy of this machine in addition to copies of each associated
324
- # event, so that the list of transitions for each event don't conflict
325
- # with different machines
275
+ # event/states/callback, so that the modifications to those collections do
276
+ # not affect the original machine.
326
277
  def initialize_copy(orig) #:nodoc:
327
278
  super
328
279
 
329
- @events = @events.inject({}) do |events, (name, event)|
330
- event = event.dup
331
- event.machine = self
332
- events[name] = event
333
- events
334
- end
335
- @events_order = @events_order.dup
336
- @states = @states.inject({}) do |states, (value, state)|
337
- state = state.dup
338
- state.machine = self
339
- states[value] = state
340
- states
341
- end
342
- @initial_state = @states[@initial_state.value]
280
+ @events = @events.dup
281
+ @events.machine = self
282
+ @states = @states.dup
283
+ @states.machine = self
343
284
  @callbacks = {:before => @callbacks[:before].dup, :after => @callbacks[:after].dup}
344
285
  end
345
286
 
346
- # Creates a copy of this machine within the context of the given class.
347
- # This should be used for inheritance support of state machines.
348
- def within_context(owner_class, options = {}, &block) #:nodoc:
349
- machine = dup
350
- machine.set_context(owner_class, {:integration => @integration}.merge(options))
351
- machine
352
- end
353
-
354
- # Changes the context of this machine to the given class so that new
355
- # events and transitions are created in the proper context.
356
- #
357
- # Configuration options:
358
- # * +initial+ - The initial value to set the attribute to
359
- # * +integration+ - The name of the integration for extending this machine with library-specific behavior
360
- #
361
- # All other configuration options for the machine can only be set on
362
- # creation.
363
- def set_context(owner_class, options = {}) #:nodoc:
364
- assert_valid_keys(options, :initial, :integration)
365
-
366
- @owner_class = owner_class
367
- @initial_state = add_states([options[:initial]]).first if options.include?(:initial) || !@initial_state
368
- states.each {|name, state| state.initial = (state == @initial_state)}
287
+ # Sets the class which is the owner of this state machine. Any methods
288
+ # generated by states, events, or other parts of the machine will be defined
289
+ # on the given owner class.
290
+ def owner_class=(klass)
291
+ @owner_class = klass
369
292
 
370
- # Find an integration that can be used for implementing various parts
371
- # of the state machine that may behave differently in different libraries
372
- if @integration = options[:integration] || StateMachine::Integrations.constants.find {|name| StateMachine::Integrations.const_get(name).matches?(owner_class)}
373
- extend StateMachine::Integrations.const_get(@integration.to_s.gsub(/(?:^|_)(.)/) {$1.upcase})
374
- end
293
+ # Add class-/instance-level methods to the owner class for state initialization
294
+ owner_class.class_eval do
295
+ extend StateMachine::ClassMethods
296
+ include StateMachine::InstanceMethods
297
+ end unless owner_class.included_modules.include?(StateMachine::InstanceMethods)
375
298
 
376
299
  # Record this machine as matched to the attribute in the current owner
377
300
  # class. This will override any machines mapped to the same attribute
@@ -379,30 +302,40 @@ module StateMachine
379
302
  owner_class.state_machines[attribute] = self
380
303
  end
381
304
 
305
+ # Sets the initial state of the machine. This can be either the static name
306
+ # of a state or a lambda block which determines the initial state at
307
+ # creation time.
308
+ def initial_state=(new_initial_state)
309
+ @initial_state = new_initial_state
310
+ add_states([@initial_state]) unless @initial_state.is_a?(Proc)
311
+
312
+ # Update all states to reflect the new initial state
313
+ states.each {|state| state.initial = (state.name == @initial_state)}
314
+ end
315
+
382
316
  # Gets the initial state of the machine for the given object. If a dynamic
383
317
  # initial state was configured for this machine, then the object will be
384
- # passed into the lambda block to help determine the actual value of the
385
- # initial state.
318
+ # passed into the lambda block to help determine the actual state.
386
319
  #
387
320
  # == Examples
388
321
  #
389
322
  # With a static initial state:
390
323
  #
391
324
  # class Vehicle
392
- # state_machine :initial => 'parked' do
325
+ # state_machine :initial => :parked do
393
326
  # ...
394
327
  # end
395
328
  # end
396
329
  #
397
330
  # vehicle = Vehicle.new
398
- # Vehicle.state_machines['state'].initial_state(vehicle) # => "parked"
331
+ # Vehicle.state_machines[:state].initial_state(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
399
332
  #
400
333
  # With a dynamic initial state:
401
334
  #
402
335
  # class Vehicle
403
336
  # attr_accessor :force_idle
404
337
  #
405
- # state_machine :initial => lambda {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do
338
+ # state_machine :initial => lambda {|vehicle| vehicle.force_idle ? :idling : :parked} do
406
339
  # ...
407
340
  # end
408
341
  # end
@@ -410,15 +343,116 @@ module StateMachine
410
343
  # vehicle = Vehicle.new
411
344
  #
412
345
  # vehicle.force_idle = true
413
- # Vehicle.state_machines['state'].initial_state(vehicle) # => "idling"
346
+ # Vehicle.state_machines[:state].initial_state(vehicle) # => #<StateMachine::State name=:idling value="idling" initial=false>
414
347
  #
415
348
  # vehicle.force_idle = false
416
- # Vehicle.state_machines['state'].initial_state(vehicle) # => "parked"
349
+ # Vehicle.state_machines[:state].initial_state(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=false>
417
350
  def initial_state(object)
418
- @initial_state && @initial_state.value(object)
351
+ states.fetch(@initial_state.is_a?(Proc) ? @initial_state.call(object) : @initial_state)
419
352
  end
420
353
 
421
- # Defines a series of behaviors to mixin with objects when the current
354
+ # Customizes the definition of one or more states in the machine.
355
+ #
356
+ # Configuration options:
357
+ # * <tt>:value</tt> - The actual value to store when an object transitions
358
+ # to the state. Default is the name (stringified).
359
+ # * <tt>:if</tt> - Determines whether an object's value matches the state
360
+ # (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
361
+ # By default, the configured value is matched.
362
+ #
363
+ # == Customizing the stored value
364
+ #
365
+ # Whenever a state is automatically discovered in the state machine, its
366
+ # default value is assumed to be the stringified version of the name. For
367
+ # example,
368
+ #
369
+ # class Vehicle
370
+ # state_machine :initial => :parked do
371
+ # event :ignite do
372
+ # transition :to => :idling, :from => :parked
373
+ # end
374
+ # end
375
+ # end
376
+ #
377
+ # In the above state machine, there are two states automatically discovered:
378
+ # :parked and :idling. These states, by default, will store their stringified
379
+ # equivalents when an object moves into that states (e.g. "parked" / "idling").
380
+ #
381
+ # For legacy systems or when tying state machines into existing frameworks,
382
+ # it's oftentimes necessary to need to store a different value for a state
383
+ # than the default. In order to continue taking advantage of an expressive
384
+ # state machine and helper methods, every defined state can be re-configured
385
+ # with a custom stored value. For example,
386
+ #
387
+ # class Vehicle
388
+ # state_machine :initial => :parked do
389
+ # event :ignite do
390
+ # transition :to => :idling, :from => :parked
391
+ # end
392
+ #
393
+ # state :idling, :value => 'IDLING'
394
+ # state :parked, :value => 'PARKED
395
+ # end
396
+ # end
397
+ #
398
+ # This is also useful if being used in association with a database and,
399
+ # instead of storing the state name in a column, you want to store the
400
+ # state's foreign key:
401
+ #
402
+ # class VehicleState < ActiveRecord::Base
403
+ # end
404
+ #
405
+ # class Vehicle < ActiveRecord::Base
406
+ # state_machine :state_id, :initial => :parked do
407
+ # event :ignite do
408
+ # transition :to => :idling, :from => :parked
409
+ # end
410
+ #
411
+ # states.each {|state| self.state(state.name, :value => VehicleState.find_by_name(state.name.to_s).id)}
412
+ # end
413
+ # end
414
+ #
415
+ # In the above example, each known state is configured to store it's
416
+ # associated database id in the +state_id+ attribute.
417
+ #
418
+ # === Dynamic values
419
+ #
420
+ # In addition to customizing states with other value types, lambda blocks
421
+ # can also be specified to allow for a state's value to be determined
422
+ # dynamically at runtime. For example,
423
+ #
424
+ # class Vehicle
425
+ # state_machine :purchased_at, :initial => :available do
426
+ # event :purchase do
427
+ # transition :to => :purchased
428
+ # end
429
+ #
430
+ # event :restock do
431
+ # transition :to => :available
432
+ # end
433
+ #
434
+ # state :available, :value => nil
435
+ # state :purchased, :if => lambda {|value| !value.nil?}, :value => lambda {Time.now}
436
+ # end
437
+ # end
438
+ #
439
+ # In the above definition, the <tt>:purchased</tt> state is customized with
440
+ # both a dynamic value *and* a value matcher.
441
+ #
442
+ # When an object transitions to the purchased state, the value's lambda
443
+ # block will be called. This will get the current time and store it in the
444
+ # object's +purchased_at+ attribute.
445
+ #
446
+ # *Note* that the custom matcher is very important here. Since there's no
447
+ # way for the state machine to figure out an object's state when it's set to
448
+ # a runtime value, it must be explicitly defined. If the <tt>:if</tt> option
449
+ # were not configured for the state, then an ArgumentError exception would
450
+ # be raised at runtime, indicating that the state machine could not figure
451
+ # out what the current state of the object was.
452
+ #
453
+ # == Behaviors
454
+ #
455
+ # Behaviors defined a series of methods to mixin with objects when the current
422
456
  # state matches the given one(s). This allows instance methods to behave
423
457
  # a specific way depending on what the value of the object's state is.
424
458
  #
@@ -428,12 +462,12 @@ module StateMachine
428
462
  # attr_accessor :driver
429
463
  # attr_accessor :passenger
430
464
  #
431
- # state_machine :initial => 'parked' do
465
+ # state_machine :initial => :parked do
432
466
  # event :ignite do
433
- # transition :to => 'idling', :from => 'parked'
467
+ # transition :to => :idling, :from => :parked
434
468
  # end
435
469
  #
436
- # state 'parked' do
470
+ # state :parked do
437
471
  # def speed
438
472
  # 0
439
473
  # end
@@ -446,16 +480,18 @@ module StateMachine
446
480
  # end
447
481
  # end
448
482
  #
449
- # state 'idling', 'first_gear' do
483
+ # state :idling, :first_gear do
450
484
  # def speed
451
485
  # 20
452
486
  # end
453
487
  #
454
488
  # def rotate_driver
455
- # self.state = "parked"
489
+ # self.state = 'parked'
456
490
  # rotate_driver
457
491
  # end
458
492
  # end
493
+ #
494
+ # other_states :backing_up
459
495
  # end
460
496
  # end
461
497
  #
@@ -493,7 +529,7 @@ module StateMachine
493
529
  # implementations changed how they behave based on what the current state
494
530
  # of the vehicle was.
495
531
  #
496
- # == Invalid behaviors
532
+ # === Invalid behaviors
497
533
  #
498
534
  # If a specific behavior has not been defined for a state, then a
499
535
  # NoMethodError exception will be raised, indicating that that method would
@@ -502,66 +538,79 @@ module StateMachine
502
538
  # Using the example from before:
503
539
  #
504
540
  # vehicle = Vehicle.new
505
- # vehicle.state = "backing_up"
541
+ # vehicle.state = 'backing_up'
506
542
  # vehicle.speed # => NoMethodError: undefined method 'speed' for #<Vehicle:0xb7d296ac> in state "backing_up"
507
- def state(*values, &block)
508
- states = add_states(values)
509
- states.each {|state| state.context(&block)} if block_given?
543
+ def state(*names, &block)
544
+ options = names.last.is_a?(Hash) ? names.pop : {}
545
+ assert_valid_keys(options, :value, :if)
546
+
547
+ states = add_states(names)
548
+ states.each do |state|
549
+ if options.include?(:value)
550
+ state.value = options[:value]
551
+ self.states.update(state)
552
+ end
553
+
554
+ state.matcher = options[:if] if options.include?(:if)
555
+ state.context(&block) if block_given?
556
+ end
557
+
510
558
  states.length == 1 ? states.first : states
511
559
  end
560
+ alias_method :other_states, :state
512
561
 
513
- # Defines additional states that are possible in the state machine, but
514
- # which are derived outside of any events/transitions or possibly
515
- # dynamically via a lambda block. This allows the given states to be:
516
- # * Queried via instance-level predicates
517
- # * Included in GraphViz visualizations
518
- # * Used in :except_from and :except_to transition/callback conditionals
562
+ # Determines whether the given object is in a specific state. If the
563
+ # object's current value doesn't match the state, then this will return
564
+ # false, otherwise true. If the given state is unknown, then an ArgumentError
565
+ # exception will be raised.
519
566
  #
520
- # == Example
567
+ # == Examples
521
568
  #
522
569
  # class Vehicle
523
- # state_machine :initial => 'parked' do
524
- # event :ignite do
525
- # transition :to => 'idling', :from => 'parked'
526
- # end
527
- #
528
- # other_states %w(stalled stopped)
529
- # end
530
- #
531
- # def stop
532
- # self.state = 'stopped'
570
+ # state_machine :initial => :parked do
571
+ # other_states :idling
533
572
  # end
534
573
  # end
535
- #
536
- # In the above state machine, the known states would be:
537
- # * +idling+
538
- # * +parked+
539
- # * +stalled+
540
- # * +stopped+
541
- #
542
- # Since +stalled+ and +stopped+ are not referenced in any transitions or
543
- # callbacks, they are explicitly defined.
544
- def other_states(*args)
545
- add_states(args.flatten)
574
+ #
575
+ # machine = Vehicle.state_machines[:state]
576
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
577
+ #
578
+ # machine.state?(vehicle, :parked) # => true
579
+ # machine.state?(vehicle, :idling) # => false
580
+ # machine.state?(vehicle, :invalid) # => ArgumentError: :invalid is an invalid key for :name index
581
+ def state?(object, name)
582
+ states.fetch(name).matches?(object.send(attribute))
546
583
  end
547
584
 
548
- # Gets the order in which states should be displayed based on where they
549
- # were first referenced. This will order states in the following priority:
550
- #
551
- # 1. Initial state
552
- # 2. Event transitions (:to, :from, :except_to, :except_from options)
553
- # 3. States with behaviors
554
- # 4. States referenced via +other_states+
555
- # 5. States referenced in callbacks
556
- #
557
- # This order will determine how the GraphViz visualizations are rendered.
558
- def states_order
559
- order = [initial_state(nil)]
585
+ # Determines the current state of the given object as configured by this
586
+ # state machine. This will attempt to find a known state that matches
587
+ # the value of the attribute on the object. If no state is found, then
588
+ # an ArgumentError will be raised.
589
+ #
590
+ # == Examples
591
+ #
592
+ # class Vehicle
593
+ # state_machine :initial => :parked do
594
+ # other_states :idling
595
+ # end
596
+ # end
597
+ #
598
+ # machine = Vehicle.state_machines[:state]
599
+ #
600
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
601
+ # machine.state_for(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
602
+ #
603
+ # vehicle.state = 'idling'
604
+ # machine.state_for(vehicle) # => #<StateMachine::State name=:idling value="idling" initial=true>
605
+ #
606
+ # vehicle.state = 'invalid'
607
+ # machine.state_for(vehicle) # => ArgumentError: "invalid" is not a known state value
608
+ def state_for(object)
609
+ value = object.send(attribute)
610
+ state = states[value, :value] || states.detect {|state| state.matches?(value)}
611
+ raise ArgumentError, "#{value.inspect} is not a known #{attribute} value" unless state
560
612
 
561
- events.each {|name, event| order |= event.known_states}
562
- order |= states.select {|value, state| state.methods.any?}.map {|state| state.first}
563
- order |= states.keys - callbacks.values.flatten.map {|callback| callback.known_states}.flatten
564
- order |= states.keys
613
+ state
565
614
  end
566
615
 
567
616
  # Defines one or more events for the machine and the transitions that can
@@ -571,10 +620,16 @@ module StateMachine
571
620
  #
572
621
  # The following instance methods are generated when a new event is defined
573
622
  # (the "park" event is used as an example):
574
- # * <tt>can_park?</tt> - Checks whether the "park" event can be fired given the current state of the object.
575
- # * <tt>next_park_transition</tt> - Gets the next transition that would be performed if the "park" event were to be fired now on the object or nil if no transitions can be performed.
576
- # * <tt>park(run_action = true)</tt> - Fires the "park" event, transitioning from the current state to the next valid state.
577
- # * <tt>park!(run_action = true)</tt> - Fires the "park" event, transitioning from the current state to the next valid state. If the transition fails, then a StateMachine::InvalidTransition error will be raised.
623
+ # * <tt>can_park?</tt> - Checks whether the "park" event can be fired given
624
+ # the current state of the object.
625
+ # * <tt>next_park_transition</tt> - Gets the next transition that would be
626
+ # performed if the "park" event were to be fired now on the object or nil
627
+ # if no transitions can be performed.
628
+ # * <tt>park(run_action = true)</tt> - Fires the "park" event, transitioning
629
+ # from the current state to the next valid state.
630
+ # * <tt>park!(run_action = true)</tt> - Fires the "park" event, transitioning
631
+ # from the current state to the next valid state. If the transition fails,
632
+ # then a StateMachine::InvalidTransition error will be raised.
578
633
  #
579
634
  # With a namespace of "car", the above names map to the following methods:
580
635
  # * <tt>can_park_car?</tt>
@@ -588,11 +643,11 @@ module StateMachine
588
643
  # transitions that can happen as a result of that event. For example,
589
644
  #
590
645
  # event :park, :stop do
591
- # transition :to => 'parked', :from => 'idling'
646
+ # transition :to => :parked, :from => :idling
592
647
  # end
593
648
  #
594
649
  # event :first_gear do
595
- # transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on?
650
+ # transition :to => :first_gear, :from => :parked, :if => :seatbelt_on?
596
651
  # end
597
652
  #
598
653
  # See StateMachine::Event#transition for more information on
@@ -604,12 +659,12 @@ module StateMachine
604
659
  #
605
660
  # class Vehicle
606
661
  # def self.safe_states
607
- # %w(parked idling stalled)
662
+ # [:parked, :idling, :stalled]
608
663
  # end
609
664
  #
610
665
  # state_machine do
611
666
  # event :park do
612
- # transition :to => 'parked', :from => Vehicle.safe_states
667
+ # transition :to => :parked, :from => Vehicle.safe_states
613
668
  # end
614
669
  # end
615
670
  # end
@@ -620,23 +675,23 @@ module StateMachine
620
675
  # state_machine do
621
676
  # # The park, stop, and halt events will all share the given transitions
622
677
  # event :park, :stop, :halt do
623
- # transition :to => 'parked', :from => %w(idling backing_up)
678
+ # transition :to => :parked, :from => [:idling, :backing_up]
624
679
  # end
625
680
  #
626
681
  # event :stop do
627
- # transition :to => 'idling', :from => 'first_gear'
682
+ # transition :to => :idling, :from => :first_gear
628
683
  # end
629
684
  #
630
685
  # event :ignite do
631
- # transition :to => 'idling', :from => 'parked'
686
+ # transition :to => :idling, :from => :parked
632
687
  # end
633
688
  # end
634
689
  # end
635
690
  def event(*names, &block)
636
691
  events = names.collect do |name|
637
- name = name.to_s
638
- event = self.events[name] ||= Event.new(self, name)
639
- @events_order << name unless @events_order.include?(name)
692
+ unless event = self.events[name]
693
+ self.events << event = Event.new(self, name)
694
+ end
640
695
 
641
696
  if block_given?
642
697
  event.instance_eval(&block)
@@ -655,15 +710,25 @@ module StateMachine
655
710
  # order for the callback to get invoked.
656
711
  #
657
712
  # Configuration options:
658
- # * +to+ - One or more states being transitioned to. If none are specified, then all states will match.
659
- # * +from+ - One or more states being transitioned from. If none are specified, then all states will match.
660
- # * +on+ - One or more events that fired the transition. If none are specified, then all events will match.
661
- # * +except_to+ - One more states *not* being transitioned to
662
- # * +except_from+ - One or more states *not* being transitioned from
663
- # * +except_on+ - One or more events that *did not* fire the transition
664
- # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string.
665
- # * +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.
666
- # * +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.
713
+ # * <tt>:from</tt> - One or more states being transitioned from. If none
714
+ # are specified, then all states will match.
715
+ # * <tt>:to</tt> - One or more states being transitioned to. If none are
716
+ # specified, then all states will match.
717
+ # * <tt>:on</tt> - One or more events that fired the transition. If none
718
+ # are specified, then all events will match.
719
+ # * <tt>:except_from</tt> - One or more states *not* being transitioned from
720
+ # * <tt>:except_to</tt> - One more states *not* being transitioned to
721
+ # * <tt>:except_on</tt> - One or more events that *did not* fire the transition
722
+ # * <tt>:do</tt> - The callback to invoke when a transition matches. This
723
+ # can be a method, proc or string.
724
+ # * <tt>:if</tt> - A method, proc or string to call to determine if the
725
+ # callback should occur (e.g. :if => :allow_callbacks, or
726
+ # :if => lambda {|user| user.signup_step > 2}). The method, proc or string
727
+ # should return or evaluate to a true or false value.
728
+ # * <tt>:unless</tt> - A method, proc or string to call to determine if the
729
+ # callback should not occur (e.g. :unless => :skip_callbacks, or
730
+ # :unless => lambda {|user| user.signup_step <= 2}). The method, proc or
731
+ # string should return or evaluate to a true or false value.
667
732
  #
668
733
  # The +except+ group of options (+except_to+, +exception_from+, and
669
734
  # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+,
@@ -676,8 +741,8 @@ module StateMachine
676
741
  #
677
742
  # class Vehicle
678
743
  # state_machine do
679
- # before_transition :to => 'parked', :do => :set_alarm
680
- # before_transition :to => 'parked' do |vehicle, transition|
744
+ # before_transition :to => :parked, :do => :set_alarm
745
+ # before_transition :to => :parked do |vehicle, transition|
681
746
  # vehicle.set_alarm
682
747
  # end
683
748
  # ...
@@ -687,18 +752,18 @@ module StateMachine
687
752
  # === Accessing the transition
688
753
  #
689
754
  # In addition to passing the object being transitioned, the actual
690
- # transition describing the context (e.g. event, from state, to state)
691
- # can be accessed as well. This additional argument is only passed if the
692
- # callback allows for it.
755
+ # transition describing the context (e.g. event, from, to) can be accessed
756
+ # as well. This additional argument is only passed if the callback allows
757
+ # for it.
693
758
  #
694
759
  # For example,
695
760
  #
696
761
  # class Vehicle
697
762
  # # Only specifies one parameter (the object being transitioned)
698
- # before_transition :to => 'parked', :do => lambda {|vehicle| vehicle.set_alarm}
763
+ # before_transition :to => :parked, :do => lambda {|vehicle| vehicle.set_alarm}
699
764
  #
700
765
  # # Specifies 2 parameters (object being transitioned and actual transition)
701
- # before_transition :to => 'parked', :do => lambda {|vehicle, transition| vehicle.set_alarm(transition)}
766
+ # before_transition :to => :parked, :do => lambda {|vehicle, transition| vehicle.set_alarm(transition)}
702
767
  # end
703
768
  #
704
769
  # *Note* that the object in the callback will only be passed in as an
@@ -719,13 +784,13 @@ module StateMachine
719
784
  # before_transition :update_dashboard
720
785
  #
721
786
  # # Before specific transition:
722
- # before_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt
787
+ # before_transition :to => :parked, :from => [:first_gear, :idling], :on => :park, :do => :take_off_seatbelt
723
788
  #
724
789
  # # With conditional callback:
725
- # before_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on?
790
+ # before_transition :to => :parked, :do => :take_off_seatbelt, :if => :seatbelt_on?
726
791
  #
727
792
  # # Using :except counterparts:
728
- # before_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard
793
+ # before_transition :except_to => :stalled, :except_from => :stalled, :except_on => :crash, :do => :update_dashboard
729
794
  # ...
730
795
  # end
731
796
  # end
@@ -742,15 +807,25 @@ module StateMachine
742
807
  # in order for the callback to get invoked.
743
808
  #
744
809
  # Configuration options:
745
- # * +to+ - One or more states being transitioned to. If none are specified, then all states will match.
746
- # * +from+ - One or more states being transitioned from. If none are specified, then all states will match.
747
- # * +on+ - One or more events that fired the transition. If none are specified, then all events will match.
748
- # * +except_to+ - One more states *not* being transitioned to
749
- # * +except_from+ - One or more states *not* being transitioned from
750
- # * +except_on+ - One or more events that *did not* fire the transition
751
- # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string.
752
- # * +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.
753
- # * +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.
810
+ # * <tt>:from</tt> - One or more states being transitioned from. If none
811
+ # are specified, then all states will match.
812
+ # * <tt>:to</tt> - One or more states being transitioned to. If none are
813
+ # specified, then all states will match.
814
+ # * <tt>:on</tt> - One or more events that fired the transition. If none
815
+ # are specified, then all events will match.
816
+ # * <tt>:except_from</tt> - One or more states *not* being transitioned from
817
+ # * <tt>:except_to</tt> - One more states *not* being transitioned to
818
+ # * <tt>:except_on</tt> - One or more events that *did not* fire the transition
819
+ # * <tt>:do</tt> - The callback to invoke when a transition matches. This
820
+ # can be a method, proc or string.
821
+ # * <tt>:if</tt> - A method, proc or string to call to determine if the
822
+ # callback should occur (e.g. :if => :allow_callbacks, or
823
+ # :if => lambda {|user| user.signup_step > 2}). The method, proc or string
824
+ # should return or evaluate to a true or false value.
825
+ # * <tt>:unless</tt> - A method, proc or string to call to determine if the
826
+ # callback should not occur (e.g. :unless => :skip_callbacks, or
827
+ # :unless => lambda {|user| user.signup_step <= 2}). The method, proc or
828
+ # string should return or evaluate to a true or false value.
754
829
  #
755
830
  # The +except+ group of options (+except_to+, +exception_from+, and
756
831
  # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+,
@@ -763,8 +838,8 @@ module StateMachine
763
838
  #
764
839
  # class Vehicle
765
840
  # state_machine do
766
- # after_transition :to => 'parked', :do => :set_alarm
767
- # after_transition :to => 'parked' do |vehicle, transition, result|
841
+ # after_transition :to => :parked, :do => :set_alarm
842
+ # after_transition :to => :parked do |vehicle, transition, result|
768
843
  # vehicle.set_alarm
769
844
  # end
770
845
  # ...
@@ -774,19 +849,18 @@ module StateMachine
774
849
  # === Accessing the transition / result
775
850
  #
776
851
  # In addition to passing the object being transitioned, the actual
777
- # transition describing the context (e.g. event, from state, to state) and
778
- # the result from calling the object's action can be optionally passed as
779
- # well. These additional arguments are only passed if the callback allows
780
- # for it.
852
+ # transition describing the context (e.g. event, from, to) and the result
853
+ # from calling the object's action can be optionally passed as well. These
854
+ # additional arguments are only passed if the callback allows for it.
781
855
  #
782
856
  # For example,
783
857
  #
784
858
  # class Vehicle
785
859
  # # Only specifies one parameter (the object being transitioned)
786
- # after_transition :to => 'parked', :do => lambda {|vehicle| vehicle.set_alarm}
860
+ # after_transition :to => :parked, :do => lambda {|vehicle| vehicle.set_alarm}
787
861
  #
788
862
  # # Specifies 3 parameters (object being transitioned, transition, and action result)
789
- # after_transition :to => 'parked', :do => lambda {|vehicle, transition, result| vehicle.set_alarm(transition) if result}
863
+ # after_transition :to => :parked, :do => lambda {|vehicle, transition, result| vehicle.set_alarm(transition) if result}
790
864
  # end
791
865
  #
792
866
  # *Note* that the object in the callback will only be passed in as an
@@ -807,13 +881,13 @@ module StateMachine
807
881
  # after_transition :update_dashboard
808
882
  #
809
883
  # # After specific transition:
810
- # after_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt
884
+ # after_transition :to => :parked, :from => [:first_gear, :idling], :on => :park, :do => :take_off_seatbelt
811
885
  #
812
886
  # # With conditional callback:
813
- # after_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on?
887
+ # after_transition :to => :parked, :do => :take_off_seatbelt, :if => :seatbelt_on?
814
888
  #
815
889
  # # Using :except counterparts:
816
- # after_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard
890
+ # after_transition :except_to => :stalled, :except_from => :stalled, :except_on => :crash, :do => :update_dashboard
817
891
  # ...
818
892
  # end
819
893
  # end
@@ -840,10 +914,14 @@ module StateMachine
840
914
  # installed on the system.
841
915
  #
842
916
  # Configuration options:
843
- # * +name+ - The name of the file to write to (without the file extension). Default is "#{owner_class.name}_#{attribute}"
844
- # * +path+ - The path to write the graph file to. Default is the current directory (".").
845
- # * +format+ - The image format to generate the graph in. Default is "png'.
846
- # * +font+ - The name of the font to draw state names in. Default is "Arial'.
917
+ # * <tt>:name</tt> - The name of the file to write to (without the file extension).
918
+ # Default is "#{owner_class.name}_#{attribute}"
919
+ # * <tt>:path</tt> - The path to write the graph file to. Default is the
920
+ # current directory (".").
921
+ # * <tt>:format</tt> - The image format to generate the graph in.
922
+ # Default is "png'.
923
+ # * <tt>:font</tt> - The name of the font to draw state names in.
924
+ # Default is "Arial".
847
925
  def draw(options = {})
848
926
  options = {
849
927
  :name => "#{owner_class.name}_#{attribute}",
@@ -861,13 +939,13 @@ module StateMachine
861
939
  graph = GraphViz.new('G', :output => options[:format], :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}"))
862
940
 
863
941
  # Add nodes
864
- Array(state(*states_order)).each do |state|
942
+ states.by_priority.each do |state|
865
943
  node = state.draw(graph)
866
944
  node.fontname = options[:font]
867
945
  end
868
946
 
869
947
  # Add edges
870
- Array(event(*events_order)).each do |event|
948
+ events.each do |event|
871
949
  edges = event.draw(graph)
872
950
  edges.each {|edge| edge.fontname = options[:font]}
873
951
  end
@@ -892,7 +970,7 @@ module StateMachine
892
970
  def default_action
893
971
  end
894
972
 
895
- # Adds reader/writer/prediate methods for accessing the attribute that
973
+ # Adds reader/writer/predicate methods for accessing the attribute that
896
974
  # this state machine is defined for.
897
975
  def define_attribute_accessor
898
976
  attribute = self.attribute
@@ -901,12 +979,15 @@ module StateMachine
901
979
  attr_reader attribute unless method_defined?(attribute) || private_method_defined?(attribute)
902
980
  attr_writer attribute unless method_defined?("#{attribute}=") || private_method_defined?("#{attribute}=")
903
981
 
904
- # Checks whether the current state is a given value. If the value
905
- # is not a known state, then an ArgumentError is raised.
982
+ # Checks whether the current state is a given value
906
983
  define_method("#{attribute}?") do |state|
907
- raise ArgumentError, "#{state.inspect} is not a known #{attribute} value" unless self.class.state_machines[attribute].states.include?(state)
908
- send(attribute) == state
984
+ self.class.state_machines[attribute].state?(self, state)
909
985
  end unless method_defined?("#{attribute}?") || private_method_defined?("#{attribute}?")
986
+
987
+ # Gets the state name for the current value
988
+ define_method("#{attribute}_name") do
989
+ self.class.state_machines[attribute].state_for(self).name
990
+ end
910
991
  end
911
992
  end
912
993
 
@@ -916,39 +997,61 @@ module StateMachine
916
997
  # automatically determined by either calling +pluralize+ on the attribute
917
998
  # name or adding an "s" to the end of the name.
918
999
  def define_scopes(custom_plural = nil)
1000
+ attribute = self.attribute
919
1001
  plural = custom_plural || (attribute.respond_to?(:pluralize) ? attribute.pluralize : "#{attribute}s")
920
1002
 
921
1003
  [attribute, plural].uniq.each do |name|
922
- define_with_scope("with_#{name}") unless owner_class.respond_to?("with_#{name}")
923
- define_without_scope("without_#{name}") unless owner_class.respond_to?("without_#{name}")
1004
+ [:with, :without].each do |kind|
1005
+ method = "#{kind}_#{name}"
1006
+
1007
+ if !owner_class.respond_to?(method) && scope = send("create_#{kind}_scope", method)
1008
+ (class << owner_class; self; end).class_eval do
1009
+ # Converts state names to their corresponding values so that
1010
+ # they can be looked up properly
1011
+ define_method(method) do |*states|
1012
+ machine_states = state_machines[attribute].states
1013
+ values = states.flatten.map {|state| machine_states.fetch(state).value}
1014
+
1015
+ # Invoke the original scope implementation
1016
+ scope.call(self, values)
1017
+ end
1018
+ end
1019
+ end
1020
+ end
924
1021
  end
925
1022
  end
926
1023
 
927
- # Defines a scope for finding objects *with* a particular value or
928
- # values for the attribute.
1024
+ # Creates a scope for finding objects *with* a particular value or values
1025
+ # for the attribute.
929
1026
  #
930
1027
  # This is only applicable to specific integrations.
931
- def define_with_scope(name)
1028
+ def create_with_scope(name)
932
1029
  end
933
1030
 
934
- # Defines a scope for finding objects *without* a particular value or
1031
+ # Creates a scope for finding objects *without* a particular value or
935
1032
  # values for the attribute.
936
1033
  #
937
1034
  # This is only applicable to specific integrations.
938
- def define_without_scope(name)
1035
+ def create_without_scope(name)
939
1036
  end
940
1037
 
941
1038
  # Adds a new transition callback of the given type.
942
1039
  def add_callback(type, options, &block)
943
- @callbacks[type] << callback = Callback.new(options, &block)
1040
+ callbacks[type] << callback = Callback.new(options, &block)
944
1041
  add_states(callback.known_states)
945
1042
  callback
946
1043
  end
947
1044
 
948
1045
  # Tracks the given set of states in the list of all known states for
949
1046
  # this machine
950
- def add_states(states)
951
- states.collect {|state| @states[state] ||= State.new(self, state)}
1047
+ def add_states(new_states)
1048
+ new_states.collect do |new_state|
1049
+ unless state = states[new_state]
1050
+ states << state = State.new(self, new_state)
1051
+ end
1052
+
1053
+ state
1054
+ end
952
1055
  end
953
1056
  end
954
1057
  end