lib/state_machine/machine.rb in state_machine-0.9.4 vs lib/state_machine/machine.rb in state_machine-0.10.0

- old
+ new

@@ -6,10 +6,11 @@ require 'state_machine/event' require 'state_machine/callback' require 'state_machine/node_collection' require 'state_machine/state_collection' require 'state_machine/event_collection' +require 'state_machine/path_collection' require 'state_machine/matcher_helpers' module StateMachine # Represents a state machine for a particular attribute. State machines # consist of states, events and a set of transitions that define how the @@ -88,12 +89,12 @@ # One important note about using this technique for running transitions is # that if the class in which the state machine is defined *also* defines the # action being invoked (and not a superclass), then it must manually run the # StateMachine hook that checks for event attributes. # - # For example, in ActiveRecord, DataMapper, MongoMapper, and Sequel, the - # default action (+save+) is already defined in a base class. As a result, + # For example, in ActiveRecord, DataMapper, Mongoid, MongoMapper, and Sequel, + # the default action (+save+) is already defined in a base class. As a result, # when a state machine is defined in a model / resource, StateMachine can # automatically hook into the +save+ action. # # On the other hand, the Vehicle class from above defined its own +save+ # method (and there is no +save+ method in its superclass). As a result, it @@ -288,14 +289,14 @@ # 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, - # ActiveModel, ActiveRecord, DataMapper, MongoMapper, 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. + # ActiveModel, ActiveRecord, DataMapper, Mongoid, MongoMapper, 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 include EvalHelpers include MatcherHelpers @@ -414,37 +415,39 @@ attr_reader :use_transactions # 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, :attribute, :initial, :action, :plural, :namespace, :integration, :messages, :use_transactions) + assert_valid_keys(options, :attribute, :initial, :initialize, :action, :plural, :namespace, :integration, :messages, :use_transactions) # Find an integration that matches this machine's owner class if options.include?(:integration) integration = StateMachine::Integrations.find(options[:integration]) if options[:integration] else integration = StateMachine::Integrations.match(owner_class) end if integration extend integration - options = integration.defaults.merge(options) if integration.respond_to?(:defaults) + options = (integration.defaults || {}).merge(options) end # Add machine-wide defaults - options = {:use_transactions => true}.merge(options) + options = {:use_transactions => true, :initialize => true}.merge(options) # Set machine configuration @name = args.first || :state @attribute = options[:attribute] || @name @events = EventCollection.new(self) @states = StateCollection.new(self) - @callbacks = {:before => [], :after => []} + @callbacks = {:before => [], :after => [], :failure => []} @namespace = options[:namespace] @messages = options[:messages] || {} @action = options[:action] @use_transactions = options[:use_transactions] + @initialize_state = options[:initialize] + @helpers = {:instance => {}, :class => {}} self.owner_class = owner_class self.initial_state = options[:initial] # Define class integration define_helpers @@ -463,35 +466,35 @@ @events = @events.dup @events.machine = self @states = @states.dup @states.machine = self - @callbacks = {:before => @callbacks[:before].dup, :after => @callbacks[:after].dup} + @callbacks = {:before => @callbacks[:before].dup, :after => @callbacks[:after].dup, :failure => @callbacks[:failure].dup} + @helpers = {:instance => @helpers[:instance].dup, :class => @helpers[:class].dup} end # Sets the class which is the owner of this state machine. Any methods # generated by states, events, or other parts of the machine will be defined # on the given owner class. def owner_class=(klass) @owner_class = klass # Create modules for extending the class with state/event-specific methods - class_helper_module = @class_helper_module = Module.new - instance_helper_module = @instance_helper_module = Module.new + @helper_modules = helper_modules = {:instance => Module.new, :class => Module.new} owner_class.class_eval do - extend class_helper_module - include instance_helper_module + extend helper_modules[:class] + include helper_modules[:instance] end # Add class-/instance-level methods to the owner class for state initialization unless owner_class < StateMachine::InstanceMethods owner_class.class_eval do extend StateMachine::ClassMethods include StateMachine::InstanceMethods end - define_state_initializer + define_state_initializer if @initialize_state end # Record this machine as matched to the name in the current owner class. # This will override any machines mapped to the same name in any superclasses. owner_class.state_machines[name] = self @@ -506,59 +509,58 @@ # Update all states to reflect the new initial state states.each {|state| state.initial = (state.name == @initial_state)} end - # Initializes the state on the given object. This will always write to the - # attribute regardless of whether a value is already present. - def initialize_state(object) - write(object, :state, initial_state(object).value) + # Initializes the state on the given object. Initial values are only set if + # the machine's attribute hasn't been previously initialized. + def initialize_state(object, options = {}) + write(object, :state, initial_state(object).value) if initialize_state?(object, options) end # Gets the actual name of the attribute on the machine's owner class that # stores data with the given name. def attribute(name = :state) name == :state ? @attribute : :"#{self.name}_#{name}" end - # Defines a new instance method with the given name on the machine's owner - # class. If the method is already defined in the class, then this will not + # Defines a new helper method in an instance or class scope with the given + # name. If the method is already defined in the scope, then this will not # override it. # # Example: # - # machine.define_instance_method(:state_name) do |machine, object| + # # Instance helper + # machine.define_helper(:instance, :state_name) do |machine, object, _super| # machine.states.match(object) # end - def define_instance_method(method, &block) - name = self.name - - @instance_helper_module.class_eval do - define_method(method) do |*args| - block.call(self.class.state_machine(name), self, *args) + # + # # Class helper + # machine.define_helper(:class, :state_machine_name) do |machine, klass, _super| + # "State" + # end + def define_helper(scope, method, &block) + @helpers.fetch(scope)[method] = block + @helper_modules.fetch(scope).class_eval <<-end_eval, __FILE__, __LINE__ + def #{method}(*args) + _super = lambda {|*new_args| new_args.empty? ? super(*args) : super(*new_args)} + #{scope == :class ? 'self' : 'self.class'}.state_machine(#{name.inspect}).call_helper(#{scope.inspect}, #{method.inspect}, self, _super, *args) end - end + end_eval end - attr_reader :instance_helper_module - # Defines a new class method with the given name on the machine's owner - # class. If the method is already defined in the class, then this will not - # override it. + # Invokes the helper method defined in the given scope. # # Example: # - # machine.define_class_method(:states) do |machine, klass| - # machine.states.keys - # end - def define_class_method(method, &block) - name = self.name - - @class_helper_module.class_eval do - define_method(method) do |*args| - block.call(self.state_machine(name), self, *args) - end - end + # # Instance helper + # machine.call_helper(:instance, :state_name, self, lambda {super}) + # + # # Class helper + # machine.call_helper(:class, :state_machine_name, self, lambda {super}) + def call_helper(scope, method, object, _super, *args) + @helpers.fetch(scope).fetch(method).call(self, object, _super, *args) 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 lambda block to help determine the actual state. @@ -899,12 +901,13 @@ # vehicle = Vehicle.new # => #<Vehicle:0xb7d94ab0 @state="parked"> # Vehicle.state_machine.write(vehicle, :state, 'idling') # => Equivalent to vehicle.state = 'idling' # Vehicle.state_machine.write(vehicle, :event, 'park') # => Equivalent to vehicle.state_event = 'park' # vehicle.state # => "idling" # vehicle.event # => "park" - def write(object, attribute, value) - object.send("#{self.attribute(attribute)}=", value) + def write(object, attribute, value, ivar = false) + attribute = self.attribute(attribute) + ivar ? object.instance_variable_set("@#{attribute}", value) : object.send("#{attribute}=", value) end # Defines one or more events for the machine and the transitions that can # be performed when those events are run. # @@ -918,34 +921,44 @@ # # == 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. This will *not* run validations in - # ORM integrations. To check whether an event can fire *and* passes - # validations, use event attributes (e.g. state_event) as described in the - # "Events" documentation of each ORM integration. - # * <tt>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. If the # last argument is a boolean, it will control whether the machine's action # gets run. # * <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. If the last argument is a boolean, it will control whether the # machine's action gets run. + # * <tt>can_park?(requirements = {})</tt> - Checks whether the "park" event can be fired given + # the current state of the object. This will *not* run validations in + # ORM integrations. To check whether an event can fire *and* passes + # validations, use event attributes (e.g. state_event) as described in the + # "Events" documentation of each ORM integration. + # * <tt>park_transition(requirements = {})</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. # # With a namespace of "car", the above names map to the following methods: # * <tt>can_park_car?</tt> # * <tt>park_car_transition</tt> # * <tt>park_car</tt> # * <tt>park_car!</tt> # + # The <tt>can_park?</tt> and <tt>park_transition</tt> helpers both take an + # optional set of requirements for determining what transitions are available + # for the current object. These requirements include: + # * <tt>:from</tt> - One or more states to transition from. If none are + # specified, then this will be the object's current state. + # * <tt>:to</tt> - One or more states to transition to. If none are + # specified, then this will match any to state. + # * <tt>:guard</tt> - Whether to guard transitions with the if/unless + # conditionals defined for each one. Default is true. + # # == 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, # @@ -1130,26 +1143,10 @@ # # before_transition :on => :ignite, :do => ... # Matches only on ignite # before_transition :on => all - :ignite, :do => ... # Matches on every event except ignite # before_transition :parked => :idling, :on => :ignite, :do => ... # Matches from parked to idling on ignite # - # == Result requirements - # - # By default, +after_transition+ callbacks and code executed after an - # +around_transition+ callback yields will only be run if the transition - # was performed successfully. A transition is successful if the machine's - # action is not configured or does not return false when it is invoked. - # In order to include failed attempts when running an +after_transition+ or - # +around_transition+ callback, the <tt>:include_failures</tt> option can be - # specified like so: - # - # after_transition :include_failures => true, :do => ... # Runs on all attempts to transition, including failures - # after_transition :do => ... # Runs only on successful attempts to transition - # - # around_transition :include_failures => true, :do => ... # Runs on all attempts to transition, including failures - # around_transition :do => ... # Runs only on successful attempts to transition - # # == Verbose Requirements # # Requirements can also be defined using verbose options rather than the # implicit Hash syntax and helper methods described above. # @@ -1317,10 +1314,119 @@ options = (args.last.is_a?(Hash) ? args.pop : {}) options[:do] = args if args.any? add_callback(:around, options, &block) end + # Creates a callback that will be invoked *after* a transition failures to + # be performed so long as the given requirements match the transition. + # + # See +before_transition+ for a description of the possible configurations + # for defining callbacks. *Note* however that you cannot define the state + # requirements in these callbacks. You may only define event requirements. + # + # = The callback + # + # Failure callbacks get invoked whenever an event fails to execute. This + # can happen when no transition is available, a +before+ callback halts + # execution, or the action associated with this machine fails to succeed. + # In any of these cases, any failure callback that matches the attempted + # transition will be run. + # + # For example, + # + # class Vehicle + # state_machine do + # after_failure do |vehicle, transition| + # logger.error "vehicle #{vehicle} failed to transition on #{transition.event}" + # end + # + # after_failure :on => :ignite, :do => :log_ignition_failure + # + # ... + # end + # end + def after_failure(*args, &block) + options = (args.last.is_a?(Hash) ? args.pop : {}) + options[:do] = args if args.any? + assert_valid_keys(options, :on, :do, :if, :unless) + + add_callback(:failure, options, &block) + end + + # Generates a list of the possible transition sequences that can be run on + # the given object. These paths can reveal all of the possible states and + # events that can be encountered in the object's state machine based on the + # object's current state. + # + # Configuration options: + # * +from+ - The initial state to start all paths from. By default, this + # is the object's current state. + # * +to+ - The target state to end all paths on. By default, paths will + # end when they loop back to the first transition on the path. + # * +deep+ - Whether to allow the target state to be crossed more than once + # in a path. By default, paths will immediately stop when the target + # state (if specified) is reached. If this is enabled, then paths can + # continue even after reaching the target state; they will stop when + # reaching the target state a second time. + # + # *Note* that the object is never modified when the list of paths is + # generated. + # + # == Examples + # + # class Vehicle + # state_machine :initial => :parked do + # event :ignite do + # transition :parked => :idling + # end + # + # event :shift_up do + # transition :idling => :first_gear, :first_gear => :second_gear + # end + # + # event :shift_down do + # transition :second_gear => :first_gear, :first_gear => :idling + # end + # end + # end + # + # vehicle = Vehicle.new # => #<Vehicle:0xb7c27024 @state="parked"> + # vehicle.state # => "parked" + # + # vehicle.state_paths + # # => [ + # # [#<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>, + # # #<StateMachine::Transition attribute=:state event=:shift_up from="idling" from_name=:idling to="first_gear" to_name=:first_gear>, + # # #<StateMachine::Transition attribute=:state event=:shift_up from="first_gear" from_name=:first_gear to="second_gear" to_name=:second_gear>, + # # #<StateMachine::Transition attribute=:state event=:shift_down from="second_gear" from_name=:second_gear to="first_gear" to_name=:first_gear>, + # # #<StateMachine::Transition attribute=:state event=:shift_down from="first_gear" from_name=:first_gear to="idling" to_name=:idling>], + # # + # # [#<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>, + # # #<StateMachine::Transition attribute=:state event=:shift_up from="idling" from_name=:idling to="first_gear" to_name=:first_gear>, + # # #<StateMachine::Transition attribute=:state event=:shift_down from="first_gear" from_name=:first_gear to="idling" to_name=:idling>] + # # ] + # + # vehicle.state_paths(:from => :parked, :to => :second_gear) + # # => [ + # # [#<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>, + # # #<StateMachine::Transition attribute=:state event=:shift_up from="idling" from_name=:idling to="first_gear" to_name=:first_gear>, + # # #<StateMachine::Transition attribute=:state event=:shift_up from="first_gear" from_name=:first_gear to="second_gear" to_name=:second_gear>] + # # ] + # + # In addition to getting the possible paths that can be accessed, you can + # also get summary information about the states / events that can be + # accessed at some point along one of the paths. For example: + # + # # Get the list of states that can be accessed from the current state + # vehicle.state_paths.to_states # => [:idling, :first_gear, :second_gear] + # + # # Get the list of events that can be accessed from the current state + # vehicle.state_paths.events # => [:ignite, :shift_up, :shift_down] + def paths_for(object, requirements = {}) + PathCollection.new(object, self, requirements) + end + # Marks the given object as invalid with the given message. # # By default, this is a no-op. def invalidate(object, attribute, message, values = []) end @@ -1398,163 +1504,194 @@ edges.each {|edge| edge.fontname = options[:font]} end # Generate the graph graphvizVersion = Constants::RGV_VERSION.split('.') + file = File.join(options[:path], "#{options[:name]}.#{options[:format]}") if graphvizVersion[1] == '9' && graphvizVersion[2] == '0' - outputOptions = { - :output => options[:format], - :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}") - } + outputOptions = {:output => options[:format], :file => file} else - outputOptions = { - options[:format] => File.join(options[:path], "#{options[:name]}.#{options[:format]}") - } + outputOptions = {options[:format] => file} end graph.output(outputOptions) graph rescue LoadError $stderr.puts 'Cannot draw the machine. `gem install ruby-graphviz` >= v0.9.0 and try again.' false end end - # Determines whether a helper method was defined for firing attribute-based - # event transitions when the configuration action gets called. - def action_helper_defined? - @action_helper_defined + # Determines whether an action hook was defined for firing attribute-based + # event transitions when the configured action gets called. + def action_hook?(self_only = false) + @action_hook_defined || !self_only && owner_class.state_machines.any? {|name, machine| machine.action == action && machine != self && machine.action_hook?(true)} end protected # Runs additional initialization hooks. By default, this is a no-op. def after_initialize end + # Determines if the machine's attribute needs to be initialized. This + # will only be true if the machine's attribute is blank. + def initialize_state?(object, options = {}) + value = read(object, :state) + value.nil? || value.respond_to?(:empty?) && value.empty? + end + # Adds helper methods for interacting with the state machine, including # for states, events, and transitions def define_helpers define_state_accessor define_state_predicate define_event_helpers - define_action_helpers if action + define_path_helpers + define_action_helpers if define_action_helpers? define_name_helpers end # Defines the initial values for state machine attributes. Static values # are set prior to the original initialize method and dynamic values are # set *after* the initialize method in case it is dependent on it. def define_state_initializer - @instance_helper_module.class_eval <<-end_eval, __FILE__, __LINE__ - def initialize(*args) - initialize_state_machines(:dynamic => false) - super - initialize_state_machines(:dynamic => true) - end - end_eval + define_helper(:instance, :initialize) do |machine, object, _super, *| + object.class.state_machines.initialize_states(object) { _super.call } + end end # Adds reader/writer methods for accessing the state attribute def define_state_accessor attribute = self.attribute - @instance_helper_module.class_eval do - attr_accessor attribute - end + @helper_modules[:instance].class_eval { attr_accessor attribute } end # Adds predicate method to the owner class for determining the name of the # current state def define_state_predicate - define_instance_method("#{name}?") do |machine, object, state| - machine.states.matches?(object, state) + call_super = owner_class_ancestor_has_method?("#{name}?") + define_helper(:instance, "#{name}?") do |machine, object, _super, *args| + args.empty? && call_super ? _super.call : machine.states.matches?(object, args.first) end end # Adds helper methods for getting information about this state machine's # events def define_event_helpers # Gets the events that are allowed to fire on the current object - define_instance_method(attribute(:events)) do |machine, object| - machine.events.valid_for(object).map {|event| event.name} + define_helper(:instance, attribute(:events)) do |machine, object, _super, *args| + machine.events.valid_for(object, *args).map {|event| event.name} end # Gets the next possible transitions that can be run on the current # object - define_instance_method(attribute(:transitions)) do |machine, object, *args| + define_helper(:instance, attribute(:transitions)) do |machine, object, _super, *args| machine.events.transitions_for(object, *args) end - # Add helpers for interacting with the action + # Add helpers for tracking the event / transition to invoke when the + # action is called if action - # Tracks the event / transition to invoke when the action is called event_attribute = attribute(:event) - event_transition_attribute = attribute(:event_transition) - @instance_helper_module.class_eval do - attr_writer event_attribute - - protected - attr_accessor event_transition_attribute - end - - # Interpret non-blank events as present - define_instance_method(attribute(:event)) do |machine, object| + define_helper(:instance, event_attribute) do |machine, object, *| + # Interpret non-blank events as present event = machine.read(object, :event, true) event && !(event.respond_to?(:empty?) && event.empty?) ? event.to_sym : nil end + + # A roundabout way of writing the attribute is used here so that + # integrations can hook into this modification + define_helper(:instance, "#{event_attribute}=") do |machine, object, _super, value| + machine.write(object, :event, value, true) + end + + event_transition_attribute = attribute(:event_transition) + @helper_modules[:instance].class_eval { protected; attr_accessor event_transition_attribute } end end + # Adds helper methods for getting information about this state machine's + # available transition paths + def define_path_helpers + # Gets the paths of transitions available to the current object + define_helper(:instance, attribute(:paths)) do |machine, object, _super, *args| + machine.paths_for(object, *args) + end + end + + # Determines whether action helpers should be defined for this machine. + # This is only true if there is an action configured and no other machines + # have process this same configuration already. + def define_action_helpers? + action && !owner_class.state_machines.any? {|name, machine| machine.action == action && machine != self} + end + # Adds helper methods for automatically firing events when an action # is invoked - def define_action_helpers(action_hook = self.action) - private_action = owner_class.private_method_defined?(action_hook) - action_defined = @action_helper_defined = owner_class.ancestors.any? do |ancestor| - ancestor != owner_class && (ancestor.method_defined?(action_hook) || ancestor.private_method_defined?(action_hook)) + def define_action_helpers + if action_hook + @action_hook_defined = true + define_action_hook end - action_overridden = owner_class.state_machines.any? {|name, machine| machine.action == action && machine != self} + end + + # Hooks directly into actions by defining the same method in an included + # module. As a result, when the action gets invoked, any state events + # defined for the object will get run. Method visibility is preserved. + def define_action_hook + action_hook = self.action_hook + action = self.action + private_action_hook = owner_class.private_method_defined?(action_hook) - # Only define helper if: - # 1. Action was originally defined somewhere other than the owner class - # 2. It hasn't already been overridden by another machine - if action_defined && !action_overridden - action = self.action - @instance_helper_module.class_eval do - define_method(action_hook) do |*args| - self.class.state_machines.transitions(self, action).perform { super(*args) } - end - - private action_hook if private_action - end - - true - else - false + # Only define helper if it hasn't + define_helper(:instance, action_hook) do |machine, object, _super, *args| + object.class.state_machines.transitions(object, action).perform { _super.call } end + + @helper_modules[:instance].class_eval { private action_hook } if private_action_hook end + # The method to hook into for triggering transitions when invoked. By + # default, this is the action configured for the machine. + # + # Since the default hook technique relies on module inheritance, the + # action must be defined in an ancestor of the owner classs in order for + # it to be the action hook. + def action_hook + action && owner_class_ancestor_has_method?(action) ? action : nil + end + + # Determines whether any of the ancestors for this machine's owner class + # has the given method defined, even if it's private. + def owner_class_ancestor_has_method?(method) + owner_class.ancestors.any? do |ancestor| + ancestor != owner_class && (ancestor.method_defined?(method) || ancestor.private_method_defined?(method)) + end + end + # Adds helper methods for accessing naming information about states and # events on the owner class def define_name_helpers # Gets the humanized version of a state - define_class_method("human_#{attribute(:name)}") do |machine, klass, state| + define_helper(:class, "human_#{attribute(:name)}") do |machine, klass, _super, state| machine.states.fetch(state).human_name(klass) end # Gets the humanized version of an event - define_class_method("human_#{attribute(:event_name)}") do |machine, klass, event| + define_helper(:class, "human_#{attribute(:event_name)}") do |machine, klass, _super, event| machine.events.fetch(event).human_name(klass) end # Gets the state name for the current value - define_instance_method(attribute(:name)) do |machine, object| + define_helper(:instance, attribute(:name)) do |machine, object, *| machine.states.match!(object).name end # Gets the human state name for the current value - define_instance_method("human_#{attribute(:name)}") do |machine, object| + define_helper(:instance, "human_#{attribute(:name)}") do |machine, object, *| machine.states.match!(object).human_name(object.class) end end # Defines the with/without scope helpers for this attribute. Both the @@ -1570,10 +1707,10 @@ method = "#{kind}_#{name}" if scope = send("create_#{kind}_scope", method) # Converts state names to their corresponding values so that they # can be looked up properly - define_class_method(method) do |machine, klass, *states| + define_helper(:class, method) do |machine, klass, _super, *states| values = states.flatten.map {|state| machine.states.fetch(state).value} scope.call(klass, values) end end end