lib/state_machine/machine.rb in state_machine-0.4.1 vs lib/state_machine/machine.rb in state_machine-0.4.2
- old
+ new
@@ -1,6 +1,7 @@
require 'state_machine/extensions'
+require 'state_machine/state'
require 'state_machine/event'
require 'state_machine/callback'
require 'state_machine/assertions'
# Load each available integration
@@ -8,11 +9,11 @@
require "state_machine/integrations/#{File.basename(path)}"
end
module StateMachine
# Represents a state machine for a particular attribute. State machines
- # consist of events and a set of transitions that define how the state
+ # consist of states, events and a set of transitions that define how the state
# changes after a particular event is fired.
#
# A state machine may not necessarily know all of the possible states for
# an object since they can be any arbitrary value. As a result, anything
# that relies on a list of all possible states should keep in mind that if
@@ -93,11 +94,11 @@
# end
# end
#
# vehicle = Vehicle.new
# vehicle.park # => false
- # vehicle.park! # => StateMachine::InvalidTransition: Cannot transition via :park from "idling"
+ # vehicle.park! # => StateMachine::InvalidTransition: Cannot transition state via :park from "idling"
#
# == Observers
#
# Observers, in the sense of external classes and *not* Ruby's Observable
# mechanism, can hook into state machines as well. Such observers use the
@@ -105,12 +106,12 @@
#
# Below are examples of defining observers for the following state machine:
#
# class Vehicle
# state_machine do
- # event :ignite do
- # transition :to => 'idling', :from => 'parked'
+ # event :park do
+ # transition :to => 'parked', :from => 'idling'
# end
# ...
# end
# ...
# end
@@ -181,36 +182,10 @@
# see how each integration affects the machine's behavior, refer to all
# constants defined under the StateMachine::Integrations namespace.
class Machine
include Assertions
- # The class that the machine is defined in
- attr_reader :owner_class
-
- # The attribute for which the machine is being defined
- attr_reader :attribute
-
- # The initial state that the machine will be in when an object is created
- attr_reader :initial_state
-
- # The events that trigger transitions
- attr_reader :events
-
- # A list of all of the states known to this state machine. This will pull
- # state names from the following sources:
- # * Initial state
- # * Event transitions (:to, :from, :except_to, and :except_from options)
- # * Transition callbacks (:to, :from, :except_to, and :except_from options)
- # * Unreferenced states (using +other_states+ helper)
- attr_reader :states
-
- # The callbacks to invoke before/after a transition is performed
- attr_reader :callbacks
-
- # The action to invoke when an object transitions
- attr_reader :action
-
class << self
# Attempts to find or create a state machine for the given class. For
# example,
#
# StateMachine::Machine.find_or_create(Switch)
@@ -261,28 +236,70 @@
class_name.split('::').each do |name|
klass = klass.const_defined?(name) ? klass.const_get(name) : klass.const_missing(name)
end
# Draw each of the class's state machines
- klass.state_machines.values.each do |machine|
+ klass.state_machines.each do |name, machine|
machine.draw(options)
end
end
end
end
+ # The class that the machine is defined in
+ attr_reader :owner_class
+
+ # The attribute for which the machine is being defined
+ attr_reader :attribute
+
+ # The initial state that the machine will be in when an object is created
+ attr_reader :initial_state
+
+ # The events that trigger transitions
+ #
+ # Maps "name" => StateMachine::Event
+ attr_reader :events
+
+ # Tracks the order in which events were defined. This is used to determine
+ # in what order events are drawn on GraphViz visualizations.
+ attr_reader :events_order
+
+ # A list of all of the states known to this state machine. This will pull
+ # state values from the following sources:
+ # * Initial state
+ # * State behaviors
+ # * Event transitions (:to, :from, :except_to, and :except_from options)
+ # * Transition callbacks (:to, :from, :except_to, and :except_from options)
+ # * Unreferenced states (using +other_states+ helper)
+ #
+ # Maps value => StateMachine::State
+ attr_reader :states
+
+ # The callbacks to invoke before/after a transition is performed
+ attr_reader :callbacks
+
+ # The action to invoke when an object transitions
+ attr_reader :action
+
+ # An identifier that forces all methods (including state predicates and
+ # event methods) to be generated with the value prefixed or suffixed,
+ # depending on the context.
+ attr_reader :namespace
+
# Creates a new state machine for the given attribute
def initialize(owner_class, *args, &block)
options = args.last.is_a?(Hash) ? args.pop : {}
- assert_valid_keys(options, :initial, :action, :plural, :integration)
+ assert_valid_keys(options, :initial, :action, :plural, :namespace, :integration)
# Set machine configuration
@attribute = (args.first || 'state').to_s
@events = {}
- @states = []
+ @events_order = []
+ @states = {}
@callbacks = {:before => [], :after => []}
@action = options[:action]
+ @namespace = options[:namespace]
# Add class-/instance-level methods to the owner class for state initialization
owner_class.class_eval do
extend StateMachine::ClassMethods
include StateMachine::InstanceMethods
@@ -313,11 +330,18 @@
event = event.dup
event.machine = self
events[name] = event
events
end
- @states = @states.dup
+ @events_order = @events_order.dup
+ @states = @states.inject({}) do |states, (value, state)|
+ state = state.dup
+ state.machine = self
+ states[value] = state
+ states
+ end
+ @initial_state = @states[@initial_state.value]
@callbacks = {:before => @callbacks[:before].dup, :after => @callbacks[:after].dup}
end
# Creates a copy of this machine within the context of the given class.
# This should be used for inheritance support of state machines.
@@ -338,12 +362,12 @@
# creation.
def set_context(owner_class, options = {}) #:nodoc:
assert_valid_keys(options, :initial, :integration)
@owner_class = owner_class
- @initial_state = options[:initial] if options[:initial]
- add_states([@initial_state])
+ @initial_state = add_states([options[:initial]]).first if options.include?(:initial) || !@initial_state
+ states.each {|name, state| state.initial = (state == @initial_state)}
# Find an integration that can be used for implementing various parts
# of the state machine that may behave differently in different libraries
if @integration = options[:integration] || StateMachine::Integrations.constants.find {|name| StateMachine::Integrations.const_get(name).matches?(owner_class)}
extend StateMachine::Integrations.const_get(@integration.to_s.gsub(/(?:^|_)(.)/) {$1.upcase})
@@ -389,13 +413,105 @@
# Vehicle.state_machines['state'].initial_state(vehicle) # => "idling"
#
# vehicle.force_idle = false
# Vehicle.state_machines['state'].initial_state(vehicle) # => "parked"
def initial_state(object)
- @initial_state.is_a?(Proc) ? @initial_state.call(object) : @initial_state
+ @initial_state && @initial_state.value(object)
end
+ # Defines a series of behaviors to mixin with objects when the current
+ # state matches the given one(s). This allows instance methods to behave
+ # a specific way depending on what the value of the object's state is.
+ #
+ # For example,
+ #
+ # class Vehicle
+ # attr_accessor :driver
+ # attr_accessor :passenger
+ #
+ # state_machine :initial => 'parked' do
+ # event :ignite do
+ # transition :to => 'idling', :from => 'parked'
+ # end
+ #
+ # state 'parked' do
+ # def speed
+ # 0
+ # end
+ #
+ # def rotate_driver
+ # driver = self.driver
+ # self.driver = passenger
+ # self.passenger = driver
+ # true
+ # end
+ # end
+ #
+ # state 'idling', 'first_gear' do
+ # def speed
+ # 20
+ # end
+ #
+ # def rotate_driver
+ # self.state = "parked"
+ # rotate_driver
+ # end
+ # end
+ # end
+ # end
+ #
+ # In the above example, there are two dynamic behaviors defined for the
+ # class:
+ # * +speed+
+ # * +rotate_driver+
+ #
+ # Each of these behaviors are instance methods on the Vehicle class. However,
+ # which method actually gets invoked is based on the current state of the
+ # object. Using the above class as the example:
+ #
+ # vehicle = Vehicle.new
+ # vehicle.driver = 'John'
+ # vehicle.passenger = 'Jane'
+ #
+ # # Behaviors in the "parked" state
+ # vehicle.state # => "parked"
+ # vehicle.speed # => 0
+ # vehicle.rotate_driver # => true
+ # vehicle.driver # => "Jane"
+ # vehicle.passenger # => "John"
+ #
+ # vehicle.ignite # => true
+ #
+ # # Behaviors in the "idling" state
+ # vehicle.state # => "idling"
+ # vehicle.speed # => 20
+ # vehicle.rotate_driver # => true
+ # vehicle.driver # => "John"
+ # vehicle.passenger # => "Jane"
+ # vehicle.state # => "parked"
+ #
+ # As can be seen, both the +speed+ and +rotate_driver+ instance method
+ # implementations changed how they behave based on what the current state
+ # of the vehicle was.
+ #
+ # == Invalid behaviors
+ #
+ # If a specific behavior has not been defined for a state, then a
+ # NoMethodError exception will be raised, indicating that that method would
+ # not normally exist for an object with that state.
+ #
+ # Using the example from before:
+ #
+ # vehicle = Vehicle.new
+ # vehicle.state = "backing_up"
+ # vehicle.speed # => NoMethodError: undefined method 'speed' for #<Vehicle:0xb7d296ac> in state "backing_up"
+ def state(*values, &block)
+ states = add_states(values)
+ states.each {|state| state.context(&block)} if block_given?
+ states.length == 1 ? states.first : states
+ end
+
# Defines additional states that are possible in the state machine, but
# which are derived outside of any events/transitions or possibly
# dynamically via a lambda block. This allows the given states to be:
# * Queried via instance-level predicates
# * Included in GraphViz visualizations
@@ -427,28 +543,54 @@
# callbacks, they are explicitly defined.
def other_states(*args)
add_states(args.flatten)
end
- # Defines an event for the machine.
+ # Gets the order in which states should be displayed based on where they
+ # were first referenced. This will order states in the following priority:
#
+ # 1. Initial state
+ # 2. Event transitions (:to, :from, :except_to, :except_from options)
+ # 3. States with behaviors
+ # 4. States referenced via +other_states+
+ # 5. States referenced in callbacks
+ #
+ # This order will determine how the GraphViz visualizations are rendered.
+ def states_order
+ order = [initial_state(nil)]
+
+ events.each {|name, event| order |= event.known_states}
+ order |= states.select {|value, state| state.methods.any?}.map {|state| state.first}
+ order |= states.keys - callbacks.values.flatten.map {|callback| callback.known_states}.flatten
+ order |= states.keys
+ end
+
+ # Defines one or more events for the machine and the transitions that can
+ # be performed when those events are run.
+ #
# == Instance methods
#
# The following instance methods are generated when a new event is defined
# (the "park" event is used as an example):
# * <tt>can_park?</tt> - Checks whether the "park" event can be fired given the current state of the object.
# * <tt>next_park_transition</tt> - Gets the next transition that would be performed if the "park" event were to be fired now on the object or nil if no transitions can be performed.
# * <tt>park(run_action = true)</tt> - Fires the "park" event, transitioning from the current state to the next valid state.
# * <tt>park!(run_action = true)</tt> - Fires the "park" event, transitioning from the current state to the next valid state. If the transition fails, then a StateMachine::InvalidTransition error will be raised.
#
+ # With a namespace of "car", the above names map to the following methods:
+ # * <tt>can_park_car?</tt>
+ # * <tt>next_park_car_transition</tt>
+ # * <tt>park_car</tt>
+ # * <tt>park_car!</tt>
+ #
# == Defining transitions
#
# +event+ requires a block which allows you to define the possible
# transitions that can happen as a result of that event. For example,
#
- # event :park do
- # transition :to => 'parked', :from => 'idle'
+ # event :park, :stop do
+ # transition :to => 'parked', :from => 'idling'
# end
#
# event :first_gear do
# transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on?
# end
@@ -474,23 +616,39 @@
#
# == Example
#
# class Vehicle
# state_machine do
- # event :park do
- # transition :to => 'parked', :from => %w(first_gear reverse)
+ # # The park, stop, and halt events will all share the given transitions
+ # event :park, :stop, :halt do
+ # transition :to => 'parked', :from => %w(idling backing_up)
# end
- # ...
+ #
+ # event :stop do
+ # transition :to => 'idling', :from => 'first_gear'
+ # end
+ #
+ # event :ignite do
+ # transition :to => 'idling', :from => 'parked'
+ # end
# end
# end
- def event(name, &block)
- name = name.to_s
- event = events[name] ||= Event.new(self, name)
- event.instance_eval(&block)
- add_states(event.known_states)
+ def event(*names, &block)
+ events = names.collect do |name|
+ name = name.to_s
+ event = self.events[name] ||= Event.new(self, name)
+ @events_order << name unless @events_order.include?(name)
+
+ if block_given?
+ event.instance_eval(&block)
+ add_states(event.known_states)
+ end
+
+ event
+ end
- event
+ events.length == 1 ? events.first : events
end
# Creates a callback that will be invoked *before* a transition is
# performed so long as the given configuration options match the transition.
# Each part of the transition (event, to state, from state) must match in
@@ -700,60 +858,25 @@
require 'rubygems'
require 'graphviz'
graph = GraphViz.new('G', :output => options[:format], :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}"))
- # Tracks unique identifiers for dynamic states (via lambda blocks)
- dynamic_states = {}
-
# Add nodes
- states.each do |state|
- shape = state == @initial_state ? 'doublecircle' : 'circle'
-
- # Use GraphViz-friendly name/label for dynamic/nil states
- if state.is_a?(Proc)
- name = "lambda#{dynamic_states.keys.length}"
- label = '*'
- dynamic_states[state] = name
- else
- name = label = state.nil? ? 'nil' : state.to_s
- end
-
- graph.add_node(name, :label => label, :width => '1', :height => '1', :fixedsize => 'true', :shape => shape, :fontname => options[:font])
+ Array(state(*states_order)).each do |state|
+ node = state.draw(graph)
+ node.fontname = options[:font]
end
# Add edges
- events.values.each do |event|
- event.guards.each do |guard|
- # From states: :from, everything but :except states, or all states
- from_states = guard.requirements[:from] || guard.requirements[:except_from] && (states - guard.requirements[:except_from]) || states
- if to_state = guard.requirements[:to]
- to_state = to_state.first
-
- # Convert to GraphViz-friendly name
- to_state = case to_state
- when Proc; dynamic_states[to_state]
- when nil; 'nil'
- else; to_state.to_s; end
- end
-
- from_states.each do |from_state|
- # Convert to GraphViz-friendly name
- from_state = case from_state
- when Proc; dynamic_states[from_state]
- when nil; 'nil'
- else; from_state.to_s; end
-
- graph.add_edge(from_state, to_state || from_state, :label => event.name, :fontname => options[:font])
- end
- end
+ Array(event(*events_order)).each do |event|
+ edges = event.draw(graph)
+ edges.each {|edge| edge.fontname = options[:font]}
end
# Generate the graph
graph.output
-
- true
+ graph
rescue LoadError
$stderr.puts 'Cannot draw the machine. `gem install ruby-graphviz` and try again.'
false
end
end
@@ -823,25 +946,9 @@
end
# Tracks the given set of states in the list of all known states for
# this machine
def add_states(states)
- new_states = states - @states
- @states += new_states
-
- # Add state predicates
- attribute = self.attribute
- new_states.each do |state|
- if state && (state.is_a?(String) || state.is_a?(Symbol))
- name = "#{state}?"
-
- owner_class.class_eval do
- # Checks whether the current state is equal to the given value
- define_method(name) do
- self.send(attribute) == state
- end unless method_defined?(name) || private_method_defined?(name)
- end
- end
- end
+ states.collect {|state| @states[state] ||= State.new(self, state)}
end
end
end