Skip to content

Commit

Permalink
Add support for customizing generated methods like #{attribute}_name …
Browse files Browse the repository at this point in the history
…using :as instead of always prefixing with the attribute name

Simplify reading from / writing to machine-related attributes on objects
  • Loading branch information
obrie committed Jun 9, 2009
1 parent 70ff774 commit 02b585f
Show file tree
Hide file tree
Showing 18 changed files with 327 additions and 55 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.rdoc
@@ -1,5 +1,7 @@
== master

* Add support for customizing generated methods like #{attribute}_name using :as instead of always prefixing with the attribute name
* Simplify reading from / writing to machine-related attributes on objects
* Fix locale for ActiveRecord getting added to the i18n load path multiple times [Reiner Dieterich]
* Fix callbacks, guards, and state-driven behaviors not always working on tainted classes [Brandon Dimcheff]
* Use Ruby 1.9's built-in Object#instance_exec for bound callbacks when it's available
Expand Down
55 changes: 52 additions & 3 deletions lib/state_machine.rb
Expand Up @@ -16,9 +16,14 @@ module MacroMethods
# * <tt>:action</tt> - The instance method to invoke when an object
# transitions. Default is nil unless otherwise specified by the
# configured integration.
# * <tt>:as</tt> - The name to use for prefixing all generated machine
# instance / class methods (e.g. if the attribute is +state_id+, then
# "state" would generate :state_name, :state_transitions, etc. instead of
# :state_id_name and :state_id_transitions)
# * <tt>:namespace</tt> - The name to use for namespacing all generated
# instance methods (e.g. "heater" would generate :turn_on_heater and
# :turn_off_heater for the :turn_on/:turn_off events). Default is nil.
# state / event instance methods (e.g. "heater" would generate
# :turn_on_heater and :turn_off_heater for the :turn_on/:turn_off events).
# Default is nil.
# * <tt>:integration</tt> - The name of the integration to use for adding
# library-specific behavior to the machine. Built-in integrations
# include :data_mapper, :active_record, and :sequel. By default, this
Expand Down Expand Up @@ -290,6 +295,50 @@ module MacroMethods
# see StateMachine::Machine#before_transition and
# StateMachine::Machine#after_transition.
#
# == Attribute aliases
#
# When a state machine is defined, several methods are generated scoped by
# the name of the attribute, such as (if the attribute were "state"):
# * <tt>state_name</tt>
# * <tt>state_event</tt>
# * <tt>state_transitions</tt>
# * etc.
#
# If the attribute for the machine were something less common, such as
# "state_id" or "state_value", this makes for more awkward scoped methods.
#
# Rather than scope based on the attribute, these methods can be customized
# using the <tt>:as</tt> option as essentially an alias.
#
# For example,
#
# class Vehicle
# state_machine :state_id, :as => :state do
# event :turn_on do
# transition all => :on
# end
#
# event :turn_off do
# transition all => :off
# end
#
# state :on, :value => 1
# state :off, :value => 2
# end
# end
#
# ...will generate the following methods:
# * <tt>state_name</tt>
# * <tt>state_event</tt>
# * <tt>state_transitions</tt>
#
# ...instead of:
# * <tt>state_id_name</tt>
# * <tt>state_id_event</tt>
# * <tt>state_id_transitions</tt>
#
# However, it will continue to read and write to the +state_id+ attribute.
#
# == Namespaces
#
# When a namespace is configured for a state machine, the name provided
Expand All @@ -301,7 +350,7 @@ module MacroMethods
# For example,
#
# class Vehicle
# state_machine :heater_state, :initial => :off :namespace => 'heater' do
# state_machine :heater_state, :initial => :off, :namespace => 'heater' do
# event :turn_on do
# transition all => :on
# end
Expand Down
4 changes: 2 additions & 2 deletions lib/state_machine/event.rb
Expand Up @@ -205,7 +205,7 @@ def draw(graph)
guards.collect {|guard| guard.draw(graph, name, valid_states)}.flatten
end

# Generates a nicely formatted description of this events's contents.
# Generates a nicely formatted description of this event's contents.
#
# For example,
#
Expand Down Expand Up @@ -244,7 +244,7 @@ def add_actions

# Fires the event, raising an exception if it fails
machine.define_instance_method("#{qualified_name}!") do |machine, object, *args|
object.send(qualified_name, *args) || raise(StateMachine::InvalidTransition, "Cannot transition #{machine.attribute} via :#{name} from #{machine.states.match(object).name.inspect}")
object.send(qualified_name, *args) || raise(StateMachine::InvalidTransition, "Cannot transition #{machine.name} via :#{name} from #{machine.states.match(object).name.inspect}")
end
end
end
Expand Down
11 changes: 5 additions & 6 deletions lib/state_machine/event_collection.rb
Expand Up @@ -92,18 +92,17 @@ def attribute_transition_for(object, invalidate = false)
return unless machine.action

result = nil
attribute = machine.attribute

if name = object.send("#{attribute}_event")
if event = self[name.to_sym, :name]
unless result = object.send("#{attribute}_event_transition") || event.transition_for(object)
if event_name = machine.read(object, :event)
if event = self[event_name.to_sym, :name]
unless result = machine.read(object, :event_transition) || event.transition_for(object)
# No valid transition: invalidate
machine.invalidate(object, "#{attribute}_event", :invalid_event, [[:state, machine.states.match!(object).name]]) if invalidate
machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).name]]) if invalidate
result = false
end
else
# Event is unknown: invalidate
machine.invalidate(object, "#{attribute}_event", :invalid) if invalidate
machine.invalidate(object, :event, :invalid) if invalidate
result = false
end
end
Expand Down
9 changes: 6 additions & 3 deletions lib/state_machine/integrations/active_record.rb
Expand Up @@ -283,6 +283,8 @@ def self.extended(base) #:nodoc:

# Adds a validation error to the given object
def invalidate(object, attribute, message, values = [])
attribute = self.attribute(attribute)

if Object.const_defined?(:I18n)
options = values.inject({}) {|options, (key, value)| options[key] = value; options}
object.errors.add(attribute, message, options.merge(
Expand Down Expand Up @@ -318,12 +320,13 @@ def define_state_accessor
# compatibility with the default predicate which determines whether
# *anything* is set for the attribute's value
def define_state_predicate
name = self.name
attribute = self.attribute

# Still use class_eval here instance of define_instance_method since
# we need to be able to call +super+
@instance_helper_module.class_eval do
define_method("#{attribute}?") do |*args|
define_method("#{name}?") do |*args|
args.empty? ? super(*args) : self.class.state_machine(attribute).states.matches?(self, *args)
end
end
Expand Down Expand Up @@ -415,13 +418,13 @@ def define_scope(name, scope)
# This will always return true regardless of the results of the
# callbacks.
def notify(type, object, transition)
attribute = transition.attribute
name = self.name
event = transition.qualified_event
from = transition.from_name
to = transition.to_name

# Machine-specific updates
["#{type}_#{event}", "#{type}_transition_#{attribute}"].each do |event_segment|
["#{type}_#{event}", "#{type}_transition_#{name}"].each do |event_segment|
["_from_#{from}", nil].each do |from_segment|
["_to_#{to}", nil].each do |to_segment|
object.class.changed
Expand Down
2 changes: 1 addition & 1 deletion lib/state_machine/integrations/data_mapper.rb
Expand Up @@ -255,7 +255,7 @@ def self.extended(base) #:nodoc:

# Adds a validation error to the given object
def invalidate(object, attribute, message, values = [])
object.errors.add(attribute, generate_message(message, values)) if supports_validations?
object.errors.add(self.attribute(attribute), generate_message(message, values)) if supports_validations?
end

# Resets any errors previously added when invalidating the given object
Expand Down
2 changes: 1 addition & 1 deletion lib/state_machine/integrations/sequel.rb
Expand Up @@ -231,7 +231,7 @@ def self.extended(base) #:nodoc:

# Adds a validation error to the given object
def invalidate(object, attribute, message, values = [])
object.errors.add(attribute, generate_message(message, values))
object.errors.add(self.attribute(attribute), generate_message(message, values))
end

# Resets any errors previously added when invalidating the given object
Expand Down
56 changes: 35 additions & 21 deletions lib/state_machine/machine.rb
Expand Up @@ -368,6 +368,10 @@ class << self; attr_accessor :default_messages; end
# The attribute for which the machine is being defined
attr_reader :attribute

# The name of the machine, used for scoping methods generated for the
# machine as a whole (not states or events)
attr_reader :name

# The events that trigger transitions. These are sorted, by default, in
# the order in which they were defined.
attr_reader :events
Expand Down Expand Up @@ -402,7 +406,7 @@ class << self; attr_accessor :default_messages; end
# Creates a new state machine for the given attribute
def initialize(owner_class, *args, &block)
options = args.last.is_a?(Hash) ? args.pop : {}
assert_valid_keys(options, :initial, :action, :plural, :namespace, :integration, :messages, :use_transactions)
assert_valid_keys(options, :as, :initial, :action, :plural, :namespace, :integration, :messages, :use_transactions)

# Find an integration that matches this machine's owner class
if integration = options[:integration] ? StateMachine::Integrations.find(options[:integration]) : StateMachine::Integrations.match(owner_class)
Expand All @@ -415,6 +419,7 @@ def initialize(owner_class, *args, &block)

# Set machine configuration
@attribute = args.first || :state
@name = options[:as] || @attribute
@events = EventCollection.new(self)
@states = StateCollection.new(self)
@callbacks = {:before => [], :after => []}
Expand Down Expand Up @@ -484,13 +489,18 @@ def initial_state=(new_initial_state)
states.each {|state| state.initial = (state.name == @initial_state)}
end

# Gets the actual name of the attribute on the machine's owner class that
# stores data with the given name.
def attribute(name = :state)
name == :state ? @attribute : :"#{self.name}_#{name}"
end

# Defines a new instance method with the given name on the machine's owner
# class. If the method is already defined in the class, then this will not
# override it.
#
# Example:
#
# attribute = machine.attribute
# machine.define_instance_method(:state_name) do |machine, object|
# machine.states.match(object)
# end
Expand Down Expand Up @@ -616,7 +626,7 @@ def initial_state(object)
# end
#
# class Vehicle < ActiveRecord::Base
# state_machine :state_id, :initial => :parked do
# state_machine :state_id, :as => 'state', :initial => :parked do
# event :ignite do
# transition :parked => :idling
# end
Expand Down Expand Up @@ -824,7 +834,7 @@ def state(*names, &block)
end
alias_method :other_states, :state

# Gets the current value stored in the given object's state.
# Gets the current value stored in the given object's attribute.
#
# For example,
#
Expand All @@ -834,10 +844,12 @@ def state(*names, &block)
# end
# end
#
# vehicle = Vehicle.new # => #<Vehicle:0xb7d94ab0 @state="parked">
# Vehicle.state_machine.read(vehicle) # => "parked"
def read(object)
object.send(attribute)
# 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)
end

# Sets a new value in the given object's state.
Expand All @@ -853,8 +865,8 @@ def read(object)
# vehicle = Vehicle.new # => #<Vehicle:0xb7d94ab0 @state="parked">
# Vehicle.state_machine.write(vehicle, 'idling')
# vehicle.state # => "idling"
def write(object, value)
object.send("#{attribute}=", value)
def write(object, attribute, value)
object.send("#{self.attribute(attribute)}=", value)
end

# Defines one or more events for the machine and the transitions that can
Expand Down Expand Up @@ -1272,7 +1284,7 @@ def define_helpers
define_action_helpers if action

# Gets the state name for the current value
define_instance_method("#{attribute}_name") do |machine, object|
define_instance_method(attribute(:name)) do |machine, object|
machine.states.match!(object).name
end
end
Expand All @@ -1289,7 +1301,7 @@ def define_state_accessor
# Adds predicate method to the owner class for determining the name of the
# current state
def define_state_predicate
define_instance_method("#{attribute}?") do |machine, object, state|
define_instance_method("#{name}?") do |machine, object, state|
machine.states.matches?(object, state)
end
end
Expand All @@ -1298,31 +1310,33 @@ def define_state_predicate
# events
def define_event_helpers
# Gets the events that are allowed to fire on the current object
define_instance_method("#{attribute}_events") do |machine, object|
define_instance_method(attribute(:events)) do |machine, object|
machine.events.valid_for(object).map {|event| event.name}
end

# Gets the next possible transitions that can be run on the current
# object
define_instance_method("#{attribute}_transitions") do |machine, object, *args|
define_instance_method(attribute(:transitions)) do |machine, object, *args|
machine.events.transitions_for(object, *args)
end

# Add helpers for interacting with the action
if action
attribute = self.attribute
name = self.name

# Tracks the event / transition to invoke when the action is called
event_attribute = attribute(:event)
event_transition_attribute = attribute(:event_transition)
@instance_helper_module.class_eval do
attr_writer "#{attribute}_event"
attr_writer event_attribute

protected
attr_accessor "#{attribute}_event_transition"
attr_accessor event_transition_attribute
end

# Interpret non-blank events as present
define_instance_method("#{attribute}_event") do |machine, object|
event = object.instance_variable_get("@#{attribute}_event")
define_instance_method(attribute(:event)) do |machine, object|
event = machine.read(object, :event, true)
event && !(event.respond_to?(:empty?) && event.empty?) ? event.to_sym : nil
end
end
Expand Down Expand Up @@ -1358,9 +1372,9 @@ def define_action_helpers(action_hook = self.action)
# 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 || (attribute.to_s.respond_to?(:pluralize) ? attribute.to_s.pluralize : "#{attribute}s")
plural = custom_plural || (name.to_s.respond_to?(:pluralize) ? name.to_s.pluralize : "#{name}s")

[attribute, plural].uniq.each do |name|
[name, plural].uniq.each do |name|
[:with, :without].each do |kind|
method = "#{kind}_#{name}"

Expand Down
16 changes: 7 additions & 9 deletions lib/state_machine/machine_collection.rb
Expand Up @@ -6,8 +6,8 @@ class MachineCollection < Hash
# (which must mean the defaults are being skipped)
def initialize_states(object)
each do |attribute, machine|
value = machine.read(object)
machine.write(object, machine.initial_state(object).value) if value.nil? || value.respond_to?(:empty?) && value.empty?
value = machine.read(object, :state)
machine.write(object, :state, machine.initial_state(object).value) if value.nil? || value.respond_to?(:empty?) && value.empty?
end
end

Expand Down Expand Up @@ -118,27 +118,25 @@ def fire_event_attributes(object, action, complete = true)
begin
result = Transition.perform(transitions, :after => complete) do
# Prevent events from being evaluated multiple times if actions are nested
transitions.each {|transition| object.send("#{transition.attribute}_event=", nil)}
transitions.each {|transition| transition.machine.write(object, :event, nil)}
action_value = yield
end
rescue Exception
# Revert attribute modifications
transitions.each do |transition|
object.send("#{transition.attribute}_event=", transition.event)
object.send("#{transition.attribute}_event_transition=", nil) if complete
transition.machine.write(object, :event, transition.event)
transition.machine.write(object, :event_transition, nil) if complete
end

raise
end

transitions.each do |transition|
attribute = transition.attribute

# Revert event unless transition was successful
object.send("#{attribute}_event=", transition.event) unless complete && result
transition.machine.write(object, :event, transition.event) unless complete && result

# Track transition if partial transition completed successfully
object.send("#{attribute}_event_transition=", !complete && result ? transition : nil)
transition.machine.write(object, :event_transition, !complete && result ? transition : nil)
end
end

Expand Down

0 comments on commit 02b585f

Please sign in to comment.