pboling/strict_states

View on GitHub
lib/strict_states.rb

Summary

Maintainability
A
45 mins
Test Coverage
require "strict_states/version"
require "strict_states/strict_hash"
require "strict_states/checker"

# The *STRICT* paradigm:
#
#   * Will raise an error if states are spelled wrong when lookups happen through this paradigm.
#   * Typos will be noisy, and many of them will error at app-load, so impossible to miss.
#
# Uses the StrictHash to accomplish this.  See lib/strict_states/strict_hash.rb
#
# The *INCLUDE WITH ARGUMENTS* paradigm:
#
#   * future-proof support for any/all state machines
#   * easily integrate with any state machine engine not already supported by this gem
#
# Uses a method (StrictStates.checker) that returns a module (StrictStates::Checker) to accomplish this.
module StrictStates
  # Usage:
  #
  #     class MyModel < ActiveRecord::Base
  #       # ...
  #       # <<<===--- AFTER STATE MACHINE DEFINITION ---===>>>
  #       # ...
  #       include StrictStates.checker(
  #                   klass: self,
  #                   machines: {
  #                       state: :pluginaweek,
  #                       awesome_level: :pluginaweek,
  #                       bogus_level: ->(context, machine_name) {
  #                         context.state_machines[machine_name.to_sym].states.map(&:name)
  #                       }
  #                   }
  #               )
  #     end
  #
  def self.checker(**config)
    validate_config(config)
    config[:machines] = states_for_machines(config[:klass], config[:machines])
    set_strict_state_lookup(config)
    ::StrictStates::Checker
  end

  private

  # Supported engines:
  #
  #   :pluginaweek    - for pluginaweek/state_machine
  #   :seuros         - for seuros/state_machine
  #   :state_machines - for state-machines/state_machines
  #   :aasm           - for aasm/aasm version < 4.3.0
  #   :aasm_multiple  - for aasm/aasm version >= 4.3.0
  #
  def self.engine_name_apis
    {
        pluginaweek:    ->(context, machine_name) { context.state_machines[machine_name.to_sym].states.map(&:name) },
        seuros:         ->(context, machine_name) { context.state_machines[machine_name.to_sym].states.map(&:name) },
        state_machines: ->(context, machine_name) { context.state_machines[machine_name.to_sym].states.map(&:name) },
        aasm:           ->(context, _)            { context.aasm.states.map(&:name) },              # aasm gem version < 4.3.0
        aasm_multiple:  ->(context, machine_name) { context.aasm(machine_name).states.map(&:name) } # aasm gem version >= 4.3.0
    }
  end

  def self.strict_states_to_stings(states)
    states.map {|state| state.to_s }
  end

  def self.create_strict_state_lookup(names)
    default_strict_hash = names.each_with_object({}) do |state, memo|
      memo[state.to_sym] = state
    end
    StrictHash[**default_strict_hash]
  end

  def self.validate_config(**config)
    raise ArgumentError, "config must have a :machines key with Hash value  but was #{config[:machines]}" unless config[:machines] && config[:machines].is_a?(Hash)
    raise ArgumentError, ":machines Hash must have values either from #{engine_name_apis.keys} or as Procs but was #{config[:machines]}" unless test_machines(config[:machines])
    raise ArgumentError, "config must have a :klass key with a Class value but was #{config[:klass]}" unless config[:klass] && config[:klass].class == Class
    true
  end

  def self.test_machines(machines)
    machines.values.all? do |engine|
      engine_name_apis.keys.include?(engine) ||
          engine.respond_to?(:call)
    end
  end

  # params:
  #   klass - any Class object with a state machine
  #   machines -
  #     {
  #         state: :pluginaweek,
  #         awesome_level: :pluginaweek,
  #         bogus_level: ->(context, machine_name) {
  #           context.state_machines[machine_name.to_sym].states.map(&:name)}
  #     }
  #
  # Example result
  #
  #     {
  #         state:          ["one", "two", "three"],
  #         awesome_level:  ["not_awesome", "awesome_11", "bad", "good"],
  #         bogus_level:    ["new", "pending", "goofy"]
  #     }
  #
  def self.states_for_machines(klass, machines)
    machines.inject({}) do |memo, (machine_name, engine)|
      proc = get_proc_for_engine(engine)
      memo[machine_name] =
          strict_states_to_stings(
              proc.call(klass, machine_name)
          )
      memo
    end
  end

  def self.get_proc_for_engine(engine)
    if (proc = engine_name_apis[engine])
      # Predefined Engine within this gem
      proc
    else
      # Custom state machine name extraction Proc provided by caller
      engine
    end
  end

  # params:
  #   config -
  #     {
  #       klass: Car, # any Class object with a state machine
  #       machines: { # the machine names, and states defined within each
  #         state:          ["one", "two", "three"],
  #         awesome_level:  ["not_awesome", "awesome_11", "bad", "good"],
  #         bogus_level:    ["new", "pending", "goofy"]
  #       }
  #     }
  #
  # Result:
  #
  #   MyModel.strict_state_lookup
  #   =>  {
  #         :state =>
  #           { :one => "one", :two => "two", :three => "three" }
  #         :awesome_level =>
  #           { :not_awesome => "not_awesome", :awesome_11 => "awesome_11", :bad => "bad", :good => "good" },
  #         :bogus_level =>
  #           { :new => "new", :pending => "pending", :goofy => "goofy" }
  #       }
  def self.set_strict_state_lookup(config)
    klass = config[:klass]
    machines = config[:machines]
    class << klass
      attr_reader :strict_state_lookup
    end
    klass.instance_variable_set(:@strict_state_lookup, StrictStates::StrictHash.new)
    machines.each do |machine_name, state_array|
      klass.strict_state_lookup[machine_name.to_sym] = StrictStates.create_strict_state_lookup(state_array).freeze
    end
  end

end