lib/state_machines/node_collection.rb
module StateMachines
# Represents a collection of nodes in a state machine, be it events or states.
# Nodes will not differentiate between the String and Symbol versions of the
# values being indexed.
class NodeCollection
include Enumerable
# The machine associated with the nodes
attr_reader :machine
# Creates a new collection of nodes for the given state machine. By default,
# the collection is empty.
#
# Configuration options:
# * <tt>:index</tt> - One or more attributes to automatically generate
# hashed indices for in order to perform quick lookups. Default is to
# index by the :name attribute
def initialize(machine, options = {})
options.assert_valid_keys(:index)
options = { index: :name }.merge(options)
@machine = machine
@nodes = []
@index_names = Array(options[:index])
@indices = @index_names.reduce({}) do |indices, name|
indices[name] = {}
indices[:"#{name}_to_s"] = {}
indices[:"#{name}_to_sym"] = {}
indices
end
@default_index = Array(options[:index]).first
@contexts = []
end
# Creates a copy of this collection such that modifications don't affect
# the original collection
def initialize_copy(orig) #:nodoc:
super
nodes = @nodes
contexts = @contexts
@nodes = []
@contexts = []
@indices = @indices.reduce({}) { |indices, (name, *)| indices[name] = {}; indices }
# Add nodes *prior* to copying over the contexts so that they don't get
# evaluated multiple times
concat(nodes.map { |n| n.dup })
@contexts = contexts.dup
end
# Changes the current machine associated with the collection. In turn, this
# will change the state machine associated with each node in the collection.
def machine=(new_machine)
@machine = new_machine
each { |node| node.machine = new_machine }
end
# Gets the number of nodes in this collection
def length
@nodes.length
end
# Gets the set of unique keys for the given index
def keys(index_name = @default_index)
index(index_name).keys
end
# Tracks a context that should be evaluated for any nodes that get added
# which match the given set of nodes. Matchers can be used so that the
# context can get added once and evaluated after multiple adds.
def context(nodes, &block)
nodes = nodes.first.is_a?(Matcher) ? nodes.first : WhitelistMatcher.new(nodes)
@contexts << context = { nodes: nodes, block: block }
# Evaluate the new context for existing nodes
each { |node| eval_context(context, node) }
context
end
# Adds a new node to the collection. By doing so, this will also add it to
# the configured indices. This will also evaluate any existings contexts
# that match the new node.
def <<(node)
@nodes << node
@index_names.each { |name| add_to_index(name, value(node, name), node) }
@contexts.each { |context| eval_context(context, node) }
self
end
# Appends a group of nodes to the collection
def concat(nodes)
nodes.each { |node| self << node }
end
# Updates the indexed keys for the given node. If the node's attribute
# has changed since it was added to the collection, the old indexed keys
# will be replaced with the updated ones.
def update(node)
@index_names.each { |name| update_index(name, node) }
end
# Calls the block once for each element in self, passing that element as a
# parameter.
#
# states = StateMachines::NodeCollection.new
# states << StateMachines::State.new(machine, :parked)
# states << StateMachines::State.new(machine, :idling)
# states.each {|state| puts state.name, ' -- '}
#
# ...produces:
#
# parked -- idling --
def each
@nodes.each { |node| yield node }
self
end
# Gets the node at the given index.
#
# states = StateMachines::NodeCollection.new
# states << StateMachines::State.new(machine, :parked)
# states << StateMachines::State.new(machine, :idling)
#
# states.at(0).name # => :parked
# states.at(1).name # => :idling
def at(index)
@nodes[index]
end
# Gets the node indexed by the given key. By default, this will look up the
# key in the first index configured for the collection. A custom index can
# be specified like so:
#
# collection['parked', :value]
#
# The above will look up the "parked" key in a hash indexed by each node's
# +value+ attribute.
#
# If the key cannot be found, then nil will be returned.
def [](key, index_name = @default_index)
index(index_name)[key] ||
index(:"#{index_name}_to_s")[key.to_s] ||
to_sym?(key) && index(:"#{index_name}_to_sym")[:"#{key}"] ||
nil
end
# Gets the node indexed by the given key. By default, this will look up the
# key in the first index configured for the collection. A custom index can
# be specified like so:
#
# collection['parked', :value]
#
# The above will look up the "parked" key in a hash indexed by each node's
# +value+ attribute.
#
# If the key cannot be found, then an IndexError exception will be raised:
#
# collection['invalid', :value] # => IndexError: "invalid" is an invalid value
def fetch(key, index_name = @default_index)
self[key, index_name] || fail(IndexError, "#{key.inspect} is an invalid #{index_name}")
end
protected
# Gets the given index. If the index does not exist, then an ArgumentError
# is raised.
def index(name)
fail ArgumentError, 'No indices configured' unless @indices.any?
@indices[name] || fail(ArgumentError, "Invalid index: #{name.inspect}")
end
# Gets the value for the given attribute on the node
def value(node, attribute)
node.send(attribute)
end
# Adds the given key / node combination to an index, including the string
# and symbol versions of the index
def add_to_index(name, key, node)
index(name)[key] = node
index(:"#{name}_to_s")[key.to_s] = node
index(:"#{name}_to_sym")[:"#{key}"] = node if to_sym?(key)
end
# Removes the given key from an index, including the string and symbol
# versions of the index
def remove_from_index(name, key)
index(name).delete(key)
index(:"#{name}_to_s").delete(key.to_s)
index(:"#{name}_to_sym").delete(:"#{key}") if to_sym?(key)
end
# Updates the node for the given index, including the string and symbol
# versions of the index
def update_index(name, node)
index = self.index(name)
old_key = index.key(node)
new_key = value(node, name)
# Only replace the key if it's changed
if old_key != new_key
remove_from_index(name, old_key)
add_to_index(name, new_key, node)
end
end
# Determines whether the given value can be converted to a symbol
def to_sym?(value)
"#{value}" != ''
end
# Evaluates the given context for a particular node. This will only
# evaluate the context if the node matches.
def eval_context(context, node)
node.context(&context[:block]) if context[:nodes].matches?(node.name)
end
end
end