A statesmanlike state machine library.
For our policy on compatibility with Ruby and Rails versions, see [COMPATIBILITY.md](docs/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:
- State behaviour is defined in a separate, "state machine" class, rather than
added directly onto a model. State machines are then instantiated with the model
to which they should apply.
- State transitions are also modelled as a class, which can optionally be
persisted to the database for a full audit history. This audit history can
include JSON metadata set during a transition.
- Database indices are used to offer database-level transaction duplication
protection.
## Installation
To get started, just add Statesman to your `Gemfile`, and then run `bundle`:
```ruby
gem 'statesman', '~> 13.0.0'
```
## Usage
First, create a state machine based on `Statesman::Machine`:
```
…
```
Then, link it to your model:
```ruby
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:
```ruby
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:
```
…
```
If you'd like, you can also define a template for a generic state machine, then alter classes which extend it as required:
```
…
```
## Persistence
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`:
```ruby
Statesman.configure do
storage_adapter(Statesman::Adapters::ActiveRecord)
end
```
Generate the transition model:
```bash
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`:
```ruby
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
```
### 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
or 5. To do that
- Change `metadata` column type in the transition model migration to `json` or `jsonb`
```ruby
# Before
t.text :metadata, default: "{}"
# After (Rails 4)
t.json :metadata, default: "{}"
# After (Rails 5)
t.json :metadata, default: {}
```
* Remove the `include Statesman::Adapters::ActiveRecordTransition` statement from
your transition model, which would've instructed ActiveRecord to serialize the
metadata.
* The module that you just removed enables customizing the updatated timestamp column
as described above. Having removed it, if you want to customise your transition class's
"updated timestamp column", you should define a `.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.
## Configuration
### `storage_adapter`
```ruby
Statesman.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_transition`
```ruby
def 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:
- Keep an accurate record of the initial state even if configuration changes
- Keep a record of how long the state machine spent in the initial state
- Utilise a transition hook for the transition to the initial state
## 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. 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_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.after_transition_failure`
```ruby
Machine.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_failure`
```ruby
Machine.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.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).
### `Machine.states`
Returns an array of all possible state names as strings.
### `Machine.successors`
Returns a hash of states and the states it is valid for them to transition to.
```ruby
Machine.successors
{
"pending" => ["checking_out", "cancelled"],
"checking_out" => ["purchased", "cancelled"],
"purchased" => ["shipped", "failed"],
"shipped" => ["refunded"]
}
```
## Class constants
Adding a state to a state machine will automatically create a constant for the value, for example:
```ruby
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"
```
## Instance methods
### `Machine#current_state`
Returns 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#history`
Returns a sorted array of all transition objects.
### `Machine#last_transition`
Returns the most recent transition object.
### `Machine#last_transition_to(:state)`
Returns the most recent transition object to a given state.
### `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.)
## Errors
### Initialization errors
These errors are raised when the Machine and/or Model is initialized. A simple spec like
```ruby
expec