NxtStateMachine
NxtStateMachine is a simple state machine library that ships with an easy to use integration for ActiveRecord. It was build with the intend in mind to make it easy to implement other integrations. Beside the ActiveRecord integration, it ships with in memory adapters for Hash and attr_accessor.
Installation
Add this line to your application's Gemfile:
gem 'nxt_state_machine'
And then execute:
$ bundle
Or install it yourself as:
$ gem install nxt_state_machine
Usage
ActiveRecord Example
class ArticleWorkflow
include NxtStateMachine::ActiveRecord
def initialize(article, **options)
@article = article
@options = options
end
attr_accessor :article
state_machine(target: :article, state_attr: :status) do
# First we setup the states
state :draft, initial: true
states :written, :submitted # define multiple states at the same time
state :approved
state :published
state :rejected, negative: true # You can pass options to states that you can query in the transition
state :deleted, negative: true do # States can even have custom methods if options are not sufficient
def deleted_at
Time.current
end
end
event :write do
transition from: %i[draft written deleted], to: :written
end
event :submit do
# If you want transitions to take arguments, we recommend to use keyword arguments
# When the block takes arguments (instead of just keyword arguments) the first argument
# passed to the block will always be the transition!
transition from: %i[written rejected deleted], to: :submitted do |transition, *others|
puts transition.from.enum
puts transition.to.enum
end
end
event :approve do
# use methods as callbacks with run:
before_transition from: %i[written submitted deleted], to: :approved, run: :call_me_back
transition from: %i[written submitted deleted], to: :approved do |headline:|
article.headline = headline
end
after_transition from: %i[written submitted deleted], to: :approved, run: :call_me_back
# use blocks with callbacks
around_transition from: any_state, to: :approved do |block|
# Note that around transition callbacks get passed a proc object that you have to call
puts 'around transition enter'
block.call
puts 'around transition exit'
end
on_success from: any_state, to: :approved do |transition|
# This is the last callback in the chain - It runs outside of the active record transaction
end
on_error CustomError from: any_state, to: :approved do |error, transition|
end
end
event :publish do
before_transition from: any_state, to: :published, run: :some_method
transition from: :approved, to: :published
end
event :reject do
transition from: %i[draft submitted deleted], to: :rejected
end
event :delete do
transition from: any_state, to: :deleted do
article.deleted_at = Time.current
end
end
on_error! CustomError from: any_state, to: :approved do |error, transition|
# Would overwrite an existing error handler
end
end
private
def some_method
end
def call_me_back(transition)
puts transition.from.enum
puts transition.to.enum
end
end
ActiveRecord
In order to use nxt_state_machine with ActiveRecord simply include NxtStateMachine::ActiveRecord
into your class.
This does not necessarily have to be a model (thus an instance of ActiveRecord) itself. If you are a fan of the single
responsibility principle you might want to put your workflow logic in a separate class instead of into the model directly.
Therefore simply define the target of your state machine as follows. This enables you to split up complex workflows into
multiple classes (maybe orchestrated by another toplevel workflow). If you do not provide a specific target, an instance
of the class you include nxt_state_machine into will be the target (most likely your model).
Define which object holds your state with the target: option
class Workflow
include NxtStateMachine::ActiveRecord
def initialize(article)
@article = article
end
attr_reader :article
state_machine(target: :article) do
# ...
end
end
Define which attribute holds your state with the state_attr: option
Customize which attribute is used to persist and fetch your state with state_machine(state_attr: :state) do
.
If this is not customized, nxt_state_machine assumes your target has a :state
attribute.
States
The initial state will be set on new records that do not yet have a state set. Of course there can only be one initial state.
class Article < ApplicationRecord
include NxtStateMachine::ActiveRecord
state_machine do
state :draft, initial: true
states :written, :submitted
# You can pass options to states that you can query in a transition later
state :deleted, end_state: true
# You can even define custom methods on states if options are not sufficient
state :advanced do
def advanced_state?
true
end
end
end
end
You can retrieve a list of states using the states
method:
states = Article.state_machine.states # returns a NxtStateMachine::StateRegistry instance
states.keys # ["draft", "written", "submitted", "approved", "published", "rejected", "deleted"]
You can also navigate between states:
state.next # will give you the next state in the order they have been registered
state.previous # will give you the previously registered state
state.first? # first registered state?
state.last? # last registered state?
state.index # gives you the index of the state in the registry
# You can also set indexes manually by passing in indexes when defining states. Make sure they are in order!
Events
Once you have defined your states you can define events and their transitions. Events trigger state transitions based on the current state of your target.
class Article < ApplicationRecord
include NxtStateMachine::ActiveRecord
state_machine do
state :draft, initial: true
states :written, :approved, :rejected, :published
event :write do
transition from: :draft, to: :written
transition from: :rejected, to: :written
# same as transition from: %i[draft rejected], to: :written
end
event :reject do
transition from: all_states, to: :rejected # all_states is equivalent to any_state
end
event :approve do
# We recommend to use keyword arguments to make events accept custom arguments
transition from: %i[written rejected], to: :approved do |approved_at:|
self.approved_at = approved_at
# NOTE: The transition is NOT halted if this returns a falsey value
end
end
end
end
The events above define the following methods in your workflow class.
article.write
article.write!
# ...
# Generally speaking
article.<event_name> # will run the transition and call save on your target
article.<event_name!> # Will run the transition and call save! on your target
# Event that accepts keyword arguments
article.approve(approved_at: Time.current)
article.approve!(approved_at: Time.current)
Note:
By default, transitions run in transactions that acquire a lock to prevent concurrency issues. Transactions will be rolled back if an exception occurs or if your target cannot be saved due to validation errors. The state is set back to the state before the transition! If you try to transition on records with unpersisted changes you will get a
RuntimeError: Locking a record with unpersisted changes is not supported.
error saying something likeUse :save to persist the changes, or :reload to discard them explicitly.
since it's not possible to acquire a lock on modified records.You can switch off locking and transactions for events by passing in the
lock_transitions: false
option when defining an event or globally on the state machine with thelock_transitions: false
option. Currently there is no option to toggle locking at runtime.
You can retrieve a list of event methods with event_methods
:
Article.state_machine.event_methods
# => [:write, :submit, :approve, :publish, :reject, :delete, :write!, :submit!, :approve!, :publish!, :reject!, :delete!]
Transitions
When your transition takes arguments other than keyword arguments, it will always be passed the transition object itself
as the first argument. You can of course still accept keyword arguments. The transition object gives you access to the
state objects with transition.from
and transition.to
. Now you can query the options and methods you've defined
on those states earlier.
event :approve do
transition from: %i[written rejected], to: :approved do |transition, approved_at:|
# The transition object provides some useful information in the current transition
puts transition.from # will give you the state object with the options and methods you defined earlier
puts transition.from.options # options hash
puts transition.to.enum # by calling :enum on the state it will give you the state enum
halt_transition if approved_at < 3.days.ago # This would halt the transition
"This is the return value if there is no error"
end
end
Return values of transitions
Be aware that transitions that take blocks, return the return value of the block! This means that when your block returns
false, the transition would return false, even though the transition was executed just fine! (In that case is not equal
to tranistion did not succeed) If a transition does not take a block, it will return the value of :save
and :save!
respectively.
Halting transitions
Transitions can be halted in callbacks and during the transition itself simply by calling halt_transition
Callbacks
You can register before_transition
, around_transition
, after_transition
and on_success
callbacks.
By defining the :from and :to states you decide on which transitions the callback actually runs. Around callbacks need
to call the proc object that they get passed in. Registering callbacks inside an event block or on the state_machine top
level behaves exactly the same way and is only a matter of structure. The only thing that defines when callbacks run is
the :from and :to parameters with which they are registered.
event :approve do
before_transition from: %i[written submitted deleted], to: :approved, run: :call_me_back
transition from: %i[written submitted deleted], to: :approved
after_transition from: %i[written submitted deleted], to: :approved, run: :call_me_back
around_transition from: any_state, to: :approved do |block, _transition|
# Note that around transition callbacks get passed a proc object that you have to call
puts 'around transition enter'
block.call
puts 'around transition exit'
end
# Use this to trigger another event after the transaction around the transition completed
on_success from: any_state, to: :approved do |transition|
# This is the last callback in the chain - It runs outside of the active record transaction
end
end
In callbacks you also have access to the current transition object. Through it you also have access to the arguments and options that have been passed in when the transition was triggered:
before_transition from: any_state, to: :processed do |transition|
puts transition.arguments # => :arg_1, :arg_2 what was passed to the process!(:arg_1, :arg_2)
puts transition.options # => { arg_1: 'arg 1', arg_2: 'arg 2' } what was passed to the process!(arg_1: 'arg 1', arg_2: 'arg 2')
end
Error Callbacks
You can also register callbacks that run in case of an error occurs. By defining the error class you can restrict
error callbacks to run on certain errors only. Error callbacks are applied in the order they are registered. You
can also overwrite previously registered errors with the bang method on_error! CustomError ...
. By omitting the
error class a error callback is registered for all errors that inherit from StandardError
.
state_machine do
# ...
event :approve do
transition from: %i[written submitted deleted], to: :approved do |headline:|
article.headline = headline
end
on_error CustomError from: any_state, to: :approved do |error, transition|
# do something about the error here
end
end
on_error! CustomError from: any_state, to: :approved do |error, transition|
# overwrites previously registered error callbacks
end
end
ActiveRecord transaction, rollback and locks - breaking the flow by defusing errors
You want to break out of your transition (which is wrapped inside a lock)?
You can raise an error, have everything rolled back and then have your error handler take over.
NOTE: Unless you reload your model all assignments you did, previous to the error, should still be available in your
error handler. You can also defuse errors. This means they will not cause a rollback of the transaction during the
transition and you can actually persist changes to your model before the defused error is raised and handled. You can
also switch off locking (and transactions) for events by passing the lock_transitions: false
option when defining an event. This
can also by set globally for a state_machine by passing the lock_transitions: false
option when setting up the state
machine.
state_machine do
# ...
#
defuse CustomError, from: any_state, to: all_states
event :approve do
# You can also defuse on event level
# defuse CustomError, from: %i[written submitted deleted], to: :approved
transition from: %i[written submitted deleted], to: :approved do |headline:|
# This will be save to the database even if defused CustomError is raised after
article.update!(headline: headline)
raise CustomError, 'This does not rollback the headline update above'
end
end
event :approve_without_lock, lock_transitions: false do
transition from: %i[written submitted deleted], to: :approved do |headline:|
# This will be saved to the database because the event does not wrap the transition in a transaction
article.update!(headline: headline)
raise StandardError, 'This does not rollback the headline update above'
end
end
on_error! CustomError from: any_state, to: :approved do |error, transition|
# You can still handle the defused Error if you want to
# You should probably reload your model here to not accidentally save changes that
# were made to the model during the transition before a non defused error was raised
article.reload
# The error callback does not run inside the transaction. No more strings attached here.
# You can now persist changes to your model again.
article.update!(error: error.message)
end
end
In theory you can also have multiple state_machines in the same class. To do so you have to give each state_machine a name. Events need to be unique globally in order to determine which state_machine will be called. You can also trigger events from one another.
class Article < ApplicationRecord
include NxtStateMachine::ActiveRecord
state_machine(:workflow) do
state :draft, initial: true
states :written, :approved, :rejected, :published
# ...
end
state_machine(:error_handling) do
# events need to be unique globally
end
end
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/nxt_state_machine.
License
The gem is available as open source under the terms of the MIT License.