Skip to content

Instantly share code, notes, and snippets.

@cmorss
Created April 8, 2020 22:55
Show Gist options
  • Save cmorss/6b53cc52059602a2773e0af39de32c74 to your computer and use it in GitHub Desktop.
Save cmorss/6b53cc52059602a2773e0af39de32c74 to your computer and use it in GitHub Desktop.
ActiveRecord state machine
module Instructify
# 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
# Default states that are valid on all models that include Stateful
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
end
module Macro
def machine
@machine ||= Machine.new self
end
def stateful &block
machine.instance_eval(&block)
end
def states
machine.states
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
@states = []
@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["#{ctx.dest.to_s}_at"] = DateTime.now if instance.has_attribute? "#{ctx.dest.to_s}_at"
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
@states.push(name).uniq!
unless @model.respond_to? name
@model.scope name, -> { @model.where(state: name) }
@model.scope "not_#{name}", -> { @model.where("state != '#{name}'") }
@model.send(:define_method, "#{name}?") { name == (state && state.to_s) }
end
end
def states
@states
end
private
def munge names
names.flatten!
names.empty? ? [ANY] : names
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment