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