Lightweight state machine for Active Record and Active Model.
After experimenting with state machines in a recent project, I became interested in a workflow that felt more natural for rails. In particular, I wanted to reduce architectural overlap incurred by flow control, guard, and callback workflows.
The goal of Police State is to let you easily work with state machines based on ActiveModel::Dirty
, ActiveModel::Validation
, and ActiveModel::Callbacks
Police State revolves around the use of TransitionValidator
and two helper methods, attribute_transitioning?
and attribute_transitioned?
.
To get started, just include PoliceState
in your model and define a set of valid transitions:
class Model < ApplicationRecord
include PoliceState
enum status: {
queued: 0,
active: 1,
complete: 2,
failed: 3
}
validates :status, transition: { from: nil, to: :queued }
validates :status, transition: { from: :queued, to: :active }
validates :status, transition: { from: :active, to: :complete }
validates :status, transition: { from: [:queued, :active], to: :failed }
end
One aspect of Police State that will feel different than other ruby state machines is the idea that in-memory state has not fully transitioned until it is persisted to the database. This lets you operate within a traditional Active Record workflow:
model = Model.new(status: :complete)
# => #<Model:0x007fa94844d088 @status=:complete>
model.status_transitioning?(from: nil)
# => true
model.status_transitioning?(to: :complete)
# => true
model.valid?
# => false
model.errors.to_hash
# => {:status=>["can't transition to complete"]}
model.save
# => false
model.save!
# => ActiveRecord::RecordInvalid: Validation failed: Status can't transition to complete
model.status = :queued
# => :queued
model.valid?
# => true
model.save
# => true
model.status_transitioned?(from: nil, to: :queued)
# => true
Guard conditions can be introduced for a state by adding a conditional ActiveRecord validation:
validates :another_field, :presence, if: -> { queued? }
Callbacks can be attached to specific transitions by adding a condition on attribute_transitioned?
. If the callback needs to occur before persistence, attribute_transitioning?
can also be used.
after_commit :notify, if: -> { status_transitioned?(to: :complete) }
after_commit :alert, if: -> { status_transitioned?(from: :active, to: :failed) }
after_commit :log, if: -> { status_transitioned? }
Explicit event languge can be added to models by wrapping update
and / or update!
def run
update(status: :active)
end
def run!
update!(status: :active)
end
The bang methods defined by ActiveRecord::Enum
work as well:
model.active!
# => ActiveRecord::RecordInvalid: Validation failed: Status can't transition to active
One important note about TransitionValidator
is that it performs a unidirectional validation. For example, the following ensures that the active
state can only be reached from the queued
state:
validates :status, transition: { from: :queued, to: :active }
However, this does not prevent queued
from transitioning to other states. Those states must be controlled by their own validators.
If you are using Active Model, make sure your class correctly implements ActiveModel::Dirty
. For an example, check out spec/test_model.rb
Add this line to your application's Gemfile:
gem 'police_state'
And then execute:
$ bundle
Or install it yourself as:
$ gem install police_state
The gem is available as open source under the terms of the MIT License.