lib/state_machines/integrations/active_record.rb
require 'state_machines-activemodel'
require 'active_record'
require 'state_machines/integrations/active_record/version'
module StateMachines
module Integrations #:nodoc:
# Adds support for integrating state machines with ActiveRecord models.
#
# == Examples
#
# Below is an example of a simple state machine defined within an
# ActiveRecord model:
#
# class Vehicle < ApplicationRecord
# 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, the action that will be invoked when a state is transitioned
# is the +save+ action. This will cause the record to save the changes
# made to the state machine's attribute. *Note* that if any other changes
# were made to the record prior to transition, then those changes will
# be saved as well.
#
# For example,
#
# vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
# vehicle.name = 'Ford Explorer'
# vehicle.ignite # => true
# vehicle.reload # => #<Vehicle id: 1, name: "Ford Explorer", state: "idling">
#
# *Note* that if you want a transition to update additional attributes of the record,
# either the changes need to be made in a +before_transition+ callback or you need
# to save the record manually.
#
# == Events
#
# As described in StateMachines::InstanceMethods#state_machine, event
# attributes are created for every machine that allow transitions to be
# performed automatically when the object's action (in this case, :save)
# is called.
#
# In ActiveRecord, these automated events are run in the following order:
# * before validation - Run before callbacks and persist new states, then validate
# * before save - If validation was skipped, run before callbacks and persist new states, then save
# * after save - Run after callbacks
#
# For example,
#
# vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
# vehicle.state_event # => nil
# vehicle.state_event = 'invalid'
# vehicle.valid? # => false
# vehicle.errors.full_messages # => ["State event is invalid"]
#
# vehicle.state_event = 'ignite'
# vehicle.valid? # => true
# vehicle.save # => true
# vehicle.state # => "idling"
# vehicle.state_event # => nil
#
# Note that this can also be done on a mass-assignment basis:
#
# vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle id: 1, name: nil, state: "idling">
# vehicle.state # => "idling"
#
# This technique is always used for transitioning states when the +save+
# action (which is the default) is configured for the machine.
#
# === Security implications
#
# Beware that public event attributes mean that events can be fired
# whenever mass-assignment is being used. If you want to prevent malicious
# users from tampering with events through URLs / forms, the attribute
# should be protected like so:
#
# class Vehicle < ApplicationRecord
# attr_protected :state_event
# # attr_accessible ... # Alternative technique
#
# state_machine do
# ...
# end
# end
#
# If you want to only have *some* events be able to fire via mass-assignment,
# you can build two state machines (one public and one protected) like so:
#
# class Vehicle < ApplicationRecord
# attr_protected :state_event # Prevent access to events in the first machine
#
# state_machine do
# # Define private events here
# end
#
# # Public machine targets the same state as the private machine
# state_machine :public_state, :attribute => :state do
# # Define public events here
# end
# end
#
# == Transactions
#
# In order to ensure that any changes made during transition callbacks
# are rolled back during a failed attempt, every transition is wrapped
# within a transaction.
#
# For example,
#
# class Message < ApplicationRecord
# end
#
# Vehicle.state_machine do
# before_transition do |vehicle, transition|
# Message.create(:content => transition.inspect)
# false
# end
# end
#
# vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
# vehicle.ignite # => false
# Message.count # => 0
#
# *Note* that only before callbacks that halt the callback chain and
# failed attempts to save the record will result in the transaction being
# rolled back. If an after callback halts the chain, the previous result
# still applies and the transaction is *not* rolled back.
#
# To turn off transactions:
#
# class Vehicle < ApplicationRecord
# state_machine :initial => :parked, :use_transactions => false do
# ...
# end
# end
#
# == Validations
#
# As mentioned in StateMachines::Machine#state, you can define behaviors,
# like validations, that only execute for certain states. One *important*
# caveat here is that, due to a constraint in ActiveRecord's validation
# framework, custom validators will not work as expected when defined to run
# in multiple states. For example:
#
# class Vehicle < ApplicationRecord
# state_machine do
# ...
# state :first_gear, :second_gear do
# validate :speed_is_legal
# end
# end
# end
#
# In this case, the <tt>:speed_is_legal</tt> validation will only get run
# for the <tt>:second_gear</tt> state. To avoid this, you can define your
# custom validation like so:
#
# class Vehicle < ApplicationRecord
# state_machine do
# ...
# state :first_gear, :second_gear do
# validate {|vehicle| vehicle.speed_is_legal}
# end
# end
# end
#
# == Validation errors
#
# If an event fails to successfully fire because there are no matching
# transitions for the current record, a validation error is added to the
# record's state attribute to help in determining why it failed and for
# reporting via the UI.
#
# For example,
#
# vehicle = Vehicle.create(:state => 'idling') # => #<Vehicle id: 1, name: nil, state: "idling">
# vehicle.ignite # => false
# vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""]
#
# If an event fails to fire because of a validation error on the record and
# *not* because a matching transition was not available, no error messages
# will be added to the state attribute.
#
# In addition, if you're using the <tt>ignite!</tt> version of the event,
# then the failure reason (such as the current validation errors) will be
# included in the exception that gets raised when the event fails. For
# example, assuming there's a validation on a field called +name+ on the class:
#
# vehicle = Vehicle.new
# vehicle.ignite! # => StateMachines::InvalidTransition: Cannot transition state via :ignite from :parked (Reason(s): Name cannot be blank)
#
# == Scopes
#
# To assist in filtering models with specific states, a series of named
# scopes are defined on the model for finding records with or without a
# particular set of states.
#
# These named scopes are essentially the functional equivalent of the
# following definitions:
#
# class Vehicle < ApplicationRecord
# # with_states also aliased to with_state
#
# named_scope :without_states, lambda {|*states| {:conditions => ['state NOT IN (?)', states]}}
# # without_states also aliased to without_state
# end
#
# *Note*, however, that the states are converted to their stored values
# before being passed into the query.
#
# Because of the way named scopes work in ActiveRecord, they can be
# chained like so:
#
# Vehicle.with_state(:parked).all(:order => 'id DESC')
#
# Note that states can also be referenced by the string version of their
# name:
#
# Vehicle.with_state('parked')
#
# == Callbacks
#
# All before/after transition callbacks defined for ActiveRecord models
# behave in the same way that other ActiveRecord callbacks behave. The
# object involved in the transition is passed in as an argument.
#
# For example,
#
# class Vehicle < ApplicationRecord
# 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.
#
# === Failure callbacks
#
# +after_failure+ callbacks allow you to execute behaviors when a transition
# is allowed, but fails to save. This could be useful for something like
# auditing transition attempts. Since callbacks run within transactions in
# ActiveRecord, a save failure will cause any records that get created in
# your callback to roll back. You can work around this issue like so:
#
# class TransitionLog < ApplicationRecord
# establish_connection Rails.env.to_sym
# end
#
# class Vehicle < ApplicationRecord
# state_machine do
# after_failure do |vehicle, transition|
# TransitionLog.create(:vehicle => vehicle, :transition => transition)
# end
#
# ...
# end
# end
#
# The +TransitionLog+ model establishes a second connection to the database
# that allows new records to be saved without being affected by rollbacks
# in the +Vehicle+ model's transaction.
#
# === Callback Order
#
# Callbacks occur in the following order. Callbacks specific to state_machine
# are bolded. The remaining callbacks are part of ActiveRecord.
#
# * (-) save
# * (-) begin transaction (if enabled)
# * (1) *before_transition*
# * (-) valid
# * (2) before_validation
# * (-) validate
# * (3) after_validation
# * (4) before_save
# * (5) before_create
# * (-) create
# * (6) after_create
# * (7) after_save
# * (8) *after_transition*
# * (-) end transaction (if enabled)
# * (9) after_commit
#
# == Observers
#
# In addition to support for ActiveRecord-like hooks, there is additional
# support for ActiveRecord observers. Because of the way ActiveRecord
# 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 record's +state+
# attribute changes the state from +parked+ to +idling+ via the +ignite+
# event, the following observer methods are supported:
# * before/after/after_failure_to-_ignite_from_parked_to_idling
# * before/after/after_failure_to-_ignite_from_parked
# * before/after/after_failure_to-_ignite_to_idling
# * before/after/after_failure_to-_ignite
# * before/after/after_failure_to-_transition_state_from_parked_to_idling
# * before/after/after_failure_to-_transition_state_from_parked
# * before/after/after_failure_to-_transition_state_to_idling
# * before/after/after_failure_to-_transition_state
# * before/after/after_failure_to-_transition
#
# The following class shows an example of some of these hooks:
#
# class VehicleObserver < ActiveRecord::Observer
# def before_save(vehicle)
# # log message
# end
#
# # 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 StateMachines::Machine#before_transition
# and StateMachines::Machine#after_transition.
#
# To define a single observer for multiple state machines:
#
# class StateMachineObserver < ActiveRecord::Observer
# observe Vehicle, Switch, Project
#
# def after_transition(record, transition)
# Audit.log(record, transition)
# end
# end
#
# == Internationalization
#
# In Rails 2.2+, any error message that is generated from performing invalid
# transitions can be localized. The following default translations are used:
#
# en:
# activerecord:
# errors:
# messages:
# invalid: "is invalid"
# # %{value} = attribute value, %{state} = Human state name
# invalid_event: "cannot transition when %{state}"
# # %{value} = attribute value, %{event} = Human event name, %{state} = Human current state name
# invalid_transition: "cannot transition via %{event}"
#
# Notice that the interpolation syntax is %{key} in Rails 3+. In Rails 2.x,
# the appropriate syntax is {{key}}.
#
# You can override these for a specific model like so:
#
# en:
# activerecord:
# errors:
# models:
# user:
# invalid: "is not valid"
#
# In addition to the above, you can also provide translations for the
# various states / events in each state machine. Using the Vehicle example,
# state translations will be looked for using the following keys, where
# +model_name+ = "vehicle", +machine_name+ = "state" and +state_name+ = "parked":
# * <tt>activerecord.state_machines.#{model_name}.#{machine_name}.states.#{state_name}</tt>
# * <tt>activerecord.state_machines.#{model_name}.states.#{state_name}</tt>
# * <tt>activerecord.state_machines.#{machine_name}.states.#{state_name}</tt>
# * <tt>activerecord.state_machines.states.#{state_name}</tt>
#
# Event translations will be looked for using the following keys, where
# +model_name+ = "vehicle", +machine_name+ = "state" and +event_name+ = "ignite":
# * <tt>activerecord.state_machines.#{model_name}.#{machine_name}.events.#{event_name}</tt>
# * <tt>activerecord.state_machines.#{model_name}.events.#{event_name}</tt>
# * <tt>activerecord.state_machines.#{machine_name}.events.#{event_name}</tt>
# * <tt>activerecord.state_machines.events.#{event_name}</tt>
#
# An example translation configuration might look like so:
#
# es:
# activerecord:
# state_machines:
# states:
# parked: 'estacionado'
# events:
# park: 'estacionarse'
module ActiveRecord
include Base
include ActiveModel
# The default options to use for state machines using this integration
@defaults = {:action => :save, use_transactions: true}
class << self
# Classes that inherit from ActiveRecord::Base will automatically use
# the ActiveRecord integration.
def matching_ancestors
[::ActiveRecord::Base]
end
end
protected
# Only runs validations on the action if using <tt>:save</tt>
def runs_validations_on_action?
action == :save
end
# Gets the db default for the machine's attribute
def owner_class_attribute_default
if owner_class.connected? && owner_class.table_exists?
owner_class.column_defaults[attribute.to_s]
end
end
def define_state_initializer
define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
def initialize(attributes = nil, *)
super(attributes) do |*args|
scoped_attributes = (attributes || {}).merge(self.class.scope_attributes)
self.class.state_machines.initialize_states(self, {}, scoped_attributes)
yield(*args) if block_given?
end
end
end_eval
end
# Uses around callbacks to run state events if using the :save hook
def define_action_hook
if action_hook == :save
define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
def save(*, **)
self.class.state_machine(#{name.inspect}).send(:around_save, self) { super }
end
def save!(*, **)
result = self.class.state_machine(#{name.inspect}).send(:around_save, self) { super }
result || raise(ActiveRecord::RecordInvalid.new(self))
end
def changed_for_autosave?
super || self.class.state_machines.any? {|name, machine| machine.action == :save && machine.read(self, :event)}
end
end_eval
else
super
end
end
# Runs state events around the machine's :save action
def around_save(object)
object.class.state_machines.transitions(object, action).perform { yield }
end
# Creates a scope for finding records *with* a particular state or
# states for the attribute
def create_with_scope(name)
create_scope(name, ->(values) { ["#{attribute_column} IN (?)", values] })
end
# Creates a scope for finding records *without* a particular state or
# states for the attribute
def create_without_scope(name)
create_scope(name, ->(values) { ["#{attribute_column} NOT IN (?)", values] })
end
# Generates the fully-qualifed column name for this machine's attribute
def attribute_column
connection = owner_class.connection
"#{connection.quote_table_name(owner_class.table_name)}.#{connection.quote_column_name(attribute)}"
end
# Runs a new database transaction, rolling back any changes by raising
# an ActiveRecord::Rollback exception if the yielded block fails
# (i.e. returns false).
def transaction(object)
result = nil
object.class.transaction do
raise ::ActiveRecord::Rollback unless result = yield
end
result
end
def locale_path
"#{File.dirname(__FILE__)}/active_record/locale.rb"
end
private
# Defines a new named scope with the given name
def create_scope(name, scope)
lambda { |model, values| model.where(scope.call(values)) }
end
# ActiveModel's use of method_missing / respond_to for attribute methods
# breaks both ancestor lookups and defined?(super). Need to special-case
# the existence of query attribute methods.
def owner_class_ancestor_has_method?(scope, method)
scope == :instance && method == "#{attribute}?" ? owner_class : super
end
end
register(ActiveRecord)
end
end