state-machines/state_machines

View on GitHub
lib/state_machines/transition.rb

Summary

Maintainability
C
1 day
Test Coverage
module StateMachines
  # A transition represents a state change for a specific attribute.
  #
  # Transitions consist of:
  # * An event
  # * A starting state
  # * An ending state
  class Transition
    # The object being transitioned
    attr_reader :object

    # The state machine for which this transition is defined
    attr_reader :machine

    # The original state value *before* the transition
    attr_reader :from

    # The new state value *after* the transition
    attr_reader :to

    # The arguments passed in to the event that triggered the transition
    # (does not include the +run_action+ boolean argument if specified)
    attr_accessor :args

    # The result of invoking the action associated with the machine
    attr_reader :result

    # Whether the transition is only existing temporarily for the object
    attr_writer :transient

    # Determines whether the current ruby implementation supports pausing and
    # resuming transitions
    def self.pause_supported?
      %w(ruby maglev).include?(RUBY_ENGINE)
    end

    # Creates a new, specific transition
    def initialize(object, machine, event, from_name, to_name, read_state = true) #:nodoc:
      @object = object
      @machine = machine
      @args = []
      @transient = false
      @resume_block = nil

      @event = machine.events.fetch(event)
      @from_state = machine.states.fetch(from_name)
      @from = read_state ? machine.read(object, :state) : @from_state.value
      @to_state = machine.states.fetch(to_name)
      @to = @to_state.value

      reset
    end

    # The attribute which this transition's machine is defined for
    def attribute
      machine.attribute
    end

    # The action that will be run when this transition is performed
    def action
      machine.action
    end

    # The event that triggered the transition
    def event
      @event.name
    end

    # The fully-qualified name of the event that triggered the transition
    def qualified_event
      @event.qualified_name
    end

    # The human-readable name of the event that triggered the transition
    def human_event
      @event.human_name(@object.class)
    end

    # The state name *before* the transition
    def from_name
      @from_state.name
    end

    # The fully-qualified state name *before* the transition
    def qualified_from_name
      @from_state.qualified_name
    end

    # The human-readable state name *before* the transition
    def human_from_name
      @from_state.human_name(@object.class)
    end

    # The new state name *after* the transition
    def to_name
      @to_state.name
    end

    # The new fully-qualified state name *after* the transition
    def qualified_to_name
      @to_state.qualified_name
    end

    # The new human-readable state name *after* the transition
    def human_to_name
      @to_state.human_name(@object.class)
    end

    # Does this transition represent a loopback (i.e. the from and to state
    # are the same)
    #
    # == Example
    #
    #   machine = StateMachine.new(Vehicle)
    #   StateMachines::Transition.new(Vehicle.new, machine, :park, :parked, :parked).loopback?   # => true
    #   StateMachines::Transition.new(Vehicle.new, machine, :park, :idling, :parked).loopback?   # => false
    def loopback?
      from_name == to_name
    end

    # Is this transition existing for a short period only?  If this is set, it
    # indicates that the transition (or the event backing it) should not be
    # written to the object if it fails.
    def transient?
      @transient
    end

    # A hash of all the core attributes defined for this transition with their
    # names as keys and values of the attributes as values.
    #
    # == Example
    #
    #   machine = StateMachine.new(Vehicle)
    #   transition = StateMachines::Transition.new(Vehicle.new, machine, :ignite, :parked, :idling)
    #   transition.attributes   # => {:object => #<Vehicle:0xb7d60ea4>, :attribute => :state, :event => :ignite, :from => 'parked', :to => 'idling'}
    def attributes
      @attributes ||= {object: object, attribute: attribute, event: event, from: from, to: to}
    end

    # Runs the actual transition and any before/after callbacks associated
    # with the transition.  The action associated with the transition/machine
    # can be skipped by passing in +false+.
    #
    # == Examples
    #
    #   class Vehicle
    #     state_machine :action => :save do
    #       ...
    #     end
    #   end
    #
    #   vehicle = Vehicle.new
    #   transition = StateMachines::Transition.new(vehicle, machine, :ignite, :parked, :idling)
    #   transition.perform                  # => Runs the +save+ action after setting the state attribute
    #   transition.perform(false)           # => Only sets the state attribute
    #   transition.perform(Time.now)        # => Passes in additional arguments and runs the +save+ action
    #   transition.perform(Time.now, false) # => Passes in additional arguments and only sets the state attribute
    def perform(*args)
      run_action = [true, false].include?(args.last) ? args.pop : true
      self.args = args

      # Run the transition
      !!TransitionCollection.new([self], {use_transactions: machine.use_transactions, actions: run_action}).perform
    end

    # Runs a block within a transaction for the object being transitioned.
    # By default, transactions are a no-op unless otherwise defined by the
    # machine's integration.
    def within_transaction
      machine.within_transaction(object) do
        yield
      end
    end

    # Runs the before / after callbacks for this transition.  If a block is
    # provided, then it will be executed between the before and after callbacks.
    #
    # Configuration options:
    # * +before+ - Whether to run before callbacks.
    # * +after+ - Whether to run after callbacks.  If false, then any around
    #   callbacks will be paused until called again with +after+ enabled.
    #   Default is true.
    #
    # This will return true if all before callbacks gets executed.  After
    # callbacks will not have an effect on the result.
    def run_callbacks(options = {}, &block)
      options = {before: true, after: true}.merge(options)
      @success = false

      halted = pausable { before(options[:after], &block) } if options[:before]

      # After callbacks are only run if:
      # * An around callback didn't halt after yielding
      # * They're enabled or the run didn't succeed
      after if !(@before_run && halted) && (options[:after] || !@success)

      @before_run
    end

    # Transitions the current value of the state to that specified by the
    # transition.  Once the state is persisted, it cannot be persisted again
    # until this transition is reset.
    #
    # == Example
    #
    #   class Vehicle
    #     state_machine do
    #       event :ignite do
    #         transition :parked => :idling
    #       end
    #     end
    #   end
    #
    #   vehicle = Vehicle.new
    #   transition = StateMachines::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
    #   transition.persist
    #
    #   vehicle.state   # => 'idling'
    def persist
      unless @persisted
        machine.write(object, :state, to)
        @persisted = true
      end
    end

    # Rolls back changes made to the object's state via this transition.  This
    # will revert the state back to the +from+ value.
    #
    # == Example
    #
    #   class Vehicle
    #     state_machine :initial => :parked do
    #       event :ignite do
    #         transition :parked => :idling
    #       end
    #     end
    #   end
    #
    #   vehicle = Vehicle.new     # => #<Vehicle:0xb7b7f568 @state="parked">
    #   transition = StateMachines::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
    #
    #   # Persist the new state
    #   vehicle.state             # => "parked"
    #   transition.persist
    #   vehicle.state             # => "idling"
    #
    #   # Roll back to the original state
    #   transition.rollback
    #   vehicle.state             # => "parked"
    def rollback
      reset
      machine.write(object, :state, from)
    end

    # Resets any tracking of which callbacks have already been run and whether
    # the state has already been persisted
    def reset
      @before_run = @persisted = @after_run = false
      @paused_block = nil
    end

    # Determines equality of transitions by testing whether the object, states,
    # and event involved in the transition are equal
    def ==(other)
      other.instance_of?(self.class) &&
      other.object == object &&
      other.machine == machine &&
      other.from_name == from_name &&
      other.to_name == to_name &&
      other.event == event
    end

    # Generates a nicely formatted description of this transitions's contents.
    #
    # For example,
    #
    #   transition = StateMachines::Transition.new(object, machine, :ignite, :parked, :idling)
    #   transition   # => #<StateMachines::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
    def inspect
      "#<#{self.class} #{%w(attribute event from from_name to to_name).map { |attr| "#{attr}=#{send(attr).inspect}" } * ' '}>"
    end

  private

    # Runs a block that may get paused.  If the block doesn't pause, then
    # execution will continue as normal.  If the block gets paused, then it
    # will take care of switching the execution context when it's resumed.
    #
    # This will return true if the given block halts for a reason other than
    # getting paused.
    def pausable
      begin
        halted = !catch(:halt) { yield; true }
      rescue => error
        raise unless @resume_block
      end

      if @resume_block
        @resume_block.call(halted, error)
      else
        halted
      end
    end

    # Pauses the current callback execution.  This should only occur within
    # around callbacks when the remainder of the callback will be executed at
    # a later point in time.
    def pause
      raise ArgumentError, 'around_transition callbacks cannot be called in multiple execution contexts in java implementations of Ruby. Use before/after_transitions instead.' unless self.class.pause_supported?

      unless @resume_block
        require 'continuation' unless defined?(callcc)
        callcc do |block|
          @paused_block = block
          throw :halt, true
        end
      end
    end

    # Resumes the execution of a previously paused callback execution.  Once
    # the paused callbacks complete, the current execution will continue.
    def resume
      if @paused_block
        halted, error = callcc do |block|
          @resume_block = block
          @paused_block.call
        end

        @resume_block = @paused_block = nil

        raise error if error

        !halted
      else
        true
      end
    end

    # Runs the machine's +before+ callbacks for this transition.  Only
    # callbacks that are configured to match the event, from state, and to
    # state will be invoked.
    #
    # Once the callbacks are run, they cannot be run again until this transition
    # is reset.
    def before(complete = true, index = 0, &block)
      unless @before_run
        while callback = machine.callbacks[:before][index]
          index += 1

          if callback.type == :around
            # Around callback: need to handle recursively.  Execution only gets
            # paused if:
            # * The block fails and the callback doesn't run on failures OR
            # * The block succeeds, but after callbacks are disabled (in which
            #   case a continuation is stored for later execution)
            return if catch(:cancel) do
              callback.call(object, context, self) do
                before(complete, index, &block)

                pause if @success && !complete
                throw :cancel, true unless @success
              end
            end
          else
            # Normal before callback
            callback.call(object, context, self)
          end
        end

        @before_run = true
      end

      action = {success: true}.merge(block_given? ? yield : {})
      @result, @success = action[:result], action[:success]
    end

    # Runs the machine's +after+ callbacks for this transition.  Only
    # callbacks that are configured to match the event, from state, and to
    # state will be invoked.
    #
    # Once the callbacks are run, they cannot be run again until this transition
    # is reset.
    #
    # == Halting
    #
    # If any callback throws a <tt>:halt</tt> exception, it will be caught
    # and the callback chain will be automatically stopped.  However, this
    # exception will not bubble up to the caller since +after+ callbacks
    # should never halt the execution of a +perform+.
    def after
      unless @after_run
        # First resume previously paused callbacks
        if resume
          catch(:halt) do
            type = @success ? :after : :failure
            machine.callbacks[type].each { |callback| callback.call(object, context, self) }
          end
        end

        @after_run = true
      end
    end

    # Gets a hash of the context defining this unique transition (including
    # event, from state, and to state).
    #
    # == Example
    #
    #   machine = StateMachine.new(Vehicle)
    #   transition = StateMachines::Transition.new(Vehicle.new, machine, :ignite, :parked, :idling)
    #   transition.context    # => {:on => :ignite, :from => :parked, :to => :idling}
    def context
      @context ||= {on: event, from: from_name, to: to_name}
    end
  end
end