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,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