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