A statesmanlike state machine library.
See the code
A statesmanlike state machine library.
For our policy on compatibility with Ruby and Rails versions, see COMPATIBILITY.md.
Statesman is an opinionated state machine library designed to provide a robust audit trail and data integrity. It decouples the state machine logic from the underlying model and allows for easy composition with one or more model classes.
As such, the design of statesman is a little different from other state machine libraries:
To get started, just add Statesman to your Gemfile, and then run bundle:
gem 'statesman', '~> 13.0.0'
First, create a state machine based on Statesman::Machine:
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
Then, link it to your model:
class Order < ActiveRecord::Base
has_many :order_transitions, autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: :pending
]
def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
end
end
Next, you'll need to create a further model to represent state transitions:
class OrderTransition < ActiveRecord::Base
include Statesman::Adapters::ActiveRecordTransition
validates :to_state, inclusion: { in: OrderStateMachine.states }
belongs_to :order, inverse_of: :order_transitions
end
Now, you can start working with your state machine:
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.first.state_machine.transition_to!(:cancelled) # => true/exception
Order.first.state_machine.last_transition # => transition model or nil
Order.first.state_machine.last_transition_to(:pending) # => transition model or nil
Order.in_state(:cancelled) # => [#<Order id: "123">]
Order.not_in_state(:checking_out) # => [#<Order id: "123">]
If you'd like, you can also define a template for a generic state machine, then alter classes which extend it as required:
module Template
def define_states
state :a, initial: true
state :b
state :c
end
def define_transitions
transition from: :a, to: :b
transition from: :b, to: :c
transition from: :c, to: :a
end
end
class Circular
include Statesman::Machine
extend Template
define_states
define_transitions
end
class Linear
include Statesman::Machine
extend Template
define_states
define_transitions
remove_transitions from: :c, to: :a
end
class Shorter
include Statesman::Machine
extend Template
define_states
define_transitions
remove_state :c
end
By default Statesman stores transition history in memory only. It can be persisted by configuring Statesman to use a different adapter. For example, for 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
Your transition class should
include Statesman::Adapters::ActiveRecordTransition if you're using the
ActiveRecord adapter.
If you're using the ActiveRecord adapter and decide not to include the default
updated_at column in your transition table, you'll need to configure the
updated_timestamp_column option on the transition class, setting it to another column
name (e.g. :updated_on) or nil.
And add an association from the parent model:
app/models/order.rb:
class Order < ActiveRecord::Base
has_many :transitions, class_name: "OrderTransition", autosave: false
# Initialize the state machine
def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition,
association_name: :transitions)
end
# Optionally delegate some methods
delegate :can_transition_to?,
:current_state, :history, :last_transition, :last_transition_to,
:transition_to!, :transition_to, :in_state?, to: :state_machine
end
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
or 5. To do that
Change metadata column type in the transition model migration to json or jsonb
# Before
t.text :metadata, default: "{}"
# After (Rails 4)
t.json :metadata, default: "{}"
# After (Rails 5)
t.json :metadata, default: {}
include Statesman::Adapters::ActiveRecordTransition statement from
your transition model, which would've instructed ActiveRecord to serialize the
metadata..updated_timestamp_column method on
your class and return the name of the column as a symbol, or nil if you don't want
to record an updated timestamp on transitions.storage_adapterStatesman.configure do
storage_adapter(Statesman::Adapters::ActiveRecord)
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 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.
initial_transitiondef state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition,
association_name: :transitions,
initial_transition: true)
end
By default Statesman does not record a transition to the initial state of the state machine.
You can configure Statesman to record a transition to the initial state, this will allow you to:
Machine.stateMachine.state(:some_state, initial: true)
Machine.state(:another_state)
Define a new state and optionally mark as the initial state.
Machine.transitionMachine.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_transitionMachine.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. The guard will pass when it
evaluates to a truthy value and fail when it evaluates to a falsey value (nil or false).
Machine.before_transitionMachine.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_transitionMachine.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.after_transition_failureMachine.after_transition_failure(from: :some_state, to: :another_state) do |object, exception|
Logger.info("transition to #{exception.to} failed for #{object.id}")
end
Define a callback to run if Statesman::TransitionFailedError is raised
during the execution of transition callbacks. to and from
parameters are optional, a nil parameter means run after all transitions.
The model object, and exception are passed as arguments to the callback.
This is executed outside of the transaction wrapping other callbacks.
If using transition! the exception is re-raised after these callbacks are
executed.
Machine.after_guard_failureMachine.after_guard_failure(from: :some_state, to: :another_state) do |object, exception|
Logger.info("guard failed during transition to #{exception.to} for #{object.id}")
end
Define a callback to run if Statesman::GuardFailedError is raised
during the execution of guard callbacks. to and from
parameters are optional, a nil parameter means run after all transitions.
The model object, and exception are passed as arguments to the callback.
This is executed outside of the transaction wrapping other callbacks.
If using transition! the exception is re-raised after these callbacks are
executed.
Machine.newmy_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_conflictsMachine.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).
Machine.statesReturns an array of all possible state names as strings.
Machine.successorsReturns a hash of states and the states it is valid for them to transition to.
Machine.successors
{
"pending" => ["checking_out", "cancelled"],
"checking_out" => ["purchased", "cancelled"],
"purchased" => ["shipped", "failed"],
"shipped" => ["refunded"]
}
Adding a state to a state machine will automatically create a constant for the value, for example:
class OrderStateMachine
include Statesman::Machine
state :pending, initial: true
state :checking_out
state :cancelled
# Constants created as a side effect of adding state
transition from: PENDING, to: [CHECKING_OUT, CANCELLED]
end
OrderStateMachine::PENDING #=> "pending"
OrderStateMachine::CHECKING_OUT # => "checking_out"
Machine#current_stateReturns the current state based on existing transition objects.
Takes an optional keyword argument to force a reload of data from the
database.
e.g current_state(force_reload: true)
Machine#in_state?(:state_1, :state_2, ...)Returns true if the machine is in any of the given states.
Machine#historyReturns a sorted array of all transition objects.
Machine#last_transitionReturns the most recent transition object.
Machine#last_transition_to(:state)Returns the most recent transition object to a given state.
Machine#allowed_transitionsReturns 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.)
These errors are raised when the Machine and/or Model is initialized. A simple spec like
expect { OrderStateMachine.new(Order.new, transition_class: OrderTransition) }.to_not raise_error
will expose these errors as part of your test suite
Raised if:
to state.initial.Raised if:
from a state that has no valid transitions (A terminal state).to the initial state if that state has no transitions to it.from and to where any of the pairs have no transition between them.Raised if:
Raised if:
metadata attribute into
to Database column backing it. See the Using PostgreSQL JSON column section.Raised if:
metadata in the
Database and the model. See the Using PostgreSQL JSON column section.Raised if:
Statesman::Adapters::ActiveRecordQueries is included in
does not have a has_many association to the transition_class.These errors are raised by transition_to!. Using transition_to will
suppress GuardFailedError and TransitionFailedError and return false instead.
Raised if:
from and to state returned a falsey value.The model object that failed to transition is available via object:
begin
my_model.transition_to!(:some_state)
rescue Statesman::GuardFailedError => e
logger.error("Guard failed for #{e.object.class}##{e.object.id}: #{e.message}")
end
Raised if:
current_state -> new_state is not a valid pair.Raised if:
sort_key or most_recent columns occurs
when attempting a transition.
Retried automatically if it occurs wrapped in retry_conflicts.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 passing in transition_class and initial_state as options.
In 4.1.2 and below, these two options had to be defined as methods on the model, but 5.0.0 and above allow this style of configuration as well. The old way pollutes the model with extra class methods, and is deprecated, to be removed in the future.
class Order < ActiveRecord::Base
has_many :order_transitions, autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: OrderStateMachine.initial_state
]
end
If the transition class-name differs from the association name, you will also
need to pass transition_name as an option:
class Order < ActiveRecord::Base
has_many :transitions, class_name: "OrderTransition", autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: OrderStateMachine.initial_state,
transition_name: :transitions
]
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.
Model.most_recent_transition_joinThis joins the model to its most recent transition whatever that may be.
We expose this method to ease use of ActiveRecord's or e.g
Model.in_state(:state_1).or(
Model.most_recent_transition_join.where(model_field: 123)
)
If you find yourself frequently needing the current state without wanting to
join or query the transitions table,
Statesman::Adapters::ConfigureCachedCurrentState can maintain a
denormalised column on the parent model for you:
class MyModel < ActiveRecord::Base
extend Statesman::Adapters::TypeSafeActiveRecordQueries
include Statesman::Adapters::ConfigureCachedCurrentState
configure_state_machine transition_class: MyModelTransition,
initial_state: :initial
configure_cached_current_state
end
This sets the column (cached_current_state by default) to the initial state
when a record is created — unless it's already been explicitly set (e.g. a
test factory building a record straight into a given state), in which case
that value is left alone — and updates it via update_columns whenever
transition_to! successfully persists a new most_recent transition.
Including the module by itself does nothing — the write is only wired up once
configure_cached_current_state is called.
The write happens inside the ActiveRecord storage adapter itself, right
after a transition is persisted as the new most_recent row, rather than via
an ActiveRecord callback on transition_class. That single code path is
shared by every Machine subclass and by every adapter instance for a model,
so it fires reliably even under Statesman's mysql_gaplock_protection config
— where the most_recent flip happens via a raw SQL UPDATE that bypasses
ActiveRecord callbacks entirely.
One consequence: a transition row created directly on transition_class,
bypassing the machine (e.g. parent.transitions.create!(...) in a test or
backfill), does not update the cache — only real transitions performed
through transition_to! do.
Two further options are available:
column: — use a column name other than cached_current_state.touch_updated_at: — also bump updated_at on the parent when the cached
column changes (defaults to false).Because the write happens in the shared adapter code rather than against a
specific Machine subclass, this also works for models driven by several
different machine classes chosen dynamically (e.g. per scheme). It always
runs before any of a machine's own after_transition callbacks, since it
happens as part of persisting the transition, before Statesman invokes its
own callbacks.
configure_cached_current_state also defines Model.cached_state_column_name,
returning the configured column, for generic tooling that needs to introspect
or repair the cached column (e.g. a cache-repair rake task working across
several models).
If you wish to store the model state on the model directly, you can keep it up
to date using an after_transition hook.
Combine it with the after_commit option to ensure the model state will only be
saved once the transition has made it irreversibly to the database:
after_transition(after_commit: true) do |model, transition|
model.state = transition.to_state
model.save!
end
You could also use a calculated column or view in your database.
Given a field foo that was stored in the metadata, you can access it like so:
model_instance.state_machine.last_transition.metadata["foo"]
If you need to delete the Parent model regularly you will need to change
either the association deletion behaviour or add a DELETE CASCADE condition
to foreign key in your database.
E.g
has_many :order_transitions, autosave: false, dependent: :destroy
or when migrating the transition model
add_foreign_key :order_transitions, :orders, on_delete: :cascade
This answer was abstracted from this issue.
At GoCardless we focus on testing that:
Guards can be tested by asserting that transition_to! does or does not raise a Statesman::GuardFailedError:
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
Callbacks are tested by asserting that the action they perform occurs:
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
Including ActiveRecordQueries to your model can cause issues with type checkers such as Sorbet, this is because this technically is using a dynamic include, which is not supported by Sorbet.
To avoid these issues you can instead include the TypeSafeActiveRecordQueries module and pass in configuration.
class Order < ActiveRecord::Base
has_many :order_transitions, autosave: false
include Statesman::Adapters::TypeSafeActiveRecordQueries
configure_state_machine transition_class: OrderTransition,
initial_state: :pending
def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
end
end
statesman-sequel - An adapter to make Statesman work with Sequel
GoCardless ♥ open source. If you do too, come join us.
Overall
Maintained
Code review
RubyGems
Latest release
Releases this year
3,272 followers · starred Sep 2014
819 followers · starred Aug 2021
234 followers · starred Mar 2014
500 followers · starred Nov 2013
A statesmanlike state machine library.
See the code
A statesmanlike state machine library.
For our policy on compatibility with Ruby and Rails versions, see COMPATIBILITY.md.
Statesman is an opinionated state machine library designed to provide a robust audit trail and data integrity. It decouples the state machine logic from the underlying model and allows for easy composition with one or more model classes.
As such, the design of statesman is a little different from other state machine libraries:
To get started, just add Statesman to your Gemfile, and then run bundle:
gem 'statesman', '~> 13.0.0'
First, create a state machine based on Statesman::Machine:
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
Then, link it to your model:
class Order < ActiveRecord::Base
has_many :order_transitions, autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: :pending
]
def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
end
end
Next, you'll need to create a further model to represent state transitions:
class OrderTransition < ActiveRecord::Base
include Statesman::Adapters::ActiveRecordTransition
validates :to_state, inclusion: { in: OrderStateMachine.states }
belongs_to :order, inverse_of: :order_transitions
end
Now, you can start working with your state machine:
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.first.state_machine.transition_to!(:cancelled) # => true/exception
Order.first.state_machine.last_transition # => transition model or nil
Order.first.state_machine.last_transition_to(:pending) # => transition model or nil
Order.in_state(:cancelled) # => [#<Order id: "123">]
Order.not_in_state(:checking_out) # => [#<Order id: "123">]
If you'd like, you can also define a template for a generic state machine, then alter classes which extend it as required:
module Template
def define_states
state :a, initial: true
state :b
state :c
end
def define_transitions
transition from: :a, to: :b
transition from: :b, to: :c
transition from: :c, to: :a
end
end
class Circular
include Statesman::Machine
extend Template
define_states
define_transitions
end
class Linear
include Statesman::Machine
extend Template
define_states
define_transitions
remove_transitions from: :c, to: :a
end
class Shorter
include Statesman::Machine
extend Template
define_states
define_transitions
remove_state :c
end
By default Statesman stores transition history in memory only. It can be persisted by configuring Statesman to use a different adapter. For example, for 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
Your transition class should
include Statesman::Adapters::ActiveRecordTransition if you're using the
ActiveRecord adapter.
If you're using the ActiveRecord adapter and decide not to include the default
updated_at column in your transition table, you'll need to configure the
updated_timestamp_column option on the transition class, setting it to another column
name (e.g. :updated_on) or nil.
And add an association from the parent model:
app/models/order.rb:
class Order < ActiveRecord::Base
has_many :transitions, class_name: "OrderTransition", autosave: false
# Initialize the state machine
def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition,
association_name: :transitions)
end
# Optionally delegate some methods
delegate :can_transition_to?,
:current_state, :history, :last_transition, :last_transition_to,
:transition_to!, :transition_to, :in_state?, to: :state_machine
end
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
or 5. To do that
Change metadata column type in the transition model migration to json or jsonb
# Before
t.text :metadata, default: "{}"
# After (Rails 4)
t.json :metadata, default: "{}"
# After (Rails 5)
t.json :metadata, default: {}
include Statesman::Adapters::ActiveRecordTransition statement from
your transition model, which would've instructed ActiveRecord to serialize the
metadata..updated_timestamp_column method on
your class and return the name of the column as a symbol, or nil if you don't want
to record an updated timestamp on transitions.storage_adapterStatesman.configure do
storage_adapter(Statesman::Adapters::ActiveRecord)
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 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.
initial_transitiondef state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition,
association_name: :transitions,
initial_transition: true)
end
By default Statesman does not record a transition to the initial state of the state machine.
You can configure Statesman to record a transition to the initial state, this will allow you to:
Machine.stateMachine.state(:some_state, initial: true)
Machine.state(:another_state)
Define a new state and optionally mark as the initial state.
Machine.transitionMachine.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_transitionMachine.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. The guard will pass when it
evaluates to a truthy value and fail when it evaluates to a falsey value (nil or false).
Machine.before_transitionMachine.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_transitionMachine.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.after_transition_failureMachine.after_transition_failure(from: :some_state, to: :another_state) do |object, exception|
Logger.info("transition to #{exception.to} failed for #{object.id}")
end
Define a callback to run if Statesman::TransitionFailedError is raised
during the execution of transition callbacks. to and from
parameters are optional, a nil parameter means run after all transitions.
The model object, and exception are passed as arguments to the callback.
This is executed outside of the transaction wrapping other callbacks.
If using transition! the exception is re-raised after these callbacks are
executed.
Machine.after_guard_failureMachine.after_guard_failure(from: :some_state, to: :another_state) do |object, exception|
Logger.info("guard failed during transition to #{exception.to} for #{object.id}")
end
Define a callback to run if Statesman::GuardFailedError is raised
during the execution of guard callbacks. to and from
parameters are optional, a nil parameter means run after all transitions.
The model object, and exception are passed as arguments to the callback.
This is executed outside of the transaction wrapping other callbacks.
If using transition! the exception is re-raised after these callbacks are
executed.
Machine.newmy_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_conflictsMachine.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).
Machine.statesReturns an array of all possible state names as strings.
Machine.successorsReturns a hash of states and the states it is valid for them to transition to.
Machine.successors
{
"pending" => ["checking_out", "cancelled"],
"checking_out" => ["purchased", "cancelled"],
"purchased" => ["shipped", "failed"],
"shipped" => ["refunded"]
}
Adding a state to a state machine will automatically create a constant for the value, for example:
class OrderStateMachine
include Statesman::Machine
state :pending, initial: true
state :checking_out
state :cancelled
# Constants created as a side effect of adding state
transition from: PENDING, to: [CHECKING_OUT, CANCELLED]
end
OrderStateMachine::PENDING #=> "pending"
OrderStateMachine::CHECKING_OUT # => "checking_out"
Machine#current_stateReturns the current state based on existing transition objects.
Takes an optional keyword argument to force a reload of data from the
database.
e.g current_state(force_reload: true)
Machine#in_state?(:state_1, :state_2, ...)Returns true if the machine is in any of the given states.
Machine#historyReturns a sorted array of all transition objects.
Machine#last_transitionReturns the most recent transition object.
Machine#last_transition_to(:state)Returns the most recent transition object to a given state.
Machine#allowed_transitionsReturns 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.)
These errors are raised when the Machine and/or Model is initialized. A simple spec like
expect { OrderStateMachine.new(Order.new, transition_class: OrderTransition) }.to_not raise_error
will expose these errors as part of your test suite
Raised if:
to state.initial.Raised if:
from a state that has no valid transitions (A terminal state).to the initial state if that state has no transitions to it.from and to where any of the pairs have no transition between them.Raised if:
Raised if:
metadata attribute into
to Database column backing it. See the Using PostgreSQL JSON column section.Raised if:
metadata in the
Database and the model. See the Using PostgreSQL JSON column section.Raised if:
Statesman::Adapters::ActiveRecordQueries is included in
does not have a has_many association to the transition_class.These errors are raised by transition_to!. Using transition_to will
suppress GuardFailedError and TransitionFailedError and return false instead.
Raised if:
from and to state returned a falsey value.The model object that failed to transition is available via object:
begin
my_model.transition_to!(:some_state)
rescue Statesman::GuardFailedError => e
logger.error("Guard failed for #{e.object.class}##{e.object.id}: #{e.message}")
end
Raised if:
current_state -> new_state is not a valid pair.Raised if:
sort_key or most_recent columns occurs
when attempting a transition.
Retried automatically if it occurs wrapped in retry_conflicts.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 passing in transition_class and initial_state as options.
In 4.1.2 and below, these two options had to be defined as methods on the model, but 5.0.0 and above allow this style of configuration as well. The old way pollutes the model with extra class methods, and is deprecated, to be removed in the future.
class Order < ActiveRecord::Base
has_many :order_transitions, autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: OrderStateMachine.initial_state
]
end
If the transition class-name differs from the association name, you will also
need to pass transition_name as an option:
class Order < ActiveRecord::Base
has_many :transitions, class_name: "OrderTransition", autosave: false
include Statesman::Adapters::ActiveRecordQueries[
transition_class: OrderTransition,
initial_state: OrderStateMachine.initial_state,
transition_name: :transitions
]
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.
Model.most_recent_transition_joinThis joins the model to its most recent transition whatever that may be.
We expose this method to ease use of ActiveRecord's or e.g
Model.in_state(:state_1).or(
Model.most_recent_transition_join.where(model_field: 123)
)
If you find yourself frequently needing the current state without wanting to
join or query the transitions table,
Statesman::Adapters::ConfigureCachedCurrentState can maintain a
denormalised column on the parent model for you:
class MyModel < ActiveRecord::Base
extend Statesman::Adapters::TypeSafeActiveRecordQueries
include Statesman::Adapters::ConfigureCachedCurrentState
configure_state_machine transition_class: MyModelTransition,
initial_state: :initial
configure_cached_current_state
end
This sets the column (cached_current_state by default) to the initial state
when a record is created — unless it's already been explicitly set (e.g. a
test factory building a record straight into a given state), in which case
that value is left alone — and updates it via update_columns whenever
transition_to! successfully persists a new most_recent transition.
Including the module by itself does nothing — the write is only wired up once
configure_cached_current_state is called.
The write happens inside the ActiveRecord storage adapter itself, right
after a transition is persisted as the new most_recent row, rather than via
an ActiveRecord callback on transition_class. That single code path is
shared by every Machine subclass and by every adapter instance for a model,
so it fires reliably even under Statesman's mysql_gaplock_protection config
— where the most_recent flip happens via a raw SQL UPDATE that bypasses
ActiveRecord callbacks entirely.
One consequence: a transition row created directly on transition_class,
bypassing the machine (e.g. parent.transitions.create!(...) in a test or
backfill), does not update the cache — only real transitions performed
through transition_to! do.
Two further options are available:
column: — use a column name other than cached_current_state.touch_updated_at: — also bump updated_at on the parent when the cached
column changes (defaults to false).Because the write happens in the shared adapter code rather than against a
specific Machine subclass, this also works for models driven by several
different machine classes chosen dynamically (e.g. per scheme). It always
runs before any of a machine's own after_transition callbacks, since it
happens as part of persisting the transition, before Statesman invokes its
own callbacks.
configure_cached_current_state also defines Model.cached_state_column_name,
returning the configured column, for generic tooling that needs to introspect
or repair the cached column (e.g. a cache-repair rake task working across
several models).
If you wish to store the model state on the model directly, you can keep it up
to date using an after_transition hook.
Combine it with the after_commit option to ensure the model state will only be
saved once the transition has made it irreversibly to the database:
after_transition(after_commit: true) do |model, transition|
model.state = transition.to_state
model.save!
end
You could also use a calculated column or view in your database.
Given a field foo that was stored in the metadata, you can access it like so:
model_instance.state_machine.last_transition.metadata["foo"]
If you need to delete the Parent model regularly you will need to change
either the association deletion behaviour or add a DELETE CASCADE condition
to foreign key in your database.
E.g
has_many :order_transitions, autosave: false, dependent: :destroy
or when migrating the transition model
add_foreign_key :order_transitions, :orders, on_delete: :cascade
This answer was abstracted from this issue.
At GoCardless we focus on testing that:
Guards can be tested by asserting that transition_to! does or does not raise a Statesman::GuardFailedError:
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
Callbacks are tested by asserting that the action they perform occurs:
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
Including ActiveRecordQueries to your model can cause issues with type checkers such as Sorbet, this is because this technically is using a dynamic include, which is not supported by Sorbet.
To avoid these issues you can instead include the TypeSafeActiveRecordQueries module and pass in configuration.
class Order < ActiveRecord::Base
has_many :order_transitions, autosave: false
include Statesman::Adapters::TypeSafeActiveRecordQueries
configure_state_machine transition_class: OrderTransition,
initial_state: :pending
def state_machine
@state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
end
end
statesman-sequel - An adapter to make Statesman work with Sequel
GoCardless ♥ open source. If you do too, come join us.
Overall
Maintained
Code review
RubyGems
Latest release
Releases this year
3,272 followers · starred Sep 2014
819 followers · starred Aug 2021
234 followers · starred Mar 2014
500 followers · starred Nov 2013