As you've probably guessed by the title of my article, I still consider Ruby on Rails as a relevant technology that offers a lot of value, especially when combined with ReactJS as it's frontend counterpart. Here's how I approach the topic.
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:
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
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
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.