state-machines/state_machines

View on GitHub
lib/state_machines/branch.rb

Summary

Maintainability
A
1 hr
Test Coverage
module StateMachines
  # Represents a set of requirements that must be met in order for a transition
  # or callback to occur.  Branches verify that the event, from state, and to
  # state of the transition match, in addition to if/unless conditionals for
  # an object's state.
  class Branch

    include EvalHelpers

    # The condition that must be met on an object
    attr_reader :if_condition

    # The condition that must *not* be met on an object
    attr_reader :unless_condition

    # The requirement for verifying the event being matched
    attr_reader :event_requirement

    # One or more requirements for verifying the states being matched.  All
    # requirements contain a mapping of {:from => matcher, :to => matcher}.
    attr_reader :state_requirements

    # A list of all of the states known to this branch.  This will pull states
    # from the following options (in the same order):
    # * +from+ / +except_from+
    # * +to+ / +except_to+
    attr_reader :known_states

    # Creates a new branch
    def initialize(options = {}) #:nodoc:
      # Build conditionals
      @if_condition = options.delete(:if)
      @unless_condition = options.delete(:unless)

      # Build event requirement
      @event_requirement = build_matcher(options, :on, :except_on)

      if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on]).empty?
        # Explicit from/to requirements specified
        @state_requirements = [{from: build_matcher(options, :from, :except_from), to: build_matcher(options, :to, :except_to)}]
      else
        # Separate out the event requirement
        options.delete(:on)
        options.delete(:except_on)

        # Implicit from/to requirements specified
        @state_requirements = options.collect do |from, to|
          from = WhitelistMatcher.new(from) unless from.is_a?(Matcher)
          to = WhitelistMatcher.new(to) unless to.is_a?(Matcher)
          {from: from, to: to}
        end
      end

      # Track known states.  The order that requirements are iterated is based
      # on the priority in which tracked states should be added.
      @known_states = []
      @state_requirements.each do |state_requirement|
        [:from, :to].each { |option| @known_states |= state_requirement[option].values }
      end
    end

    # Determines whether the given object / query matches the requirements
    # configured for this branch.  In addition to matching the event, from state,
    # and to state, this will also check whether the configured :if/:unless
    # conditions pass on the given object.
    #
    # == Examples
    #
    #   branch = StateMachines::Branch.new(:parked => :idling, :on => :ignite)
    #
    #   # Successful
    #   branch.matches?(object, :on => :ignite)                                   # => true
    #   branch.matches?(object, :from => nil)                                     # => true
    #   branch.matches?(object, :from => :parked)                                 # => true
    #   branch.matches?(object, :to => :idling)                                   # => true
    #   branch.matches?(object, :from => :parked, :to => :idling)                 # => true
    #   branch.matches?(object, :on => :ignite, :from => :parked, :to => :idling) # => true
    #
    #   # Unsuccessful
    #   branch.matches?(object, :on => :park)                                     # => false
    #   branch.matches?(object, :from => :idling)                                 # => false
    #   branch.matches?(object, :to => :first_gear)                               # => false
    #   branch.matches?(object, :from => :parked, :to => :first_gear)             # => false
    #   branch.matches?(object, :on => :park, :from => :parked, :to => :idling)   # => false
    def matches?(object, query = {})
      !match(object, query).nil?
    end

    # Attempts to match the given object / query against the set of requirements
    # configured for this branch.  In addition to matching the event, from state,
    # and to state, this will also check whether the configured :if/:unless
    # conditions pass on the given object.
    #
    # If a match is found, then the event/state requirements that the query
    # passed successfully will be returned.  Otherwise, nil is returned if there
    # was no match.
    #
    # Query options:
    # * <tt>:from</tt> - One or more states being transitioned from.  If none
    #   are specified, then this will always match.
    # * <tt>:to</tt> - One or more states being transitioned to.  If none are
    #   specified, then this will always match.
    # * <tt>:on</tt> - One or more events that fired the transition.  If none
    #   are specified, then this will always match.
    # * <tt>:guard</tt> - Whether to guard matches with the if/unless
    #   conditionals defined for this branch.  Default is true.
    #
    # == Examples
    #
    #   branch = StateMachines::Branch.new(:parked => :idling, :on => :ignite)
    #
    #   branch.match(object, :on => :ignite)  # => {:to => ..., :from => ..., :on => ...}
    #   branch.match(object, :on => :park)    # => nil
    def match(object, query = {})
      query.assert_valid_keys(:from, :to, :on, :guard)

      if (match = match_query(query)) && matches_conditions?(object, query)
        match
      end
    end

    def draw(graph, event, valid_states)
     fail NotImplementedError
    end

  protected

    # Builds a matcher strategy to use for the given options.  If neither a
    # whitelist nor a blacklist option is specified, then an AllMatcher is
    # built.
    def build_matcher(options, whitelist_option, blacklist_option)
      options.assert_exclusive_keys(whitelist_option, blacklist_option)

      if options.include?(whitelist_option)
        value = options[whitelist_option]
        value.is_a?(Matcher) ? value : WhitelistMatcher.new(options[whitelist_option])
      elsif options.include?(blacklist_option)
        value = options[blacklist_option]
        raise ArgumentError, ":#{blacklist_option} option cannot use matchers; use :#{whitelist_option} instead" if value.is_a?(Matcher)

        BlacklistMatcher.new(value)
      else
        AllMatcher.instance
      end
    end

    # Verifies that all configured requirements (event and state) match the
    # given query.  If a match is found, then a hash containing the
    # event/state requirements that passed will be returned; otherwise, nil.
    def match_query(query)
      query ||= {}

      if match_event(query) && (state_requirement = match_states(query))
        state_requirement.merge(on: event_requirement)
      end
    end

    # Verifies that the event requirement matches the given query
    def match_event(query)
      matches_requirement?(query, :on, event_requirement)
    end

    # Verifies that the state requirements match the given query.  If a
    # matching requirement is found, then it is returned.
    def match_states(query)
      state_requirements.detect do |state_requirement|
        [:from, :to].all? { |option| matches_requirement?(query, option, state_requirement[option]) }
      end
    end

    # Verifies that an option in the given query matches the values required
    # for that option
    def matches_requirement?(query, option, requirement)
      !query.include?(option) || requirement.matches?(query[option], query)
    end

    # Verifies that the conditionals for this branch evaluate to true for the
    # given object
    def matches_conditions?(object, query)
      query[:guard] == false ||
      Array(if_condition).all? { |condition| evaluate_method(object, condition) } &&
      !Array(unless_condition).any? { |condition| evaluate_method(object, condition) }
    end
  end
end