Created
July 24, 2009 06:17
-
-
Save zilkey/153885 to your computer and use it in GitHub Desktop.
Data-backed state machine example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
=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