require 'state_machine/matcher' require 'state_machine/eval_helpers' require 'state_machine/assertions' module StateMachine # 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 Assertions 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 = StateMachine::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: # * :from - One or more states being transitioned from. If none # are specified, then this will always match. # * :to - One or more states being transitioned to. If none are # specified, then this will always match. # * :on - One or more events that fired the transition. If none # are specified, then this will always match. # * :guard - Whether to guard matches with the if/unless # conditionals defined for this branch. Default is true. # # == Examples # # branch = StateMachine::Branch.new(:parked => :idling, :on => :ignite) # # branch.match(object, :on => :ignite) # => {:to => ..., :from => ..., :on => ...} # branch.match(object, :on => :park) # => nil def match(object, query = {}) assert_valid_keys(query, :from, :to, :on, :guard) if (match = match_query(query)) && matches_conditions?(object, query) match end end # Draws a representation of this branch on the given graph. This will draw # an edge between every state this branch matches *from* to either the # configured to state or, if none specified, then a loopback to the from # state. # # For example, if the following from states are configured: # * +idling+ # * +first_gear+ # * +backing_up+ # # ...and the to state is +parked+, then the following edges will be created: # * +idling+ -> +parked+ # * +first_gear+ -> +parked+ # * +backing_up+ -> +parked+ # # Each edge will be labeled with the name of the event that would cause the # transition. def draw(graph, event, valid_states) state_requirements.each do |state_requirement| # From states determined based on the known valid states from_states = state_requirement[:from].filter(valid_states) # If a to state is not specified, then it's a loopback and each from # state maps back to itself if state_requirement[:to].values.empty? loopback = true else to_state = state_requirement[:to].values.first to_state = to_state ? to_state.to_s : 'nil' loopback = false end # Generate an edge between each from and to state from_states.each do |from_state| from_state = from_state ? from_state.to_s : 'nil' graph.add_edges(from_state, loopback ? from_state : to_state, :label => event.to_s) end end true 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) assert_exclusive_keys(options, 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