lib/state_machine/machine.rb in state_machine-0.3.1 vs lib/state_machine/machine.rb in state_machine-0.4.0

- old
+ new

@@ -1,412 +1,806 @@ +require 'state_machine/extensions' require 'state_machine/event' +require 'state_machine/callback' +require 'state_machine/assertions' -module PluginAWeek #:nodoc: - module StateMachine - # Represents a state machine for a particular attribute. State machines - # consist of events (a.k.a. actions) and a set of transitions that define - # how the state changes after a particular event is fired. +# Load each available integration +Dir["#{File.dirname(__FILE__)}/integrations/*.rb"].sort.each do |path| + 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 + # 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 + # a state has not been referenced *anywhere* in the state machine definition, + # then it will *not* be a known state unless the +other_states+ is used. + # + # == State values + # + # While string are the most common object type used for setting values on + # the state of the machine, there are no restrictions on what can be used. + # This means that symbols, integers, dates/times, etc. can all be used. + # + # With string states: + # + # class Vehicle + # state_machine :initial => 'parked' do + # event :ignite do + # transition :to => 'idling', :from => 'parked' + # end + # end + # end + # + # With symbolic states: + # + # class Vehicle + # state_machine :initial => :parked do + # event :ignite do + # transition :to => :idling, :from => :parked + # end + # end + # end + # + # With time states: + # + # class Switch + # state_machine :activated_at + # event :activate do + # transition :to => lambda {Time.now} + # end + # + # event :deactivate do + # transition :to => nil + # end + # end + # end + # + # == Callbacks + # + # Callbacks are supported for hooking before and after every possible + # transition in the machine. Each callback is invoked in the order in which + # it was defined. See StateMachine::Machine#before_transition + # and StateMachine::Machine#after_transition for documentation + # on how to define new callbacks. + # + # === Canceling callbacks + # + # Callbacks can be canceled by throwing :halt at any point during the + # callback. For example, + # + # ... + # throw :halt + # ... + # + # If a +before+ callback halts the chain, the associated transition and all + # later callbacks are canceled. If an +after+ callback halts the chain, + # the later callbacks are canceled, but the transition is still successful. + # + # *Note* that if a +before+ callback fails and the bang version of an event + # was invoked, an exception will be raised instead of returning false. For + # example, + # + # class Vehicle + # state_machine, :initial => 'parked' do + # before_transition :to => 'idling', :do => lambda {|vehicle| throw :halt} + # ... + # end + # end + # + # vehicle = Vehicle.new + # vehicle.park # => false + # vehicle.park! # => StateMachine::InvalidTransition: Cannot transition 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 + # same callback api that's used internally. + # + # Below are examples of defining observers for the following state machine: + # + # class Vehicle + # state_machine do + # event :ignite do + # transition :to => 'idling', :from => 'parked' + # end + # ... + # end + # ... + # end + # + # Event/Transition behaviors: + # + # class VehicleObserver + # def self.before_park(vehicle, transition) + # logger.info "#{vehicle} instructed to park... state is: #{transition.from}, state will be: #{transition.to}" + # end + # + # def self.after_park(vehicle, transition, result) + # logger.info "#{vehicle} instructed to park... state was: #{transition.from}, state is: #{transition.to}" + # end + # + # def self.before_transition(vehicle, transition) + # logger.info "#{vehicle} instructed to #{transition.event}... #{transition.attribute} is: #{transition.from}, #{transition.attribute} will be: #{transition.to}" + # end + # + # def self.after_transition(vehicle, transition, result) + # logger.info "#{vehicle} instructed to #{transition.event}... #{transition.attribute} was: #{transition.from}, #{transition.attribute} is: #{transition.to}" + # end + # end + # + # Vehicle.state_machine do + # before_transition :on => :park, :do => VehicleObserver.method(:before_park) + # before_transition VehicleObserver.method(:before_transition) + # + # after_transition :on => :park, :do => VehicleObserver.method(:after_park) + # after_transition VehicleObserver.method(:after_transition) + # end + # + # One common callback is to record transitions for all models in the system + # for auditing/debugging purposes. Below is an example of an observer that + # can easily automate this process for all models: + # + # class StateMachineObserver + # def self.before_transition(object, transition) + # Audit.log_transition(object.attributes) + # end + # end + # + # [Vehicle, Switch, Project].each do |klass| + # klass.state_machines.each do |machine| + # machine.before_transition klass.method(:before_transition) + # end + # end + # + # Additional observer-like behavior may be exposed by the various + # integrations available. See below for more information. + # + # == Integrations + # + # By default, state machines are library-agnostic, meaning that they work + # on any Ruby class and have no external dependencies. However, there are + # certain libraries which expose additional behavior that can be taken + # advantage of by state machines. + # + # This library is built to work out of the box with a few popular Ruby + # libraries that allow for additional behavior to provide a cleaner and + # smoother experience. This is especially the case for objects backed by a + # database that may allow for transactions, persistent storage, + # search/filters, callbacks, etc. + # + # When a state machine is defined for classes using any of the above libraries, + # it will try to automatically determine the integration to use (Agnostic, + # ActiveRecord, DataMapper, or Sequel) based on the class definition. To + # 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) + # StateMachine::Machine.find_or_create(Switch, :initial => 'off') + # StateMachine::Machine.find_or_create(Switch, 'status') + # StateMachine::Machine.find_or_create(Switch, 'status', :initial => 'off') + # + # If a machine of the given name already exists in one of the class's + # superclasses, then a copy of that machine will be created and stored + # in the new owner class (the original will remain unchanged). + def find_or_create(owner_class, *args) + options = args.last.is_a?(Hash) ? args.pop : {} + attribute = args.any? ? args.first.to_s : 'state' + + # Attempts to find an existing machine + if owner_class.respond_to?(:state_machines) && machine = owner_class.state_machines[attribute] + machine = machine.within_context(owner_class, options) unless machine.owner_class == owner_class + else + # No existing machine: create a new one + machine = new(owner_class, attribute, options) + end + + machine + end + + # Draws the state machines defined in the given classes using GraphViz. + # The given classes must be a comma-delimited string of class names. + # + # Configuration options: + # * +file+ - A comma-delimited string of files to load that contain the state machine definitions to draw + # * +path+ - The path to write the graph file to + # * +format+ - The image format to generate the graph in + # * +font+ - The name of the font to draw state names in + def draw(class_names, options = {}) + raise ArgumentError, 'At least one class must be specified' unless class_names && class_names.split(',').any? + + # Load any files + if files = options.delete(:file) + files.split(',').each {|file| require file} + end + + class_names.split(',').each do |class_name| + # Navigate through the namespace structure to get to the class + klass = Object + 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| + machine.draw(options) + end + end + end + end + + # 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) + + # Set machine configuration + @attribute = (args.first || 'state').to_s + @events = {} + @states = [] + @callbacks = {:before => [], :after => []} + @action = options[:action] + + # Add class-/instance-level methods to the owner class for state initialization + owner_class.class_eval do + extend StateMachine::ClassMethods + include StateMachine::InstanceMethods + end unless owner_class.included_modules.include?(StateMachine::InstanceMethods) + + # Initialize the context of the machine + set_context(owner_class, :initial => options[:initial], :integration => options[:integration], &block) + + # Set integration-specific configurations + @action ||= default_action unless options.include?(:action) + define_attribute_accessor + define_scopes(options[:plural]) + + # Call after hook for integration-specific extensions + after_initialize + end + + # Creates a copy of this machine in addition to copies of each associated + # event, so that the list of transitions for each event don't conflict + # with different machines + def initialize_copy(orig) #:nodoc: + super + + @events = @events.inject({}) do |events, (name, event)| + event = event.dup + event.machine = self + events[name] = event + events + end + @states = @states.dup + @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. + def within_context(owner_class, options = {}) #:nodoc: + machine = dup + machine.set_context(owner_class, {:integration => @integration}.merge(options)) + machine + end + + # Changes the context of this machine to the given class so that new + # events and transitions are created in the proper context. # - # 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 - # a state has not been referenced *anywhere* in the state machine definition, - # then it will *not* be a known state. + # Configuration options: + # * +initial+ - The initial value to set the attribute to + # * +integration+ - The name of the integration for extending this machine with library-specific behavior # - # == Callbacks + # All other configuration options for the machine can only be set on + # creation. + def set_context(owner_class, options = {}) #:nodoc: + assert_valid_keys(options, :initial, :integration) + + @owner_class = owner_class + if options[:initial] + @initial_state = options[:initial] + add_states([@initial_state]) unless @initial_state.is_a?(Proc) + end + + # 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}) + end + + # Record this machine as matched to the attribute in the current owner + # class. This will override any machines mapped to the same attribute + # in any superclasses. + owner_class.state_machines[attribute] = self + end + + # Gets the initial state of the machine for the given object. If a dynamic + # initial state was configured for this machine, then the object will be + # passed into the proc to help determine the actual value of the initial + # state. # - # Callbacks are supported for hooking before and after every possible - # transition in the machine. Each callback is invoked in the order in which - # it was defined. See PluginAWeek::StateMachine::Machine#before_transition - # and PluginAWeek::StateMachine::Machine#after_transition for documentation - # on how to define new callbacks. + # == Examples # - # === Cancelling callbacks + # With static initial state: # - # If a +before+ callback returns +false+, all the later callbacks and - # associated transition are cancelled. If an +after+ callback returns false, - # the later callbacks are cancelled, but the transition is still successful. - # This is the same behavior as exposed by ActiveRecord's callback support. + # class Vehicle + # state_machine :initial => 'parked' do + # ... + # end + # end + # + # Vehicle.state_machines['state'].initial_state(vehicle) # => "parked" # - # *Note* that if a +before+ callback fails and the bang version of an event - # was invoked, an exception will be raised instead of returning false. + # With dynamic initial state: # - # == Observers + # class Vehicle + # state_machine :initial => lambda {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do + # ... + # end + # end + # + # Vehicle.state_machines['state'].initial_state(vehicle) # => "idling" + def initial_state(object) + @initial_state.is_a?(Proc) ? @initial_state.call(object) : @initial_state + end + + # Defines additional states that are possible in the state machine, but + # which are derived outside of any events/transitions or possibly + # dynamically via Proc. This allows the creation of state conditionals + # which are not defined in the standard :to or :from structure. # - # ActiveRecord observers can also hook into state machines in addition to - # the conventional before_save, after_save, etc. behaviors. The following - # types of behaviors can be observed: - # * events (e.g. before_park/after_park, before_ignite/after_ignite) - # * transitions (before_transition/after_transition) + # == Example # - # Each method takes a set of parameters that provides additional information - # about the transition that caused the observer to be notified. Below are - # examples of defining observers for the following state machine: + # class Vehicle + # state_machine :initial => 'parked' do + # event :ignite do + # transition :to => 'idling', :from => 'parked' + # end + # + # other_states %w(stalled stopped) + # end + # + # def stop + # self.state = 'stopped' + # end + # end # - # class Vehicle < ActiveRecord::Base + # In the above state machine, the known states would be: + # * +idling+ + # * +parked+ + # * +stalled+ + # * +stopped+ + # + # Since +stalled+ and +stopped+ are not referenced in any transitions or + # callbacks, they are explicitly defined. + def other_states(*args) + add_states(args.flatten) + end + + # Defines an event for the machine. + # + # == 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. + # + # == 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' + # end + # + # event :first_gear do + # transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on? + # end + # + # See StateMachine::Event#transition for more information on + # the possible options that can be passed in. + # + # *Note* that this block is executed within the context of the actual event + # object. As a result, you will not be able to reference any class methods + # on the model without referencing the class itself. For example, + # + # class Vehicle + # def self.safe_states + # %w(parked idling stalled) + # end + # # state_machine do # event :park do - # transition :to => 'parked', :from => 'idling' + # transition :to => 'parked', :from => Car.safe_states # end + # end + # end + # + # == Example + # + # class Vehicle + # state_machine do + # event :park do + # transition :to => 'parked', :from => %w(first_gear reverse) + # 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) + + event + 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 + # order for the callback to get invoked. # - # Event behaviors: + # Configuration options: + # * +to+ - One or more states being transitioned to. If none are specified, then all states will match. + # * +from+ - One or more states being transitioned from. If none are specified, then all states will match. + # * +on+ - One or more events that fired the transition. If none are specified, then all events will match. + # * +except_to+ - One more states *not* being transitioned to + # * +except_from+ - One or more states *not* being transitioned from + # * +except_on+ - One or more events that *did not* fire the transition + # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string. + # * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value. + # * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value. # - # class VehicleObserver < ActiveRecord::Observer - # def before_park(vehicle, from_state, to_state) - # logger.info "Vehicle #{vehicle.id} instructed to park... state is: #{from_state}, state will be: #{to_state}" + # The +except+ group of options (+except_to+, +exception_from+, and + # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+, + # +from+, and +on+, respectively) + # + # == The callback + # + # When defining additional configuration options, callbacks must be defined + # in either the :do option or as a block. For example, + # + # class Vehicle + # state_machine do + # before_transition :to => 'parked', :do => :set_alarm + # before_transition :to => 'parked' do |vehicle, transition| + # vehicle.set_alarm + # end + # ... # end + # end + # + # === Accessing the transition + # + # In addition to passing the object being transitioned, the actual + # transition describing the context (e.g. event, from state, to state) + # can be accessed as well. This additional argument is only passed if the + # callback allows for it. + # + # For example, + # + # class Vehicle + # # Only specifies one parameter (the object being transitioned) + # before_transition :to => 'parked', :do => lambda {|vehicle| vehicle.set_alarm} # - # def after_park(vehicle, from_state, to_state) - # logger.info "Vehicle #{vehicle.id} instructed to park... state was: #{from_state}, state is: #{to_state}" + # # Specifies 2 parameters (object being transitioned and actual transition) + # before_transition :to => 'parked', :do => lambda {|vehicle, transition| vehicle.set_alarm(transition)} + # end + # + # *Note* that the object in the callback will only be passed in as an + # argument if callbacks are configured to *not* be bound to the object + # involved. This is the default and may change on a per-integration basis. + # + # See StateMachine::Transition for more information about the + # attributes available on the transition. + # + # == Examples + # + # Below is an example of a class with one state machine and various types + # of +before+ transitions defined for it: + # + # class Vehicle + # state_machine do + # # Before all transitions + # before_transition :update_dashboard + # + # # Before specific transition: + # before_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt + # + # # With conditional callback: + # before_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on? + # + # # Using :except counterparts: + # before_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard + # ... # end # end # - # Transition behaviors: + # As can be seen, any number of transitions can be created using various + # combinations of configuration options. + def before_transition(options = {}, &block) + add_callback(:before, options.is_a?(Hash) ? options : {:do => options}, &block) + end + + # Creates a callback that will be invoked *after* 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 order for the callback to get invoked. # - # class VehicleObserver < ActiveRecord::Observer - # def before_transition(vehicle, attribute, event, from_state, to_state) - # logger.info "Vehicle #{vehicle.id} instructed to #{event}... #{attribute} is: #{from_state}, #{attribute} will be: #{to_state}" + # Configuration options: + # * +to+ - One or more states being transitioned to. If none are specified, then all states will match. + # * +from+ - One or more states being transitioned from. If none are specified, then all states will match. + # * +on+ - One or more events that fired the transition. If none are specified, then all events will match. + # * +except_to+ - One more states *not* being transitioned to + # * +except_from+ - One or more states *not* being transitioned from + # * +except_on+ - One or more events that *did not* fire the transition + # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string. + # * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value. + # * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value. + # + # The +except+ group of options (+except_to+, +exception_from+, and + # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+, + # +from+, and +on+, respectively) + # + # == The callback + # + # When defining additional configuration options, callbacks must be defined + # in either the :do option or as a block. For example, + # + # class Vehicle + # state_machine do + # after_transition :to => 'parked', :do => :set_alarm + # after_transition :to => 'parked' do |vehicle, transition, result| + # vehicle.set_alarm + # end + # ... # end - # - # def after_transition(vehicle, attribute, event, from_state, to_state) - # logger.info "Vehicle #{vehicle.id} instructed to #{event}... #{attribute} was: #{from_state}, #{attribute} is: #{to_state}" - # end # end # - # One common callback is to record transitions for all models in the system - # for audit/debugging purposes. Below is an example of an observer that can - # easily automate this process for all models: + # === Accessing the transition / result # - # class StateMachineObserver < ActiveRecord::Observer - # observe Vehicle, Switch, AutoShop + # In addition to passing the object being transitioned, the actual + # transition describing the context (e.g. event, from state, to state) and + # the result from calling the object's action can be optionally passed as + # well. These additional arguments are only passed if the callback allows + # for it. + # + # For example, + # + # class Vehicle + # # Only specifies one parameter (the object being transitioned) + # after_transition :to => 'parked', :do => lambda {|vehicle| vehicle.set_alarm} # - # def before_transition(record, attribute, event, from_state, to_state) - # transition = StateTransition.build(:record => record, :attribute => attribute, :event => event, :from_state => from_state, :to_state => to_state) - # transition.save # Will cancel rollback/cancel transition if this fails + # # Specifies 3 parameters (object being transitioned, transition, and action result) + # after_transition :to => 'parked', :do => lambda {|vehicle, transition, result| vehicle.set_alarm(transition) if result} + # end + # + # *Note* that the object in the callback will only be passed in as an + # argument if callbacks are configured to *not* be bound to the object + # involved. This is the default and may change on a per-integration basis. + # + # See StateMachine::Transition for more information about the + # attributes available on the transition. + # + # == Examples + # + # Below is an example of a model with one state machine and various types + # of +after+ transitions defined for it: + # + # class Vehicle + # state_machine do + # # After all transitions + # after_transition :update_dashboard + # + # # After specific transition: + # after_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt + # + # # With conditional callback: + # after_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on? + # + # # Using :except counterparts: + # after_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard + # ... # end # end - class Machine - # The class that the machine is defined for - attr_reader :owner_class + # + # As can be seen, any number of transitions can be created using various + # combinations of configuration options. + def after_transition(options = {}, &block) + add_callback(:after, options.is_a?(Hash) ? options : {:do => options}, &block) + end + + # Runs a transaction, rolling back any changes if the yielded block fails. + # + # This is only applicable to integrations that involve databases. By + # default, this will not run any transactions, since the changes aren't + # taking place within the context of a database. + def within_transaction(object) + yield + end + + # Draws a directed graph of the machine for visualizing the various events, + # states, and their transitions. + # + # This requires both the Ruby graphviz gem and the graphviz library be + # installed on the system. + # + # Configuration options: + # * +name+ - The name of the file to write to (without the file extension). Default is "#{owner_class.name}_#{attribute}" + # * +path+ - The path to write the graph file to. Default is the current directory ("."). + # * +format+ - The image format to generate the graph in. Default is "png'. + # * +font+ - The name of the font to draw state names in. Default is "Arial'. + def draw(options = {}) + options = { + :name => "#{owner_class.name}_#{attribute}", + :path => '.', + :format => 'png', + :font => 'Arial' + }.merge(options) + assert_valid_keys(options, :name, :font, :path, :format) - # The attribute for which the state machine is being defined - attr_reader :attribute - - # The initial state that the machine will be in when a record is created - attr_reader :initial_state - - # A list of the states defined in the transitions of all of the events - attr_reader :states - - # The events that trigger transitions - attr_reader :events - - # Creates a new state machine for the given attribute - # - # Configuration options: - # * +initial+ - The initial value to set the attribute to. This can be an actual value or a proc, which will be evaluated at runtime. - # - # == Scopes - # - # This will automatically create a named scope called with_#{attribute} - # that will find all records that have the attribute set to a given value. - # For example, - # - # Switch.with_state('on') # => Finds all switches where the state is on - # Switch.with_states('on', 'off') # => Finds all switches where the state is either on or off - # - # *Note* that if class methods already exist with those names (i.e. "with_state" - # or "with_states"), then a scope will not be defined for that name. - def initialize(owner_class, attribute = 'state', options = {}) - set_context(owner_class, options) + begin + # Load the graphviz library + require 'rubygems' + require 'graphviz' - @attribute = attribute.to_s - @states = [] - @events = {} + graph = GraphViz.new('G', :output => options[:format], :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}")) - add_transition_callbacks - add_named_scopes - end - - # Creates a copy of this machine in addition to copies of each associated - # event, so that the list of transitions for each event don't conflict - # with different machines - def initialize_copy(orig) #:nodoc: - super + # Add nodes + states.each do |state| + shape = state == @initial_state ? 'doublecircle' : 'circle' + state = state.is_a?(Proc) ? 'lambda' : state.to_s + graph.add_node(state, :width => '1', :height => '1', :fixedsize => 'true', :shape => shape, :fontname => options[:font]) + end - @states = @states.dup - @events = @events.inject({}) do |events, (name, event)| - event = event.dup - event.machine = self - events[name] = event - events + # Add edges + events.values.each do |event| + event.guards.each do |guard| + # From states: :from, everything but :except states, or all states + from_states = Array(guard.requirements[:from]) || guard.requirements[:except_from] && (states - Array(guard.requirements[:except_from])) || states + to_state = guard.requirements[:to] + to_state = to_state.is_a?(Proc) ? 'lambda' : to_state.to_s if to_state + + from_states.each do |from_state| + from_state = from_state.to_s + graph.add_edge(from_state, to_state || from_state, :label => event.name, :fontname => options[:font]) + end + end end + + # Generate the graph + graph.output + + true + rescue LoadError + $stderr.puts 'Cannot draw the machine. `gem install ruby-graphviz` and try again.' + false end + end + + protected + # Runs additional initialization hooks. By default, this is a no-op. + def after_initialize + end - # Creates a copy of this machine within the context of the given class. - # This should be used for inheritance support of state machines. - def within_context(owner_class, options = {}) #:nodoc: - machine = dup - machine.set_context(owner_class, options) - machine + # Gets the default action that should be invoked when performing a + # transition on the attribute for this machine. This may change + # depending on the configured integration for the owner class. + def default_action end - # Changes the context of this machine to the given class so that new - # events and transitions are created in the proper context. - def set_context(owner_class, options = {}) #:nodoc: - options.assert_valid_keys(:initial) + # Adds reader/writer methods for accessing the attribute that this state + # machine is defined for. + def define_attribute_accessor + attribute = self.attribute - @owner_class = owner_class - @initial_state = options[:initial] if options[:initial] + owner_class.class_eval do + attr_reader attribute unless method_defined?(attribute) || private_method_defined?(attribute) + attr_writer attribute unless method_defined?("#{attribute}=") || private_method_defined?("#{attribute}=") + + # Checks whether the current state is a given value. If the value + # is not a known state, then an ArgumentError is raised. + define_method("#{attribute}?") do |state| + raise ArgumentError, "#{state.inspect} is not a known #{attribute} value" unless self.class.state_machines[attribute].states.include?(state) + send(attribute) == state + end unless method_defined?("#{attribute}?") || private_method_defined?("#{attribute}?") + end end - # Gets the initial state of the machine for the given record. If a record - # is specified a and a dynamic initial state was configured for the machine, - # then that record will be passed into the proc to help determine the actual - # value of the initial state. - # - # == Examples - # - # With normal initial state: - # - # class Vehicle < ActiveRecord::Base - # state_machine :initial => 'parked' do - # ... - # end - # end - # - # Vehicle.state_machines['state'].initial_state(@vehicle) # => "parked" - # - # With dynamic initial state: - # - # class Vehicle < ActiveRecord::Base - # state_machine :initial => lambda {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do - # ... - # end - # end - # - # Vehicle.state_machines['state'].initial_state(@vehicle) # => "idling" - def initial_state(record) - @initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state - end - - # Defines an event of the system - # - # == Instance methods - # - # The following instance methods are generated when a new event is defined - # (the "park" event is used as an example): - # * <tt>park</tt> - Fires the "park" event, transitioning from the current state to the next valid state. - # * <tt>park!</tt> - Fires the "park" event, transitioning from the current state to the next valid state. If the transition cannot happen (for validation, database, etc. reasons), then an error will be raised. - # * <tt>can_park?</tt> - Checks whether the "park" event can be fired given the current state of the record. - # - # == 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' - # end - # - # event :first_gear do - # transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on? - # end - # - # See PluginAWeek::StateMachine::Event#transition for more information on - # the possible options that can be passed in. - # - # *Note* that this block is executed within the context of the actual event - # object. As a result, you will not be able to reference any class methods - # on the model without referencing the class itself. For example, - # - # class Car < ActiveRecord::Base - # def self.safe_states - # %w(parked idling stalled) - # end - # - # state_machine :state do - # event :park do - # transition :to => 'parked', :from => Car.safe_states - # end - # end - # end - # - # == Example - # - # class Car < ActiveRecord::Base - # state_machine(:state, :initial => 'parked') do - # event :park, :after => :release_seatbelt do - # transition :to => 'parked', :from => %w(first_gear reverse) - # end - # ... - # end - # end - def event(name, &block) - name = name.to_s - event = events[name] ||= Event.new(self, name) - event.instance_eval(&block) + # Defines the with/without scope helpers for this attribute. Both the + # singular and plural versions of the attribute are defined for each + # scope helper. A custom plural can be specified if it cannot be + # automatically determined by either calling +pluralize+ on the attribute + # name or adding an "s" to the end of the name. + def define_scopes(custom_plural = nil) + plural = custom_plural || (attribute.respond_to?(:pluralize) ? attribute.pluralize : "#{attribute}s") - # Record the states so that the machine can keep a list of all known - # states that have been defined - event.transitions.each do |transition| - @states |= [transition.options[:to]] + Array(transition.options[:from]) + Array(transition.options[:except_from]) - @states.sort! + [attribute, plural].uniq.each do |name| + define_with_scope("with_#{name}") unless owner_class.respond_to?("with_#{name}") + define_without_scope("without_#{name}") unless owner_class.respond_to?("without_#{name}") end - - event end - # Creates a callback that will be invoked *before* a transition has been - # performed, so long as the given configuration options match the transition. - # Each part of the transition (to state, from state, and event) must match - # in order for the callback to get invoked. + # Defines a scope for finding objects *with* a particular value or + # values for the attribute. # - # Configuration options: - # * +to+ - One or more states being transitioned to. If none are specified, then all states will match. - # * +from+ - One or more states being transitioned from. If none are specified, then all states will match. - # * +on+ - One or more events that fired the transition. If none are specified, then all events will match. - # * +except_to+ - One more states *not* being transitioned to - # * +except_from+ - One or more states *not* being transitioned from - # * +except_on+ - One or more events that *did not* fire the transition - # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string. - # * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value. - # * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value. - # - # The +except+ group of options (+except_to+, +exception_from+, and - # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+, - # +from+, and +on+, respectively) - # - # == The callback - # - # When defining additional configuration options, callbacks must be defined - # in the :do option like so: - # - # class Vehicle < ActiveRecord::Base - # state_machine do - # before_transition :to => 'parked', :do => :set_alarm - # ... - # end - # end - # - # == Examples - # - # Below is an example of a model with one state machine and various types - # of +before+ transitions defined for it: - # - # class Vehicle < ActiveRecord::Base - # state_machine do - # # Before all transitions - # before_transition :update_dashboard - # - # # Before specific transition: - # before_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt - # - # # With conditional callback: - # before_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on? - # - # # Using :except counterparts: - # before_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard - # ... - # end - # end - # - # As can be seen, any number of transitions can be created using various - # combinations of configuration options. - def before_transition(options = {}) - add_transition_callback(:before, options) + # This is only applicable to specific integrations. + def define_with_scope(name) end - # Creates a callback that will be invoked *after* a transition has been - # performed, so long as the given configuration options match the transition. - # Each part of the transition (to state, from state, and event) must match - # in order for the callback to get invoked. + # Defines a scope for finding objects *without* a particular value or + # values for the attribute. # - # Configuration options: - # * +to+ - One or more states being transitioned to. If none are specified, then all states will match. - # * +from+ - One or more states being transitioned from. If none are specified, then all states will match. - # * +on+ - One or more events that fired the transition. If none are specified, then all events will match. - # * +except_to+ - One more states *not* being transitioned to - # * +except_from+ - One or more states *not* being transitioned from - # * +except_on+ - One or more events that *did not* fire the transition - # * +do+ - The callback to invoke when a transition matches. This can be a method, proc or string. - # * +if+ - A method, proc or string to call to determine if the callback should occur (e.g. :if => :allow_callbacks, or :if => lambda {|user| user.signup_step > 2}). The method, proc or string should return or evaluate to a true or false value. - # * +unless+ - A method, proc or string to call to determine if the callback should not occur (e.g. :unless => :skip_callbacks, or :unless => lambda {|user| user.signup_step <= 2}). The method, proc or string should return or evaluate to a true or false value. - # - # The +except+ group of options (+except_to+, +exception_from+, and - # +except_on+) acts as the +unless+ equivalent of their counterparts (+to+, - # +from+, and +on+, respectively) - # - # == The callback - # - # When defining additional configuration options, callbacks must be defined - # in the :do option like so: - # - # class Vehicle < ActiveRecord::Base - # state_machine do - # after_transition :to => 'parked', :do => :set_alarm - # ... - # end - # end - # - # == Examples - # - # Below is an example of a model with one state machine and various types - # of +after+ transitions defined for it: - # - # class Vehicle < ActiveRecord::Base - # state_machine do - # # After all transitions - # after_transition :update_dashboard - # - # # After specific transition: - # after_transition :to => 'parked', :from => %w(first_gear idling), :on => 'park', :do => :take_off_seatbelt - # - # # With conditional callback: - # after_transition :to => 'parked', :do => :take_off_seatbelt, :if => :seatbelt_on? - # - # # Using :except counterparts: - # after_transition :except_to => 'stalled', :except_from => 'stalled', :except_on => 'crash', :do => :update_dashboard - # ... - # end - # end - # - # As can be seen, any number of transitions can be created using various - # combinations of configuration options. - def after_transition(options = {}) - add_transition_callback(:after, options) + # This is only applicable to specific integrations. + def define_without_scope(name) end - private - # Adds the given callback to the callback chain during a state transition - def add_transition_callback(type, options) - options = {:do => options} unless options.is_a?(Hash) - options.assert_valid_keys(:to, :from, :on, :except_to, :except_from, :except_on, :do, :if, :unless) - - # The actual callback (defined in the :do option) must be defined - raise ArgumentError, ':do callback must be specified' unless options[:do] - - # Create the callback - owner_class.send("#{type}_transition_#{attribute}", options.delete(:do), options) - end + # Adds a new transition callback of the given type. + def add_callback(type, options, &block) + @callbacks[type] << callback = Callback.new(options, &block) + add_states(callback.known_states) + callback + 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 before/after callbacks for when the attribute transitions to a - # different value - def add_transition_callbacks - %w(before after).each {|type| owner_class.define_callbacks("#{type}_transition_#{attribute}") } - end - - # Add named scopes for finding records with a particular value or values - # for the attribute - def add_named_scopes - [attribute, attribute.pluralize].uniq.each do |name| - name = "with_#{name}" - owner_class.named_scope name.to_sym, lambda {|*values| {:conditions => {attribute => values.flatten}}} unless owner_class.respond_to?(name) + # Add state predicates + attribute = self.attribute + new_states.each do |state| + if 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 - end + end end end