Skip to content

Commit

Permalink
Merge branch 'Advanon-chores/refactor-callback-invokers'
Browse files Browse the repository at this point in the history
  • Loading branch information
Anil Maurya committed Jul 30, 2018
2 parents 35d6f68 + 89544e5 commit b5e71b0
Show file tree
Hide file tree
Showing 16 changed files with 933 additions and 102 deletions.
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,26 @@ the transition succeeds :

### Callbacks

You can define a number of callbacks for your transitions. These methods will be
You can define a number of callbacks for your events, transitions and states. These methods, Procs or classes will be
called when certain criteria are met, like entering a particular state:

```ruby
class LogRunTime
def initialize(resource)
@resource = resource
end

def call
# Do whatever you want with @resource
end
end

class Job
include AASM

aasm do
state :sleeping, :initial => true, :before_enter => :do_something
state :running
state :running, before_enter: Proc.new { do_something && notify_somebody }
state :finished

after_all_transitions :log_status_change
Expand Down Expand Up @@ -195,6 +205,8 @@ is finished.

AASM will also initialize `LogRunTime` and run the `call` method for you after the transition from `running` to `finished` in the example above. You can pass arguments to the class by defining an initialize method on it, like this:

Note that Procs are executed in the context of a record, it means that you don't need to expect the record as an argument, just call the methods you need.

```ruby
class LogRunTime
# optional args parameter can be omitted, but if you define initialize
Expand Down
5 changes: 5 additions & 0 deletions lib/aasm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
require 'aasm/core/transition'
require 'aasm/core/event'
require 'aasm/core/state'
require 'aasm/core/invoker'
require 'aasm/core/invokers/base_invoker'
require 'aasm/core/invokers/class_invoker'
require 'aasm/core/invokers/literal_invoker'
require 'aasm/core/invokers/proc_invoker'
require 'aasm/localizer'
require 'aasm/state_machine_store'
require 'aasm/state_machine'
Expand Down
26 changes: 5 additions & 21 deletions lib/aasm/core/event.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module AASM::Core
class Event
include DslHelper
Expand Down Expand Up @@ -156,27 +158,9 @@ def _fire(obj, options={}, to_state=::AASM::NO_VALUE, *args)
end

def invoke_callbacks(code, record, args)
case code
when Symbol, String
unless record.respond_to?(code, true)
raise NoMethodError.new("NoMethodError: undefined method `#{code}' for #{record.inspect}:#{record.class}")
end
arity = record.__send__(:method, code.to_sym).arity
record.__send__(code, *(arity < 0 ? args : args[0...arity]))
true

when Proc
arity = code.arity
record.instance_exec(*(arity < 0 ? args : args[0...arity]), &code)
true

when Array
code.each {|a| invoke_callbacks(a, record, args)}
true

else
false
end
Invoker.new(code, record, args)
.with_default_return_value(false)
.invoke
end
end
end # AASM
129 changes: 129 additions & 0 deletions lib/aasm/core/invoker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# frozen_string_literal: true

module AASM
module Core
##
# main invoker class which encapsulates the logic
# for invoking literal-based, proc-based, class-based
# and array-based callbacks for different entities.
class Invoker
DEFAULT_RETURN_VALUE = true

##
# Initialize a new invoker instance.
# NOTE that invoker must be used per-subject/record
# (one instance per subject/record)
#
# ==Options:
#
# +subject+ - invoking subject, may be Proc,
# Class, String, Symbol or Array
# +record+ - invoking record
# +args+ - arguments which will be passed to the callback

def initialize(subject, record, args)
@subject = subject
@record = record
@args = args
@options = {}
@failures = []
@default_return_value = DEFAULT_RETURN_VALUE
end

##
# Pass additional options to concrete invoker
#
# ==Options:
#
# +options+ - hash of options which will be passed to
# concrete invokers
#
# ==Example:
#
# with_options(guard: proc {...})

def with_options(options)
@options = options
self
end

##
# Collect failures to a specified buffer
#
# ==Options:
#
# +failures+ - failures buffer to collect failures

def with_failures(failures)
@failures = failures
self
end

##
# Change default return value of #invoke method
# if none of invokers processed the request.
#
# The default return value is #DEFAULT_RETURN_VALUE
#
# ==Options:
#
# +value+ - default return value for #invoke method

def with_default_return_value(value)
@default_return_value = value
self
end

##
# Find concrete invoker for specified subject and invoker it,
# or return default value set by #DEFAULT_RETURN_VALUE or
# overridden by #with_default_return_value

# rubocop:disable Metrics/AbcSize
def invoke
return invoke_array if subject.is_a?(Array)
return literal_invoker.invoke if literal_invoker.may_invoke?
return proc_invoker.invoke if proc_invoker.may_invoke?
return class_invoker.invoke if class_invoker.may_invoke?
default_return_value
end
# rubocop:enable Metrics/AbcSize

private

attr_reader :subject, :record, :args, :options, :failures,
:default_return_value

def invoke_array
return subject.all? { |item| sub_invoke(item) } if options[:guard]
return subject.all? { |item| !sub_invoke(item) } if options[:unless]
subject.map { |item| sub_invoke(item) }
end

def sub_invoke(new_subject)
self.class.new(new_subject, record, args)
.with_failures(failures)
.with_options(options)
.invoke
end

def proc_invoker
@proc_invoker ||= Invokers::ProcInvoker
.new(subject, record, args)
.with_failures(failures)
end

def class_invoker
@class_invoker ||= Invokers::ClassInvoker
.new(subject, record, args)
.with_failures(failures)
end

def literal_invoker
@literal_invoker ||= Invokers::LiteralInvoker
.new(subject, record, args)
.with_failures(failures)
end
end
end
end
75 changes: 75 additions & 0 deletions lib/aasm/core/invokers/base_invoker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

module AASM
module Core
module Invokers
##
# Base concrete invoker class which contain basic
# invoking and logging definitions
class BaseInvoker
attr_reader :failures, :subject, :record, :args, :result

##
# Initialize a new concrete invoker instance.
# NOTE that concrete invoker must be used per-subject/record
# (one instance per subject/record)
#
# ==Options:
#
# +subject+ - invoking subject comparable with this invoker
# +record+ - invoking record
# +args+ - arguments which will be passed to the callback

def initialize(subject, record, args)
@subject = subject
@record = record
@args = args
@result = false
@failures = []
end

##
# Collect failures to a specified buffer
#
# ==Options:
#
# +failures+ - failures buffer to collect failures

def with_failures(failures_buffer)
@failures = failures_buffer
self
end

##
# Execute concrete invoker, log the error and return result

def invoke
return unless may_invoke?
log_failure unless invoke_subject
result
end

##
# Check if concrete invoker may be invoked for a specified subject

def may_invoke?
raise NoMethodError, '"#may_invoke?" is not implemented'
end

##
# Log failed invoking

def log_failure
raise NoMethodError, '"#log_failure" is not implemented'
end

##
# Execute concrete invoker

def invoke_subject
raise NoMethodError, '"#invoke_subject" is not implemented'
end
end
end
end
end
52 changes: 52 additions & 0 deletions lib/aasm/core/invokers/class_invoker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module AASM
module Core
module Invokers
##
# Class invoker which allows to use classes which respond to #call
# to be used as state/event/transition callbacks.
class ClassInvoker < BaseInvoker
def may_invoke?
subject.is_a?(Class) && subject.instance_methods.include?(:call)
end

def log_failure
return log_source_location if Method.method_defined?(:source_location)
log_method_info
end

def invoke_subject
@result = retrieve_instance.call
end

private

def log_source_location
failures << instance.method(:call).source_location.join('#')
end

def log_method_info
failures << instance.method(:call)
end

def instance
@instance ||= retrieve_instance
end

# rubocop:disable Metrics/AbcSize
def retrieve_instance
return subject.new if subject_arity.zero?
return subject.new(record) if subject_arity == 1
return subject.new(record, *args) if subject_arity < 0
subject.new(record, *args[0..(subject_arity - 2)])
end
# rubocop:enable Metrics/AbcSize

def subject_arity
@arity ||= subject.instance_method(:initialize).arity
end
end
end
end
end
47 changes: 47 additions & 0 deletions lib/aasm/core/invokers/literal_invoker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module AASM
module Core
module Invokers
##
# Literal invoker which allows to use strings or symbols to call
# record methods as state/event/transition callbacks.
class LiteralInvoker < BaseInvoker
def may_invoke?
subject.is_a?(String) || subject.is_a?(Symbol)
end

def log_failure
failures << subject
end

def invoke_subject
@result = exec_subject
end

private

def subject_arity
@arity ||= record.__send__(:method, subject.to_sym).arity
end

# rubocop:disable Metrics/AbcSize
def exec_subject
raise(*record_error) unless record.respond_to?(subject, true)
return record.__send__(subject) if subject_arity.zero?
return record.__send__(subject, *args) if subject_arity < 0
record.__send__(subject, *args[0..(subject_arity - 1)])
end
# rubocop:enable Metrics/AbcSize

def record_error
[
NoMethodError,
'NoMethodError: undefined method ' \
"`#{subject}' for #{record.inspect}:#{record.class}"
]
end
end
end
end
end
Loading

0 comments on commit b5e71b0

Please sign in to comment.