ruby_state_machine

Ruby State Machine – AASM Tutorial with Sequel, SQLite, Rake and RSpec

   Back to list

Have you ever had a problem managing a record’s state change logic? For example, managing products or orders in a shop or invoices (changing their states – from new, to delivered etc.)? Here comes AASM – a state machine for Ruby. It works with plain Ruby and also with many libraries like ActiveRecord, Sequel, Dynamoid, Redis or Mongoid.

The greatest thing about AASM is that it’s really intuitive and easy to implement. It allows you painlessly add a fully working state machine in Ruby.

In this tutorial, I will show how to connect AASM with Sequel and how to create a micro Ruby application using Rake, DotEnv, SQLite and RSpec.

What is a state machine?

Formal definition: It’s a finite automaton or an abstract machine that can be in exactly one of a finite number of states at any given time.

Real world example: A security code lock system. It has a finite number of states and t changes from one state to another all the time.

Waiting -> Analyzing entered code -> Opening a door / returning an error that code is invalid -> Waiting -> ….

Our problem

Let’s imagine that we have a problem with invoices in our system. It’s hard to control their states and check which state change is allowed after another one. What’s more, we struggle with callbacks and logic after a single state change.

For instance, after one change we might want to add some logic by not change the state while after another one, we only want to change the state.

Logic and states

Here are our invoice logic and all available states:

ruby_state_machine_screenshot1

We have 5 states:

  • Draft
  • Unpaid
  • Sent
  • Paid
  • Archived

It’s allowed to change a status:

  • From Draft to Unpaid (confirm)
  • From Unpaid to Sent (sent)
  • From Sent to Paid (pay)
  • From Paid to Archived (archive)
  • From Unpaid to Draft (draft)
  • From Unpaid to Archived (archive)

Implementation

Logic seems easy, right? Yeah, that’s true, moreover, implementation is easy too!

Let’s do it – firstly prepare all needed files and install all gems.

Start by creating the main project folder and enter there:

$ mkdir aasm_sequel_tutorial
$ cd aasm_sequel_tutorial

Now let’s create folders for our files and Gemfile:

$ mkdir app config spec
$ touch Gemfile

Add needed gems for now:

$ vim Gemfile
source 'https://rubygems.org'

gem 'aasm'
gem 'rspec

Now, please install everything using bundler:

$ bundle install

Create the main file, which is responsible for wrapping all application settings:

$ touch config/application.rb
require 'bundler'
Bundler.require

Dir.glob('./app/*.rb').each { |file| require file }

This file includes bundler itself and all dependencies installed by it, by calling Bundler.require.

Also, includes all *.rb files in the /app folder.

Now let’s add a Rakefile, which allows us to run commands like rake console or rake db:setup etc.

$ vim Rakefile
desc 'Run console'
task :console do
  sh 'irb -r ./config/application'
end

By calling rake console, we run irb, which reads config from application.rb file. Why do we need it? Because we need to expose all installed modules and all needed files to irb.

Now let’s configure RSpec. Let’s add just a color output, we don’t need more for now.

$ vim .rspec

And add:

--color

Main class – invoice

Next let’s create our main file, which includes the invoice class.

$ vim app/invoice.rb
class Invoice
  include AASM

  attr_reader :name

  aasm do
    state :draft, initial: true
    state :unpaid
    state :sent
    state :paid
    state :archived

    event :draft do
      transitions from: :unpaid, to: :draft
    end

    event :confirm do
      transitions from: :draft, to: :unpaid
    end

    event :sent do
      transitions from: :unpaid, to: :sent
    end

    event :pay do
      transitions from: :sent, to: :paid
    end

    event :archive do
      transitions from: [:upaid, :paid], to: :archived
    end
  end

  def initialize(name)
    @name = name
  end
end

First, we need to include AASM by adding: include AASM. Our class, for now, has only one field – name – defined in the attr_reader method.

How can we define the entire state machine logic? By defining everything in the AASN block. We list all available states, by writing:

state :my_state

States’ order isn’t important. If you want to define an initial state, add initial: true option.

To define a state change, define event :my_event_name and in a block, define transitions, from and to. The from and to parameters accept one and more states.

Pretty easy, right?! Ok, let’s add more logic to the invoice class. What if we want to call a method after a state is changed? Like sending an email or uploading a file to S3?

It’s easy, after an event name, define a method name under an after: key. Moreover, if you want to call a method after each state change, define it in after_all_transitions, like after_all_transitions :run_worker.

Please check the code above and add it:

class Invoice
  ...

  aasm do
    after_all_transitions :log_status_change

    ...

    event :sent, after: :send_invoice do
      transitions from: :unpaid, to: :sent
    end

    ...

    event :archive, after: :archive_data do
      transitions from: [:upaid, :paid], to: :archived
    end
  end

  ...

  def send_invoice
    puts 'Sending an invoice...'
  end

  def archive_data
    puts 'Archiving data...'
  end

  def log_status_change
    puts "Changed from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})"
  end
end

Ok, now let’s test everything. Run rake console:

$ rake console

ruby_state_machine_screenshot2

ruby_state_machine_screenshot3

As you can see, when you want to change to unpermitted state, AASM throws an exception. When everything is fine, it changes a state. If you want to change exceptions behavior and return just true/false, add to the AASM block: aasm whiny_transitions: false

Testing – RSpec

Let’s write some specs to test our invoices. Add two files:

spec/invoice_spec.rb and spec/spec_helper.rb

We need to require our application config in the spec_helper.rb, in order to require all dependencies and AASM spec helpers:

require 'aasm/rspec'
require_relative '../config/application'

After when we are ready with a basic configuration, let’s think about what we can test.

I think that checking an initial state and whether defined methods are run after a state change is a good idea. Let’s do it! Add the following code to the invoice_spec.rb:

require_relative 'spec_helper'
require_relative '../app/invoice'

RSpec.describe Invoice do
  let(:name) { 'Test invoice' }
  let(:instance) { described_class.new(name: name) }

  describe 'initial state' do
    subject { instance }

    it { is_expected.to have_state(:draft) }
  end

  describe 'archive' do
    subject { instance.archive }

    before { instance.aasm.current_state = :paid }

    it 'calls all needed methods' do
      expect(instance).to receive(:archive_data)
      expect(instance).to receive(:log_status_change)

      subject
    end
  end

  describe 'sent' do
    subject { instance.sent }

    before { instance.aasm.current_state = :unpaid }

    it 'calls all needed methods' do
      expect(instance).to receive(:send_invoice)
      expect(instance).to receive(:log_status_change)

      subject
    end
  end
end
Sign up for free

To run added specs, run:

$ rspec

ruby_state_machine_screenshot4

Yeah, specs pass and everything is green 🙂

Database – Sequel

We now have a working version of our state machine and invoices but we can’t save them to a database. Have you ever tried to add a database system to a plain Ruby? If not, I’ll show you how to implement and connect everything together.

First of all, let’s update our Gemfile and add needed gems:

gem ‘sqlite3’
gem ‘sequel’
gem ‘dotenv

Now please install everything by running bundler:

$ bundle install

As you probably noticed, I added dotenv gem, to manage with environment variables – development and test. Please add the environment.rb file under the config folder and add the following code:

if ENV['RACK_ENV'] == 'test'
  Dotenv.load('.env.test')
else
  Dotenv.load
end

As you can see, based on an environment, we load a different file and environment variables. Ok, now let’s add a database configuration and later we will add environment variables later.

Add the database.rb file under to config folder and add the following code:

DB = Sequel.connect(ENV.fetch('DATABASE_URL'))
Sequel::Model.plugin :timestamps

As you can see, we define a database connection under the DB constant and add the timestamps module.

Ok, we need to load all added files when we run irb console, so let’s modify the application.rb file:

require 'bundler'
Bundler.require

require_relative '../config/environment'
require_relative '../config/database'

Dir.glob('./app/*.rb').each { |file| require file }

As stated previously, we need to add environment variables, let’s do it by adding these two files – .env and .env.test.

DATABASE_URL='sqlite://database.db'
RACK_ENV=development
DATABASE_URL='sqlite://test-database.db'

We’ve defined a database connection and an environment name.Next, we should add the .gitignore file, to exclude SQLite databases from git.

# database
database.db
test-database.db

Almost everything is ready, a database connection is prepared but, we don’t have any tables for now. How can we create them? Let’s add a new namespace to the Rakefile and a task, which creates all needed tables.

Add to the Rakefile, the new namespace:

require 'bundler'
Bundler.require

desc 'Run console'
task :console do
  sh 'irb -r ./config/application'
end

namespace :db do
  require_relative 'config/application'

  desc 'Create and setup a database'
  task :create do
    DB.create_table? :invoices do
      primary_key :id
      String :name
      String :state
      DateTime :created_at
      DateTime :updated_at
    end
  end
end

What does it do? It creates a table called invoices when it doesn’t exist. Otherwise the create_table? method allows us to skip this part.

Ok, let’s run it by:

$ rake db:create

Woohoo, we have full database setup. Now we need to define, that our invoice class, in fact, uses the database. Add the following modifications the the invoice.rb file and remember to remove the initialize method – it will override default Sequel’s method:

class Invoice < Sequel::Model
  ...
  
  aasm column: :state do
    ...
  end
end

Since we added environment configuration, we need to define the environment name in the spec_helper.rb. Why there? It must be defined before reading the application.rb file.

Add the following line to the bottom of the file:

ENV[‘RACK_ENV’] = ‘test’

Yeah, everything is ready now! Let’s play with our application and create a few invoices.

$ rake console

Let’s check if our database is empty:

$ DB[:invoices].all

or

$ Invoice.all
$ Invoice.create(name: "Test invoice")

or

DB[:invoices].insert(name: "Test invoice")

What is the difference? When we call DB[:invoices].all, we directly call the invoices table, so as a result, we get an array of hashes while running a query like Invoices.all, we get an array of Invoices. On a hash result we can’t run methods like: update, destroy.

Conclusion

In this Ruby State Machine tutorial, I showed how to connect plain Ruby with Sequel and used AASM to explain state machines. I hope that you liked it and it will be useful for you! Thank you for reading and the source code can be found here.

Send this to a friend