require "overlord/searchable" module Overlord # Mark an ActiveRecord model as having a state machine. Requires a # string attribute called `state`. To set the default state for a # class, set a default column value for `state` in the database. # # Use Symbols for all keys and values in state definitions. module Stateful ANY = Object.new def self.included model model.validates :state, presence: true model.extend Macro model.stateful do state :active state :inactive state :deleted on :activate do move :inactive => :active end on :deactivate do move :active => :inactive end on :delete do move any => :deleted end end if model < Overlord::Searchable Sunspot.setup model do string :state, stored: true end end end module Macro def machine @machine ||= Machine.new self end def stateful &block machine.instance_eval(&block) end end class Context < Struct.new :instance, :event, :src, :dest, :args def trigger hooks hooks.values_at(dest, ANY).compact.flatten.each do |hook| instance.instance_exec self, &hook end end end class Event def initialize model, name @moves = {} model.send :define_method, "#{name}!" do |*args| model.machine.fire self, name, *args end end def any ANY end def dest current @moves[current.to_sym] || @moves[any] end def move pair Array(pair.keys.first).each do |s| @moves[s] = pair.values.first end end end class Machine def initialize model @model = model @entered = Hash.new { |h, k| h[k] = [] } @entering = Hash.new { |h, k| h[k] = [] } @events = Hash.new { |h, k| h[k] = Event.new @model, k } @persisted = Hash.new { |h, k| h[k] = [] } end def entered *names, &block munge(names).each { |n| @entered[n] << block } end def entering *names, &block munge(names).each { |n| @entering[n] << block } end def fire instance, name, *args raise "No [#{name}] event." unless @events.include? name src = instance.state dest = @events[name].dest src unless dest raise "Can't [#{name}] while [#{instance.state}]: #{instance}" end ctx = Context.new instance, name, src.to_sym, dest, args ActiveRecord::Base.transaction do ctx.trigger @entering instance.state = ctx.dest.to_s ctx.trigger @entered instance.save! end ctx.trigger @persisted instance end def on *names, &block names.flatten.each { |n| @events[n].instance_eval(&block) } end def persisted *names, &block munge(names).each { |n| @persisted[n] << block} end def state name name = name.to_s unless @model.respond_to? name @model.scope name, @model.where(state: name) @model.send(:define_method, "#{name}?") { name == state } end end private def munge names names.flatten! names.empty? ? [ANY] : names end end end end