Skip to content

Commit

Permalink
MAJOR REWRITE! Replace all before/after_exit/enter/loopback callback …
Browse files Browse the repository at this point in the history
…hooks and :before/:after options for events with before_transition/after_transition callbacks

Fix state machines in subclasses not knowing what states/events/transitions were defined by superclasses
Don't use callbacks for performing transitions
Add support for can_#{event}? for checking whether an event can be fired based on the current state of the record
No longer allow additional arguments to be passed into event actions
  • Loading branch information
obrie committed Sep 7, 2008
1 parent cc34689 commit 6a94afc
Show file tree
Hide file tree
Showing 16 changed files with 1,242 additions and 844 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.rdoc
@@ -1,5 +1,14 @@
== master

* No longer allow additional arguments to be passed into event actions
* Add support for can_#{event}? for checking whether an event can be fired based on the current state of the record
* Don't use callbacks for performing transitions
* Fix state machines in subclasses not knowing what states/events/transitions were defined by superclasses
* Replace all before/after_exit/enter/loopback callback hooks and :before/:after options for events with before_transition/after_transition callbacks, e.g.

before_transition :from => 'parked', :do => :lock_doors # was before_exit :parked, :lock_doors
after_transition :on => 'ignite', :do => :turn_on_radio # was event :ignite, :after => :turn_on_radio do

* Always save when an event is fired even if it results in a loopback [Jürgen Strobel]
* Ensure initial state callbacks are invoked in the proper order when an event is fired on a new record
* Add before_loopback and after_loopback hooks [Jürgen Strobel]
Expand Down
78 changes: 64 additions & 14 deletions README.rdoc
Expand Up @@ -40,14 +40,15 @@ making it so simple you don't even need to know what a state machine is :)

Below is an example of many of the features offered by this plugin, including
* Initial states
* State callbacks
* Event callbacks
* Transition callbacks
* Conditional transitions

class Vehicle < ActiveRecord::Base
state_machine :state, :initial => 'idling' do
before_exit 'parked', :put_on_seatbelt
after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
before_transition :from => %w(parked idling), :do => :put_on_seatbelt
after_transition :to => 'parked', :do => lambda {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
after_transition :on => 'crash', :do => :tow!
after_transition :on => 'repair', :do => :fix!

event :park do
transition :to => 'parked', :from => %w(idling first_gear)
Expand All @@ -73,19 +74,21 @@ Below is an example of many of the features offered by this plugin, including
transition :to => 'first_gear', :from => 'second_gear'
end

event :crash, :after => :tow! do
event :crash do
transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :unless => :auto_shop_busy?
end

event :repair, :after => :fix! do
event :repair do
transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy?
end
end

def tow!
# do something here
end

def fix!
# do something here
end

def auto_shop_busy?
Expand All @@ -96,16 +99,62 @@ Below is an example of many of the features offered by this plugin, including
Using the above model as an example, you can interact with the state machine
like so:

vehicle = Vehicle.create # => #<Vehicle id: 1, seatbelt_on: false, state: "parked">
vehicle.ignite # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "idling">
vehicle.shift_up # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "first_gear">
vehicle.shift_up # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "second_gear">
vehicle = Vehicle.create # => #<Vehicle id: 1, seatbelt_on: false, state: "parked">
vehicle.ignite # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "idling">
vehicle.shift_up # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "first_gear">
vehicle.shift_up # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state: "second_gear">

# The bang (!) operator can raise exceptions if the event fails
vehicle.park! # => PluginAWeek::StateMachine::InvalidTransition: Cannot transition via :park from "second_gear"
vehicle.park! # => PluginAWeek::StateMachine::InvalidTransition: Cannot transition via :park from "second_gear"

=== With enumerations

Using the acts_as_enumeration[http://github.com/pluginaweek/acts_as_enumeration] plugin
states can be transparently stored using record ids in the database like so:

class VehicleState < ActiveRecord::Base
acts_as_enumeration

create :id => 1, :name => 'parked'
create :id => 2, :name => 'idling'
create :id => 3, :name => 'first_gear'
...
end

class Vehicle < ActiveRecord::Base
belongs_to :state, :class_name => 'VehicleState'

state_machine :state, :initial => 'idling' do
...

event :park do
transition :to => 'parked', :from => %w(idling first_gear)
end

event :ignite do
transition :to => 'stalled', :from => 'stalled'
transition :to => 'idling', :from => 'parked'
end
end

...
end

Notice in the above example, the state machine definition remains *exactly* the
same. However, when interacting with the records, the actual state will be
stored using the identifiers defined for the enumeration:

vehicle = Vehicle.create # => #<Vehicle id: 1, seatbelt_on: false, state_id: 1>
vehicle.ignite # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state_id: 2>
vehicle.shift_up # => true
vehicle # => #<Vehicle id: 1, seatbelt_on: true, state_id: 3>

This allows states to take on more complex functionality other than just being
a string value.

== Tools

Expand All @@ -130,3 +179,4 @@ To run against a specific version of Rails:
== References

* Scott Barron - acts_as_state_machine[http://elitists.textdriven.com/svn/plugins/acts_as_state_machine]
* acts_as_enumeration[http://github.com/pluginaweek/acts_as_enumeration]
69 changes: 21 additions & 48 deletions lib/state_machine.rb
@@ -1,9 +1,9 @@
require 'state_machine/machine'

module PluginAWeek #:nodoc:
# A state machine is a model of behavior composed of states, transitions,
# and events. This helper adds support for defining this type of
# functionality within your ActiveRecord models.
# A state machine is a model of behavior composed of states, events, and
# transitions. This helper adds support for defining this type of
# functionality within ActiveRecord models.
module StateMachine
def self.included(base) #:nodoc:
base.class_eval do
Expand All @@ -19,10 +19,10 @@ module MacroMethods
# * +initial+ - The initial value of the attribute. This can either be the actual value or a Proc for dynamic initial states.
#
# This also requires a block which will be used to actually configure the
# events and transitions for the state machine. *Note* that this block will
# be executed within the context of the state machine. As a result, you will
# not be able to access any class methods on the model unless you refer to
# them directly (i.e. specifying the class name).
# events and transitions for the state machine. *Note* that this block
# will be executed within the context of the state machine. As a result,
# you will not be able to access any class methods on the model unless you
# refer to them directly (i.e. specifying the class name).
#
# For examples on the types of configured state machines and blocks, see
# the section below.
Expand Down Expand Up @@ -61,7 +61,7 @@ module MacroMethods
# With a dynamic initial state:
#
# class Switch < ActiveRecord::Base
# state_machine :status, :initial => Proc.new {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
# state_machine :status, :initial => lambda {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
# ...
# end
# end
Expand All @@ -74,39 +74,28 @@ module MacroMethods
# == Defining callbacks
#
# Within the +state_machine+ block, you can also define callbacks for
# particular states. These states are enumerated in the PluginAWeek::StateMachine::Machine
# documentation. Below are examples of defining the various types of callbacks:
#
# class Switch < ActiveRecord::Base
# state_machine do
# before_exit :off, :alert_homeowner
# before_enter :on, Proc.new {|switch| ...}
# before_loopback :on, :display_warning
#
# after_exit :off, :on, :play_sound
# after_enter :off, :on, :play_sound
# after_loopback :on, Proc.new {|switch| ...}
#
# ...
# end
# particular states. For more information about defining these callbacks,
# see PluginAWeek::StateMachine::Machine#before_transition and
# PluginAWeek::StateMachine::Machine#after_transition.
def state_machine(*args, &block)
unless included_modules.include?(PluginAWeek::StateMachine::InstanceMethods)
write_inheritable_attribute :state_machines, {}
class_inheritable_reader :state_machines

after_create :run_initial_state_machine_actions

include PluginAWeek::StateMachine::InstanceMethods
end

options = args.extract_options!
attribute = args.any? ? args.first.to_s : 'state'
options[:initial] = state_machines[attribute].initial_state_without_processing if !options.include?(:initial) && state_machines[attribute]

# This will create a new machine for subclasses as well so that the owner_class and
# initial state can be overridden
machine = state_machines[attribute] = PluginAWeek::StateMachine::Machine.new(self, attribute, options)
# Creates the state machine for this class. If a superclass has already
# defined the machine, then a copy of it will be used with its context
# changed to this class. If no machine has been defined before for the
# attribute, a new one will be created.
original = state_machines[attribute]
machine = state_machines[attribute] = original ? original.within_context(self, options) : PluginAWeek::StateMachine::Machine.new(self, attribute, options)
machine.instance_eval(&block) if block

machine
end
end
Expand All @@ -122,31 +111,15 @@ def self.included(base) #:nodoc:
def initialize_with_state_machine(attributes = nil)
initialize_without_state_machine(attributes)

attribute_keys = (attributes || {}).keys.map!(&:to_s)

# Set the initial value of each state machine as long as the value wasn't
# included in the attribute hash passed in
# included in the initial attributes
attributes = (attributes || {}).stringify_keys
self.class.state_machines.each do |attribute, machine|
unless attribute_keys.include?(attribute)
send("#{attribute}=", machine.initial_state(self))
end
send("#{attribute}=", machine.initial_state(self)) unless attributes.include?(attribute)
end

yield self if block_given?
end

# Records the transition for the record going into its initial state
def run_initial_state_machine_actions
# Make sure that these initial actions are only invoked once
unless @processed_initial_state_machine_actions
@processed_initial_state_machine_actions = true

self.class.state_machines.each do |attribute, machine|
callback = "after_enter_#{attribute}_#{self[attribute]}"
run_callbacks(callback) if self[attribute] && self.class.respond_to?(callback)
end
end
end
end
end
end
Expand Down

0 comments on commit 6a94afc

Please sign in to comment.