A statesmanlike state machine library for Ruby 1.9.3 and 2.0.
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 tojson
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.