State Manager
StateManager is a state machine implementation for Ruby that is heavily inspired by the FSM implementation in Ember.js. Compared to other FSM implementations, it has the following defining characteristics:
- Sub-states are supported (e.g. 'submitted.reviewing').
- State logic can be kept separate from model classes.
- State definitions are modular, underlying each state is a separate class definition.
- Supports integrations. Comes out of the box with an integration with active_record and delayed_job to support automatic delayed transtions.
We believe this is an improvement over existing state machines, but just for good measure, check out state_machine and workflow.
Getting Started
Install the state_manager
gem. If you are using bundler, add the following to your Gemfile:
gem 'state_manager'
After the gem is installed, create a file to contain the definition of your state manager, e.g. app/states/post_states.rb
. Edit this file and define your states:
class PostStates < StateManager::Base
state :unsubmitted do
event :submit, :transitions_to => 'submitted.awaiting_review'
end
state :submitted
event :reject, :transitions_to => 'rejected'
state :awaiting_review do
event :review, :transitions_to => 'submitted.reviewing'
end
state :reviewing do
event :accept, :transitions_to => 'active'
event :clarify, :transitions_to => 'submitted.clarifying'
end
state :clarifying do
event :review, :transitions_to => 'submitted.reviewing'
end
end
state :active
state :rejected
end
Once your states are defined, you need to extend the StateManager::Resource
module on your resource class and define a state managed property:
class Post
attr_accessor :state
extend StateManager::Resource
state_manager
end
The code above infers the existence of PostStates
class and a state
property. An alternate states definition and property could be specified as follows:
class Post
attr_accessor :workflow_state
extend StateManager::Resource
state_manager :workflow_state, PostStates
end
Helper Methods
Unless otherwise specified with {:helpers => false}
as the third argument to the state_manager
macro, helper methods will be added to the resource class. In the above example, some of the methods that will be available are:
post = Post.new
post.unsubmitted?
post.can_submit?
post.can_review?
post.submit!
post.submitted_awaiting_review?
post.submitted?
Handling Events
Most applications will require special logic to be performed during state transitions. Handlers for events can be defined as follows:
class PostStates < StateManager::Base
state :unsubmitted do
event :submit, :transitions_to => 'submitted.awaiting_review'
end
state :submitted
event :reject, :transitions_to => 'rejected'
state :awaiting_review do
event :review, :transitions_to => 'submitted.reviewing'
end
state :reviewing do
event :accept, :transitions_to => 'active'
event :clarify, :transitions_to => 'submitted.clarifying'
end
state :clarifying do
event :review, :transitions_to => 'submitted.reviewing'
end
end
state :active
state :rejected
class Unsubmitted
def submit(reason=nil)
end
end
class Submitted
class AwaitingReview
def review
end
end
def reject
end
end
end
Under the Hood
As suggested in the above example, states and events really just correspond to classes and methods of the state manager. In fact, the state
macro is really just syntactic sugar around defining a StateManager::State
subclass to the current state--the root state manager is also a state.
On the resource, the state_manager
macro makes an instance of the specified StateManager::Base
subclass available through the "#{property}_manager" attribute on the resource. The above examples of helper methods is essentially syntactic sugar on the following:
post = Post.new
post.state_manager.in_state?('unsubmitted')
post.state_manager.respond_to_event?('submit')
post.state_manager.respond_to_event?('review')?
post.state_manager.send_event!('submit')
post.state_manager.in_state?('submitted.awaiting_review')?
post.state_manager.in_state?('submitted')?
Furthermore, only leaf states are valid states for a resource. The state manager can also be explicitly transitioned to a state, however this should normally only be used inside an event handler:
post.state_manager.transition_to('submitted.awaiting_review')
post.state_manager.transition_to('submitted')
By default, the initial state will be the first state that was defined. This can be customized by setting the initial state:
class PostStates < StateManager::Base
initial_state :rejected
...
end
The current state can also be accessed from the state manager:
post.state_manager.current_state.name
post.state_manager.current_state.path
Callbacks
StateManager has several callbacks that can be hooked into:
class PostStates < StateManager::Base
state :unsubmitted do
event :submit, :transitions_to => 'submitted.awaiting_review'
end
state :submitted
def enter
end
def exit
end
def entered
end
def exited
end
event :reject, :transitions_to => 'rejected'
state :awaiting_review do
event :review, :transitions_to => 'submitted.reviewing'
end
...
def will_transition(from, to, event)
end
def did_transition(from, to, event)
end
end
In the above example, transitioning between 'submitted.awaiting_review' and 'submitted.reviewing' will not trigger the the enter/exit callbacks for the 'submitted' state, however it will be called for the two sub-states.
ActiveRecord
StateManager works out of the box with ActiveRecord. enter
, entered
, exit
, and exited
callbacks match up to their equivalent ActiveRecord callbacks: before_save
and after_save
. In addition there are enter_committed
and exit_committed
ActiveRecord-specific callbacks that are triggered when a transtion has been committed to the underlying database. These hooks are useful for actions that are performed external to the database (e.g. enqueuing to an external task worker).
Delayed Job Integration
StateManager comes out of the box with support for delayed_job. If delayed_job is available, events can be defined with a :delay
property which indicates a delay after which the event should automatically be triggered:
class UserStates < StateManager::Base
state :submitted do
event :activate, :transitions_to => :active, :delay => 2.days
end
state :active
end
In this example, the activate
event will be called by delayed_job automatically after 2 days unless it is called programatically before then.
Contributing to statemanager
- Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet.
- Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it.
- Fork the project.
- Start a feature/bugfix branch.
- Commit and push until you are happy with your contribution.
- Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
- Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
Copyright
Copyright (c) 2012 Gordon Hempton. See LICENSE.txt for
further details.