state_machine 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +15 -0
- data/README.rdoc +64 -14
- data/Rakefile +1 -1
- data/lib/state_machine.rb +25 -28
- data/lib/state_machine/event.rb +47 -111
- data/lib/state_machine/machine.rb +302 -67
- data/lib/state_machine/transition.rb +173 -68
- data/test/app_root/app/models/auto_shop.rb +2 -2
- data/test/app_root/app/models/switch_observer.rb +20 -0
- data/test/app_root/app/models/vehicle.rb +14 -7
- data/test/app_root/config/environment.rb +7 -0
- data/test/factory.rb +6 -0
- data/test/functional/state_machine_test.rb +53 -1
- data/test/unit/event_test.rb +43 -188
- data/test/unit/machine_test.rb +154 -30
- data/test/unit/state_machine_test.rb +39 -29
- data/test/unit/transition_test.rb +347 -218
- metadata +6 -3
data/CHANGELOG.rdoc
CHANGED
@@ -1,5 +1,20 @@
|
|
1
1
|
== master
|
2
2
|
|
3
|
+
== 0.3.0 / 2008-09-07
|
4
|
+
|
5
|
+
* No longer allow additional arguments to be passed into event actions
|
6
|
+
* Add support for can_#{event}? for checking whether an event can be fired based on the current state of the record
|
7
|
+
* Don't use callbacks for performing transitions
|
8
|
+
* Fix state machines in subclasses not knowing what states/events/transitions were defined by superclasses
|
9
|
+
* Replace all before/after_exit/enter/loopback callback hooks and :before/:after options for events with before_transition/after_transition callbacks, e.g.
|
10
|
+
|
11
|
+
before_transition :from => 'parked', :do => :lock_doors # was before_exit :parked, :lock_doors
|
12
|
+
after_transition :on => 'ignite', :do => :turn_on_radio # was event :ignite, :after => :turn_on_radio do
|
13
|
+
|
14
|
+
* Always save when an event is fired even if it results in a loopback [Jürgen Strobel]
|
15
|
+
* Ensure initial state callbacks are invoked in the proper order when an event is fired on a new record
|
16
|
+
* Add before_loopback and after_loopback hooks [Jürgen Strobel]
|
17
|
+
|
3
18
|
== 0.2.1 / 2008-07-05
|
4
19
|
|
5
20
|
* Add more descriptive exceptions
|
data/README.rdoc
CHANGED
@@ -40,14 +40,15 @@ making it so simple you don't even need to know what a state machine is :)
|
|
40
40
|
|
41
41
|
Below is an example of many of the features offered by this plugin, including
|
42
42
|
* Initial states
|
43
|
-
*
|
44
|
-
* Event callbacks
|
43
|
+
* Transition callbacks
|
45
44
|
* Conditional transitions
|
46
45
|
|
47
46
|
class Vehicle < ActiveRecord::Base
|
48
47
|
state_machine :state, :initial => 'idling' do
|
49
|
-
|
50
|
-
|
48
|
+
before_transition :from => %w(parked idling), :do => :put_on_seatbelt
|
49
|
+
after_transition :to => 'parked', :do => lambda {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
|
50
|
+
after_transition :on => 'crash', :do => :tow!
|
51
|
+
after_transition :on => 'repair', :do => :fix!
|
51
52
|
|
52
53
|
event :park do
|
53
54
|
transition :to => 'parked', :from => %w(idling first_gear)
|
@@ -73,19 +74,21 @@ Below is an example of many of the features offered by this plugin, including
|
|
73
74
|
transition :to => 'first_gear', :from => 'second_gear'
|
74
75
|
end
|
75
76
|
|
76
|
-
event :crash
|
77
|
+
event :crash do
|
77
78
|
transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :unless => :auto_shop_busy?
|
78
79
|
end
|
79
80
|
|
80
|
-
event :repair
|
81
|
+
event :repair do
|
81
82
|
transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy?
|
82
83
|
end
|
83
84
|
end
|
84
85
|
|
85
86
|
def tow!
|
87
|
+
# do something here
|
86
88
|
end
|
87
89
|
|
88
90
|
def fix!
|
91
|
+
# do something here
|
89
92
|
end
|
90
93
|
|
91
94
|
def auto_shop_busy?
|
@@ -96,16 +99,62 @@ Below is an example of many of the features offered by this plugin, including
|
|
96
99
|
Using the above model as an example, you can interact with the state machine
|
97
100
|
like so:
|
98
101
|
|
99
|
-
vehicle = Vehicle.create
|
100
|
-
vehicle.ignite
|
101
|
-
vehicle
|
102
|
-
vehicle.shift_up
|
103
|
-
vehicle
|
104
|
-
vehicle.shift_up
|
105
|
-
vehicle
|
102
|
+
vehicle = Vehicle.create # => #<Vehicle id: 1, seatbelt_on: false, state: "parked">
|
103
|
+
vehicle.ignite # => true
|
104
|
+
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "idling">
|
105
|
+
vehicle.shift_up # => true
|
106
|
+
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "first_gear">
|
107
|
+
vehicle.shift_up # => true
|
108
|
+
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "second_gear">
|
106
109
|
|
107
110
|
# The bang (!) operator can raise exceptions if the event fails
|
108
|
-
vehicle.park!
|
111
|
+
vehicle.park! # => PluginAWeek::StateMachine::InvalidTransition: Cannot transition via :park from "second_gear"
|
112
|
+
|
113
|
+
=== With enumerations
|
114
|
+
|
115
|
+
Using the acts_as_enumeration[http://github.com/pluginaweek/acts_as_enumeration] plugin
|
116
|
+
states can be transparently stored using record ids in the database like so:
|
117
|
+
|
118
|
+
class VehicleState < ActiveRecord::Base
|
119
|
+
acts_as_enumeration
|
120
|
+
|
121
|
+
create :id => 1, :name => 'parked'
|
122
|
+
create :id => 2, :name => 'idling'
|
123
|
+
create :id => 3, :name => 'first_gear'
|
124
|
+
...
|
125
|
+
end
|
126
|
+
|
127
|
+
class Vehicle < ActiveRecord::Base
|
128
|
+
belongs_to :state, :class_name => 'VehicleState'
|
129
|
+
|
130
|
+
state_machine :state, :initial => 'idling' do
|
131
|
+
...
|
132
|
+
|
133
|
+
event :park do
|
134
|
+
transition :to => 'parked', :from => %w(idling first_gear)
|
135
|
+
end
|
136
|
+
|
137
|
+
event :ignite do
|
138
|
+
transition :to => 'stalled', :from => 'stalled'
|
139
|
+
transition :to => 'idling', :from => 'parked'
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
...
|
144
|
+
end
|
145
|
+
|
146
|
+
Notice in the above example, the state machine definition remains *exactly* the
|
147
|
+
same. However, when interacting with the records, the actual state will be
|
148
|
+
stored using the identifiers defined for the enumeration:
|
149
|
+
|
150
|
+
vehicle = Vehicle.create # => #<Vehicle id: 1, seatbelt_on: false, state_id: 1>
|
151
|
+
vehicle.ignite # => true
|
152
|
+
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state_id: 2>
|
153
|
+
vehicle.shift_up # => true
|
154
|
+
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state_id: 3>
|
155
|
+
|
156
|
+
This allows states to take on more complex functionality other than just being
|
157
|
+
a string value.
|
109
158
|
|
110
159
|
== Tools
|
111
160
|
|
@@ -130,3 +179,4 @@ To run against a specific version of Rails:
|
|
130
179
|
== References
|
131
180
|
|
132
181
|
* Scott Barron - acts_as_state_machine[http://elitists.textdriven.com/svn/plugins/acts_as_state_machine]
|
182
|
+
* acts_as_enumeration[http://github.com/pluginaweek/acts_as_enumeration]
|
data/Rakefile
CHANGED
@@ -5,7 +5,7 @@ require 'rake/contrib/sshpublisher'
|
|
5
5
|
|
6
6
|
spec = Gem::Specification.new do |s|
|
7
7
|
s.name = 'state_machine'
|
8
|
-
s.version = '0.
|
8
|
+
s.version = '0.3.0'
|
9
9
|
s.platform = Gem::Platform::RUBY
|
10
10
|
s.summary = 'Adds support for creating state machines for attributes within a model'
|
11
11
|
|
data/lib/state_machine.rb
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
require 'state_machine/machine'
|
2
2
|
|
3
3
|
module PluginAWeek #:nodoc:
|
4
|
-
# A state machine is a model of behavior composed of states,
|
5
|
-
#
|
6
|
-
# functionality within
|
4
|
+
# A state machine is a model of behavior composed of states, events, and
|
5
|
+
# transitions. This helper adds support for defining this type of
|
6
|
+
# functionality within ActiveRecord models.
|
7
7
|
module StateMachine
|
8
8
|
def self.included(base) #:nodoc:
|
9
9
|
base.class_eval do
|
@@ -19,10 +19,10 @@ module PluginAWeek #:nodoc:
|
|
19
19
|
# * +initial+ - The initial value of the attribute. This can either be the actual value or a Proc for dynamic initial states.
|
20
20
|
#
|
21
21
|
# This also requires a block which will be used to actually configure the
|
22
|
-
# events and transitions for the state machine. *Note* that this block
|
23
|
-
# be executed within the context of the state machine. As a result,
|
24
|
-
# not be able to access any class methods on the model unless you
|
25
|
-
# them directly (i.e. specifying the class name).
|
22
|
+
# events and transitions for the state machine. *Note* that this block
|
23
|
+
# will be executed within the context of the state machine. As a result,
|
24
|
+
# you will not be able to access any class methods on the model unless you
|
25
|
+
# refer to them directly (i.e. specifying the class name).
|
26
26
|
#
|
27
27
|
# For examples on the types of configured state machines and blocks, see
|
28
28
|
# the section below.
|
@@ -61,7 +61,7 @@ module PluginAWeek #:nodoc:
|
|
61
61
|
# With a dynamic initial state:
|
62
62
|
#
|
63
63
|
# class Switch < ActiveRecord::Base
|
64
|
-
# state_machine :status, :initial =>
|
64
|
+
# state_machine :status, :initial => lambda {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
|
65
65
|
# ...
|
66
66
|
# end
|
67
67
|
# end
|
@@ -70,24 +70,32 @@ module PluginAWeek #:nodoc:
|
|
70
70
|
#
|
71
71
|
# For more information about how to configure an event and its associated
|
72
72
|
# transitions, see PluginAWeek::StateMachine::Machine#event
|
73
|
+
#
|
74
|
+
# == Defining callbacks
|
75
|
+
#
|
76
|
+
# Within the +state_machine+ block, you can also define callbacks for
|
77
|
+
# particular states. For more information about defining these callbacks,
|
78
|
+
# see PluginAWeek::StateMachine::Machine#before_transition and
|
79
|
+
# PluginAWeek::StateMachine::Machine#after_transition.
|
73
80
|
def state_machine(*args, &block)
|
74
81
|
unless included_modules.include?(PluginAWeek::StateMachine::InstanceMethods)
|
75
82
|
write_inheritable_attribute :state_machines, {}
|
76
83
|
class_inheritable_reader :state_machines
|
77
84
|
|
78
|
-
after_create :run_initial_state_machine_actions
|
79
|
-
|
80
85
|
include PluginAWeek::StateMachine::InstanceMethods
|
81
86
|
end
|
82
87
|
|
83
88
|
options = args.extract_options!
|
84
89
|
attribute = args.any? ? args.first.to_s : 'state'
|
85
|
-
options[:initial] = state_machines[attribute].initial_state_without_processing if !options.include?(:initial) && state_machines[attribute]
|
86
90
|
|
87
|
-
#
|
88
|
-
#
|
89
|
-
|
91
|
+
# Creates the state machine for this class. If a superclass has already
|
92
|
+
# defined the machine, then a copy of it will be used with its context
|
93
|
+
# changed to this class. If no machine has been defined before for the
|
94
|
+
# attribute, a new one will be created.
|
95
|
+
original = state_machines[attribute]
|
96
|
+
machine = state_machines[attribute] = original ? original.within_context(self, options) : PluginAWeek::StateMachine::Machine.new(self, attribute, options)
|
90
97
|
machine.instance_eval(&block) if block
|
98
|
+
|
91
99
|
machine
|
92
100
|
end
|
93
101
|
end
|
@@ -103,26 +111,15 @@ module PluginAWeek #:nodoc:
|
|
103
111
|
def initialize_with_state_machine(attributes = nil)
|
104
112
|
initialize_without_state_machine(attributes)
|
105
113
|
|
106
|
-
attribute_keys = (attributes || {}).keys.map!(&:to_s)
|
107
|
-
|
108
114
|
# Set the initial value of each state machine as long as the value wasn't
|
109
|
-
# included in the
|
115
|
+
# included in the initial attributes
|
116
|
+
attributes = (attributes || {}).stringify_keys
|
110
117
|
self.class.state_machines.each do |attribute, machine|
|
111
|
-
unless
|
112
|
-
send("#{attribute}=", machine.initial_state(self))
|
113
|
-
end
|
118
|
+
send("#{attribute}=", machine.initial_state(self)) unless attributes.include?(attribute)
|
114
119
|
end
|
115
120
|
|
116
121
|
yield self if block_given?
|
117
122
|
end
|
118
|
-
|
119
|
-
# Records the transition for the record going into its initial state
|
120
|
-
def run_initial_state_machine_actions
|
121
|
-
self.class.state_machines.each do |attribute, machine|
|
122
|
-
callback = "after_enter_#{attribute}_#{self[attribute]}"
|
123
|
-
run_callbacks(callback) if self[attribute] && self.class.respond_to?(callback)
|
124
|
-
end
|
125
|
-
end
|
126
123
|
end
|
127
124
|
end
|
128
125
|
end
|
data/lib/state_machine/event.rb
CHANGED
@@ -6,7 +6,7 @@ module PluginAWeek #:nodoc:
|
|
6
6
|
# another
|
7
7
|
class Event
|
8
8
|
# The state machine for which this event is defined
|
9
|
-
|
9
|
+
attr_accessor :machine
|
10
10
|
|
11
11
|
# The name of the action that fires the event
|
12
12
|
attr_reader :name
|
@@ -17,22 +17,20 @@ module PluginAWeek #:nodoc:
|
|
17
17
|
delegate :owner_class,
|
18
18
|
:to => :machine
|
19
19
|
|
20
|
-
# Creates a new event within the context of the given machine
|
21
|
-
|
22
|
-
# Configuration options:
|
23
|
-
# * +before+ - Callbacks to invoke before the event is fired
|
24
|
-
# * +after+ - Callbacks to invoke after the event is fired
|
25
|
-
def initialize(machine, name, options = {})
|
26
|
-
options.assert_valid_keys(:before, :after)
|
27
|
-
|
20
|
+
# Creates a new event within the context of the given machine
|
21
|
+
def initialize(machine, name)
|
28
22
|
@machine = machine
|
29
23
|
@name = name
|
30
|
-
@options = options.stringify_keys
|
31
24
|
@transitions = []
|
32
25
|
|
33
|
-
|
34
|
-
|
35
|
-
|
26
|
+
add_actions
|
27
|
+
end
|
28
|
+
|
29
|
+
# Creates a copy of this event in addition to the list of associated
|
30
|
+
# transitions to prevent conflicts across different events.
|
31
|
+
def initialize_copy(orig) #:nodoc:
|
32
|
+
super
|
33
|
+
@transitions = @transitions.dup
|
36
34
|
end
|
37
35
|
|
38
36
|
# Creates a new transition to the specified state.
|
@@ -41,7 +39,7 @@ module PluginAWeek #:nodoc:
|
|
41
39
|
# * +to+ - The state that being transitioned to
|
42
40
|
# * +from+ - A state or array of states that can be transitioned from. If not specified, then the transition can occur for *any* from state
|
43
41
|
# * +except_from+ - A state or array of states that *cannot* be transitioned from.
|
44
|
-
# * +if+ - Specifies a method, proc or string to call to determine if the
|
42
|
+
# * +if+ - Specifies a method, proc or string to call to determine if the transition should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value.
|
45
43
|
# * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value.
|
46
44
|
#
|
47
45
|
# == Examples
|
@@ -52,128 +50,66 @@ module PluginAWeek #:nodoc:
|
|
52
50
|
# transition :to => 'parked', :from => 'first_gear', :if => :moving?
|
53
51
|
# transition :to => 'parked', :from => 'first_gear', :unless => :stopped?
|
54
52
|
# transition :to => 'parked', :except_from => 'parked'
|
55
|
-
def transition(options
|
56
|
-
|
57
|
-
options.symbolize_keys!
|
58
|
-
callback_options = {:if => options.delete(:if), :unless => options.delete(:unless)}
|
59
|
-
|
60
|
-
transition = Transition.new(self, options)
|
61
|
-
|
62
|
-
# Add the callback to the model. If the callback fails, then the next
|
63
|
-
# available callback for the event will run until one is successful.
|
64
|
-
callback = Proc.new {|record, *args| try_transition(transition, false, record, *args)}
|
65
|
-
owner_class.send("transition_on_#{name}", callback, callback_options)
|
66
|
-
|
67
|
-
# Add the callback! to the model similar to above
|
68
|
-
callback = Proc.new {|record, *args| try_transition(transition, true, record, *args)}
|
69
|
-
owner_class.send("transition_bang_on_#{name}", callback, callback_options)
|
70
|
-
|
71
|
-
transitions << transition
|
53
|
+
def transition(options)
|
54
|
+
transitions << transition = Transition.new(self, options)
|
72
55
|
transition
|
73
56
|
end
|
74
57
|
|
58
|
+
# Determines whether any transitions can be performed for this event based
|
59
|
+
# on the current state of the given record.
|
60
|
+
#
|
61
|
+
# If the event can't be fired, then this will return false, otherwise true.
|
62
|
+
def can_fire?(record)
|
63
|
+
transitions.any? {|transition| transition.can_perform?(record)}
|
64
|
+
end
|
65
|
+
|
75
66
|
# Attempts to perform one of the event's transitions for the given record.
|
76
67
|
# Any additional arguments will be passed to the event's callbacks.
|
77
|
-
def fire(record
|
78
|
-
|
68
|
+
def fire(record)
|
69
|
+
run(record, false) || false
|
79
70
|
end
|
80
71
|
|
81
72
|
# Attempts to perform one of the event's transitions for the given record.
|
82
73
|
# If the transition cannot be made, then a PluginAWeek::StateMachine::InvalidTransition
|
83
74
|
# error will be raised.
|
84
|
-
def fire!(record
|
85
|
-
|
75
|
+
def fire!(record)
|
76
|
+
run(record, true) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{name} from \"#{record.send(machine.attribute)}\"")
|
86
77
|
end
|
87
78
|
|
88
79
|
private
|
89
|
-
# Fires the event
|
90
|
-
def fire_with_optional_bang(record, bang, *args)
|
91
|
-
record.class.transaction do
|
92
|
-
invoke_transition_callbacks(record, bang, *args) || raise(ActiveRecord::Rollback)
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
80
|
# Add the various instance methods that can transition the record using
|
97
81
|
# the current event
|
98
|
-
def
|
82
|
+
def add_actions
|
83
|
+
attribute = machine.attribute
|
99
84
|
name = self.name
|
100
|
-
owner_class = self.owner_class
|
101
|
-
machine = self.machine
|
102
85
|
|
103
86
|
owner_class.class_eval do
|
104
|
-
|
105
|
-
define_method(name)
|
106
|
-
|
107
|
-
end
|
108
|
-
|
109
|
-
# Fires the event, raising an exception if it fails
|
110
|
-
define_method("#{name}!") do |*args|
|
111
|
-
owner_class.state_machines[machine.attribute].events[name].fire!(self, *args)
|
112
|
-
end
|
87
|
+
define_method(name) {self.class.state_machines[attribute].events[name].fire(self)}
|
88
|
+
define_method("#{name}!") {self.class.state_machines[attribute].events[name].fire!(self)}
|
89
|
+
define_method("can_#{name}?") {self.class.state_machines[attribute].events[name].can_fire?(self)}
|
113
90
|
end
|
114
91
|
end
|
115
92
|
|
116
|
-
#
|
117
|
-
def add_transition_callbacks
|
118
|
-
%W(transition transition_bang).each do |callback_name|
|
119
|
-
callback_name = "#{callback_name}_on_#{name}"
|
120
|
-
owner_class.define_callbacks(callback_name)
|
121
|
-
end
|
122
|
-
end
|
123
|
-
|
124
|
-
# Adds the before/after callbacks for when the event is performed
|
125
|
-
def add_event_callbacks
|
126
|
-
%w(before after).each do |type|
|
127
|
-
callback_name = "#{type}_#{name}"
|
128
|
-
owner_class.define_callbacks(callback_name)
|
129
|
-
|
130
|
-
# Add each defined callback
|
131
|
-
Array(@options[type]).each {|callback| owner_class.send(callback_name, callback)}
|
132
|
-
end
|
133
|
-
end
|
134
|
-
|
135
|
-
# Attempts to perform the given transition. If it can't be performed based
|
136
|
-
# on the state of the given record, then the transition will be skipped
|
137
|
-
# and the next available one will be tried.
|
93
|
+
# Attempts to find a transition that can be performed for this event.
|
138
94
|
#
|
139
|
-
#
|
140
|
-
#
|
141
|
-
def
|
142
|
-
|
143
|
-
return false if invoke_event_callbacks(:before, record, *args) == false
|
144
|
-
result = bang ? transition.perform!(record, *args) : transition.perform(record, *args)
|
145
|
-
invoke_event_callbacks(:after, record, *args)
|
146
|
-
result
|
147
|
-
else
|
148
|
-
# Indicate that the transition cannot be performed
|
149
|
-
:skip
|
150
|
-
end
|
151
|
-
end
|
152
|
-
|
153
|
-
# Invokes a particulary type of callback for the event
|
154
|
-
def invoke_event_callbacks(type, record, *args)
|
155
|
-
args = [record] + args
|
95
|
+
# +bang+ indicates whether +perform+ or <tt>perform!</tt> will be
|
96
|
+
# invoked on transitions.
|
97
|
+
def run(record, bang)
|
98
|
+
result = false
|
156
99
|
|
157
|
-
record.class.
|
158
|
-
|
159
|
-
|
100
|
+
record.class.transaction do
|
101
|
+
transitions.each do |transition|
|
102
|
+
if transition.can_perform?(record)
|
103
|
+
result = bang ? transition.perform!(record) : transition.perform(record)
|
104
|
+
break
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Rollback any changes if the transition failed
|
109
|
+
raise ActiveRecord::Rollback unless result
|
160
110
|
end
|
161
|
-
end
|
162
|
-
|
163
|
-
# Invokes the callbacks for each transition in order to find one that
|
164
|
-
# completes successfully.
|
165
|
-
#
|
166
|
-
# +bang+ indicates whether perform or perform! will be invoked on the
|
167
|
-
# transitions in the callback chain
|
168
|
-
def invoke_transition_callbacks(record, bang, *args)
|
169
|
-
args = [record] + args
|
170
|
-
callback_chain = "transition#{'_bang' if bang}_on_#{name}_callback_chain"
|
171
111
|
|
172
|
-
result
|
173
|
-
result = callback.call(*args)
|
174
|
-
break result if [true, false].include?(result)
|
175
|
-
end
|
176
|
-
result == true
|
112
|
+
result
|
177
113
|
end
|
178
114
|
end
|
179
115
|
end
|