SimpleStateMachine

<img src=“https://github.com/mdh/ssm/actions/workflows/build.yml/badge.svg” />

A simple DSL to decorate existing methods with state transition guards.

Instead of using a DSL to define events, SimpleStateMachine decorates methods to help you encapsulate state and guard state transitions.

It supports exception rescuing, google chart visualization and mountable state machines.

Usage

Define an event and specify how the state should transition. If we want the state to change from pending to active we write:

event :activate_account, :pending => :active

That’s it. You can now call activate_account and the state will automatically change. If the state change is not allowed, a SimpleStateMachine::IllegalStateTransitionError is raised.

Methods with arguments

If you want to pass arguments and call other methods before the state transition, define your event as a method.

def activate_account(activation_code)
  # call other methods, no need to add these in callbacks
  ..
end

Now mark the method as an event and specify how the state should transition when the method is called.

event :activate_account, :pending => :active

Basic example

class LampSwitch

   extend SimpleStateMachine

   def initialize
     self.state = 'off'
   end

   event :push_switch, :off => :on,
                       :on  => :off

end

lamp = LampSwitch.new
lamp.state          # => 'off'
lamp.off?           # => true
lamp.push_switch    #
lamp.state          # => 'on'
lamp.on?            # => true
lamp.push_switch    #
lamp.off?           # => true

ActiveRecord

For ActiveRecord methods are decorated with state transition guards and persistence. Methods marked as events behave like ActiveRecord save and save!.

Example

To add a state machine to an ActiveRecord class, you will have to:

  • extend SimpleStateMachine::ActiveRecord,

  • set the initial state in after_initialize,

  • turn methods into events

    class User < ActiveRecord::Base
    
      extend SimpleStateMachine::ActiveRecord
    
      after_initialize do
        self.state ||= 'pending'
      end
    
      def invite
        self.activation_code = Digest::SHA1.hexdigest("salt #{Time.now.to_f}")
      end
      event :invite, :pending => :invited
    end
    
    user = User.new
    user.pending?         # => true
    user.invite           # => true
    user.invited?         # => true
    user.activation_code  # => 'SOMEDIGEST'
    

For the invite method this generates the following event methods

  • invite (behaves like ActiveRecord save )

  • invite! (behaves like ActiveRecord save!)

If you want to be more verbose you can also use:

  • invite_and_save (alias for invite)

  • invite_and_save! (alias for invite!)

Using ActiveRecord / ActiveModel validations

When using ActiveRecord / ActiveModel you can add an error to the errors object. This will prevent the state from being changed.

If we add an activate_account method to User

class User  < ActiveRecord::Base
  ...
  def activate_account(activation_code)
    if activation_code_invalid?(activation_code)
      errors.add(:activation_code, 'Invalid')
    end
  end
  event :activate_account, :invited => :confirmed
  ...
end

user.confirm_invitation!('INVALID') # => raises ActiveRecord::RecordInvalid,
                                    #           "Validation failed: Activation code is invalid"
user.confirmed?                     # => false
user.confirm_invitation!('VALID')
user.confirmed?                     # => true

Mountable StateMachines

If you like to separate your state machine from your model class, you can do so as following:

class MyStateMachine < SimpleStateMachine::StateMachineDefinition

  event :invite,             :new     => :invited
  event :confirm_invitation, :invited => :active

  def decorator_class
    SimpleStateMachine::Decorator::Default
  end
end

class User < ActiveRecord::Base

  extend SimpleStateMachine::Mountable
  mount_state_machine MyStateMachine

  after_initialize do
    self.state ||= 'new'
  end

end

Transitions

Catching all from states

If an event should transition from all other defined states, you can use the :all state:

event :suspend, :all => :suspended

Catching exceptions

You can let the state machine handle exceptions by specifying the failure state for an Error:

def download_data
  raise Service::ConnectionError, "Uhoh"
end
event :download_data, Service::ConnectionError => :download_failed

download_data               # catches Service::ConnectionError
state                       # => "download_failed"
state_machine.raised_error  # "Uhoh"

Default error state

To automatically catch all exceptions to a default error state use default_error_state:

state_machine_definition.default_error_state = :failed

Transactions

If you want to run events in transactions run them in a transaction block:

user.transaction { user.invite! }

Tools

Generating state diagrams

When using Rails/ActiveRecord you can generate a state diagram of the state machine via the built in rake tasks. For details run:

rake -T ssm

A Googlechart example: tinyurl.com/79xztr6

Installation

Use gem install:

gem install simple_state_machine

Or add it to your Gemfile:

gem 'simple_state_machine'

Note on Patches/Pull Requests

  • Fork the project.

  • Make your feature addition or bug fix.

  • Add tests for it. This is important so I don’t break it in a future version unintentionally.

  • Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)

  • Send me a pull request. Bonus points for topic branches.

Copyright © 2010 Marek & Petrik. See LICENSE for details.