pluginaweek-state_machine 0.7.6
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 +273 -0
- data/LICENSE +20 -0
- data/README.rdoc +466 -0
- data/Rakefile +98 -0
- data/examples/AutoShop_state.png +0 -0
- data/examples/Car_state.png +0 -0
- data/examples/TrafficLight_state.png +0 -0
- data/examples/Vehicle_state.png +0 -0
- data/examples/auto_shop.rb +11 -0
- data/examples/car.rb +19 -0
- 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/examples/traffic_light.rb +7 -0
- data/examples/vehicle.rb +31 -0
- data/init.rb +1 -0
- data/lib/state_machine.rb +429 -0
- data/lib/state_machine/assertions.rb +36 -0
- data/lib/state_machine/callback.rb +189 -0
- data/lib/state_machine/condition_proxy.rb +94 -0
- data/lib/state_machine/eval_helpers.rb +67 -0
- data/lib/state_machine/event.rb +251 -0
- data/lib/state_machine/event_collection.rb +113 -0
- data/lib/state_machine/extensions.rb +158 -0
- data/lib/state_machine/guard.rb +219 -0
- data/lib/state_machine/integrations.rb +68 -0
- data/lib/state_machine/integrations/active_record.rb +444 -0
- data/lib/state_machine/integrations/active_record/locale.rb +10 -0
- data/lib/state_machine/integrations/active_record/observer.rb +41 -0
- data/lib/state_machine/integrations/data_mapper.rb +325 -0
- data/lib/state_machine/integrations/data_mapper/observer.rb +139 -0
- data/lib/state_machine/integrations/sequel.rb +292 -0
- data/lib/state_machine/machine.rb +1431 -0
- data/lib/state_machine/machine_collection.rb +146 -0
- data/lib/state_machine/matcher.rb +123 -0
- data/lib/state_machine/matcher_helpers.rb +54 -0
- data/lib/state_machine/node_collection.rb +152 -0
- data/lib/state_machine/state.rb +249 -0
- data/lib/state_machine/state_collection.rb +112 -0
- data/lib/state_machine/transition.rb +367 -0
- data/tasks/state_machine.rake +1 -0
- data/tasks/state_machine.rb +30 -0
- data/test/classes/switch.rb +11 -0
- data/test/functional/state_machine_test.rb +941 -0
- data/test/test_helper.rb +4 -0
- data/test/unit/assertions_test.rb +40 -0
- data/test/unit/callback_test.rb +455 -0
- data/test/unit/condition_proxy_test.rb +328 -0
- data/test/unit/eval_helpers_test.rb +129 -0
- data/test/unit/event_collection_test.rb +293 -0
- data/test/unit/event_test.rb +605 -0
- data/test/unit/guard_test.rb +862 -0
- data/test/unit/integrations/active_record_test.rb +1001 -0
- data/test/unit/integrations/data_mapper_test.rb +694 -0
- data/test/unit/integrations/sequel_test.rb +486 -0
- data/test/unit/integrations_test.rb +42 -0
- data/test/unit/invalid_event_test.rb +7 -0
- data/test/unit/invalid_transition_test.rb +7 -0
- data/test/unit/machine_collection_test.rb +710 -0
- data/test/unit/machine_test.rb +1910 -0
- data/test/unit/matcher_helpers_test.rb +37 -0
- data/test/unit/matcher_test.rb +155 -0
- data/test/unit/node_collection_test.rb +207 -0
- data/test/unit/state_collection_test.rb +280 -0
- data/test/unit/state_machine_test.rb +31 -0
- data/test/unit/state_test.rb +795 -0
- data/test/unit/transition_test.rb +1113 -0
- metadata +161 -0
@@ -0,0 +1,249 @@
|
|
1
|
+
require 'state_machine/assertions'
|
2
|
+
require 'state_machine/condition_proxy'
|
3
|
+
|
4
|
+
module StateMachine
|
5
|
+
# A state defines a value that an attribute can be in after being transitioned
|
6
|
+
# 0 or more times. States can represent a value of any type in Ruby, though
|
7
|
+
# the most common (and default) type is String.
|
8
|
+
#
|
9
|
+
# In addition to defining the machine's value, a state can also define a
|
10
|
+
# behavioral context for an object when that object is in the state. See
|
11
|
+
# StateMachine::Machine#state for more information about how state-driven
|
12
|
+
# behavior can be utilized.
|
13
|
+
class State
|
14
|
+
include Assertions
|
15
|
+
|
16
|
+
# The state machine for which this state is defined
|
17
|
+
attr_accessor :machine
|
18
|
+
|
19
|
+
# The unique identifier for the state used in event and callback definitions
|
20
|
+
attr_reader :name
|
21
|
+
|
22
|
+
# The fully-qualified identifier for the state, scoped by the machine's
|
23
|
+
# namespace
|
24
|
+
attr_reader :qualified_name
|
25
|
+
|
26
|
+
# The value that is written to a machine's attribute when an object
|
27
|
+
# transitions into this state
|
28
|
+
attr_writer :value
|
29
|
+
|
30
|
+
# Whether this state's value should be cached after being evaluated
|
31
|
+
attr_accessor :cache
|
32
|
+
|
33
|
+
# Whether or not this state is the initial state to use for new objects
|
34
|
+
attr_accessor :initial
|
35
|
+
alias_method :initial?, :initial
|
36
|
+
|
37
|
+
# A custom lambda block for determining whether a given value matches this
|
38
|
+
# state
|
39
|
+
attr_accessor :matcher
|
40
|
+
|
41
|
+
# Tracks all of the methods that have been defined for the machine's owner
|
42
|
+
# class when objects are in this state.
|
43
|
+
#
|
44
|
+
# Maps :method_name => UnboundMethod
|
45
|
+
attr_reader :methods
|
46
|
+
|
47
|
+
# Creates a new state within the context of the given machine.
|
48
|
+
#
|
49
|
+
# Configuration options:
|
50
|
+
# * <tt>:initial</tt> - Whether this state is the beginning state for the
|
51
|
+
# machine. Default is false.
|
52
|
+
# * <tt>:value</tt> - The value to store when an object transitions to this
|
53
|
+
# state. Default is the name (stringified).
|
54
|
+
# * <tt>:cache</tt> - If a dynamic value (via a lambda block) is being used,
|
55
|
+
# then setting this to true will cache the evaluated result
|
56
|
+
# * <tt>:if</tt> - Determines whether a value matches this state
|
57
|
+
# (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
|
58
|
+
# By default, the configured value is matched.
|
59
|
+
def initialize(machine, name, options = {}) #:nodoc:
|
60
|
+
assert_valid_keys(options, :initial, :value, :cache, :if)
|
61
|
+
|
62
|
+
@machine = machine
|
63
|
+
@name = name
|
64
|
+
@qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
|
65
|
+
@value = options.include?(:value) ? options[:value] : name && name.to_s
|
66
|
+
@cache = options[:cache]
|
67
|
+
@matcher = options[:if]
|
68
|
+
@methods = {}
|
69
|
+
@initial = options[:initial] == true
|
70
|
+
|
71
|
+
add_predicate
|
72
|
+
end
|
73
|
+
|
74
|
+
# Creates a copy of this state in addition to the list of associated
|
75
|
+
# methods to prevent conflicts across different states.
|
76
|
+
def initialize_copy(orig) #:nodoc:
|
77
|
+
super
|
78
|
+
@methods = methods.dup
|
79
|
+
end
|
80
|
+
|
81
|
+
# Determines whether there are any states that can be transitioned to from
|
82
|
+
# this state. If there are none, then this state is considered *final*.
|
83
|
+
# Any objects in a final state will remain so forever given the current
|
84
|
+
# machine's definition.
|
85
|
+
def final?
|
86
|
+
!machine.events.any? do |event|
|
87
|
+
event.guards.any? do |guard|
|
88
|
+
guard.state_requirements.any? do |requirement|
|
89
|
+
requirement[:from].matches?(name) && !requirement[:to].matches?(name, :from => name)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Generates a human-readable description of this state's name / value:
|
96
|
+
#
|
97
|
+
# For example,
|
98
|
+
#
|
99
|
+
# State.new(machine, :parked).description # => "parked"
|
100
|
+
# State.new(machine, :parked, :value => :parked).description # => "parked"
|
101
|
+
# State.new(machine, :parked, :value => nil).description # => "parked (nil)"
|
102
|
+
# State.new(machine, :parked, :value => 1).description # => "parked (1)"
|
103
|
+
# State.new(machine, :parked, :value => lambda {Time.now}).description # => "parked (*)
|
104
|
+
def description
|
105
|
+
description = name ? name.to_s : name.inspect
|
106
|
+
description << " (#{@value.is_a?(Proc) ? '*' : @value.inspect})" unless name.to_s == @value.to_s
|
107
|
+
description
|
108
|
+
end
|
109
|
+
|
110
|
+
# The value that represents this state. This will optionally evaluate the
|
111
|
+
# original block if it's a lambda block. Otherwise, the static value is
|
112
|
+
# returned.
|
113
|
+
#
|
114
|
+
# For example,
|
115
|
+
#
|
116
|
+
# State.new(machine, :parked, :value => 1).value # => 1
|
117
|
+
# State.new(machine, :parked, :value => lambda {Time.now}).value # => Tue Jan 01 00:00:00 UTC 2008
|
118
|
+
# State.new(machine, :parked, :value => lambda {Time.now}).value(false) # => <Proc:0xb6ea7ca0@...>
|
119
|
+
def value(eval = true)
|
120
|
+
if @value.is_a?(Proc) && eval
|
121
|
+
if cache_value?
|
122
|
+
@value = @value.call
|
123
|
+
machine.states.update(self)
|
124
|
+
@value
|
125
|
+
else
|
126
|
+
@value.call
|
127
|
+
end
|
128
|
+
else
|
129
|
+
@value
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# Determines whether this state matches the given value. If no matcher is
|
134
|
+
# configured, then this will check whether the values are equivalent.
|
135
|
+
# Otherwise, the matcher will determine the result.
|
136
|
+
#
|
137
|
+
# For example,
|
138
|
+
#
|
139
|
+
# # Without a matcher
|
140
|
+
# state = State.new(machine, :parked, :value => 1)
|
141
|
+
# state.matches?(1) # => true
|
142
|
+
# state.matches?(2) # => false
|
143
|
+
#
|
144
|
+
# # With a matcher
|
145
|
+
# state = State.new(machine, :parked, :value => lambda {Time.now}, :if => lambda {|value| !value.nil?})
|
146
|
+
# state.matches?(nil) # => false
|
147
|
+
# state.matches?(Time.now) # => true
|
148
|
+
def matches?(other_value)
|
149
|
+
matcher ? matcher.call(other_value) : other_value == value
|
150
|
+
end
|
151
|
+
|
152
|
+
# Defines a context for the state which will be enabled on instances of
|
153
|
+
# the owner class when the machine is in this state.
|
154
|
+
#
|
155
|
+
# This can be called multiple times. Each time a new context is created,
|
156
|
+
# a new module will be included in the owner class.
|
157
|
+
def context(&block)
|
158
|
+
owner_class = machine.owner_class
|
159
|
+
attribute = machine.attribute
|
160
|
+
name = self.name
|
161
|
+
|
162
|
+
# Evaluate the method definitions
|
163
|
+
context = ConditionProxy.new(owner_class, lambda {|object| object.class.state_machine(attribute).states.matches?(object, name)})
|
164
|
+
context.class_eval(&block)
|
165
|
+
context.instance_methods.each do |method|
|
166
|
+
methods[method.to_sym] = context.instance_method(method)
|
167
|
+
|
168
|
+
# Calls the method defined by the current state of the machine
|
169
|
+
context.class_eval <<-end_eval, __FILE__, __LINE__
|
170
|
+
def #{method}(*args, &block)
|
171
|
+
self.class.state_machine(#{attribute.inspect}).states.match!(self).call(self, #{method.inspect}, *args, &block)
|
172
|
+
end
|
173
|
+
end_eval
|
174
|
+
end
|
175
|
+
|
176
|
+
# Include the context so that it can be bound to the owner class (the
|
177
|
+
# context is considered an ancestor, so it's allowed to be bound)
|
178
|
+
owner_class.class_eval { include context }
|
179
|
+
|
180
|
+
context
|
181
|
+
end
|
182
|
+
|
183
|
+
# Calls a method defined in this state's context on the given object. All
|
184
|
+
# arguments and any block will be passed into the method defined.
|
185
|
+
#
|
186
|
+
# If the method has never been defined for this state, then a NoMethodError
|
187
|
+
# will be raised.
|
188
|
+
def call(object, method, *args, &block)
|
189
|
+
if context_method = methods[method.to_sym]
|
190
|
+
# Method is defined by the state: proxy it through
|
191
|
+
context_method.bind(object).call(*args, &block)
|
192
|
+
else
|
193
|
+
# Raise exception as if the method never existed on the original object
|
194
|
+
raise NoMethodError, "undefined method '#{method}' for #{object} in state #{machine.states.match(object).name.inspect}"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Draws a representation of this state on the given machine. This will
|
199
|
+
# create a new node on the graph with the following properties:
|
200
|
+
# * +label+ - The human-friendly description of the state.
|
201
|
+
# * +width+ - The width of the node. Always 1.
|
202
|
+
# * +height+ - The height of the node. Always 1.
|
203
|
+
# * +shape+ - The actual shape of the node. If the state is a final
|
204
|
+
# state, then "doublecircle", otherwise "ellipse".
|
205
|
+
#
|
206
|
+
# The actual node generated on the graph will be returned.
|
207
|
+
def draw(graph)
|
208
|
+
node = graph.add_node(name ? name.to_s : 'nil',
|
209
|
+
:label => description,
|
210
|
+
:width => '1',
|
211
|
+
:height => '1',
|
212
|
+
:shape => final? ? 'doublecircle' : 'ellipse'
|
213
|
+
)
|
214
|
+
|
215
|
+
# Add open arrow for initial state
|
216
|
+
graph.add_edge(graph.add_node('starting_state', :shape => 'point'), node) if initial?
|
217
|
+
|
218
|
+
node
|
219
|
+
end
|
220
|
+
|
221
|
+
# Generates a nicely formatted description of this state's contents.
|
222
|
+
#
|
223
|
+
# For example,
|
224
|
+
#
|
225
|
+
# state = StateMachine::State.new(machine, :parked, :value => 1, :initial => true)
|
226
|
+
# state # => #<StateMachine::State name=:parked value=1 initial=true context=[]>
|
227
|
+
def inspect
|
228
|
+
attributes = [[:name, name], [:value, @value], [:initial, initial?], [:context, methods.keys]]
|
229
|
+
"#<#{self.class} #{attributes.map {|attr, value| "#{attr}=#{value.inspect}"} * ' '}>"
|
230
|
+
end
|
231
|
+
|
232
|
+
private
|
233
|
+
# Should the value be cached after it's evaluated for the first time?
|
234
|
+
def cache_value?
|
235
|
+
@cache
|
236
|
+
end
|
237
|
+
|
238
|
+
# Adds a predicate method to the owner class so long as a name has
|
239
|
+
# actually been configured for the state
|
240
|
+
def add_predicate
|
241
|
+
return unless name
|
242
|
+
|
243
|
+
# Checks whether the current value matches this state
|
244
|
+
machine.define_instance_method("#{qualified_name}?") do |machine, object|
|
245
|
+
machine.states.matches?(object, name)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'state_machine/node_collection'
|
2
|
+
|
3
|
+
module StateMachine
|
4
|
+
# Represents a collection of states in a state machine
|
5
|
+
class StateCollection < NodeCollection
|
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, :state))
|
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.
|
36
|
+
#
|
37
|
+
# == Examples
|
38
|
+
#
|
39
|
+
# class Vehicle
|
40
|
+
# state_machine :initial => :parked do
|
41
|
+
# other_states :idling
|
42
|
+
# end
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# states = Vehicle.state_machine.states
|
46
|
+
#
|
47
|
+
# vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
|
48
|
+
# states.match(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
|
49
|
+
#
|
50
|
+
# vehicle.state = 'idling'
|
51
|
+
# states.match(vehicle) # => #<StateMachine::State name=:idling value="idling" initial=true>
|
52
|
+
#
|
53
|
+
# vehicle.state = 'invalid'
|
54
|
+
# states.match(vehicle) # => nil
|
55
|
+
def match(object)
|
56
|
+
value = machine.read(object, :state)
|
57
|
+
self[value, :value] || detect {|state| state.matches?(value)}
|
58
|
+
end
|
59
|
+
|
60
|
+
# Determines the current state of the given object as configured by this
|
61
|
+
# state machine. If no state is found, then an ArgumentError will be
|
62
|
+
# raised.
|
63
|
+
#
|
64
|
+
# == Examples
|
65
|
+
#
|
66
|
+
# class Vehicle
|
67
|
+
# state_machine :initial => :parked do
|
68
|
+
# other_states :idling
|
69
|
+
# end
|
70
|
+
# end
|
71
|
+
#
|
72
|
+
# states = Vehicle.state_machine.states
|
73
|
+
#
|
74
|
+
# vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
|
75
|
+
# states.match!(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
|
76
|
+
#
|
77
|
+
# vehicle.state = 'invalid'
|
78
|
+
# states.match!(vehicle) # => ArgumentError: "invalid" is not a known state value
|
79
|
+
def match!(object)
|
80
|
+
match(object) || raise(ArgumentError, "#{machine.read(object, :state).inspect} is not a known #{machine.name} value")
|
81
|
+
end
|
82
|
+
|
83
|
+
# Gets the order in which states should be displayed based on where they
|
84
|
+
# were first referenced. This will order states in the following priority:
|
85
|
+
#
|
86
|
+
# 1. Initial state
|
87
|
+
# 2. Event transitions (:from, :except_from, :to, :except_to options)
|
88
|
+
# 3. States with behaviors
|
89
|
+
# 4. States referenced via +state+ or +other_states+
|
90
|
+
# 5. States referenced in callbacks
|
91
|
+
#
|
92
|
+
# This order will determine how the GraphViz visualizations are rendered.
|
93
|
+
def by_priority
|
94
|
+
order = select {|state| state.initial}.map {|state| state.name}
|
95
|
+
|
96
|
+
machine.events.each {|event| order += event.known_states}
|
97
|
+
order += select {|state| state.methods.any?}.map {|state| state.name}
|
98
|
+
order += keys(:name) - machine.callbacks.values.flatten.map {|callback| callback.known_states}.flatten
|
99
|
+
order += keys(:name)
|
100
|
+
|
101
|
+
order.uniq!
|
102
|
+
order.map! {|name| self[name]}
|
103
|
+
order
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
# Gets the value for the given attribute on the node
|
108
|
+
def value(node, attribute)
|
109
|
+
attribute == :value ? node.value(false) : super
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,367 @@
|
|
1
|
+
module StateMachine
|
2
|
+
# An invalid transition was attempted
|
3
|
+
class InvalidTransition < StandardError
|
4
|
+
end
|
5
|
+
|
6
|
+
# A transition represents a state change for a specific attribute.
|
7
|
+
#
|
8
|
+
# Transitions consist of:
|
9
|
+
# * An event
|
10
|
+
# * A starting state
|
11
|
+
# * An ending state
|
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
|
+
|
89
|
+
# The object being transitioned
|
90
|
+
attr_reader :object
|
91
|
+
|
92
|
+
# The state machine for which this transition is defined
|
93
|
+
attr_reader :machine
|
94
|
+
|
95
|
+
# The event that triggered the transition
|
96
|
+
attr_reader :event
|
97
|
+
|
98
|
+
# The fully-qualified name of the event that triggered the transition
|
99
|
+
attr_reader :qualified_event
|
100
|
+
|
101
|
+
# The original state value *before* the transition
|
102
|
+
attr_reader :from
|
103
|
+
|
104
|
+
# The original state name *before* the transition
|
105
|
+
attr_reader :from_name
|
106
|
+
|
107
|
+
# The original fully-qualified state name *before* transition
|
108
|
+
attr_reader :qualified_from_name
|
109
|
+
|
110
|
+
# The new state value *after* the transition
|
111
|
+
attr_reader :to
|
112
|
+
|
113
|
+
# The new state name *after* the transition
|
114
|
+
attr_reader :to_name
|
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
|
+
|
126
|
+
# Creates a new, specific transition
|
127
|
+
def initialize(object, machine, event, from_name, to_name) #:nodoc:
|
128
|
+
@object = object
|
129
|
+
@machine = machine
|
130
|
+
@args = []
|
131
|
+
|
132
|
+
# Event information
|
133
|
+
event = machine.events.fetch(event)
|
134
|
+
@event = event.name
|
135
|
+
@qualified_event = event.qualified_name
|
136
|
+
|
137
|
+
# From state information
|
138
|
+
from_state = machine.states.fetch(from_name)
|
139
|
+
@from = machine.read(object, :state)
|
140
|
+
@from_name = from_state.name
|
141
|
+
@qualified_from_name = from_state.qualified_name
|
142
|
+
|
143
|
+
# To state information
|
144
|
+
to_state = machine.states.fetch(to_name)
|
145
|
+
@to = to_state.value
|
146
|
+
@to_name = to_state.name
|
147
|
+
@qualified_to_name = to_state.qualified_name
|
148
|
+
end
|
149
|
+
|
150
|
+
# The attribute which this transition's machine is defined for
|
151
|
+
def attribute
|
152
|
+
machine.attribute
|
153
|
+
end
|
154
|
+
|
155
|
+
# The action that will be run when this transition is performed
|
156
|
+
def action
|
157
|
+
machine.action
|
158
|
+
end
|
159
|
+
|
160
|
+
# Does this transition represent a loopback (i.e. the from and to state
|
161
|
+
# are the same)
|
162
|
+
#
|
163
|
+
# == Example
|
164
|
+
#
|
165
|
+
# machine = StateMachine.new(Vehicle)
|
166
|
+
# StateMachine::Transition.new(Vehicle.new, machine, :park, :parked, :parked).loopback? # => true
|
167
|
+
# StateMachine::Transition.new(Vehicle.new, machine, :park, :idling, :parked).loopback? # => false
|
168
|
+
def loopback?
|
169
|
+
from_name == to_name
|
170
|
+
end
|
171
|
+
|
172
|
+
# A hash of all the core attributes defined for this transition with their
|
173
|
+
# names as keys and values of the attributes as values.
|
174
|
+
#
|
175
|
+
# == Example
|
176
|
+
#
|
177
|
+
# machine = StateMachine.new(Vehicle)
|
178
|
+
# transition = StateMachine::Transition.new(Vehicle.new, machine, :ignite, :parked, :idling)
|
179
|
+
# transition.attributes # => {:object => #<Vehicle:0xb7d60ea4>, :attribute => :state, :event => :ignite, :from => 'parked', :to => 'idling'}
|
180
|
+
def attributes
|
181
|
+
@attributes ||= {:object => object, :attribute => attribute, :event => event, :from => from, :to => to}
|
182
|
+
end
|
183
|
+
|
184
|
+
# Runs the actual transition and any before/after callbacks associated
|
185
|
+
# with the transition. The action associated with the transition/machine
|
186
|
+
# can be skipped by passing in +false+.
|
187
|
+
#
|
188
|
+
# == Examples
|
189
|
+
#
|
190
|
+
# class Vehicle
|
191
|
+
# state_machine :action => :save do
|
192
|
+
# ...
|
193
|
+
# end
|
194
|
+
# end
|
195
|
+
#
|
196
|
+
# vehicle = Vehicle.new
|
197
|
+
# transition = StateMachine::Transition.new(vehicle, machine, :ignite, :parked, :idling)
|
198
|
+
# transition.perform # => Runs the +save+ action after setting the state attribute
|
199
|
+
# transition.perform(false) # => Only sets the state attribute
|
200
|
+
def perform(*args)
|
201
|
+
run_action = [true, false].include?(args.last) ? args.pop : true
|
202
|
+
self.args = args
|
203
|
+
|
204
|
+
# Run the transition
|
205
|
+
self.class.perform_within_transaction([self], :action => run_action)
|
206
|
+
end
|
207
|
+
|
208
|
+
# Runs a block within a transaction for the object being transitioned.
|
209
|
+
# By default, transactions are a no-op unless otherwise defined by the
|
210
|
+
# machine's integration.
|
211
|
+
def within_transaction
|
212
|
+
machine.within_transaction(object) do
|
213
|
+
yield
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
# Runs the machine's +before+ callbacks for this transition. Only
|
218
|
+
# callbacks that are configured to match the event, from state, and to
|
219
|
+
# state will be invoked.
|
220
|
+
#
|
221
|
+
# == Example
|
222
|
+
#
|
223
|
+
# class Vehicle
|
224
|
+
# state_machine do
|
225
|
+
# before_transition :on => :ignite, :do => lambda {|vehicle| ...}
|
226
|
+
# end
|
227
|
+
# end
|
228
|
+
#
|
229
|
+
# vehicle = Vehicle.new
|
230
|
+
# transition = StateMachine::Transition.new(vehicle, machine, :ignite, :parked, :idling)
|
231
|
+
# transition.before
|
232
|
+
def before
|
233
|
+
result = false
|
234
|
+
|
235
|
+
catch(:halt) do
|
236
|
+
callback(:before)
|
237
|
+
result = true
|
238
|
+
end
|
239
|
+
|
240
|
+
result
|
241
|
+
end
|
242
|
+
|
243
|
+
# Transitions the current value of the state to that specified by the
|
244
|
+
# transition.
|
245
|
+
#
|
246
|
+
# == Example
|
247
|
+
#
|
248
|
+
# class Vehicle
|
249
|
+
# state_machine do
|
250
|
+
# event :ignite do
|
251
|
+
# transition :parked => :idling
|
252
|
+
# end
|
253
|
+
# end
|
254
|
+
# end
|
255
|
+
#
|
256
|
+
# vehicle = Vehicle.new
|
257
|
+
# transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
|
258
|
+
# transition.persist
|
259
|
+
#
|
260
|
+
# vehicle.state # => 'idling'
|
261
|
+
def persist
|
262
|
+
machine.write(object, :state, to)
|
263
|
+
end
|
264
|
+
|
265
|
+
# Runs the machine's +after+ callbacks for this transition. Only
|
266
|
+
# callbacks that are configured to match the event, from state, and to
|
267
|
+
# state will be invoked.
|
268
|
+
#
|
269
|
+
# The result is used to indicate whether the associated machine action
|
270
|
+
# was executed successfully.
|
271
|
+
#
|
272
|
+
# == Halting
|
273
|
+
#
|
274
|
+
# If any callback throws a <tt>:halt</tt> exception, it will be caught
|
275
|
+
# and the callback chain will be automatically stopped. However, this
|
276
|
+
# exception will not bubble up to the caller since +after+ callbacks
|
277
|
+
# should never halt the execution of a +perform+.
|
278
|
+
#
|
279
|
+
# == Example
|
280
|
+
#
|
281
|
+
# class Vehicle
|
282
|
+
# state_machine do
|
283
|
+
# after_transition :on => :ignite, :do => lambda {|vehicle| ...}
|
284
|
+
#
|
285
|
+
# event :ignite do
|
286
|
+
# transition :parked => :idling
|
287
|
+
# end
|
288
|
+
# end
|
289
|
+
# end
|
290
|
+
#
|
291
|
+
# vehicle = Vehicle.new
|
292
|
+
# transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
|
293
|
+
# transition.after(true)
|
294
|
+
def after(result = nil)
|
295
|
+
@result = result
|
296
|
+
|
297
|
+
catch(:halt) do
|
298
|
+
callback(:after)
|
299
|
+
end
|
300
|
+
|
301
|
+
true
|
302
|
+
end
|
303
|
+
|
304
|
+
# Rolls back changes made to the object's state via this transition. This
|
305
|
+
# will revert the state back to the +from+ value.
|
306
|
+
#
|
307
|
+
# == Example
|
308
|
+
#
|
309
|
+
# class Vehicle
|
310
|
+
# state_machine :initial => :parked do
|
311
|
+
# event :ignite do
|
312
|
+
# transition :parked => :idling
|
313
|
+
# end
|
314
|
+
# end
|
315
|
+
# end
|
316
|
+
#
|
317
|
+
# vehicle = Vehicle.new # => #<Vehicle:0xb7b7f568 @state="parked">
|
318
|
+
# transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
|
319
|
+
#
|
320
|
+
# # Persist the new state
|
321
|
+
# vehicle.state # => "parked"
|
322
|
+
# transition.persist
|
323
|
+
# vehicle.state # => "idling"
|
324
|
+
#
|
325
|
+
# # Roll back to the original state
|
326
|
+
# transition.rollback
|
327
|
+
# vehicle.state # => "parked"
|
328
|
+
def rollback
|
329
|
+
machine.write(object, :state, from)
|
330
|
+
end
|
331
|
+
|
332
|
+
# Generates a nicely formatted description of this transitions's contents.
|
333
|
+
#
|
334
|
+
# For example,
|
335
|
+
#
|
336
|
+
# transition = StateMachine::Transition.new(object, machine, :ignite, :parked, :idling)
|
337
|
+
# transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
|
338
|
+
def inspect
|
339
|
+
"#<#{self.class} #{%w(attribute event from from_name to to_name).map {|attr| "#{attr}=#{send(attr).inspect}"} * ' '}>"
|
340
|
+
end
|
341
|
+
|
342
|
+
protected
|
343
|
+
# Gets a hash of the context defining this unique transition (including
|
344
|
+
# event, from state, and to state).
|
345
|
+
#
|
346
|
+
# == Example
|
347
|
+
#
|
348
|
+
# machine = StateMachine.new(Vehicle)
|
349
|
+
# transition = StateMachine::Transition.new(Vehicle.new, machine, :ignite, :parked, :idling)
|
350
|
+
# transition.context # => {:on => :ignite, :from => :parked, :to => :idling}
|
351
|
+
def context
|
352
|
+
@context ||= {:on => event, :from => from_name, :to => to_name}
|
353
|
+
end
|
354
|
+
|
355
|
+
# Runs the callbacks of the given type for this transition. This will
|
356
|
+
# only invoke callbacks that exactly match the event, from state, and
|
357
|
+
# to state that describe this transition.
|
358
|
+
#
|
359
|
+
# Additional callback parameters can be specified. By default, this
|
360
|
+
# transition is also passed into callbacks.
|
361
|
+
def callback(type)
|
362
|
+
machine.callbacks[type].each do |callback|
|
363
|
+
callback.call(object, context, self)
|
364
|
+
end
|
365
|
+
end
|
366
|
+
end
|
367
|
+
end
|