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
Copyright © 2010 Marek & Petrik. See LICENSE for details.