geekq/workflow

View on GitHub
lib/workflow.rb

Summary

Maintainability
B
4 hrs
Test Coverage
A
93%
require 'rubygems'

require 'workflow/specification'

# See also README for documentation
module Workflow
  module ClassMethods
    attr_reader :workflow_spec

    # Workflow does not provide any state persistence - it is the job of particular
    # persistence libraries for workflow and activerecord or remodel.
    # But it still makes sense to provide a default name and override feature.
    def workflow_column(column_name=nil)
      if column_name
        @workflow_state_column_name = column_name.to_sym
      end
      if !instance_variable_defined?('@workflow_state_column_name') && superclass.respond_to?(:workflow_column)
        @workflow_state_column_name = superclass.workflow_column
      end
      @workflow_state_column_name ||= :workflow_state
    end

    def workflow(&specification)
      assign_workflow Specification.new(Hash.new, &specification)
    end

    private

    # Creates the convinience methods like `my_transition!`
    def assign_workflow(specification_object)

      # Merging two workflow specifications can **not** be done automically, so
      # just make the latest specification win. Same for inheritance -
      # definition in the subclass wins.
      if respond_to? :inherited_workflow_spec # undefine methods defined by the old workflow_spec
        inherited_workflow_spec.states.values.each do |state|
          state_name = state.name
          module_eval do
            undef_method "#{state_name}?"
          end

          state.events.flat.each do |event|
            event_name = event.name
            module_eval do
              undef_method "#{event_name}!".to_sym
              undef_method "can_#{event_name}?"
            end
          end
        end
      end

      @workflow_spec = specification_object
      @workflow_spec.states.values.each do |state|
        state_name = state.name
        module_eval do
          define_method "#{state_name}?" do
            state_name == current_state.name
          end
        end

        state.events.flat.each do |event|
          event_name = event.name
          module_eval do
            define_method "#{event_name}!".to_sym do |*args, **kwargs|
              process_event!(event_name, *args, **kwargs)
            end

            define_method "can_#{event_name}?".to_sym do |*args, **kwargs|
              return !!current_state.events.first_applicable(event_name, self, args)
            end
          end
        end
      end
    end
  end

  module InstanceMethods

    def current_state
      loaded_state = load_workflow_state
      res = spec.states[loaded_state.to_sym] if loaded_state
      res || spec.initial_state
    end

    # See the 'Guards' section in the README
    # @return true if the last transition was halted by one of the transition callbacks.
    def halted?
      @halted
    end

    # @return the reason of the last transition abort as set by the previous
    # call of `halt` or `halt!` method.
    def halted_because
      @halted_because
    end

    def process_event!(name, *args, **kwargs)
      event = current_state.events.first_applicable(name, self, args)
      raise NoTransitionAllowed.new(
        "There is no event #{name.to_sym} defined for the #{current_state} state") \
        if event.nil?
      @halted_because = nil
      @halted = false

      check_transition(event)

      from = current_state
      to = spec.states[event.transitions_to]

      run_before_transition(from, to, name, *args, **kwargs)
      return false if @halted

      begin
        return_value = run_action(event.action, *args, **kwargs) || run_action_callback(event.name, *args, **kwargs)
      rescue StandardError => e
        run_on_error(e, from, to, name, *args, **kwargs)
      end

      return false if @halted

      run_on_transition(from, to, name, *args, **kwargs)

      run_on_exit(from, to, name, *args, **kwargs)

      transition_value = persist_workflow_state to.to_s

      run_on_entry(to, from, name, *args, **kwargs)

      run_after_transition(from, to, name, *args, **kwargs)

      return_value.nil? ? transition_value : return_value
    end

    def halt(reason = nil)
      @halted_because = reason
      @halted = true
    end

    def halt!(reason = nil)
      @halted_because = reason
      @halted = true
      raise TransitionHalted.new(reason)
    end

    def spec
      # check the singleton class first
      class << self
        return workflow_spec if workflow_spec
      end

      c = self.class
      # using a simple loop instead of class_inheritable_accessor to avoid
      # dependency on Rails' ActiveSupport
      until c.workflow_spec || !(c.include? Workflow)
        c = c.superclass
      end
      c.workflow_spec
    end

    private

    def check_transition(event)
      # Create a meaningful error message instead of
      # "undefined method `on_entry' for nil:NilClass"
      # Reported by Kyle Burton
      if !spec.states[event.transitions_to]
        raise WorkflowError.new("Event[#{event.name}]'s " +
            "transitions_to[#{event.transitions_to}] is not a declared state.")
      end
    end

    def run_before_transition(from, to, event, *args, **kwargs)
      instance_exec(from.name, to.name, event, *args, **kwargs, &spec.before_transition_proc) if
        spec.before_transition_proc
    end

    def run_on_error(error, from, to, event, *args, **kwargs)
      if spec.on_error_proc
        instance_exec(error, from.name, to.name, event, *args, **kwargs, &spec.on_error_proc)
        halt(error.message)
      else
        raise error
      end
    end

    def run_on_transition(from, to, event, *args, **kwargs)
      instance_exec(from.name, to.name, event, *args, **kwargs, &spec.on_transition_proc) if spec.on_transition_proc
    end

    def run_after_transition(from, to, event, *args, **kwargs)
      instance_exec(from.name, to.name, event, *args, **kwargs, &spec.after_transition_proc) if
        spec.after_transition_proc
    end

    def run_action(action, *args, **kwargs)
      instance_exec(*args, **kwargs, &action) if action
    end

    def has_callback?(action)
      # 1. public callback method or
      # 2. protected method somewhere in the class hierarchy or
      # 3. private in the immediate class (parent classes ignored)
      action = action.to_sym
      self.respond_to?(action) or
        self.class.protected_method_defined?(action) or
        self.private_methods(false).map(&:to_sym).include?(action)
    end

    def run_action_callback(action_name, *args, **kwargs)
      action = action_name.to_sym
      self.send(action, *args, **kwargs) if has_callback?(action)
    end

    def run_on_entry(state, prior_state, triggering_event, *args, **kwargs)
      if state.on_entry
        instance_exec(prior_state.name, triggering_event, *args, **kwargs, &state.on_entry)
      else
        hook_name = "on_#{state}_entry"
        self.send hook_name, prior_state, triggering_event, *args, **kwargs if has_callback?(hook_name)
      end
    end

    def run_on_exit(state, new_state, triggering_event, *args, **kwargs)
      if state
        if state.on_exit
          instance_exec(new_state.name, triggering_event, *args, **kwargs, &state.on_exit)
        else
          hook_name = "on_#{state}_exit"
          self.send hook_name, new_state, triggering_event, *args, **kwargs if has_callback?(hook_name)
        end
      end
    end

    # load_workflow_state and persist_workflow_state
    # can be overriden to handle the persistence of the workflow state.
    #
    # Default (non ActiveRecord) implementation stores the current state
    # in a variable.
    #
    # Default ActiveRecord implementation uses a 'workflow_state' database column.
    def load_workflow_state
      @workflow_state if instance_variable_defined? :@workflow_state
    end

    def persist_workflow_state(new_value)
      @workflow_state = new_value
    end
  end

  def self.included(klass)
    klass.send :include, InstanceMethods

    # backup the parent workflow spec, making accessible through #inherited_workflow_spec
    if klass.superclass.respond_to?(:workflow_spec, true)
      klass.module_eval do
        # see http://stackoverflow.com/a/2495650/111995 for implementation explanation
        pro = Proc.new { klass.superclass.workflow_spec }
        singleton_class = class << self; self; end
        singleton_class.send(:define_method, :inherited_workflow_spec) do
          pro.call
        end
      end
    end

    klass.extend ClassMethods

    # Look for a hook; otherwise detect based on ancestor class.
    if klass.respond_to?(:workflow_adapter)
      klass.send :include, klass.workflow_adapter
    end
  end
end