Skip to content

Instantly share code, notes, and snippets.

@jbarnette
Created April 6, 2011 14:55
Show Gist options
  • Save jbarnette/905783 to your computer and use it in GitHub Desktop.
Save jbarnette/905783 to your computer and use it in GitHub Desktop.
A naïve state machine for ActiveRecord. Ruby 1.9.
require "stateful"
class Folder < ActiveRecord::Base
include Stateful
# ...
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
entering :deleted do
children.each { |c| c.delete! unless c.deleted? }
end
entering :inactive do
children.each { |c| c.deactivate! if c.active? }
end
end
# Provides `active`, `inactive`, and `deleted` scopes and
# `active?`-style predicates, plus `activate!`-style transition
# helpers.
end
# 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
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 name = nil, &block
@entered[name || ANY] << block
end
def entering name = nil, &block
@entering[name || ANY] << 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
instance.save!
ctx.trigger @entered
end
ctx.trigger @persisted
end
def on name, &block
@events[name].instance_eval(&block)
end
def persisted name = nil, &block
@persisted[name || ANY] << block
end
def state name
name = name.to_s
@model.scope name, @model.where(state: name)
@model.send(:define_method, "#{name}?") { name == state }
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment