Statesman

A statesmanlike state machine library for Ruby 1.9.3 and 2.0.

Gem Version Build Status Code Climate Gitter

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

```ruby 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::ActiveRecordQueries

has_many :order_transitions

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

private

def self.transition_class OrderTransition end

def self.initial_state :pending 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

```ruby 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:

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

Generate the transition model:

bash $ rails g statesman:active_record_transition Order OrderTransition

And add an association from the parent model:

app/models/order.rb:

```ruby 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

    ruby # Before t.text :metadata, default: "{}" # After t.json :metadata, default: "{}"

  • Remove include Statesman::Adapters::ActiveRecordTransition statement from your transition model

Creating transitions without using #transition_to with ActiveRecord

By default, Statesman will include a most_recent column on the transitions table, and update its value each time #transition_to is called. If you create transitions manually (for example to backfill for a new state) you will need to set the most_recent attribute manually.

Configuration

storage_adapter

ruby 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

ruby Machine.state(:some_state, initial: true) Machine.state(:another_state) Define a new state and optionally mark as the initial state.

Machine.transition

ruby 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

ruby 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

ruby 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

ruby 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.

If you specify after_commit: true, the callback will be executed once the transition has been committed to the database.

Machine.new

ruby 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

ruby 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 transition_class and initial_state class methods:

```ruby class Order < ActiveRecord::Base include Statesman::Adapters::ActiveRecordQueries

private

def self.transition_class OrderTransition end

def self.initial_state OrderStateMachine.initial_state end end ```

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

Returns all models currently in any of the supplied states. Prior to 1.0 this ignored all models in the initial state, and the initial_state class method was not required.

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

Returns all models not currently in any of the supplied states. Prior to 1.0 this always excluded models in the initial state, and the initial_state class method was not required.

Frequently Asked Questions

Storing the state on the model object

If you wish to store the model state on the model directly, you can keep it up to date using an after_transition hook:

ruby after_transition do |model, transition| model.state = transition.to_state model.save! end

You could also use a calculated column or view in your database.

Accessing metadata from the last transition

Given a field foo that was stored in the metadata, you can access it like so:

ruby model_instance.last_transition.metadata["foo"]

Testing Statesman Implementations

This answer was abstracted from this issue.

At GoCardless we focus on testing that: - guards correctly prevent / allow transitions - callbacks execute when expected and perform the expected actions

Testing Guards

Guards can be tested by asserting that transition_to! does or does not raise a Statesman::GuardFailedError:

```ruby describe “guards” do it “cannot transition from state foo to state bar” do expect { some_model.transition_to!(:bar) }.to raise_error(Statesman::GuardFailedError) end

it “can transition from state foo to state baz” do expect { some_model.transition_to!(:baz).to_not raise_error end end ```

Testing Callbacks

Callbacks are tested by asserting that the action they perform occurs:

ruby describe "some callback" do it "adds one to the count property on the model" do expect { some_model.transition_to!(:some_state) }. to change { some_model.reload.count }.by(1) end end


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