state_machine 0.6.3 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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,145 @@
1
+ module StateMachine
2
+ # Represents a collection of state machines for a class
3
+ class MachineCollection < Hash
4
+ # Initializes the state of each machine in the given object. Initial
5
+ # values are only set if the machine's attribute doesn't already exist
6
+ # (which must mean the defaults are being skipped)
7
+ def initialize_states(object)
8
+ each do |attribute, machine|
9
+ value = machine.read(object)
10
+ machine.write(object, machine.initial_state(object).value) if value.nil? || value.respond_to?(:empty?) && value.empty?
11
+ end
12
+ end
13
+
14
+ # Runs one or more events in parallel on the given object. See
15
+ # StateMachine::InstanceMethods#fire_events for more information.
16
+ def fire_events(object, *events)
17
+ run_action = [true, false].include?(events.last) ? events.pop : true
18
+
19
+ # Generate the transitions to run for each event
20
+ transitions = events.collect do |name|
21
+ # Find the actual event being run
22
+ event = nil
23
+ detect do |attribute, machine|
24
+ event = machine.events[name, :qualified_name]
25
+ end
26
+
27
+ raise InvalidEvent, "#{name.inspect} is an unknown state machine event" unless event
28
+
29
+ # Get the transition that will be performed for the event
30
+ unless transition = event.transition_for(object)
31
+ machine = event.machine
32
+ machine.invalidate(object, machine.attribute, :invalid_transition, [[:event, name]])
33
+ end
34
+
35
+ transition
36
+ end.compact
37
+
38
+ # Run the events in parallel only if valid transitions were found for
39
+ # all of them
40
+ if events.length == transitions.length
41
+ Transition.perform_within_transaction(transitions, :action => run_action)
42
+ else
43
+ false
44
+ end
45
+ end
46
+
47
+ # Runs one or more attribute events in parallel during the invocation of
48
+ # an action on the given object. After transition callbacks can be
49
+ # optionally disabled if the events are being only partially fired (for
50
+ # example, when validating records in ORM integrations).
51
+ #
52
+ # The attribute events that will be fired are based on which machines
53
+ # match the action that is being invoked.
54
+ #
55
+ # == Examples
56
+ #
57
+ # class Vehicle
58
+ # include DataMapper::Resource
59
+ # property :id, Integer, :serial => true
60
+ #
61
+ # state_machine :initial => :parked do
62
+ # event :ignite do
63
+ # transition :parked => :idling
64
+ # end
65
+ # end
66
+ #
67
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :active do
68
+ # event :disable do
69
+ # transition all => :off
70
+ # end
71
+ # end
72
+ # end
73
+ #
74
+ # With valid events:
75
+ #
76
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
77
+ # vehicle.state_event = 'ignite'
78
+ # vehicle.alarm_state_event = 'disable'
79
+ #
80
+ # Vehicle.state_machines.fire_attribute_events(vehicle, :save) { true }
81
+ # vehicle.state # => "idling"
82
+ # vehicle.state_event # => nil
83
+ # vehicle.alarm_state # => "off"
84
+ # vehicle.alarm_state_event # => nil
85
+ #
86
+ # With invalid events:
87
+ #
88
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
89
+ # vehicle.state_event = 'park'
90
+ # vehicle.alarm_state_event = 'disable'
91
+ #
92
+ # Vehicle.state_machines.fire_attribute_events(vehicle, :save) { true }
93
+ # vehicle.state # => "parked"
94
+ # vehicle.state_event # => nil
95
+ # vehicle.alarm_state # => "active"
96
+ # vehicle.alarm_state_event # => nil
97
+ # vehicle.errors # => #<DataMapper::Validate::ValidationErrors:0xb7af9abc @errors={"state_event"=>["is invalid"]}>
98
+ #
99
+ # With partial firing:
100
+ #
101
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
102
+ # vehicle.state_event = 'ignite'
103
+ #
104
+ # Vehicle.state_machines.fire_attribute_events(vehicle, :save, false) { true }
105
+ # vehicle.state # => "idling"
106
+ # vehicle.state_event # => "ignite"
107
+ # vehicle.state_event_transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
108
+ def fire_attribute_events(object, action, complete = true)
109
+ # Get the transitions to fire for each applicable machine
110
+ transitions = map {|attribute, machine| machine.action == action ? machine.events.attribute_transition_for(object, true) : nil}.compact
111
+ return yield if transitions.empty?
112
+
113
+ # Make sure all events were valid
114
+ if result = transitions.all? {|transition| transition != false}
115
+ begin
116
+ result = Transition.perform(transitions, :after => complete) do
117
+ # Prevent events from being evaluated multiple times if actions are nested
118
+ transitions.each {|transition| object.send("#{transition.attribute}_event=", nil)}
119
+ yield
120
+ end
121
+ rescue Exception
122
+ # Revert attribute modifications
123
+ transitions.each do |transition|
124
+ object.send("#{transition.attribute}_event=", transition.event)
125
+ object.send("#{transition.attribute}_event_transition=", nil) if complete
126
+ end
127
+
128
+ raise
129
+ end
130
+
131
+ transitions.each do |transition|
132
+ attribute = transition.attribute
133
+
134
+ # Revert event unless transition was successful
135
+ object.send("#{attribute}_event=", transition.event) unless complete && result
136
+
137
+ # Track transition if partial transition completed successfully
138
+ object.send("#{attribute}_event_transition=", !complete && result ? transition : nil)
139
+ end
140
+ end
141
+
142
+ result
143
+ end
144
+ end
145
+ end
@@ -29,7 +29,7 @@ module StateMachine
29
29
  #
30
30
  # == Examples
31
31
  #
32
- # matcher = StateMachine::AllMatcher.new - [:parked, :idling]
32
+ # matcher = StateMachine::AllMatcher.instance - [:parked, :idling]
33
33
  # matcher.matches?(:parked) # => false
34
34
  # matcher.matches?(:first_gear) # => true
35
35
  def -(blacklist)
@@ -108,7 +108,7 @@ module StateMachine
108
108
  #
109
109
  # == Examples
110
110
  #
111
- # matcher = StateMachine::LoopbackMatcher.new
111
+ # matcher = StateMachine::LoopbackMatcher.instance
112
112
  # matcher.matches?(:parked, :from => :parked) # => true
113
113
  # matcher.matches?(:parked, :from => :idling) # => false
114
114
  def matches?(value, context)
@@ -6,6 +6,9 @@ module StateMachine
6
6
  include Enumerable
7
7
  include Assertions
8
8
 
9
+ # The machine associated with the nodes
10
+ attr_reader :machine
11
+
9
12
  # Creates a new collection of nodes for the given state machine. By default,
10
13
  # the collection is empty.
11
14
  #
@@ -13,10 +16,11 @@ module StateMachine
13
16
  # * <tt>:index</tt> - One or more attributes to automatically generate
14
17
  # hashed indices for in order to perform quick lookups. Default is to
15
18
  # index by the :name attribute
16
- def initialize(options = {})
19
+ def initialize(machine, options = {})
17
20
  assert_valid_keys(options, :index)
18
21
  options = {:index => :name}.merge(options)
19
22
 
23
+ @machine = machine
20
24
  @nodes = []
21
25
  @indices = Array(options[:index]).inject({}) {|indices, attribute| indices[attribute] = {}; indices}
22
26
  @default_index = Array(options[:index]).first
@@ -36,6 +40,7 @@ module StateMachine
36
40
  # Changes the current machine associated with the collection. In turn, this
37
41
  # will change the state machine associated with each node in the collection.
38
42
  def machine=(new_machine)
43
+ @machine = new_machine
39
44
  each {|node| node.machine = new_machine}
40
45
  end
41
46
 
@@ -62,7 +67,7 @@ module StateMachine
62
67
  # will be replaced with the updated ones.
63
68
  def update(node)
64
69
  @indices.each do |attribute, index|
65
- old_key = index.respond_to?(:key) ? index.key(node) : index.index(node)
70
+ old_key = RUBY_VERSION < '1.9' ? index.index(node) : index.key(node)
66
71
  new_key = node.send(attribute)
67
72
 
68
73
  # Only replace the key if it's changed
@@ -74,7 +79,7 @@ module StateMachine
74
79
  end
75
80
 
76
81
  # Calls the block once for each element in self, passing that element as a
77
- # parameters.
82
+ # parameter.
78
83
  #
79
84
  # states = StateMachine::NodeCollection.new
80
85
  # states << StateMachine::State.new(machine, :parked)
@@ -128,7 +133,7 @@ module StateMachine
128
133
  #
129
134
  # collection['invalid', :value] # => IndexError: "invalid" is an invalid value
130
135
  def fetch(key, index_name = @default_index)
131
- self[key, index_name] || raise(ArgumentError, "#{key.inspect} is an invalid #{index_name}")
136
+ self[key, index_name] || raise(IndexError, "#{key.inspect} is an invalid #{index_name}")
132
137
  end
133
138
 
134
139
  private
@@ -19,6 +19,10 @@ module StateMachine
19
19
  # The unique identifier for the state used in event and callback definitions
20
20
  attr_reader :name
21
21
 
22
+ # The fully-qualified identifier for the state, scoped by the machine's
23
+ # namespace
24
+ attr_reader :qualified_name
25
+
22
26
  # The value that is written to a machine's attribute when an object
23
27
  # transitions into this state
24
28
  attr_writer :value
@@ -52,10 +56,11 @@ module StateMachine
52
56
 
53
57
  @machine = machine
54
58
  @name = name
59
+ @qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
55
60
  @value = options.include?(:value) ? options[:value] : name && name.to_s
56
61
  @matcher = options[:if]
57
62
  @methods = {}
58
- @initial = options.include?(:initial) && options[:initial]
63
+ @initial = options[:initial] == true
59
64
 
60
65
  add_predicate
61
66
  end
@@ -127,11 +132,11 @@ module StateMachine
127
132
  matcher ? matcher.call(other_value) : other_value == value
128
133
  end
129
134
 
130
- # Defines a context for the state which will be enabled on instances of the
131
- # owner class when the machine is in this state.
135
+ # Defines a context for the state which will be enabled on instances of
136
+ # the owner class when the machine is in this state.
132
137
  #
133
- # This can be called multiple times. Each time a new context is created, a
134
- # new module will be included in the owner class.
138
+ # This can be called multiple times. Each time a new context is created,
139
+ # a new module will be included in the owner class.
135
140
  def context(&block)
136
141
  owner_class = machine.owner_class
137
142
  attribute = machine.attribute
@@ -140,32 +145,20 @@ module StateMachine
140
145
  # Evaluate the method definitions
141
146
  context = ConditionProxy.new(owner_class, lambda {|object| object.send("#{attribute}_name") == name})
142
147
  context.class_eval(&block)
143
-
144
- # Define all of the methods that were created in the module so that they
145
- # don't override the core behavior (i.e. calling the state method)
146
148
  context.instance_methods.each do |method|
147
- unless owner_class.instance_methods.include?(method)
148
- # Calls the method defined by the current state of the machine. This
149
- # is done using string evaluation so that any block passed into the
150
- # method can then be passed to the state's context method, which is
151
- # not possible with lambdas in Ruby 1.8.6.
152
- owner_class.class_eval <<-end_eval, __FILE__, __LINE__
153
- def #{method}(*args, &block)
154
- self.class.state_machines[#{attribute.inspect}].state_for(self).call(self, #{method.inspect}, *args, &block)
155
- end
156
- end_eval
157
- end
158
-
159
- # Track the method defined for the context so that it can be invoked
160
- # at a later point in time
161
149
  methods[method.to_sym] = context.instance_method(method)
150
+
151
+ # Calls the method defined by the current state of the machine
152
+ context.class_eval <<-end_eval, __FILE__, __LINE__
153
+ def #{method}(*args, &block)
154
+ self.class.state_machine(#{attribute.inspect}).states.match(self).call(self, #{method.inspect}, *args, &block)
155
+ end
156
+ end_eval
162
157
  end
163
158
 
164
159
  # Include the context so that it can be bound to the owner class (the
165
160
  # context is considered an ancestor, so it's allowed to be bound)
166
- owner_class.class_eval do
167
- include context
168
- end
161
+ owner_class.class_eval { include context }
169
162
 
170
163
  context
171
164
  end
@@ -181,7 +174,7 @@ module StateMachine
181
174
  context_method.bind(object).call(*args, &block)
182
175
  else
183
176
  # Raise exception as if the method never existed on the original object
184
- raise NoMethodError, "undefined method '#{method}' for #{object} in state #{machine.state_for(object).name.inspect}"
177
+ raise NoMethodError, "undefined method '#{method}' for #{object} in state #{machine.states.match(object).name.inspect}"
185
178
  end
186
179
  end
187
180
 
@@ -190,8 +183,8 @@ module StateMachine
190
183
  # * +label+ - The human-friendly description of the state.
191
184
  # * +width+ - The width of the node. Always 1.
192
185
  # * +height+ - The height of the node. Always 1.
193
- # * +shape+ - The actual shape of the node. If the state is the initial
194
- # state, then "doublecircle", otherwise "circle".
186
+ # * +shape+ - The actual shape of the node. If the state is a final
187
+ # state, then "doublecircle", otherwise "ellipse".
195
188
  #
196
189
  # The actual node generated on the graph will be returned.
197
190
  def draw(graph)
@@ -225,12 +218,9 @@ module StateMachine
225
218
  def add_predicate
226
219
  return unless name
227
220
 
228
- qualified_name = name = self.name
229
- qualified_name = "#{machine.namespace}_#{name}" if machine.namespace
230
-
231
221
  # Checks whether the current value matches this state
232
222
  machine.define_instance_method("#{qualified_name}?") do |machine, object|
233
- machine.state?(object, name)
223
+ machine.states.matches?(object, name)
234
224
  end
235
225
  end
236
226
  end
@@ -3,8 +3,62 @@ require 'state_machine/node_collection'
3
3
  module StateMachine
4
4
  # Represents a collection of states in a state machine
5
5
  class StateCollection < NodeCollection
6
- def initialize #:nodoc:
7
- super(:index => [:name, :value])
6
+ def initialize(machine) #:nodoc:
7
+ super(machine, :index => [:name, :value])
8
+ end
9
+
10
+ # Determines whether the given object is in a specific state. If the
11
+ # object's current value doesn't match the state, then this will return
12
+ # false, otherwise true. If the given state is unknown, then an IndexError
13
+ # will be raised.
14
+ #
15
+ # == Examples
16
+ #
17
+ # class Vehicle
18
+ # state_machine :initial => :parked do
19
+ # other_states :idling
20
+ # end
21
+ # end
22
+ #
23
+ # states = Vehicle.state_machine.states
24
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
25
+ #
26
+ # states.matches?(vehicle, :parked) # => true
27
+ # states.matches?(vehicle, :idling) # => false
28
+ # states.matches?(vehicle, :invalid) # => IndexError: :invalid is an invalid key for :name index
29
+ def matches?(object, name)
30
+ fetch(name).matches?(machine.read(object))
31
+ end
32
+
33
+ # Determines the current state of the given object as configured by this
34
+ # state machine. This will attempt to find a known state that matches
35
+ # the value of the attribute on the object. If no state is found, then
36
+ # an ArgumentError will be raised.
37
+ #
38
+ # == Examples
39
+ #
40
+ # class Vehicle
41
+ # state_machine :initial => :parked do
42
+ # other_states :idling
43
+ # end
44
+ # end
45
+ #
46
+ # states = Vehicle.state_machine.states
47
+ #
48
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
49
+ # states.match(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
50
+ #
51
+ # vehicle.state = 'idling'
52
+ # states.match(vehicle) # => #<StateMachine::State name=:idling value="idling" initial=true>
53
+ #
54
+ # vehicle.state = 'invalid'
55
+ # states.match(vehicle) # => ArgumentError: "invalid" is not a known state value
56
+ def match(object)
57
+ value = machine.read(object)
58
+ state = self[value, :value] || detect {|state| state.matches?(value)}
59
+ raise ArgumentError, "#{value.inspect} is not a known #{machine.attribute} value" unless state
60
+
61
+ state
8
62
  end
9
63
 
10
64
  # Gets the order in which states should be displayed based on where they
@@ -18,21 +72,16 @@ module StateMachine
18
72
  #
19
73
  # This order will determine how the GraphViz visualizations are rendered.
20
74
  def by_priority
21
- if first = @nodes.first
22
- machine = first.machine
23
- order = select {|state| state.initial}.map {|state| state.name}
24
-
25
- machine.events.each {|event| order += event.known_states}
26
- order += select {|state| state.methods.any?}.map {|state| state.name}
27
- order += keys(:name) - machine.callbacks.values.flatten.map {|callback| callback.known_states}.flatten
28
- order += keys(:name)
29
-
30
- order.uniq!
31
- order.map! {|name| self[name]}
32
- order
33
- else
34
- []
35
- end
75
+ order = select {|state| state.initial}.map {|state| state.name}
76
+
77
+ machine.events.each {|event| order += event.known_states}
78
+ order += select {|state| state.methods.any?}.map {|state| state.name}
79
+ order += keys(:name) - machine.callbacks.values.flatten.map {|callback| callback.known_states}.flatten
80
+ order += keys(:name)
81
+
82
+ order.uniq!
83
+ order.map! {|name| self[name]}
84
+ order
36
85
  end
37
86
  end
38
87
  end
@@ -10,6 +10,82 @@ module StateMachine
10
10
  # * A starting state
11
11
  # * An ending state
12
12
  class Transition
13
+ class << self
14
+ # Runs one or more transitions in parallel. All transitions will run
15
+ # through the following steps:
16
+ # 1. Before callbacks
17
+ # 2. Persist state
18
+ # 3. Invoke action
19
+ # 4. After callbacks if configured
20
+ # 5. Rollback if action is unsuccessful
21
+ #
22
+ # Configuration options:
23
+ # * <tt>:action</tt> - Whether to run the action configured for each transition
24
+ # * <tt>:after</tt> - Whether to run after callbacks
25
+ #
26
+ # If a block is passed to this method, that block will be called instead
27
+ # of invoking each transition's action.
28
+ def perform(transitions, options = {})
29
+ # Validate that the transitions are for separate machines / attributes
30
+ attributes = transitions.map {|transition| transition.attribute}.uniq
31
+ raise ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != transitions.length
32
+
33
+ success = false
34
+
35
+ # Run before callbacks. If any callback halts, then the entire chain
36
+ # is halted for every transition.
37
+ if transitions.all? {|transition| transition.before}
38
+ # Persist the new state for each attribute
39
+ transitions.each {|transition| transition.persist}
40
+
41
+ # Run the actions associated with each machine
42
+ begin
43
+ results = {}
44
+ success =
45
+ if block_given?
46
+ # Block was given: use the result for each transition
47
+ result = yield
48
+ transitions.each {|transition| results[transition.action] = result}
49
+ result
50
+ elsif options[:action] == false
51
+ # Skip the action
52
+ true
53
+ else
54
+ # Run each transition's action (only once)
55
+ object = transitions.first.object
56
+ transitions.all? do |transition|
57
+ action = transition.action
58
+ action && !results.include?(action) ? results[action] = object.send(action) : true
59
+ end
60
+ end
61
+ rescue Exception
62
+ # Action failed: rollback
63
+ transitions.each {|transition| transition.rollback}
64
+ raise
65
+ end
66
+
67
+ # Always run after callbacks regardless of whether the actions failed
68
+ transitions.each {|transition| transition.after(results[transition.action])} unless options[:after] == false
69
+
70
+ # Rollback the transitions if the transaction was unsuccessful
71
+ transitions.each {|transition| transition.rollback} unless success
72
+ end
73
+
74
+ success
75
+ end
76
+
77
+ # Runs one or more transitions within a transaction. See StateMachine::Transition.perform
78
+ # for more information.
79
+ def perform_within_transaction(transitions, options = {})
80
+ success = false
81
+ transitions.first.within_transaction do
82
+ success = perform(transitions, options)
83
+ end
84
+
85
+ success
86
+ end
87
+ end
88
+
13
89
  # The object being transitioned
14
90
  attr_reader :object
15
91
 
@@ -19,36 +95,84 @@ module StateMachine
19
95
  # The event that triggered the transition
20
96
  attr_reader :event
21
97
 
98
+ # The fully-qualified name of the event that triggered the transition
99
+ attr_reader :qualified_event
100
+
22
101
  # The original state value *before* the transition
23
102
  attr_reader :from
24
103
 
25
104
  # The original state name *before* the transition
26
105
  attr_reader :from_name
27
106
 
107
+ # The original fully-qualified state name *before* transition
108
+ attr_reader :qualified_from_name
109
+
28
110
  # The new state value *after* the transition
29
111
  attr_reader :to
30
112
 
31
113
  # The new state name *after* the transition
32
114
  attr_reader :to_name
33
115
 
116
+ # The new fully-qualified state name *after* the transition
117
+ attr_reader :qualified_to_name
118
+
119
+ # The arguments passed in to the event that triggered the transition
120
+ # (does not include the +run_action+ boolean argument if specified)
121
+ attr_accessor :args
122
+
123
+ # The result of invoking the action associated with the machine
124
+ attr_reader :result
125
+
34
126
  # Creates a new, specific transition
35
127
  def initialize(object, machine, event, from_name, to_name) #:nodoc:
36
128
  @object = object
37
129
  @machine = machine
38
- @event = event
39
- @from = object.send(machine.attribute)
40
- @from_name = from_name
41
- @to = machine.states[to_name].value
42
- @to_name = to_name
130
+ @args = []
131
+
132
+ # Event information (no-ops don't have events)
133
+ if event
134
+ event = machine.events.fetch(event)
135
+ @event = event.name
136
+ @qualified_event = event.qualified_name
137
+ end
138
+
139
+ # From state information
140
+ from_state = machine.states.fetch(from_name)
141
+ @from = machine.read(object)
142
+ @from_name = from_state.name
143
+ @qualified_from_name = from_state.qualified_name
144
+
145
+ # To state information
146
+ to_state = machine.states.fetch(to_name)
147
+ @to = to_state.value
148
+ @to_name = to_state.name
149
+ @qualified_to_name = to_state.qualified_name
43
150
  end
44
151
 
45
- # Gets the attribute which this transition's machine is defined for
152
+ # The attribute which this transition's machine is defined for
46
153
  def attribute
47
154
  machine.attribute
48
155
  end
49
156
 
50
- # Gets a hash of all the core attributes defined for this transition with
51
- # their names as keys and values of the attributes as values.
157
+ # The action that will be run when this transition is performed
158
+ def action
159
+ machine.action
160
+ end
161
+
162
+ # Does this transition represent a loopback (i.e. the from and to state
163
+ # are the same)
164
+ #
165
+ # == Example
166
+ #
167
+ # machine = StateMachine.new(Vehicle)
168
+ # StateMachine::Transition.new(Vehicle.new, machine, :park, :parked, :parked).loopback? # => true
169
+ # StateMachine::Transition.new(Vehicle.new, machine, :park, :idling, :parked).loopback? # => false
170
+ def loopback?
171
+ from_name == to_name
172
+ end
173
+
174
+ # A hash of all the core attributes defined for this transition with their
175
+ # names as keys and values of the attributes as values.
52
176
  #
53
177
  # == Example
54
178
  #
@@ -75,31 +199,138 @@ module StateMachine
75
199
  # transition = StateMachine::Transition.new(vehicle, machine, :ignite, :parked, :idling)
76
200
  # transition.perform # => Runs the +save+ action after setting the state attribute
77
201
  # transition.perform(false) # => Only sets the state attribute
78
- def perform(run_action = true)
79
- result = false
202
+ def perform(*args)
203
+ run_action = [true, false].include?(args.last) ? args.pop : true
204
+ self.args = args
80
205
 
206
+ # Run the transition
207
+ self.class.perform_within_transaction([self], :action => run_action)
208
+ end
209
+
210
+ # Runs a block within a transaction for the object being transitioned.
211
+ # By default, transactions are a no-op unless otherwise defined by the
212
+ # machine's integration.
213
+ def within_transaction
81
214
  machine.within_transaction(object) do
82
- catch(:halt) do
83
- # Run before callbacks
84
- callback(:before)
85
-
86
- # Updates the object's attribute to the ending state
87
- object.send("#{attribute}=", to)
88
- result = run_action && machine.action ? object.send(machine.action) != false : true
89
-
90
- # Always run after callbacks regardless of whether the action failed.
91
- # Result is included in case the callback depends on this value
92
- callback(:after, result)
93
- end
94
-
95
- # Make sure the transaction gets the correct return value for determining
96
- # whether it should rollback or not
97
- result = result != false
215
+ yield
216
+ end
217
+ end
218
+
219
+ # Runs the machine's +before+ callbacks for this transition. Only
220
+ # callbacks that are configured to match the event, from state, and to
221
+ # state will be invoked.
222
+ #
223
+ # == Example
224
+ #
225
+ # class Vehicle
226
+ # state_machine do
227
+ # before_transition :on => :ignite, :do => lambda {|vehicle| ...}
228
+ # end
229
+ # end
230
+ #
231
+ # vehicle = Vehicle.new
232
+ # transition = StateMachine::Transition.new(vehicle, machine, :ignite, :parked, :idling)
233
+ # transition.before
234
+ def before
235
+ result = false
236
+
237
+ catch(:halt) do
238
+ callback(:before)
239
+ result = true
98
240
  end
99
241
 
100
242
  result
101
243
  end
102
244
 
245
+ # Transitions the current value of the state to that specified by the
246
+ # transition.
247
+ #
248
+ # == Example
249
+ #
250
+ # class Vehicle
251
+ # state_machine do
252
+ # event :ignite do
253
+ # transition :parked => :idling
254
+ # end
255
+ # end
256
+ # end
257
+ #
258
+ # vehicle = Vehicle.new
259
+ # transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
260
+ # transition.persist
261
+ #
262
+ # vehicle.state # => 'idling'
263
+ def persist
264
+ machine.write(object, to)
265
+ end
266
+
267
+ # Runs the machine's +after+ callbacks for this transition. Only
268
+ # callbacks that are configured to match the event, from state, and to
269
+ # state will be invoked.
270
+ #
271
+ # The result is used to indicate whether the associated machine action
272
+ # was executed successfully.
273
+ #
274
+ # == Halting
275
+ #
276
+ # If any callback throws a <tt>:halt</tt> exception, it will be caught
277
+ # and the callback chain will be automatically stopped. However, this
278
+ # exception will not bubble up to the caller since +after+ callbacks
279
+ # should never halt the execution of a +perform+.
280
+ #
281
+ # == Example
282
+ #
283
+ # class Vehicle
284
+ # state_machine do
285
+ # after_transition :on => :ignite, :do => lambda {|vehicle| ...}
286
+ #
287
+ # event :ignite do
288
+ # transition :parked => :idling
289
+ # end
290
+ # end
291
+ # end
292
+ #
293
+ # vehicle = Vehicle.new
294
+ # transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
295
+ # transition.after(true)
296
+ def after(result = nil)
297
+ @result = result
298
+
299
+ catch(:halt) do
300
+ callback(:after)
301
+ end
302
+
303
+ true
304
+ end
305
+
306
+ # Rolls back changes made to the object's state via this transition. This
307
+ # will revert the state back to the +from+ value.
308
+ #
309
+ # == Example
310
+ #
311
+ # class Vehicle
312
+ # state_machine :initial => :parked do
313
+ # event :ignite do
314
+ # transition :parked => :idling
315
+ # end
316
+ # end
317
+ # end
318
+ #
319
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7b7f568 @state="parked">
320
+ # transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
321
+ #
322
+ # # Persist the new state
323
+ # vehicle.state # => "parked"
324
+ # transition.persist
325
+ # vehicle.state # => "idling"
326
+ #
327
+ # # Roll back to the original state
328
+ # transition.rollback
329
+ # vehicle.state # => "parked"
330
+ def rollback
331
+ machine.write(object, from)
332
+ end
333
+
103
334
  # Generates a nicely formatted description of this transitions's contents.
104
335
  #
105
336
  # For example,
@@ -129,9 +360,9 @@ module StateMachine
129
360
  #
130
361
  # Additional callback parameters can be specified. By default, this
131
362
  # transition is also passed into callbacks.
132
- def callback(type, *args)
363
+ def callback(type)
133
364
  machine.callbacks[type].each do |callback|
134
- callback.call(object, context, self, *args)
365
+ callback.call(object, context, self)
135
366
  end
136
367
  end
137
368
  end