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