Skip to content

Instantly share code, notes, and snippets.

@zilkey
Created July 24, 2009 06:17
Show Gist options
  • Save zilkey/153885 to your computer and use it in GitHub Desktop.
Save zilkey/153885 to your computer and use it in GitHub Desktop.
Data-backed state machine example
=begin
See http://www.websequencediagrams.com/ for awesome diagrams. It has an api as well.
DSL-based state machine implementations make it hard to follow good coding practices, such as:
- keeping things DRY
- making sure that objects have single responsibility
- making sure that classes change at the same pace (open closed)
- making sure that classes only depend on classes that change less often than they do
DSL based state machines
- add a new responsibility to the model
- require you to modify the class to modify details of the state transitions
What happens when a new requirement comes in that asks developers to allow admins to configure the workflow of certain objects?
The following is an example of how to model a finite state machine using ruby objects. It supports:
- determining if a crud action can be taken (can_read?, can_update? etc...) in a given state, with conditions
- blocking illegal state changes, with conditions
- executing entry and exit events for states, with conditions
- relying entirely on objects that can be persisted in a database - that is, no DSL or ruby code necessary beyond including the module
- storing a history of state transitions
- being able to safely store method names that will be called on an object, using a mapping so that you can control the methods that are executed
- is only about 70 lines of code, and could be shortened
Just change ActiveHash::Base to ActiveRecord::Base, write a few migrations and you could easily manage state machine meta data in a database
=end
require 'rubygems'
require 'spec'
require 'active_hash'
class IllegalStateTransition < StandardError
end
class Action < ActiveHash::Base
fields :name, :target
field :conditions, :default => []
end
# this concept might be useful at some point to organize admin screens
class Machine < ActiveHash::Base
fields :states, :initial
end
class State < ActiveHash::Base
field :name
field :input_actions, :default => []
field :transition_actions, :default => []
field :entry_actions, :default => []
field :exit_actions, :default => []
def actions_hash
{
:input => input_actions,
:transition => transition_actions,
:entry => entry_actions,
:exit => exit_actions
}
end
end
class StateTransition < ActiveHash::Base
fields :from, :to, :by, :on, :message
end
class User < ActiveHash::Base
fields :admin?, :account_admin?
end
module HasState
def state_transitions
@state_transitions ||= []
end
def can_update?
can_perform_action?(:update)
end
def can_delete?
can_perform_action?(:delete)
end
def can_view?
can_perform_action?(:view)
end
def can_perform_action?(action)
available_input_actions.map(&:name).include?(action)
end
def available_transitions
available_actions(state, :transition)
end
def available_new_state_names
available_actions(state, :transition).map(&:target)
end
def available_input_actions
available_actions(state, :input)
end
def state=(new_state)
if new_state.present? && state?
raise IllegalStateTransition unless available_new_state_names.include?(new_state.name)
execute_callbacks_for(state, :exit)
execute_callbacks_for(new_state, :entry)
end
state_transitions << StateTransition.new(:from => state, :to => new_state, :on => Time.now)
attributes[:state] = new_state
end
def available_actions(state, type)
[].tap do |results|
if state
state.actions_hash[type].each do |action|
conditions = action.conditions.map{|condition| send_action_method(condition) }
if conditions.empty? || (conditions.present? && conditions.all?{|condition| condition == true})
results << action
end
end
end
end
end
def execute_callbacks_for(state, action_name)
available_actions(state, action_name).each{ |action| send_action_method(action.name) }
end
def send_action_method(method)
send action_method_mapping[method]
end
def action_method_mapping
{}
end
end
class Invoice < ActiveHash::Base
fields :user, :state, :paid
attr_reader :emails, :number
include HasState
def deliver_emails
@emails = [:email1, :email2]
end
def assign_number
@number = 1234
end
# You could expose this list to an admin UI
def action_method_mapping
{
:send_emails => :deliver_emails,
:paid? => :paid?,
:assign_number => :assign_number
}
end
end
describe Invoice do
describe "#state=" do
context "to a nil state" do
it "sets state to nil" do
draft = State.new :id => 1, :name => :draft
invoice = Invoice.new :state => draft
invoice.state.should == draft
invoice.state = nil
invoice.state.should be_nil
end
it "records a state transition" do
draft = State.new :id => 1, :name => :draft
invoice = Invoice.new :state => draft
invoice.state_transitions.should be_empty
invoice.state = nil
invoice.state_transitions.length.should == 1
end
end
context "from a nil state" do
it "sets state to nil" do
draft = State.new :id => 1, :name => :draft
invoice = Invoice.new
invoice.state.should be_nil
invoice.state = draft
invoice.state.should == draft
end
end
context "from a state that has a transition that includes the new state" do
before do
@draft = State.new :id => 1, :name => :draft, :transition_actions => [
Action.new(:name => :post, :target => :posted)
]
@posted = State.new :id => 2, :name => :posted
end
it "allows the state transition to happen" do
invoice = Invoice.new :state => @draft
invoice.state.should == @draft
invoice.state = @posted
invoice.state.should == @posted
end
end
context "from a state for which there are no transitions that match the new state" do
before do
@draft = State.new :id => 1, :name => :draft, :transition_actions => [
Action.new(:name => :fry, :target => :fried)
]
@posted = State.new :id => 2, :name => :posted
end
it "allows the state transition to happen" do
invoice = Invoice.new :state => @draft
invoice.state.should == @draft
proc do
invoice.state = @posted
end.should raise_error(IllegalStateTransition)
end
end
context "from a state for which there is a transition with conditions" do
before do
@draft = State.new :id => 1, :name => :draft, :transition_actions => [
Action.new(:name => :post, :target => :posted, :conditions => [:paid?])
]
@posted = State.new :id => 2, :name => :posted
end
context "and the object meets the criteria" do
it "allows the transition to happen" do
invoice = Invoice.new :state => @draft, :paid => true
invoice.state.should == @draft
invoice.state = @posted
invoice.state.should == @posted
end
end
context "and the object does not meet the criteria" do
it "does not allow the transition to happen" do
invoice = Invoice.new :state => @draft, :paid => false
invoice.state.should == @draft
proc do
invoice.state = @posted
end.should raise_error(IllegalStateTransition)
end
end
end
describe "new state entry events" do
context "when the new state is nil" do
it "does nothing" do
proc do
invoice = Invoice.new
invoice.state = nil
end.should_not raise_error
end
end
context "when the new state has entry events with no conditions" do
before do
@draft = State.new :name => :draft, :transition_actions => [
Action.new(:name => :post, :target => :posted)
]
@posted = State.new :name => :posted,
:entry_actions => [
Action.new(:name => :send_emails),
Action.new(:name => :assign_number)
]
end
it "fires each of the entry events" do
invoice = Invoice.new :state => @draft
invoice.emails.should be_nil
invoice.number.should be_nil
invoice.state = @posted
invoice.emails.should_not be_nil
invoice.number.should_not be_nil
end
end
context "when the new state has entry events with conditions" do
context "and the object meets the conditions" do
before do
@draft = State.new :name => :draft, :transition_actions => [
Action.new(:name => :post, :target => :posted)
]
@posted = State.new :name => :posted,
:entry_actions => [
Action.new(:name => :send_emails, :conditions => [:paid?])
]
end
it "fires each of the entry events" do
invoice = Invoice.new :state => @draft, :paid => true
invoice.emails.should be_nil
invoice.state = @posted
invoice.emails.should_not be_nil
end
end
context "and the object does not meet the conditions" do
before do
@draft = State.new :name => :draft, :transition_actions => [
Action.new(:name => :post, :target => :posted)
]
@posted = State.new :name => :posted,
:entry_actions => [
Action.new(:name => :send_emails, :conditions => [:paid?]),
Action.new(:name => :assign_number)
]
end
it "fires each of the entry events for which it passes the conditions" do
invoice = Invoice.new :state => @draft, :paid => false
invoice.emails.should be_nil
invoice.number.should be_nil
invoice.state = @posted
invoice.emails.should be_nil
invoice.number.should_not be_nil
end
end
end
end
describe "old state exit actions" do
context "when the old state has exit actions with no conditions" do
before do
@draft = State.new :name => :draft,
:transition_actions => [
Action.new(:name => :post, :target => :posted)
],
:exit_actions => [
Action.new(:name => :send_emails),
Action.new(:name => :assign_number)
]
@posted = State.new :name => :posted
end
it "fires each of the exit actions" do
invoice = Invoice.new :state => @draft
invoice.emails.should be_nil
invoice.number.should be_nil
invoice.state = @posted
invoice.emails.should_not be_nil
invoice.number.should_not be_nil
end
end
context "when the old state has exit actions with conditions" do
context "and the object meets the conditions" do
before do
@draft = State.new :name => :draft,
:transition_actions => [
Action.new(:name => :post, :target => :posted)
],
:exit_actions => [
Action.new(:name => :send_emails, :conditions => [:paid?])
]
@posted = State.new :name => :posted
end
it "fires each of the exit actions" do
invoice = Invoice.new :state => @draft, :paid => true
invoice.emails.should be_nil
invoice.state = @posted
invoice.emails.should_not be_nil
end
end
context "and the object does not meet the conditions" do
before do
@draft = State.new :name => :draft,
:transition_actions => [
Action.new(:name => :post, :target => :posted)
],
:exit_actions => [
Action.new(:name => :send_emails, :conditions => [:paid?]),
Action.new(:name => :assign_number)
]
@posted = State.new :name => :posted
end
it "fires each of the exit actions for which it passes the conditions" do
invoice = Invoice.new :state => @draft, :paid => false
invoice.emails.should be_nil
invoice.number.should be_nil
invoice.state = @posted
invoice.emails.should be_nil
invoice.number.should_not be_nil
end
end
end
end
end
describe "#available_transitions" do
context "when there is an object without a state" do
it "returns an empty array" do
Invoice.new.available_transitions.should be_empty
end
end
context "when there is a state with no transition actions" do
before do
@draft = State.new :name => :draft
end
it "returns an empty array" do
invoice = Invoice.new :state => @draft
invoice.available_transitions.should be_empty
end
end
context "when there is a state with transition actions" do
before do
@draft = State.new :name => :draft,
:transition_actions => [
Action.new(:id => 1, :name => :post, :target => :posted)
]
end
it "returns the array of transition actions" do
invoice = Invoice.new :state => @draft
invoice.available_transitions.should == [Action.new(:id => 1)]
end
end
context "when there is a state with transition actions with conditions" do
before do
@draft = State.new :name => :draft,
:transition_actions => [
Action.new(:id => 1, :name => :post, :target => :posted, :conditions => [:paid?]),
Action.new(:id => 2, :name => :post, :target => :posted)
]
end
context "and the object meets those conditions" do
it "returns the array of transition actions" do
invoice = Invoice.new :state => @draft, :paid => true
invoice.available_transitions.should include( Action.new(:id => 1) )
end
end
context "and the object does not meet those conditions" do
it "does not include the transitions for which the conditions are not met" do
invoice = Invoice.new :state => @draft, :paid => false
invoice.available_transitions.should_not include( Action.new(:id => 1) )
end
end
end
end
describe "available_input_actions" do
context "when there is an object without a state" do
it "returns an empty array" do
Invoice.new.available_input_actions.should be_empty
end
end
context "when there is a state with no input actions" do
before do
@draft = State.new :name => :draft
end
it "returns an empty array" do
invoice = Invoice.new :state => @draft
invoice.available_input_actions.should be_empty
end
end
context "when there are input actions :update" do
before do
@draft = State.new :name => :draft,
:input_actions => [
Action.new(:id => 1, :name => :update),
Action.new(:id => 2, :name => :delete)
]
end
it "returns the input actions" do
invoice = Invoice.new :state => @draft
invoice.available_input_actions.should include( Action.new(:id => 1) )
invoice.available_input_actions.should include( Action.new(:id => 2) )
invoice.can_update?.should be_true
invoice.can_delete?.should be_true
invoice.can_view?.should be_false
end
end
context "when there is a state with input actions with conditions" do
before do
@draft = State.new :name => :draft,
:input_actions => [
Action.new(:id => 1, :name => :update, :conditions => [:paid?])
]
end
context "and the object meets those conditions" do
it "returns the array of input actions" do
invoice = Invoice.new :state => @draft, :paid => true
invoice.available_input_actions.should include( Action.new(:id => 1) )
end
end
context "and the object does not meet those conditions" do
it "does not include the transitions for which the conditions are not met" do
invoice = Invoice.new :state => @draft, :paid => false
invoice.available_input_actions.should_not include( Action.new(:id => 1) )
end
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment