state_machine 0.6.3 → 0.7.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 (56) hide show
  1. data/CHANGELOG.rdoc +31 -1
  2. data/README.rdoc +33 -21
  3. data/Rakefile +2 -2
  4. data/examples/merb-rest/controller.rb +51 -0
  5. data/examples/merb-rest/model.rb +28 -0
  6. data/examples/merb-rest/view_edit.html.erb +24 -0
  7. data/examples/merb-rest/view_index.html.erb +23 -0
  8. data/examples/merb-rest/view_new.html.erb +13 -0
  9. data/examples/merb-rest/view_show.html.erb +17 -0
  10. data/examples/rails-rest/controller.rb +43 -0
  11. data/examples/rails-rest/migration.rb +11 -0
  12. data/examples/rails-rest/model.rb +23 -0
  13. data/examples/rails-rest/view_edit.html.erb +25 -0
  14. data/examples/rails-rest/view_index.html.erb +23 -0
  15. data/examples/rails-rest/view_new.html.erb +14 -0
  16. data/examples/rails-rest/view_show.html.erb +17 -0
  17. data/lib/state_machine/assertions.rb +2 -2
  18. data/lib/state_machine/callback.rb +14 -8
  19. data/lib/state_machine/condition_proxy.rb +3 -3
  20. data/lib/state_machine/event.rb +19 -21
  21. data/lib/state_machine/event_collection.rb +114 -0
  22. data/lib/state_machine/extensions.rb +127 -11
  23. data/lib/state_machine/guard.rb +1 -1
  24. data/lib/state_machine/integrations/active_record/locale.rb +2 -1
  25. data/lib/state_machine/integrations/active_record.rb +117 -39
  26. data/lib/state_machine/integrations/data_mapper/observer.rb +20 -64
  27. data/lib/state_machine/integrations/data_mapper.rb +71 -26
  28. data/lib/state_machine/integrations/sequel.rb +69 -21
  29. data/lib/state_machine/machine.rb +267 -139
  30. data/lib/state_machine/machine_collection.rb +145 -0
  31. data/lib/state_machine/matcher.rb +2 -2
  32. data/lib/state_machine/node_collection.rb +9 -4
  33. data/lib/state_machine/state.rb +22 -32
  34. data/lib/state_machine/state_collection.rb +66 -17
  35. data/lib/state_machine/transition.rb +259 -28
  36. data/lib/state_machine.rb +121 -56
  37. data/tasks/state_machine.rake +1 -0
  38. data/tasks/state_machine.rb +26 -0
  39. data/test/active_record.log +116877 -0
  40. data/test/functional/state_machine_test.rb +118 -12
  41. data/test/sequel.log +28542 -0
  42. data/test/unit/callback_test.rb +46 -1
  43. data/test/unit/condition_proxy_test.rb +55 -28
  44. data/test/unit/event_collection_test.rb +228 -0
  45. data/test/unit/event_test.rb +51 -46
  46. data/test/unit/integrations/active_record_test.rb +128 -70
  47. data/test/unit/integrations/data_mapper_test.rb +150 -58
  48. data/test/unit/integrations/sequel_test.rb +63 -6
  49. data/test/unit/invalid_event_test.rb +7 -0
  50. data/test/unit/machine_collection_test.rb +678 -0
  51. data/test/unit/machine_test.rb +198 -91
  52. data/test/unit/node_collection_test.rb +33 -30
  53. data/test/unit/state_collection_test.rb +112 -5
  54. data/test/unit/state_test.rb +23 -3
  55. data/test/unit/transition_test.rb +750 -89
  56. metadata +28 -3
@@ -0,0 +1,14 @@
1
+ <h1>New User</h1>
2
+
3
+ <% form_for @user do |f| %>
4
+ <%= f.error_messages %>
5
+
6
+ <p>
7
+ <%= f.label :name %><br />
8
+ <%= f.text_field :name %>
9
+ </p>
10
+
11
+ <p><%= f.submit 'Create' %></p>
12
+ <% end %>
13
+
14
+ <%= link_to 'Back', users_path %>
@@ -0,0 +1,17 @@
1
+ <p>
2
+ <b>Name:</b>
3
+ <%=h @user.name %>
4
+ </p>
5
+
6
+ <p>
7
+ <b>State:</b>
8
+ <%=h @user.state %>
9
+ </p>
10
+
11
+ <p>
12
+ <b>Access State:</b>
13
+ <%=h @user.access_state %>
14
+ </p>
15
+
16
+ <%= link_to 'Edit', edit_user_path(@user) %> |
17
+ <%= link_to 'Back', users_path %>
@@ -1,6 +1,6 @@
1
1
  module StateMachine
2
- # Provides a set of helper methods for making assertions about the content of
3
- # various objects
2
+ # Provides a set of helper methods for making assertions about the content
3
+ # of various objects
4
4
  module Assertions
5
5
  # Validates that all keys in the given hash *only* includes the specified
6
6
  # valid keys. If any invalid keys are found, an ArgumentError will be
@@ -2,14 +2,14 @@ require 'state_machine/guard'
2
2
  require 'state_machine/eval_helpers'
3
3
 
4
4
  module StateMachine
5
- # Callbacks represent hooks into objects that allow you to trigger logic
5
+ # Callbacks represent hooks into objects that allow logic to be triggered
6
6
  # before or after a specific transition occurs.
7
7
  class Callback
8
8
  include EvalHelpers
9
9
 
10
10
  class << self
11
- # Determines whether to automatically bind the callback to the object being
12
- # transitioned. This only applies to callbacks that are defined as
11
+ # Determines whether to automatically bind the callback to the object
12
+ # being transitioned. This only applies to callbacks that are defined as
13
13
  # lambda blocks (or Procs). Some integrations, such as DataMapper, handle
14
14
  # callbacks by executing them bound to the object involved, while other
15
15
  # integrations, such as ActiveRecord, pass the object as an argument to
@@ -53,6 +53,13 @@ module StateMachine
53
53
  # end
54
54
  # end
55
55
  attr_accessor :bind_to_object
56
+
57
+ # The application-wide terminator to use for callbacks when not
58
+ # explicitly defined. Terminators determine whether to cancel a
59
+ # callback chain based on the return value of the callback.
60
+ #
61
+ # See StateMachine::Callback#terminator for more information.
62
+ attr_accessor :terminator
56
63
  end
57
64
 
58
65
  # An optional block for determining whether to cancel the callback chain
@@ -114,13 +121,12 @@ module StateMachine
114
121
  options = {}
115
122
  end
116
123
 
117
- # The actual method to invoke must be defined
118
124
  raise ArgumentError, ':do callback must be specified' unless @method
119
125
 
120
- # Proxy the method so that it's bound to the object. Note that this only
121
- # applies to lambda callbacks. All other callbacks ignore this option.
122
- bind_to_object = !options.include?(:bind_to_object) && self.class.bind_to_object || options.delete(:bind_to_object)
123
- @method = bound_method(@method) if @method.is_a?(Proc) && bind_to_object
126
+ options = {:bind_to_object => self.class.bind_to_object, :terminator => self.class.terminator}.merge(options)
127
+
128
+ # Proxy lambda blocks so that they're bound to the object
129
+ @method = bound_method(@method) if options.delete(:bind_to_object) && @method.is_a?(Proc)
124
130
  @terminator = options.delete(:terminator)
125
131
 
126
132
  @guard = Guard.new(options)
@@ -56,7 +56,7 @@ module StateMachine
56
56
  @condition = condition
57
57
  end
58
58
 
59
- # Hooks in condition merging to methods that don't exist in this module
59
+ # Hooks in condition-merging to methods that don't exist in this module
60
60
  def method_missing(*args, &block)
61
61
  # Get the configuration
62
62
  if args.last.is_a?(Hash)
@@ -77,8 +77,8 @@ module StateMachine
77
77
  # Replace the configuration condition with the one configured for this
78
78
  # proxy, merging together any existing conditions
79
79
  options[:if] = lambda do |*args|
80
- # Block may be executed within the context of the actual object, so it'll
81
- # either be the first argument or the executing context
80
+ # Block may be executed within the context of the actual object, so
81
+ # it'll either be the first argument or the executing context
82
82
  object = args.first || self
83
83
 
84
84
  proxy.evaluate_method(object, proxy_condition) &&
@@ -4,6 +4,10 @@ require 'state_machine/assertions'
4
4
  require 'state_machine/matcher_helpers'
5
5
 
6
6
  module StateMachine
7
+ # An invalid event was specified
8
+ class InvalidEvent < StandardError
9
+ end
10
+
7
11
  # An event defines an action that transitions an attribute from one state to
8
12
  # another. The state that an attribute is transitioned to depends on the
9
13
  # guards configured for the event.
@@ -14,9 +18,12 @@ module StateMachine
14
18
  # The state machine for which this event is defined
15
19
  attr_accessor :machine
16
20
 
17
- # The name of the action that fires the event
21
+ # The name of the event
18
22
  attr_reader :name
19
23
 
24
+ # The fully-qualified name of the event, scoped by the machine's namespace
25
+ attr_reader :qualified_name
26
+
20
27
  # The list of guards that determine what state this event transitions
21
28
  # objects to when fired
22
29
  attr_reader :guards
@@ -29,6 +36,7 @@ module StateMachine
29
36
  def initialize(machine, name) #:nodoc:
30
37
  @machine = machine
31
38
  @name = name
39
+ @qualified_name = machine.namespace ? :"#{name}_#{machine.namespace}" : name
32
40
  @guards = []
33
41
  @known_states = []
34
42
 
@@ -54,8 +62,8 @@ module StateMachine
54
62
  # transition :parked => :idling, :idling => :first_gear
55
63
  #
56
64
  # In this case, when the event is fired, this transition will cause the
57
- # state to be +idling+ if it's current state is +parked+ or +first_gear+ if
58
- # it's current state is +idling+.
65
+ # state to be +idling+ if it's current state is +parked+ or +first_gear+
66
+ # if it's current state is +idling+.
59
67
  #
60
68
  # To help defining these implicit transitions, a set of helpers are available
61
69
  # for defining slightly more complex matching:
@@ -149,13 +157,13 @@ module StateMachine
149
157
  #
150
158
  # If the event can't be fired, then this will return false, otherwise true.
151
159
  def can_fire?(object)
152
- !next_transition(object).nil?
160
+ !transition_for(object).nil?
153
161
  end
154
162
 
155
163
  # Finds and builds the next transition that can be performed on the given
156
164
  # object. If no transitions can be made, then this will return nil.
157
- def next_transition(object)
158
- from = machine.state_for(object).name
165
+ def transition_for(object)
166
+ from = machine.states.match(object).name
159
167
 
160
168
  guards.each do |guard|
161
169
  if match = guard.match(object, :from => from)
@@ -179,21 +187,14 @@ module StateMachine
179
187
  def fire(object, *args)
180
188
  machine.reset(object)
181
189
 
182
- if transition = next_transition(object)
190
+ if transition = transition_for(object)
183
191
  transition.perform(*args)
184
192
  else
185
- machine.invalidate(object, self)
193
+ machine.invalidate(object, machine.attribute, :invalid_transition, [[:event, name]])
186
194
  false
187
195
  end
188
196
  end
189
197
 
190
- # Attempts to perform the next available transition on the given object.
191
- # If no transitions can be made, then a StateMachine::InvalidTransition
192
- # exception will be raised, otherwise true will be returned.
193
- def fire!(object, *args)
194
- fire(object, *args) || raise(StateMachine::InvalidTransition, "Cannot transition #{machine.attribute} via :#{name} from #{machine.state_for(object).name.inspect}")
195
- end
196
-
197
198
  # Draws a representation of this event on the given graph. This will
198
199
  # create 1 or more edges on the graph for each guard (i.e. transition)
199
200
  # configured.
@@ -225,9 +226,6 @@ module StateMachine
225
226
  # Add the various instance methods that can transition the object using
226
227
  # the current event
227
228
  def add_actions
228
- qualified_name = name = self.name
229
- qualified_name = "#{name}_#{machine.namespace}" if machine.namespace
230
-
231
229
  # Checks whether the event can be fired on the current object
232
230
  machine.define_instance_method("can_#{qualified_name}?") do |machine, object|
233
231
  machine.event(name).can_fire?(object)
@@ -235,8 +233,8 @@ module StateMachine
235
233
 
236
234
  # Gets the next transition that would be performed if the event were
237
235
  # fired now
238
- machine.define_instance_method("next_#{qualified_name}_transition") do |machine, object|
239
- machine.event(name).next_transition(object)
236
+ machine.define_instance_method("#{qualified_name}_transition") do |machine, object|
237
+ machine.event(name).transition_for(object)
240
238
  end
241
239
 
242
240
  # Fires the event
@@ -246,7 +244,7 @@ module StateMachine
246
244
 
247
245
  # Fires the event, raising an exception if it fails
248
246
  machine.define_instance_method("#{qualified_name}!") do |machine, object, *args|
249
- machine.event(name).fire!(object, *args)
247
+ object.send(qualified_name, *args) || raise(StateMachine::InvalidTransition, "Cannot transition #{machine.attribute} via :#{name} from #{machine.states.match(object).name.inspect}")
250
248
  end
251
249
  end
252
250
  end
@@ -0,0 +1,114 @@
1
+ module StateMachine
2
+ # Represents a collection of events in a state machine
3
+ class EventCollection < NodeCollection
4
+ def initialize(machine) #:nodoc:
5
+ super(machine, :index => [:name, :qualified_name])
6
+ end
7
+
8
+ # Gets the list of events that can be fired on the given object.
9
+ #
10
+ # == Examples
11
+ #
12
+ # class Vehicle
13
+ # state_machine :initial => :parked do
14
+ # event :park do
15
+ # transition :idling => :parked
16
+ # end
17
+ #
18
+ # event :ignite do
19
+ # transition :parked => :idling
20
+ # end
21
+ # end
22
+ # end
23
+ #
24
+ # events = Vehicle.state_machine(:state).events
25
+ #
26
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
27
+ # events.valid_for(vehicle) # => [#<StateMachine::Event name=:ignite transitions=[:parked => :idling]>]
28
+ #
29
+ # vehicle.state = 'idling'
30
+ # events.valid_for(vehicle) # => [#<StateMachine::Event name=:park transitions=[:idling => :parked]>]
31
+ def valid_for(object)
32
+ select {|event| event.can_fire?(object)}
33
+ end
34
+
35
+ # Gets the list of transitions that can be run on the given object.
36
+ #
37
+ # == Examples
38
+ #
39
+ # class Vehicle
40
+ # state_machine :initial => :parked do
41
+ # event :park do
42
+ # transition :idling => :parked
43
+ # end
44
+ #
45
+ # event :ignite do
46
+ # transition :parked => :idling
47
+ # end
48
+ # end
49
+ # end
50
+ #
51
+ # events = Vehicle.state_machine.events
52
+ #
53
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
54
+ # events.transitions_for(vehicle) # => [#<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>]
55
+ #
56
+ # vehicle.state = 'idling'
57
+ # events.transitions_for(vehicle) # => [#<StateMachine::Transition attribute=:state event=:park from="idling" from_name=:idling to="parked" to_name=:parked>]
58
+ def transitions_for(object)
59
+ map {|event| event.transition_for(object)}.compact
60
+ end
61
+
62
+ # Gets the transition that should be performed for the event stored in the
63
+ # given object's event attribute. This also takes an additional parameter
64
+ # for automatically invalidating the object if the event or transition
65
+ # are invalid. By default, this is turned off.
66
+ #
67
+ # *Note* that if a transition has already been generated for the event,
68
+ # then that transition will be used.
69
+ #
70
+ # == Examples
71
+ #
72
+ # class Vehicle < ActiveRecord::Base
73
+ # state_machine :initial => :parked do
74
+ # event :ignite do
75
+ # transition :parked => :idling
76
+ # end
77
+ # end
78
+ # end
79
+ #
80
+ # vehicle = Vehicle.new # => #<Vehicle id: nil, state: "parked">
81
+ # events = Vehicle.state_machine.events
82
+ #
83
+ # vehicle.state_event = nil
84
+ # events.attribute_transition_for(vehicle) # => nil # Event isn't defined
85
+ #
86
+ # vehicle.state_event = 'invalid'
87
+ # events.attribute_transition_for(vehicle) # => false # Event is invalid
88
+ #
89
+ # vehicle.state_event = 'ignite'
90
+ # events.attribute_transition_for(vehicle) # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
91
+ def attribute_transition_for(object, invalidate = false)
92
+ return unless machine.action
93
+
94
+ result = nil
95
+ attribute = machine.attribute
96
+
97
+ if name = object.send("#{attribute}_event")
98
+ if event = self[name.to_sym, :qualified_name]
99
+ unless result = object.send("#{attribute}_event_transition") || event.transition_for(object)
100
+ # No valid transition: invalidate
101
+ machine.invalidate(object, "#{attribute}_event", :invalid_event, [[:state, machine.states.match(object).name]]) if invalidate
102
+ result = false
103
+ end
104
+ else
105
+ # Event is unknown: invalidate
106
+ machine.invalidate(object, "#{attribute}_event", :invalid) if invalidate
107
+ result = false
108
+ end
109
+ end
110
+
111
+ result
112
+ end
113
+ end
114
+ end
@@ -1,19 +1,21 @@
1
+ require 'state_machine/machine_collection'
2
+
1
3
  module StateMachine
2
4
  module ClassMethods
3
5
  def self.extended(base) #:nodoc:
4
6
  base.class_eval do
5
- @state_machines = {}
7
+ @state_machines = MachineCollection.new
6
8
  end
7
9
  end
8
10
 
9
11
  # Gets the current list of state machines defined for this class. This
10
12
  # class-level attribute acts like an inheritable attribute. The attribute
11
- # is available to each subclass, each subclass having a copy of its
12
- # superclass's attribute.
13
+ # is available to each subclass, each having a copy of its superclass's
14
+ # attribute.
13
15
  #
14
- # The hash of state machines maps +attribute+ => +machine+, e.g.
16
+ # The hash of state machines maps <tt>:attribute</tt> => +machine+, e.g.
15
17
  #
16
- # Vehicle.state_machines # => {:state => #<StateMachine::Machine:0xb6f6e4a4 ...>
18
+ # Vehicle.state_machines # => {:state => #<StateMachine::Machine:0xb6f6e4a4 ...>}
17
19
  def state_machines
18
20
  @state_machines ||= superclass.state_machines.dup
19
21
  end
@@ -29,14 +31,128 @@ module StateMachine
29
31
  initialize_state_machines
30
32
  end
31
33
 
34
+ # Runs one or more events in parallel. All events will run through the
35
+ # following steps:
36
+ # * Before callbacks
37
+ # * Persist state
38
+ # * Invoke action
39
+ # * After callbacks
40
+ #
41
+ # For example, if two events (for state machines A and B) are run in
42
+ # parallel, the order in which steps are run is:
43
+ # * A - Before transition callbacks
44
+ # * B - Before transition callbacks
45
+ # * A - Persist new state
46
+ # * B - Persist new state
47
+ # * A - Invoke action
48
+ # * B - Invoke action (only if different than A's action)
49
+ # * A - After transition callbacks
50
+ # * B - After transition callbacks
51
+ #
52
+ # *Note* that multiple events on the same state machine / attribute cannot
53
+ # be run in parallel. If this is attempted, an ArgumentError will be
54
+ # raised.
55
+ #
56
+ # == Halting callbacks
57
+ #
58
+ # When running multiple events in parallel, special consideration should
59
+ # be taken with regard to how halting within callbacks affects the flow.
60
+ #
61
+ # For *before* callbacks, any <tt>:halt</tt> error that's thrown will
62
+ # immediately cancel the perform for all transitions. As a result, it's
63
+ # possible for one event's transition to affect the continuation of
64
+ # another.
65
+ #
66
+ # On the other hand, any <tt>:halt</tt> error that's thrown within an
67
+ # *after* callback with only affect that event's transition. Other
68
+ # transitions will continue to run their own callbacks.
69
+ #
70
+ # == Example
71
+ #
72
+ # class Vehicle
73
+ # state_machine :initial => :parked do
74
+ # event :ignite do
75
+ # transition :parked => :idling
76
+ # end
77
+ #
78
+ # event :park do
79
+ # transition :idling => :parked
80
+ # end
81
+ # end
82
+ #
83
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :on do
84
+ # event :enable do
85
+ # transition all => :active
86
+ # end
87
+ #
88
+ # event :disable do
89
+ # transition all => :off
90
+ # end
91
+ # end
92
+ # end
93
+ #
94
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c02850 @state="parked", @alarm_state="active">
95
+ # vehicle.state # => "parked"
96
+ # vehicle.alarm_state # => "active"
97
+ #
98
+ # vehicle.fire_events(:ignite, :disable_alarm) # => true
99
+ # vehicle.state # => "idling"
100
+ # vehicle.alarm_state # => "off"
101
+ #
102
+ # # If any event fails, the entire event chain fails
103
+ # vehicle.fire_events(:ignite, :enable_alarm) # => false
104
+ # vehicle.state # => "idling"
105
+ # vehicle.alarm_state # => "off"
106
+ #
107
+ # # Exception raised on invalid event
108
+ # vehicle.fire_events(:park, :invalid) # => StateMachine::InvalidEvent: :invalid is an unknown event
109
+ # vehicle.state # => "idling"
110
+ # vehicle.alarm_state # => "off"
111
+ def fire_events(*events)
112
+ self.class.state_machines.fire_events(self, *events)
113
+ end
114
+
115
+ # Run one or more events in parallel. If any event fails to run, then
116
+ # a StateMachine::InvalidTransition exception will be raised.
117
+ #
118
+ # See StateMachine::InstanceMethods#fire_events for more information.
119
+ #
120
+ # == Example
121
+ #
122
+ # class Vehicle
123
+ # state_machine :initial => :parked do
124
+ # event :ignite do
125
+ # transition :parked => :idling
126
+ # end
127
+ #
128
+ # event :park do
129
+ # transition :idling => :parked
130
+ # end
131
+ # end
132
+ #
133
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :active do
134
+ # event :enable do
135
+ # transition all => :active
136
+ # end
137
+ #
138
+ # event :disable do
139
+ # transition all => :off
140
+ # end
141
+ # end
142
+ # end
143
+ #
144
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c02850 @state="parked", @alarm_state="active">
145
+ # vehicle.fire_events(:ignite, :disable_alarm) # => true
146
+ #
147
+ # vehicle.fire_events!(:ignite, :disable_alarm) # => StateMachine::InvalidTranstion: Cannot run events in parallel: ignite, disable_alarm
148
+ def fire_events!(*events)
149
+ run_action = [true, false].include?(events.last) ? events.pop : true
150
+ fire_events(*(events + [run_action])) || raise(StateMachine::InvalidTransition, "Cannot run events in parallel: #{events * ', '}")
151
+ end
152
+
32
153
  protected
33
154
  def initialize_state_machines #:nodoc:
34
- self.class.state_machines.each do |attribute, machine|
35
- # Set the initial value of the machine's attribute unless it already
36
- # exists (which must mean the defaults are being skipped)
37
- value = send(attribute)
38
- send("#{attribute}=", machine.initial_state(self).value) if value.nil? || value.respond_to?(:empty?) && value.empty?
39
- end
155
+ self.class.state_machines.initialize_states(self)
40
156
  end
41
157
  end
42
158
  end