require 'state_machines/branch'
require 'state_machines/eval_helpers'
module StateMachines
# Callbacks represent hooks into objects that allow logic to be triggered
# before, after, or around a specific set of transitions.
class Callback
include EvalHelpers
class << self
# Determines whether to automatically bind the callback to the object
# being transitioned. This only applies to callbacks that are defined as
# lambda blocks (or Procs). Some integrations, such as DataMapper, handle
# callbacks by executing them bound to the object involved, while other
# integrations, such as ActiveRecord, pass the object as an argument to
# the callback. This can be configured on an application-wide basis by
# setting this configuration to +true+ or +false+. The default value
# is +false+.
#
# *Note* that the DataMapper and Sequel integrations automatically
# configure this value on a per-callback basis, so it does not have to
# be enabled application-wide.
#
# == Examples
#
# When not bound to the object:
#
# class Vehicle
# state_machine do
# before_transition do |vehicle|
# vehicle.set_alarm
# end
# end
#
# def set_alarm
# ...
# end
# end
#
# When bound to the object:
#
# StateMachines::Callback.bind_to_object = true
#
# class Vehicle
# state_machine do
# before_transition do
# self.set_alarm
# end
# end
#
# def set_alarm
# ...
# end
# end
attr_accessor :bind_to_object
# The application-wide terminator to use for callbacks when not
# explicitly defined. Terminators determine whether to cancel a
# callback chain based on the return value of the callback.
#
# See StateMachines::Callback#terminator for more information.
attr_accessor :terminator
end
# The type of callback chain this callback is for. This can be one of the
# following:
# * +before+
# * +after+
# * +around+
# * +failure+
attr_accessor :type
# An optional block for determining whether to cancel the callback chain
# based on the return value of the callback. By default, the callback
# chain never cancels based on the return value (i.e. there is no implicit
# terminator). Certain integrations, such as ActiveRecord and Sequel,
# change this default value.
#
# == Examples
#
# Canceling the callback chain without a terminator:
#
# class Vehicle
# state_machine do
# before_transition do |vehicle|
# throw :halt
# end
# end
# end
#
# Canceling the callback chain with a terminator value of +false+:
#
# class Vehicle
# state_machine do
# before_transition do |vehicle|
# false
# end
# end
# end
attr_reader :terminator
# The branch that determines whether or not this callback can be invoked
# based on the context of the transition. The event, from state, and
# to state must all match in order for the branch to pass.
#
# See StateMachines::Branch for more information.
attr_reader :branch
# Creates a new callback that can get called based on the configured
# options.
#
# In addition to the possible configuration options for branches, the
# following options can be configured:
# * :bind_to_object - Whether to bind the callback to the object involved.
# If set to false, the object will be passed as a parameter instead.
# Default is integration-specific or set to the application default.
# * :terminator - A block/proc that determines what callback
# results should cause the callback chain to halt (if not using the
# default throw :halt technique).
#
# More information about how those options affect the behavior of the
# callback can be found in their attribute definitions.
def initialize(type, *args, &block)
@type = type
raise ArgumentError, 'Type must be :before, :after, :around, or :failure' unless [:before, :after, :around, :failure].include?(type)
options = args.last.is_a?(Hash) ? args.pop : {}
@methods = args
@methods.concat(Array(options.delete(:do)))
@methods << block if block_given?
raise ArgumentError, 'Method(s) for callback must be specified' unless @methods.any?
options = {bind_to_object: self.class.bind_to_object, terminator: self.class.terminator}.merge(options)
# Proxy lambda blocks so that they're bound to the object
bind_to_object = options.delete(:bind_to_object)
@methods.map! do |method|
bind_to_object && method.is_a?(Proc) ? bound_method(method) : method
end
@terminator = options.delete(:terminator)
@branch = Branch.new(options)
end
# Gets a list of the states known to this callback by looking at the
# branch's known states
def known_states
branch.known_states
end
# Runs the callback as long as the transition context matches the branch
# requirements configured for this callback. If a block is provided, it
# will be called when the last method has run.
#
# If a terminator has been configured and it matches the result from the
# evaluated method, then the callback chain should be halted.
def call(object, context = {}, *args, &block)
if @branch.matches?(object, context)
run_methods(object, context, 0, *args, &block)
true
else
false
end
end
private
# Runs all of the methods configured for this callback.
#
# When running +around+ callbacks, this will evaluate each method and
# yield when the last method has yielded. The callback will only halt if
# one of the methods does not yield.
#
# For all other types of callbacks, this will evaluate each method in
# order. The callback will only halt if the resulting value from the
# method passes the terminator.
def run_methods(object, context = {}, index = 0, *args, &block)
if type == :around
current_method = @methods[index]
if current_method
yielded = false
evaluate_method(object, current_method, *args) do
yielded = true
run_methods(object, context, index + 1, *args, &block)
end
throw :halt unless yielded
else
yield if block_given?
end
else
@methods.each do |method|
result = evaluate_method(object, method, *args)
throw :halt if @terminator && @terminator.call(result)
end
end
end
# Generates a method that can be bound to the object being transitioned
# when the callback is invoked
def bound_method(block)
type = self.type
arity = block.arity
arity += 1 if arity >= 0 # Make sure the object gets passed
arity += 1 if arity == 1 && type == :around # Make sure the block gets passed
method = lambda { |object, *args| object.instance_exec(*args, &block) }
# Proxy arity to the original block
(
class << method;
self;
end).class_eval do
define_method(:arity) { arity }
end
method
end
end
end