Skip to content

A simple but powerful state machine implementation.

License

Notifications You must be signed in to change notification settings

nxt-insurance/nxt_state_machine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CircleCI Depfu

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 like Use :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 the lock_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.