/statesman

A statesmanlike state machine library.

Primary LanguageRubyMIT LicenseMIT

Statesman

A statesmanlike state machine library for Ruby 1.9.3 and 2.0.

Gem Version Build Status Code Climate

Statesman is a little different from other state machine libraries which tack state behaviour directly onto a model. A statesman state machine is defined as a separate class which is instantiated with the model to which it should apply. State transitions are also modelled as a class which can optionally be persisted to the database for a full audit history, including JSON metadata which can be set during a transition.

This data model allows for interesting things like using a different state machine depending on the value of a model attribute.

TL;DR Usage

class OrderStateMachine
  include Statesman::Machine

  state :pending, initial: true
  state :checking_out
  state :purchased
  state :shipped
  state :cancelled
  state :failed
  state :refunded

  transition from: :pending,      to: [:checking_out, :cancelled]
  transition from: :checking_out, to: [:purchased, :cancelled]
  transition from: :purchased,    to: [:shipped, :failed]
  transition from: :shipped,      to: :refunded

  guard_transition(to: :checking_out) do |order|
    order.products_in_stock?
  end

  before_transition(from: :checking_out, to: :cancelled) do |order, transition|
    order.reallocate_stock
  end

  before_transition(to: :purchased) do |order, transition|
    PaymentService.new(order).submit
  end

  after_transition(to: :purchased) do |order, transition|
    MailerService.order_confirmation(order).deliver
  end
end

class Order < ActiveRecord::Base
  include Statesman::Adapters::ActiveRecordModel

  has_many :order_transitions

  def state_machine
    OrderStateMachine.new(self, transition_class: OrderTransition)
  end

  private

  def self.transition_class
    OrderTransition
  end
end

class OrderTransition < ActiveRecord::Base
  include Statesman::Adapters::ActiveRecordTransition

  belongs_to :order, inverse_of: :order_transitions
end

Order.first.state_machine.current_state
# => "pending"

Order.first.state_machine.allowed_transitions
# => ["checking_out", "cancelled"]

Order.first.state_machine.can_transition_to?(:cancelled)
# => true/false

Order.first.state_machine.transition_to(:cancelled, optional: :metadata)
# => true/false

Order.in_state(:cancelled)
# => [#<Order id: "123">]

Order.not_in_state(:checking_out)
# => [#<Order id: "123">]

Order.first.state_machine.transition_to!(:cancelled)
# => true/exception

Events

class TaskStateMachine
  include Statesman::Machine

  state :unstarted, initial: true
  state :started
  state :finished
  state :delivered
  state :accepted
  state :rejected

  event :start do
    transition from: :unstarted,  to: :started
  end

  event :finish do
    transition from: :started,    to: :finished
  end

  event :deliver do
    transition from: :finished,   to: :delivered
    transition from: :started,    to: :delivered
  end

  event :accept do
    transition from: :delivered, to: :accepted
  end

  event :rejected do
    transition from: :delivered, to: :rejected
  end

  event :restart do
    transition from: :rejected,   to: :started
  end

end

class Task < ActiveRecord::Base
  delegate :current_state, :trigger!, :available_events, to: :state_machine

  def state_machine
    @state_machine ||= TaskStateMachine.new(self)
  end

end

task = Task.new

task.current_state
# => "unstarted"

task.trigger!(:start)
# => true/exception

task.current_state
# => "started"

task.available_events
# => [:finish, :deliver]

Persistence

By default Statesman stores transition history in memory only. It can be persisted by configuring Statesman to use a different adapter. For example, ActiveRecord within Rails:

config/initializers/statesman.rb:

Statesman.configure do
  storage_adapter(Statesman::Adapters::ActiveRecord)
end

Generate the transition model:

$ rails g statesman:active_record_transition Order OrderTransition

And add an association from the parent model:

app/models/order.rb:

class Order < ActiveRecord::Base
  has_many :order_transitions

  # Initialize the state machine
  def state_machine
    @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
  end

  # Optionally delegate some methods
  delegate :can_transition_to?, :transition_to!, :transition_to, :current_state,
           to: :state_machine
end

Using PostgreSQL JSON column

By default, Statesman uses serialize to store the metadata in JSON format. It is also possible to use the PostgreSQL JSON column if you are using Rails 4. To do that

  • Change metadata column type in the transition model migration to json

    # Before
    t.text :metadata, default: "{}"
    # After
    t.json :metadata, default: "{}"
  • Remove include Statesman::Adapters::ActiveRecordTransition statement from your transition model

Configuration

storage_adapter

Statesman.configure do
  storage_adapter(Statesman::Adapters::ActiveRecord)
  # ...or
  storage_adapter(Statesman::Adapters::Mongoid)
end

Statesman defaults to storing transitions in memory. If you're using rails, you can instead configure it to persist transitions to the database by using the ActiveRecord or Mongoid adapter.

Statesman will fallback to memory unless you specify a transition_class when instantiating your state machine. This allows you to only persist transitions on certain state machines in your app.

Class methods

Machine.state

Machine.state(:some_state, initial: true)
Machine.state(:another_state)

Define a new state and optionally mark as the initial state.

Machine.transition

Machine.transition(from: :some_state, to: :another_state)

Define a transition rule. Both method parameters are required, to can also be an array of states (.transition(from: :some_state, to: [:another_state, :some_other_state])).

Machine.guard_transition

Machine.guard_transition(from: :some_state, to: another_state) do |object|
  object.some_boolean?
end

Define a guard. to and from parameters are optional, a nil parameter means guard all transitions. The passed block should evaluate to a boolean and must be idempotent as it could be called many times.

Machine.before_transition

Machine.before_transition(from: :some_state, to: another_state) do |object|
  object.side_effect
end

Define a callback to run before a transition. to and from parameters are optional, a nil parameter means run before all transitions. This callback can have side-effects as it will only be run once immediately before the transition.

Machine.after_transition

Machine.after_transition(from: :some_state, to: another_state) do |object, transition|
  object.side_effect
end

Define a callback to run after a successful transition. to and from parameters are optional, a nil parameter means run after all transitions. The model object and transition object are passed as arguments to the callback. This callback can have side-effects as it will only be run once immediately after the transition.

Machine.new

my_machine = Machine.new(my_model, transition_class: MyTransitionModel)

Initialize a new state machine instance. my_model is required. If using the ActiveRecord adapter my_model should have a has_many association with MyTransitionModel.

Machine.retry_conflicts

Machine.retry_conflicts { instance.transition_to(:new_state) }

Automatically retry the given block if a TransitionConflictError is raised. If you know you want to retry a transition if it fails due to a race condition call it from within this block. Takes an (optional) argument for the maximum number of retry attempts (defaults to 1).

Instance methods

Machine#current_state

Returns the current state based on existing transition objects.

Machine#history

Returns a sorted array of all transition objects.

Machine#last_transition

Returns the most recent transition object.

Machine#allowed_transitions

Returns an array of states you can transition_to from current state.

Machine#can_transition_to?(:state)

Returns true if the current state can transition to the passed state and all applicable guards pass.

Machine#transition_to!(:state)

Transition to the passed state, returning true on success. Raises Statesman::GuardFailedError or Statesman::TransitionFailedError on failure.

Machine#transition_to(:state)

Transition to the passed state, returning true on success. Swallows all Statesman exceptions and returns false on failure. (NB. if your guard or callback code throws an exception, it will not be caught.)

Model scopes

A mixin is provided for the ActiveRecord adapter which adds scopes to easily find all models currently in (or not in) a given state. Include it into your model and define a transition_class method.

class Order < ActiveRecord::Base
  include Statesman::Adapters::ActiveRecordModel

  private

  def self.transition_class
    OrderTransition
  end
end

Model.in_state(:state_1, :state_2, etc)

Returns all models currently in any of the supplied states.

Model.not_in_state(:state_1, :state_2, etc)

Returns all models not currently in any of the supplied states.


GoCardless ♥ open source. If you do too, come join us.