lib/state_machine/machine.rb in state_machine-1.1.2 vs lib/state_machine/machine.rb in state_machine-1.2.0
- old
+ new
@@ -487,20 +487,20 @@
# Default messages to use for validation errors in ORM integrations
class << self; attr_accessor :default_messages; end
@default_messages = {
:invalid => 'is invalid',
:invalid_event => 'cannot transition when %s',
- :invalid_transition => 'cannot transition via "%s"'
+ :invalid_transition => 'cannot transition via "%1$s"'
}
# Whether to ignore any conflicts that are detected for helper methods that
# get generated for a machine's owner class. Default is false.
class << self; attr_accessor :ignore_method_conflicts; end
@ignore_method_conflicts = false
# The class that the machine is defined in
- attr_accessor :owner_class
+ attr_reader :owner_class
# The name of the machine, used for scoping methods generated for the
# machine as a whole (not states or events)
attr_reader :name
@@ -540,11 +540,11 @@
options = args.last.is_a?(Hash) ? args.pop : {}
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_by_name(options[:integration]) if options[:integration]
+ @integration = options[:integration] && StateMachine::Integrations.find_by_name(options[:integration])
else
@integration = StateMachine::Integrations.match(owner_class)
end
if @integration
@@ -564,10 +564,11 @@
@namespace = options[:namespace]
@messages = options[:messages] || {}
@action = options[:action]
@use_transactions = options[:use_transactions]
@initialize_state = options[:initialize]
+ @action_hook_defined = false
self.owner_class = owner_class
self.initial_state = options[:initial] unless sibling_machines.any?
# Merge with sibling machine configurations
add_sibling_machine_configs
@@ -629,12 +630,23 @@
@initial_state = new_initial_state
add_states([@initial_state]) unless dynamic_initial_state?
# Update all states to reflect the new initial state
states.each {|state| state.initial = (state.name == @initial_state)}
+
+ # Output a warning if there are conflicting initial states for the machine's
+ # attribute
+ initial_state = states.detect {|state| state.initial}
+ if !owner_class_attribute_default.nil? && (dynamic_initial_state? || !owner_class_attribute_default_matches?(initial_state))
+ warn(
+ "Both #{owner_class.name} and its #{name.inspect} machine have defined "\
+ "a different default for \"#{attribute}\". Use only one or the other for "\
+ "defining defaults to avoid unexpected behaviors."
+ )
+ end
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.
#
# == Examples
@@ -671,11 +683,11 @@
states.fetch(dynamic_initial_state? ? evaluate_method(object, @initial_state) : @initial_state) if instance_variable_defined?('@initial_state')
end
# Whether a dynamic initial state is being used in the machine
def dynamic_initial_state?
- @initial_state.is_a?(Proc)
+ instance_variable_defined?('@initial_state') && @initial_state.is_a?(Proc)
end
# Initializes the state on the given object. Initial values are only set if
# the machine's attribute hasn't been previously initialized.
#
@@ -747,12 +759,12 @@
ancestor_name = conflicting_ancestor.name && !conflicting_ancestor.name.empty? ? conflicting_ancestor.name : conflicting_ancestor.to_s
warn "#{scope == :class ? 'Class' : 'Instance'} method \"#{method}\" is already defined in #{ancestor_name}, use generic helper instead or set StateMachine::Machine.ignore_method_conflicts = true."
else
name = self.name
helper_module.class_eval do
- define_method(method) do |*args|
- block.call((scope == :instance ? self.class : self).state_machine(name), self, *args)
+ define_method(method) do |*block_args|
+ block.call((scope == :instance ? self.class : self).state_machine(name), self, *block_args)
end
end
end
else
helper_module.class_eval(method, *args)
@@ -1072,11 +1084,15 @@
# vehicle = Vehicle.new # => #<Vehicle:0xb7d94ab0 @state="parked">
# Vehicle.state_machine.read(vehicle, :state) # => "parked" # Equivalent to vehicle.state
# Vehicle.state_machine.read(vehicle, :event) # => nil # Equivalent to vehicle.state_event
def read(object, attribute, ivar = false)
attribute = self.attribute(attribute)
- ivar ? object.instance_variable_get("@#{attribute}") : object.send(attribute)
+ if ivar
+ object.instance_variable_defined?("@#{attribute}") ? object.instance_variable_get("@#{attribute}") : nil
+ else
+ object.send(attribute)
+ end
end
# Sets a new value in the given object's attribute.
#
# For example,
@@ -1443,10 +1459,11 @@
# first transition that matches will be performed.
def transition(options)
raise ArgumentError, 'Must specify :on event' unless options[:on]
branches = []
+ options = options.dup
event(*Array(options.delete(:on))) { branches << transition(options) }
branches.length == 1 ? branches.first : branches
end
@@ -1567,11 +1584,11 @@
# Examples:
#
# before_transition :parked => :idling, :if => :moving?, :do => ...
# before_transition :on => :ignite, :unless => :seatbelt_on?, :do => ...
#
- # === Accessing the transition
+ # == Accessing the transition
#
# In addition to passing the object being transitioned, the actual
# transition describing the context (e.g. event, from, to) can be accessed
# as well. This additional argument is only passed if the callback allows
# for it.
@@ -1595,10 +1612,44 @@
# involved. This is the default and may change on a per-integration basis.
#
# See StateMachine::Transition for more information about the
# attributes available on the transition.
#
+ # == Usage with delegates
+ #
+ # As noted above, state_machine uses the callback method's argument list
+ # arity to determine whether to include the transition in the method call.
+ # If you're using delegates, such as those defined in ActiveSupport or
+ # Forwardable, the actual arity of the delegated method gets masked. This
+ # means that callbacks which reference delegates will always get passed the
+ # transition as an argument. For example:
+ #
+ # class Vehicle
+ # extend Forwardable
+ # delegate :refresh => :dashboard
+ #
+ # state_machine do
+ # before_transition :refresh
+ # ...
+ # end
+ #
+ # def dashboard
+ # @dashboard ||= Dashboard.new
+ # end
+ # end
+ #
+ # class Dashboard
+ # def refresh(transition)
+ # # ...
+ # end
+ # end
+ #
+ # In the above example, <tt>Dashboard#refresh</tt> *must* defined a
+ # +transition+ argument. Otherwise, an +ArgumentError+ exception will get
+ # raised. The only way around this is to avoid the use of delegates and
+ # manually define the delegate method so that the correct arity is used.
+ #
# == Examples
#
# Below is an example of a class with one state machine and various types
# of +before+ transitions defined for it:
#
@@ -1828,11 +1879,19 @@
end
# Generates the message to use when invalidating the given object after
# failing to transition on a specific event
def generate_message(name, values = [])
- (@messages[name] || self.class.default_messages[name]) % values.map {|value| value.last}
+ message = (@messages[name] || self.class.default_messages[name])
+
+ # Check whether there are actually any values to interpolate to avoid
+ # any warnings
+ if message.scan(/%./).any? {|match| match != '%%'}
+ message % values.map {|value| value.last}
+ else
+ message
+ end
end
# Runs a transaction, rolling back any changes if the yielded block fails.
#
# This is only applicable to integrations that involve databases. By
@@ -1861,57 +1920,26 @@
# Default is "png'.
# * <tt>:font</tt> - The name of the font to draw state names in.
# Default is "Arial".
# * <tt>:orientation</tt> - The direction of the graph ("portrait" or
# "landscape"). Default is "portrait".
- # * <tt>:output</tt> - Whether to generate the output of the graph
- def draw(options = {})
- options = {
- :name => "#{owner_class.name}_#{name}",
- :path => '.',
- :format => 'png',
- :font => 'Arial',
- :orientation => 'portrait'
- }.merge(options)
- assert_valid_keys(options, :name, :path, :format, :font, :orientation)
+ # * <tt>:human_names</tt> - Whether to use human state / event names for
+ # node labels on the graph instead of the internal name. Default is false.
+ def draw(graph_options = {})
+ name = graph_options.delete(:name) || "#{owner_class.name}_#{self.name}"
+ draw_options = {:human_name => false}
+ draw_options[:human_name] = graph_options.delete(:human_names) if graph_options.include?(:human_names)
- begin
- # Load the graphviz library
- require 'rubygems'
- gem 'ruby-graphviz', '>=0.9.0'
- require 'graphviz'
-
- graph = GraphViz.new('G', :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB')
-
- # Add nodes
- states.by_priority.each do |state|
- node = state.draw(graph)
- node.fontname = options[:font]
- end
-
- # Add edges
- events.each do |event|
- edges = event.draw(graph)
- 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[0] == '0' && graphvizVersion[1] == '9' && graphvizVersion[2] == '0'
- outputOptions = {:output => options[:format], :file => file}
- else
- outputOptions = {options[:format] => file}
- end
-
- graph.output(outputOptions)
- graph
- rescue LoadError => ex
- $stderr.puts "Cannot draw the machine (#{ex.message}). `gem install ruby-graphviz` >= v0.9.0 and try again."
- false
- end
+ graph = Graph.new(name, graph_options)
+
+ # Add nodes / edges
+ states.by_priority.each {|state| state.draw(graph, draw_options)}
+ events.each {|event| event.draw(graph, draw_options)}
+
+ # Output result
+ graph.output
+ graph
end
# 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)
@@ -2140,13 +2168,13 @@
# automatically determined by either calling +pluralize+ on the attribute
# name or adding an "s" to the end of the name.
def define_scopes(custom_plural = nil)
plural = custom_plural || pluralize(name)
- [name, plural].uniq.each do |name|
- [:with, :without].each do |kind|
- method = "#{kind}_#{name}"
+ [:with, :without].each do |kind|
+ [name, plural].map {|s| s.to_s}.uniq.each do |suffix|
+ method = "#{kind}_#{suffix}"
if scope = send("create_#{kind}_scope", method)
# Converts state names to their corresponding values so that they
# can be looked up properly
define_helper(:class, method) do |machine, klass, *states|
@@ -2190,9 +2218,21 @@
end
# Always yields
def transaction(object)
yield
+ end
+
+ # Gets the initial attribute value defined by the owner class (outside of
+ # the machine's definition). By default, this is always nil.
+ def owner_class_attribute_default
+ nil
+ end
+
+ # Checks whether the given state matches the attribute default specified
+ # by the owner class
+ def owner_class_attribute_default_matches?(state)
+ state.matches?(owner_class_attribute_default)
end
# Updates this machine based on the configuration of other machines in the
# owner class that share the same target attribute.
def add_sibling_machine_configs