lib/aasm/base.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require 'logger'

module AASM
  class Base

    attr_reader :klass, :state_machine

    def initialize(klass, name, state_machine, options={}, &block)
      @klass = klass
      @name = name
      # @state_machine = klass.aasm(@name).state_machine
      @state_machine = state_machine
      @state_machine.config.column ||= (options[:column] || default_column).to_sym
      # @state_machine.config.column = options[:column].to_sym if options[:column] # master
      @options = options

      # let's cry if the transition is invalid
      configure :whiny_transitions, true

      # create named scopes for each state
      configure :create_scopes, true

      # don't store any new state if the model is invalid (in ActiveRecord)
      configure :skip_validation_on_save, false

      # raise if the model is invalid (in ActiveRecord)
      configure :whiny_persistence, false

      # Use transactions (in ActiveRecord)
      configure :use_transactions, true

      # use requires_new for nested transactions (in ActiveRecord)
      configure :requires_new_transaction, true

      # use pessimistic locking (in ActiveRecord)
      # true for FOR UPDATE lock
      # string for a specific lock type i.e. FOR UPDATE NOWAIT
      configure :requires_lock, false

      # automatically set `"#{state_name}_at" = ::Time.now` on state changes
      configure :timestamps, false

      # set to true to forbid direct assignment of aasm_state column (in ActiveRecord)
      configure :no_direct_assignment, false

      # allow a AASM::Base sub-class to be used for state machine
      configure :with_klass, AASM::Base

      configure :enum, nil

      # Set to true to namespace reader methods and constants
      configure :namespace, false

      # Configure a logger, with default being a Logger to STDERR
      configure :logger, Logger.new(STDERR)

      # setup timestamp-setting callback if enabled
      setup_timestamps(@name)

      # make sure to raise an error if no_direct_assignment is enabled
      # and attribute is directly assigned though
      setup_no_direct_assignment(@name)
    end

    # This method is both a getter and a setter
    def attribute_name(column_name=nil)
      if column_name
        @state_machine.config.column = column_name.to_sym
      else
        @state_machine.config.column ||= :aasm_state
      end
      @state_machine.config.column
    end

    def initial_state(new_initial_state=nil)
      if new_initial_state
        @state_machine.initial_state = new_initial_state
      else
        @state_machine.initial_state
      end
    end

    # define a state
    # args
    # [0] state
    # [1] options (or nil)
    # or
    # [0] state
    # [1..] state
    def state(*args)
      names, options = interpret_state_args(args)
      names.each do |name|
        @state_machine.add_state(name, klass, options)

        aasm_name = @name.to_sym
        state = name.to_sym

        method_name = namespace? ? "#{namespace}_#{name}" : name
        safely_define_method klass, "#{method_name}?", -> do
          aasm(aasm_name).current_state == state
        end

        const_name = namespace? ? "STATE_#{namespace.upcase}_#{name.upcase}" : "STATE_#{name.upcase}"
        unless klass.const_defined?(const_name)
          klass.const_set(const_name, name)
        end
      end
    end

    # define an event
    def event(name, options={}, &block)
      @state_machine.add_event(name, options, &block)

      aasm_name = @name.to_sym
      event = name.to_sym

      # an addition over standard aasm so that, before firing an event, you can ask
      # may_event? and get back a boolean that tells you whether the guard method
      # on the transition will let this happen.
      safely_define_method klass, "may_#{name}?", ->(*args) do
        aasm(aasm_name).may_fire_event?(event, *args)
      end

      safely_define_method klass, "#{name}!", ->(*args, &block) do
        aasm(aasm_name).current_event = :"#{name}!"
        aasm_fire_event(aasm_name, event, {:persist => true}, *args, &block)
      end

      safely_define_method klass, name, ->(*args, &block) do
        aasm(aasm_name).current_event = event
        aasm_fire_event(aasm_name, event, {:persist => false}, *args, &block)
      end

      skip_instance_level_validation(event, name, aasm_name, klass)

      # Create aliases for the event methods. Keep the old names to maintain backwards compatibility.
      if namespace?
        klass.send(:alias_method, "may_#{name}_#{namespace}?", "may_#{name}?")
        klass.send(:alias_method, "#{name}_#{namespace}!", "#{name}!")
        klass.send(:alias_method, "#{name}_#{namespace}", name)
      end

    end

    def after_all_transitions(*callbacks, &block)
      @state_machine.add_global_callbacks(:after_all_transitions, *callbacks, &block)
    end

    def after_all_transactions(*callbacks, &block)
      @state_machine.add_global_callbacks(:after_all_transactions, *callbacks, &block)
    end

    def before_all_transactions(*callbacks, &block)
      @state_machine.add_global_callbacks(:before_all_transactions, *callbacks, &block)
    end

    def before_all_events(*callbacks, &block)
      @state_machine.add_global_callbacks(:before_all_events, *callbacks, &block)
    end

    def after_all_events(*callbacks, &block)
      @state_machine.add_global_callbacks(:after_all_events, *callbacks, &block)
    end

    def error_on_all_events(*callbacks, &block)
      @state_machine.add_global_callbacks(:error_on_all_events, *callbacks, &block)
    end

    def ensure_on_all_events(*callbacks, &block)
      @state_machine.add_global_callbacks(:ensure_on_all_events, *callbacks, &block)
    end

    def states
      @state_machine.states
    end

    def events
      @state_machine.events.values
    end

    # aasm.event(:event_name).human?
    def human_event_name(event) # event_name?
      AASM::Localizer.new.human_event_name(klass, event)
    end

    def states_for_select
      states.map { |state| state.for_select }
    end

    def from_states_for_state(state, options={})
      if options[:transition]
        @state_machine.events[options[:transition]].transitions_to_state(state).flatten.map(&:from).flatten
      else

        events.map {|e| e.transitions_to_state(state)}.flatten.map(&:from).flatten
      end
    end

    private

    def default_column
      @name.to_sym == :default ? :aasm_state : @name.to_sym
    end

    def configure(key, default_value)
      if @options.key?(key)
        @state_machine.config.send("#{key}=", @options[key])
      elsif @state_machine.config.send(key).nil?
        @state_machine.config.send("#{key}=", default_value)
      end
    end

    def safely_define_method(klass, method_name, method_definition)
      # Warn if method exists and it did not originate from an enum
      if klass.method_defined?(method_name) &&
         ! ( @state_machine.config.enum &&
             klass.respond_to?(:defined_enums) &&
             klass.defined_enums.values.any?{ |methods|
                 methods.keys{| enum | enum + '?' == method_name }
             })
        unless AASM::Configuration.hide_warnings
          @state_machine.config.logger.warn "#{klass.name}: overriding method '#{method_name}'!"
        end
      end

      klass.send(:define_method, method_name, method_definition).tap do |sym|
        apply_ruby2_keyword(klass, sym)
      end
    end

    def apply_ruby2_keyword(klass, sym)
      if RUBY_VERSION >= '2.7.1'
        if klass.instance_method(sym).parameters.find { |type, _| type.to_s.start_with?('rest') }
          # If there is a place where you are receiving in *args, do ruby2_keywords.
          klass.module_eval do
            ruby2_keywords sym
          end
        end
      end
    end

    def namespace?
      !!@state_machine.config.namespace
    end

    def namespace
      if @state_machine.config.namespace == true
        @name
      else
        @state_machine.config.namespace
      end
    end

    def interpret_state_args(args)
      if args.last.is_a?(Hash) && args.size == 2
        [[args.first], args.last]
      elsif args.size > 0
        [args, {}]
      else
        raise "count not parse states: #{args}"
      end
    end

    def skip_instance_level_validation(event, name, aasm_name, klass)
      # Overrides the skip_validation config for an instance (If skip validation is set to false in original config) and
      # restores it back to the original value after the event is fired.
      safely_define_method klass, "#{name}_without_validation!", ->(*args, &block) do
        original_config = AASM::StateMachineStore.fetch(self.class, true).machine(aasm_name).config.skip_validation_on_save
        begin
          AASM::StateMachineStore.fetch(self.class, true).machine(aasm_name).config.skip_validation_on_save = true unless original_config
          aasm(aasm_name).current_event = :"#{name}!"
          aasm_fire_event(aasm_name, event, {:persist => true}, *args, &block)
        ensure
          AASM::StateMachineStore.fetch(self.class, true).machine(aasm_name).config.skip_validation_on_save = original_config
        end
      end
    end

    def setup_timestamps(aasm_name)
      return unless @state_machine.config.timestamps

      after_all_transitions do
        if self.class.aasm(:"#{aasm_name}").state_machine.config.timestamps
          ts_setter = "#{aasm(aasm_name).to_state}_at="
          respond_to?(ts_setter) && send(ts_setter, ::Time.now)
        end
      end
    end

    def setup_no_direct_assignment(aasm_name)
      return unless @state_machine.config.no_direct_assignment

      @klass.send(:define_method, "#{@state_machine.config.column}=") do |state_name|
        if self.class.aasm(:"#{aasm_name}").state_machine.config.no_direct_assignment
          raise AASM::NoDirectAssignmentError.new('direct assignment of AASM column has been disabled (see AASM configuration for this class)')
        else
          super(state_name)
        end
      end
    end

  end
end