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.
- data/CHANGELOG.rdoc +31 -1
- data/README.rdoc +33 -21
- data/Rakefile +2 -2
- data/examples/merb-rest/controller.rb +51 -0
- data/examples/merb-rest/model.rb +28 -0
- data/examples/merb-rest/view_edit.html.erb +24 -0
- data/examples/merb-rest/view_index.html.erb +23 -0
- data/examples/merb-rest/view_new.html.erb +13 -0
- data/examples/merb-rest/view_show.html.erb +17 -0
- data/examples/rails-rest/controller.rb +43 -0
- data/examples/rails-rest/migration.rb +11 -0
- data/examples/rails-rest/model.rb +23 -0
- data/examples/rails-rest/view_edit.html.erb +25 -0
- data/examples/rails-rest/view_index.html.erb +23 -0
- data/examples/rails-rest/view_new.html.erb +14 -0
- data/examples/rails-rest/view_show.html.erb +17 -0
- data/lib/state_machine/assertions.rb +2 -2
- data/lib/state_machine/callback.rb +14 -8
- data/lib/state_machine/condition_proxy.rb +3 -3
- data/lib/state_machine/event.rb +19 -21
- data/lib/state_machine/event_collection.rb +114 -0
- data/lib/state_machine/extensions.rb +127 -11
- data/lib/state_machine/guard.rb +1 -1
- data/lib/state_machine/integrations/active_record/locale.rb +2 -1
- data/lib/state_machine/integrations/active_record.rb +117 -39
- data/lib/state_machine/integrations/data_mapper/observer.rb +20 -64
- data/lib/state_machine/integrations/data_mapper.rb +71 -26
- data/lib/state_machine/integrations/sequel.rb +69 -21
- data/lib/state_machine/machine.rb +267 -139
- data/lib/state_machine/machine_collection.rb +145 -0
- data/lib/state_machine/matcher.rb +2 -2
- data/lib/state_machine/node_collection.rb +9 -4
- data/lib/state_machine/state.rb +22 -32
- data/lib/state_machine/state_collection.rb +66 -17
- data/lib/state_machine/transition.rb +259 -28
- data/lib/state_machine.rb +121 -56
- data/tasks/state_machine.rake +1 -0
- data/tasks/state_machine.rb +26 -0
- data/test/active_record.log +116877 -0
- data/test/functional/state_machine_test.rb +118 -12
- data/test/sequel.log +28542 -0
- data/test/unit/callback_test.rb +46 -1
- data/test/unit/condition_proxy_test.rb +55 -28
- data/test/unit/event_collection_test.rb +228 -0
- data/test/unit/event_test.rb +51 -46
- data/test/unit/integrations/active_record_test.rb +128 -70
- data/test/unit/integrations/data_mapper_test.rb +150 -58
- data/test/unit/integrations/sequel_test.rb +63 -6
- data/test/unit/invalid_event_test.rb +7 -0
- data/test/unit/machine_collection_test.rb +678 -0
- data/test/unit/machine_test.rb +198 -91
- data/test/unit/node_collection_test.rb +33 -30
- data/test/unit/state_collection_test.rb +112 -5
- data/test/unit/state_test.rb +23 -3
- data/test/unit/transition_test.rb +750 -89
- 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.
|
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.
|
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 =
|
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
|
-
#
|
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(
|
136
|
+
self[key, index_name] || raise(IndexError, "#{key.inspect} is an invalid #{index_name}")
|
132
137
|
end
|
133
138
|
|
134
139
|
private
|
data/lib/state_machine/state.rb
CHANGED
@@ -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
|
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
|
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,
|
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
|
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.
|
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
|
194
|
-
# state, then "doublecircle", otherwise "
|
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.
|
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
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
@
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
#
|
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
|
-
#
|
51
|
-
|
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(
|
79
|
-
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
363
|
+
def callback(type)
|
133
364
|
machine.callbacks[type].each do |callback|
|
134
|
-
callback.call(object, context, self
|
365
|
+
callback.call(object, context, self)
|
135
366
|
end
|
136
367
|
end
|
137
368
|
end
|