lib/state_machines/transition_collection.rb
module StateMachines
# Represents a collection of transitions in a state machine
class TransitionCollection < Array
# Whether to skip running the action for each transition's machine
attr_reader :skip_actions
# Whether to skip running the after callbacks
attr_reader :skip_after
# Whether transitions should wrapped around a transaction block
attr_reader :use_transactions
# Creates a new collection of transitions that can be run in parallel. Each
# transition *must* be for a different attribute.
#
# Configuration options:
# * <tt>:actions</tt> - Whether to run the action configured for each transition
# * <tt>:after</tt> - Whether to run after callbacks
# * <tt>:transaction</tt> - Whether to wrap transitions within a transaction
def initialize(transitions = [], options = {})
super(transitions)
# Determine the validity of the transitions as a whole
@valid = all?
reject! { |transition| !transition }
attributes = map { |transition| transition.attribute }.uniq
fail ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != length
options.assert_valid_keys(:actions, :after, :use_transactions)
options = {actions: true, after: true, use_transactions: true}.merge(options)
@skip_actions = !options[:actions]
@skip_after = !options[:after]
@use_transactions = options[:use_transactions]
end
# Runs each of the collection's transitions in parallel.
#
# All transitions will run through the following steps:
# 1. Before callbacks
# 2. Persist state
# 3. Invoke action
# 4. After callbacks (if configured)
# 5. Rollback (if action is unsuccessful)
#
# If a block is passed to this method, that block will be called instead
# of invoking each transition's action.
def perform(&block)
reset
if valid?
if use_event_attributes? && !block_given?
each do |transition|
transition.transient = true
transition.machine.write(object, :event_transition, transition)
end
run_actions
else
within_transaction do
catch(:halt) { run_callbacks(&block) }
rollback unless success?
end
end
end
if actions.length == 1 && results.include?(actions.first)
results[actions.first]
else
success?
end
end
protected
attr_reader :results #:nodoc:
private
# Is this a valid set of transitions? If the collection was creating with
# any +false+ values for transitions, then the the collection will be
# marked as invalid.
def valid?
@valid
end
# Did each transition perform successfully? This will only be true if the
# following requirements are met:
# * No +before+ callbacks halt
# * All actions run successfully (always true if skipping actions)
def success?
@success
end
# Gets the object being transitioned
def object
first.object
end
# Gets the list of actions to run. If configured to skip actions, then
# this will return an empty collection.
def actions
empty? ? [nil] : map { |transition| transition.action }.uniq
end
# Determines whether an event attribute be used to trigger the transitions
# in this collection or whether the transitions be run directly *outside*
# of the action.
def use_event_attributes?
!skip_actions && !skip_after && actions.all? && actions.length == 1 && first.machine.action_hook?
end
# Resets any information tracked from previous attempts to perform the
# collection
def reset
@results = {}
@success = false
end
# Runs each transition's callbacks recursively. Once all before callbacks
# have been executed, the transitions will then be persisted and the
# configured actions will be run.
#
# If any transition fails to run its callbacks, :halt will be thrown.
def run_callbacks(index = 0, &block)
if transition = self[index]
throw :halt unless transition.run_callbacks(after: !skip_after) do
run_callbacks(index + 1, &block)
{result: results[transition.action], success: success?}
end
else
persist
run_actions(&block)
end
end
# Transitions the current value of the object's states to those specified by
# each transition
def persist
each { |transition| transition.persist }
end
# Runs the actions for each transition. If a block is given method, then it
# will be called instead of invoking each transition's action.
#
# The results of the actions will be used to determine #success?.
def run_actions
catch_exceptions do
@success = if block_given?
result = yield
actions.each { |action| results[action] = result }
!!result
else
actions.compact.each { |action| !skip_actions && (results[action] = object.send(action)) }
results.values.all?
end
end
end
# Rolls back changes made to the object's states via each transition
def rollback
each { |transition| transition.rollback }
end
# Wraps the given block with a rescue handler so that any exceptions that
# occur will automatically result in the transition rolling back any changes
# that were made to the object involved.
def catch_exceptions
begin
yield
rescue
rollback
raise
end
end
# Runs a block within a transaction for the object being transitioned. If
# transactions are disabled, then this is a no-op.
def within_transaction
if use_transactions && !empty?
first.within_transaction do
yield
success?
end
else
yield
end
end
end
# Represents a collection of transitions that were generated from attribute-
# based events
class AttributeTransitionCollection < TransitionCollection
def initialize(transitions = [], options = {}) #:nodoc:
super(transitions, {use_transactions: false, actions: false}.merge(options))
end
private
# Hooks into running transition callbacks so that event / event transition
# attributes can be properly updated
def run_callbacks(index = 0)
if index == 0
# Clears any traces of the event attribute to prevent it from being
# evaluated multiple times if actions are nested
each do |transition|
transition.machine.write(object, :event, nil)
transition.machine.write(object, :event_transition, nil)
end
# Rollback only if exceptions occur during before callbacks
begin
super
rescue
rollback unless @before_run
@success = nil # mimics ActiveRecord.save behavior on rollback
raise
end
# Persists transitions on the object if partial transition was successful.
# This allows us to reference them later to complete the transition with
# after callbacks.
each { |transition| transition.machine.write(object, :event_transition, transition) } if skip_after && success?
else
super
end
end
# Tracks that before callbacks have now completed
def persist
@before_run = true
super
end
# Resets callback tracking
def reset
super
@before_run = false
end
# Resets the event attribute so it can be re-evaluated if attempted again
def rollback
super
each { |transition| transition.machine.write(object, :event, transition.event) unless transition.transient? }
end
end
end