0.02
No commit activity in last 3 years
No release in over 3 years
Finite state machine implementation that keeps logic separate from model classes and supports sub-states.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

~> 1.5
>= 0

Runtime

 Project Readme

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? # true, the post will initially be in the 'unsubmitted' state
post.can_submit? # true, the 'submit' event is defined on the current state
post.can_review? # false, the 'review' event is not defined on the current state
post.submit! # invokes the submit event
post.submitted_awaiting_review? # true, the post is in the 'submitted.awaiting_review' state
post.submitted? # true, the 'submitted' state is a parent of the current state

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

  # Under the hood, the `state` macro creates a class with the same name as the state. Here we add to the definition of that class.
  class Unsubmitted
    # Defines a handler for the submit event. Events can have arguments
    def submit(reason=nil)
      # Do something, the post is available as either the `resource` or `post` property
    end
  end

  class Submitted
    class AwaitingReview
      # Handles the review event. This will *not* handle the review event for the 'submitted.clarifying' state
      def review
      end
    end

    # Events on parent states are available to their children.
    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') # true, the post will initially be in the 'unsubmitted' state
post.state_manager.respond_to_event?('submit') # true, the 'submit' event is defined on the current state
post.state_manager.respond_to_event?('review')? # false, the 'review' event is not defined on the current state
post.state_manager.send_event!('submit') # invokes the submit event
post.state_manager.in_state?('submitted.awaiting_review')? # true, the post is in the 'submitted.awaiting_review' state
post.state_manager.in_state?('submitted')? # true, the 'submitted' state is a parent of the current state

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') # puts the post is in the 'submitted.awaiting_review' state
post.state_manager.transition_to('submitted') # throws a StateManager::InvalidState error, 'submitted' is not a leaf state

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 # 'awaiting_review'
post.state_manager.current_state.path # 'submitted.awaiting_review'

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

    # Called when the 'submitted' state is being entered
    def enter
    end

    # Called when the 'submitted' state is being exited.
    def exit
    end

    # After it has been entered
    def entered
    end

    # After it has been exited
    def exited
    end

    event :reject, :transitions_to => 'rejected'
    state :awaiting_review do
      event :review, :transitions_to => 'submitted.reviewing'
    end

  ...

  # Called before every transition
  def will_transition(from, to, event)
  end

  # Called after ever transition
  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.