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 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
- * State callbacks
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
- before_exit 'parked', :put_on_seatbelt
50
- after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
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, :after => :tow! do
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, :after => :fix! do
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 # => #<Vehicle id: 1, seatbelt_on: false, state: "parked">
100
- vehicle.ignite # => true
101
- vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "idling">
102
- vehicle.shift_up # => true
103
- vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "first_gear">
104
- vehicle.shift_up # => true
105
- vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "second_gear">
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! # => PluginAWeek::StateMachine::InvalidTransition: Cannot transition via :park from "second_gear"
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.2.1'
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, transitions,
5
- # and events. This helper adds support for defining this type of
6
- # functionality within your ActiveRecord models.
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 will
23
- # be executed within the context of the state machine. As a result, you will
24
- # not be able to access any class methods on the model unless you refer to
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 => Proc.new {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
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
- # This will create a new machine for subclasses as well so that the owner_class and
88
- # initial state can be overridden
89
- machine = state_machines[attribute] = PluginAWeek::StateMachine::Machine.new(self, attribute, options)
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 attribute hash passed in
115
+ # included in the initial attributes
116
+ attributes = (attributes || {}).stringify_keys
110
117
  self.class.state_machines.each do |attribute, machine|
111
- unless attribute_keys.include?(attribute)
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
@@ -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
- attr_reader :machine
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
- add_transition_actions
34
- add_transition_callbacks
35
- add_event_callbacks
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 validation 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.
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
- # Slice out the callback options
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, *args)
78
- fire_with_optional_bang(record, false, *args) || false
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, *args)
85
- fire_with_optional_bang(record, true, *args) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{name} from #{record.send(machine.attribute).inspect}")
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 add_transition_actions
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
- # Fires the event, returning true/false
105
- define_method(name) do |*args|
106
- owner_class.state_machines[machine.attribute].events[name].fire(self, *args)
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
- # Defines callbacks for invoking transitions when this event is performed
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
- # If +bang+ is specified, then perform! will be called on the transition.
140
- # Otherwise, the default +perform+ will be invoked.
141
- def try_transition(transition, bang, record, *args)
142
- if transition.can_perform_on?(record)
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.send("#{type}_#{name}_callback_chain").each do |callback|
158
- result = callback.call(*args)
159
- break result if result == false
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 = record.class.send(callback_chain).each do |callback|
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