module StateMachine
module Integrations #:nodoc:
# Adds support for integrating state machines with ActiveModel classes.
#
# == Examples
#
# If using ActiveModel directly within your class, then any one of the
# following features need to be included in order for the integration to be
# detected:
# * ActiveModel::Dirty
# * ActiveModel::Observing
# * ActiveModel::Validations
#
# Below is an example of a simple state machine defined within an
# ActiveModel class:
#
# class Vehicle
# include ActiveModel::Dirty
# include ActiveModel::Observing
# include ActiveModel::Validations
#
# attr_accessor :state
# define_attribute_methods [:state]
#
# state_machine :initial => :parked do
# event :ignite do
# transition :parked => :idling
# end
# end
# end
#
# The examples in the sections below will use the above class as a
# reference.
#
# == Actions
#
# By default, no action will be invoked when a state is transitioned. This
# means that if you want to save changes when transitioning, you must
# define the action yourself like so:
#
# class Vehicle
# include ActiveModel::Validations
# attr_accessor :state
#
# state_machine :action => :save do
# ...
# end
#
# def save
# # Save changes
# end
# end
#
# == Validation errors
#
# In order to hook in validation support for your model, the
# ActiveModel::Validations feature must be included. If this is included
# and an event fails to successfully fire because there are no matching
# transitions for the object, a validation error is added to the object's
# state attribute to help in determining why it failed.
#
# For example,
#
# vehicle = Vehicle.new
# vehicle.ignite # => false
# vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""]
#
# == Callbacks
#
# All before/after transition callbacks defined for ActiveModel models
# behave in the same way that other ActiveSupport callbacks behave. The
# object involved in the transition is passed in as an argument.
#
# For example,
#
# class Vehicle
# include ActiveModel::Validations
# attr_accessor :state
#
# state_machine :initial => :parked do
# before_transition any => :idling do |vehicle|
# vehicle.put_on_seatbelt
# end
#
# before_transition do |vehicle, transition|
# # log message
# end
#
# event :ignite do
# transition :parked => :idling
# end
# end
#
# def put_on_seatbelt
# ...
# end
# end
#
# Note, also, that the transition can be accessed by simply defining
# additional arguments in the callback block.
#
# == Observers
#
# In order to hook in observer support for your application, the
# ActiveModel::Observing feature must be included. Because of the way
# ActiveModel observers are designed, there is less flexibility around the
# specific transitions that can be hooked in. However, a large number of
# hooks *are* supported. For example, if a transition for a object's
# +state+ attribute changes the state from +parked+ to +idling+ via the
# +ignite+ event, the following observer methods are supported:
# * before/after_ignite_from_parked_to_idling
# * before/after_ignite_from_parked
# * before/after_ignite_to_idling
# * before/after_ignite
# * before/after_transition_state_from_parked_to_idling
# * before/after_transition_state_from_parked
# * before/after_transition_state_to_idling
# * before/after_transition_state
# * before/after_transition
#
# The following class shows an example of some of these hooks:
#
# class VehicleObserver < ActiveModel::Observer
# # Callback for :ignite event *before* the transition is performed
# def before_ignite(vehicle, transition)
# # log message
# end
#
# # Callback for :ignite event *after* the transition has been performed
# def after_ignite(vehicle, transition)
# # put on seatbelt
# end
#
# # Generic transition callback *before* the transition is performed
# def after_transition(vehicle, transition)
# Audit.log(vehicle, transition)
# end
# end
#
# More flexible transition callbacks can be defined directly within the
# model as described in StateMachine::Machine#before_transition
# and StateMachine::Machine#after_transition.
#
# To define a single observer for multiple state machines:
#
# class StateMachineObserver < ActiveModel::Observer
# observe Vehicle, Switch, Project
#
# def after_transition(object, transition)
# Audit.log(object, transition)
# end
# end
#
# == Dirty Attribute Tracking
#
# In order to hook in validation support for your model, the
# ActiveModel::Validations feature must be included. If this is included
# then state attributes will always be properly marked as changed whether
# they were a callback or not.
#
# For example,
#
# class Vehicle
# include ActiveModel::Dirty
# attr_accessor :state
#
# state_machine :initial => :parked do
# event :park do
# transition :parked => :parked
# end
# end
# end
#
# vehicle = Vehicle.new
# vehicle.changed # => []
# vehicle.park # => true
# vehicle.changed # => ["state"]
#
# == Creating new integrations
#
# If you want to integrate state_machine with an ORM that implements parts
# or all of the ActiveModel API, the following features must be specified:
# * i18n scope (locale)
# * Machine defaults
#
# For example,
#
# module StateMachine::Integrations::MyORM
# include StateMachine::Integrations::ActiveModel
#
# @defaults = {:action = > :persist}
#
# def self.matches?(klass)
# defined?(::MyORM::Base) && klass <= ::MyORM::Base
# end
#
# def self.extended(base)
# locale = "#{File.dirname(__FILE__)}/my_orm/locale.rb"
# I18n.load_path << locale unless I18n.load_path.include?(locale)
# end
#
# protected
# def runs_validation_on_action?
# action == :persist
# end
#
# def i18n_scope
# :myorm
# end
# end
#
# If you wish to implement other features, such as attribute initialization
# with protected attributes, named scopes, or database transactions, you
# must add these independent of the ActiveModel integration. See the
# ActiveRecord implementation for examples of these customizations.
module ActiveModel
module ClassMethods
# The default options to use for state machines using this integration
attr_reader :defaults
# Loads additional files specific to ActiveModel
def extended(base) #:nodoc:
require 'state_machine/integrations/active_model/observer'
if Object.const_defined?(:I18n)
locale = "#{File.dirname(__FILE__)}/active_model/locale.rb"
I18n.load_path.unshift(locale) unless I18n.load_path.include?(locale)
end
end
end
def self.included(base) #:nodoc:
base.class_eval do
extend ClassMethods
end
end
extend ClassMethods
# Should this integration be used for state machines in the given class?
# Classes that include ActiveModel::Dirty, ActiveModel::Observing, or
# ActiveModel::Validations will automatically use the ActiveModel
# integration.
def self.matches?(klass)
features = %w(Dirty Observing Validations)
defined?(::ActiveModel) && features.any? {|feature| ::ActiveModel.const_defined?(feature) && klass <= ::ActiveModel.const_get(feature)}
end
@defaults = {}
# Forces the change in state to be recognized regardless of whether the
# state value actually changed
def write(object, attribute, value)
result = super
if attribute == :state && supports_dirty_tracking?(object) && !object.send("#{self.attribute}_changed?")
object.send("#{self.attribute}_will_change!")
end
result
end
# Adds a validation error to the given object
def invalidate(object, attribute, message, values = [])
if supports_validations?
attribute = self.attribute(attribute)
options = values.inject({}) do |options, (key, value)|
options[key] = value
options
end
object.errors.add(attribute, message, options.merge(:default => @messages[message]))
end
end
# Resets any errors previously added when invalidating the given object
def reset(object)
object.errors.clear if supports_validations?
end
protected
# Whether observers are supported in the integration. Only true if
# ActiveModel::Observer is available.
def supports_observers?
defined?(::ActiveModel::Observing) && owner_class <= ::ActiveModel::Observing
end
# Whether validations are supported in the integration. Only true if
# the ActiveModel feature is enabled on the owner class.
def supports_validations?
defined?(::ActiveModel::Validations) && owner_class <= ::ActiveModel::Validations
end
# Do validations run when the action configured this machine is
# invoked? This is used to determine whether to fire off attribute-based
# event transitions when the action is run.
def runs_validations_on_action?
false
end
# Whether change (dirty) tracking is supported in the integration.
# Only true if the ActiveModel feature is enabled on the owner class.
def supports_dirty_tracking?(object)
defined?(::ActiveModel::Dirty) && owner_class <= ::ActiveModel::Dirty && object.respond_to?("#{self.attribute}_changed?")
end
# Gets the terminator to use for callbacks
def callback_terminator
@terminator ||= lambda {|result| result == false}
end
# Determines the base scope to use when looking up translations
def i18n_scope
owner_class.i18n_scope
end
# Translates the given key / value combo. Translation keys are looked
# up in the following order:
# * #{i18n_scope}.state_machines.#{model_name}.#{machine_name}.#{plural_key}.#{value}
# * #{i18n_scope}.state_machines.#{machine_name}.#{plural_key}.#{value}
# * #{i18n_scope}.state_machines.#{plural_key}.#{value}
#
# If no keys are found, then the humanized value will be the fallback.
def translate(klass, key, value)
ancestors = ancestors_for(klass)
group = key.to_s.pluralize
value = value ? value.to_s : 'nil'
# Generate all possible translation keys
translations = ancestors.map {|ancestor| :"#{ancestor.model_name.underscore}.#{name}.#{group}.#{value}"}
translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase])
I18n.translate(translations.shift, :default => translations, :scope => [i18n_scope, :state_machines])
end
# Build a list of ancestors for the given class to use when
# determining which localization key to use for a particular string.
def ancestors_for(klass)
klass.lookup_ancestors
end
# Adds the default callbacks for notifying ActiveModel observers
# before/after a transition has been performed.
def after_initialize
if supports_observers?
callbacks[:before] << Callback.new(:before) {|object, transition| notify(:before, object, transition)}
callbacks[:after] << Callback.new(:after) {|object, transition| notify(:after, object, transition)}
end
end
# Skips defining reader/writer methods since this is done automatically
def define_state_accessor
name = self.name
owner_class.validates_each(attribute) do |object, attr, value|
machine = object.class.state_machine(name)
machine.invalidate(object, :state, :invalid) unless machine.states.match(object)
end if supports_validations?
end
# Adds hooks into validation for automatically firing events
def define_action_helpers(*args)
super
action = self.action
@instance_helper_module.class_eval do
define_method(:valid?) do |*args|
self.class.state_machines.transitions(self, action, :after => false).perform { super(*args) }
end
end if runs_validations_on_action?
end
# Creates a new callback in the callback chain, always inserting it
# before the default Observer callbacks that were created after
# initialization.
def add_callback(type, options, &block)
options[:terminator] = callback_terminator
if supports_observers?
@callbacks[type == :around ? :before : type].insert(-2, callback = Callback.new(type, options, &block))
add_states(callback.known_states)
callback
else
super
end
end
# Configures new states with the built-in humanize scheme
def add_states(new_states)
super.each do |state|
state.human_name = lambda {|state, klass| translate(klass, :state, state.name)}
end
end
# Configures new event with the built-in humanize scheme
def add_events(new_events)
super.each do |event|
event.human_name = lambda {|event, klass| translate(klass, :event, event.name)}
end
end
# Notifies observers on the given object that a callback occurred
# involving the given transition. This will attempt to call the
# following methods on observers:
# * #{type}_#{qualified_event}_from_#{from}_to_#{to}
# * #{type}_#{qualified_event}_from_#{from}
# * #{type}_#{qualified_event}_to_#{to}
# * #{type}_#{qualified_event}
# * #{type}_transition_#{machine_name}_from_#{from}_to_#{to}
# * #{type}_transition_#{machine_name}_from_#{from}
# * #{type}_transition_#{machine_name}_to_#{to}
# * #{type}_transition_#{machine_name}
# * #{type}_transition
#
# This will always return true regardless of the results of the
# callbacks.
def notify(type, object, transition)
name = self.name
event = transition.qualified_event
from = transition.from_name
to = transition.to_name
# Machine-specific updates
["#{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 if object.class.respond_to?(:changed)
object.class.notify_observers([event_segment, from_segment, to_segment].join, object, transition)
end
end
end
# Generic updates
object.class.changed if object.class.respond_to?(:changed)
object.class.notify_observers("#{type}_transition", object, transition)
true
end
end
end
end