#392 A Tour of State Machines pro
- Download:
- source codeProject Files in Zip (127 KB)
- mp4Full Size H.264 Video (31.5 MB)
- m4vSmaller H.264 Video (16 MB)
- webmFull Size VP8 Video (19 MB)
- ogvFull Size Theora Video (36.3 MB)
The topic of state machines has never been covered by any previous episode. They can be overused in Rails applications but there are good uses for them and there is no shortage of gems to help us out when we want to use them. In this episode we’ll give you a tour of three different state machine gems but before we do that we’ll show an application where a state machine would be a good fit. A classic case for a state machine is an e-commerce application with an Order model. Usually each order will go through a number of states as a user works their way through the checkout process and their order is processed. In our current application we use datetime
columns in the database to do this, although we could use boolean fields.
create_table "orders", :force => true do |t| t.datetime "purchased_at" t.datetime "canceled_at" t.datetime "shipped_at" t.datetime "created_at", :null => false t.datetime "updated_at", :null => false end
In the database table we store the time that the order is purchased and either cancelled or shipped. If we find ourselves adding a lot of datetime
or boolean
fields to a table this is a good indication that we should use a state machine instead, especially if these fields depend upon each other. For example in our application we don’t want to ship an order unless it’s already been purchased or if it’s been cancelled. Another indication that we should use a state machine is when we have a lot of boolean logic in a model. Our Order
model has an open?
method and for an order to be considered open it must be purchased and not cancelled or shipped. This kind of logic is duplicated in our database queries where we have an open_orders
query which performs a similar check.
def self.open_orders where('purchased_at is not null and canceled_at is null and shipped_at is null') end def open? purchased_at && !canceled_at && !shipped_at end
This isn’t too bad in itself but things can quickly get out of hand if we need to handle returned shipments, refund payments or add some other processing state. It would be better if we had a way to track the state that an order is currently in without having all this logic. (By the way, the rest of our Order
class has methods that handle different events such as the order being purchased, shipped or cancelled.)
We also have specs for this class which mainly check the behaviour of when an order is considered open. For example it’s considered open after a purchase, but not after an invalid purchase. Our goal is to modify these specs as little as possible as we want this overall behaviour to stay the same as we change our application to use a state machine.
If you’re unfamiliar with state machines it’s worth reading the Wikipedia article on the subject which provides a nice example using a turnstile with two states: locked and unlocked. When an event happens, such as adding a coin or pushing the turnstile the turnstile will move from one state to the other. The key words here are state, event and transition and these are often used in the DSL in the various gems.
The State Machine Gem
We’ll start our look at the various Ruby state machine gems with the most popular, which is simply called State Machine. This is fully-featured and can be used with a simple Ruby class although it also can be used with a number of ORMs such as ActiveRecord, DataMapper, Mongoid and more. To set it up in our application we first need to add the state_machine
gem to our gemfile.
gem 'state_machine'
Next we need to generate a migration to add a string column called state
to the model that we want to affect.
class CreateOrders < ActiveRecord::Migration def change create_table :orders do |t| t.string :state t.timestamps end end end
Inside this model we need to add a call to state_machine
. We can set an initial state here if we want then specify different events using the gem’s DSL. Each event can have a transition
so that it can transition from one state to another.
class Order < ActiveRecord::Base scope :open_orders, -> { with_state(:open) } attr_accessor :invalid_payment state_machine initial: :incomplete do event :purchase do transition :incomplete => :open end event :cancel do transition :open => :canceled end event :resume do transition :canceled => :open end event :ship do transition :open => :shipped end before_transition :incomplete => :open do |order| # process payment ... !order.invalid_payment end end end
Note that we’re not using the Ruby 1.9 hash syntax in our transitions, though there’s nothing to stop us from doing so. This gem also supports transitions and we have a before_transition
block in our class which is fired when we go from an incomplete
state to an open order and it’s this block that processes the payment. This returns a boolean value and if it returns false
the transition won’t continue so if the payment is invalid the order will stay as incomplete. Finally if we look at the top of the class we’ll see that the gem provides a with_state
scope which can be used to query the database. The scope we have returns all the open orders.
We haven’t had to change the specs for the class at all. We can still call purchase
on an order to trigger an event and each event has its own method that we can call which will transition to the appropriate state. If the transition fails then it will fall back to the previous state. All the behaviour defined in our order spec works like we expect and so there is no need to make any changes.
There’s a lot more that we can do with this gem and we’ll show you some of them in the Rails console. First we’ll create a new Order
then check its state by calling state
on it; this returns “incomplete”. We can also check a state by calling it with a query method such as incomplete?
. If we want to see if we can perform a specific event from the current state by calling can_
and the name of the event, such as can_cancel?
or can_purchase?
.
>> o = Order.create >> o.state => "incomplete" >> o.incomplete? => true >> o.can_cancel? => false >> o.can_purchase? => true
Given that can_purchase?
returned true
we’ll call purchase
on our order then call state_events
to see what events we can call from the order’s current state.
>> o.purchase >> o.state_events => [:cancel, :ship]
Most of the other state machine gems we’ll show here provide similar functionality but one feature that’s unique to this gem is the ability to define behaviour for a specific state. For example we can add validations or define methods inside a state
block which will only take place when the object is in that given state. This can be really useful for multi-page forms, although it can be a little too “magical”. One issue with this gem is its the size. It’s lib
directory contains over 3,000 lines of code which is around ten times that of some of the alternatives. If the size of your app’s dependencies is an issue then you might want to consider a different solution.
Acts As State Machine
Let’s take a look at another gem, Acts As State Machine. This project seems to be more active than the alternatives and it also works on plain Ruby classes or on ActiveRecord models. It’s installed in a similar way to the State Machine gem. First we need to add the gem to our Gemfile and run bundle to install it.
gem 'aasm'
We also need a migration to add an aasm_state
field to our orders table.
class CreateOrders < ActiveRecord::Migration def change create_table :orders do |t| t.string :aasm_state t.timestamps end end end
The name of this column is configurable, as it is will all of the gems we’re showing here, and there are more details about this in the README.
Things are a little different in the Order
model here.
class Order < ActiveRecord::Base include AASM scope :open_orders, -> { where(aasm_state: "open") } attr_accessor :invalid_payment aasm do state :incomplete, initial: true state :open state :canceled state :shipped event :purchase, before: :process_purchase do transitions from: :incomplete, to: :open, guard: :valid_payment? end event :cancel do transitions from: :open, to: :canceled end event :resume do transitions from: :canceled, to: :open end event :ship do transitions from: :open, to: :shipped end end def process_purchase # process order ... end def valid_payment? !invalid_payment end end
We need to include the AASM
module in our class to add its functionality and we use an aasm
block to define the states and events. First we use state to define the states and we can set one of these to be the initial state. The events are defined in a similar way as before except that callbacks are handled a little differently. Here we add them directly to the event and they trigger a method when the callback happens. AASM doesn’t seem to care what the callback methods return but we can add a guard
clause to a specific transition and this will stop the transition from taking place if its method returns false
. Another difference is how we query the database. There doesn’t seem to be a specific named scope for this, but we can easily use a where
condition as we have here.
We had to make some changes to our specs to get the tests to pass. One key difference is that if we call an event and that event fails the object doesn’t revert back to its previous state. Instead an AASM::InvalidTransaction
is raised. Another difference is that a record isn’t automatically saved to the database when it transitions to a new state; we need to call an event with an exclamation mark if we want it so save. Acts As State Machine is a decent option but its code does feel a little old, especially the ActiveRecord adapter and how it integrates with Rails.
Workflow
Next we’ll try Workflow. This gem is much lighter than the others and is made up of a single file that’s only a few hundred lines long. This is installed in the same way as the other gems.
gem 'workflow'
Next we need to add a string column called workflow_state
to the table we want to modify.
class CreateOrders < ActiveRecord::Migration def change create_table :orders do |t| t.string :workflow_state t.timestamps end end end
We also need to include a Workflow
module in our model.
class Order < ActiveRecord::Base include Workflow scope :open_orders, -> { where(workflow_state: "open") } workflow do state :incomplete do event :purchase, transition_to: :open end state :open do event :cancel, transition_to: :canceled event :ship, transition_to: :shipped end state :canceled do event :resume, transition_to: :open end state :shipped end def purchase(valid_payment = true) # process purchase ... halt unless valid_payment end end
The DSL for defining states and events is a little different from the other gems’. We pass a block to the state
method along with the name of the state and define the events and transitions inside that block. This works really well in our situation where we have a nice concise workflow, although the other gems’ DSLs make the events stand out a little more. What is good about Workflow is that we can handle callbacks by defining a method with the same name as the event. In our class we have a purchase
callback where we process the purchase then halt the transition if it isn’t valid. This allows an event to take arguments and we’ve used these in some of our specs when we call purchase!
.
it "is not open after invalid purchase" do order = Order.create! order.purchase!(false) order.should_not be_open end
Because of this each of the events needs to be called with an exclamation mark so that they don’t conflict with the defined method.
Other Options
So far we’ve only covered three gems but there are a many alternatives that are worth considering, such as the Transitions gem. All these work in a similar way, storing the state in a database column. We might want to keep track of more information, though, such as the history of the different states and the time that the transition took place. To accomplish this we can use the State Machine Audit Trail gem that builds on State Machine or use something more generic such as Paper Trail. That said doing this from scratch isn’t difficult so to end this episode we’ll write an alternative solution which keeps track of the history of events without using any external gems.
We’re starting with an orders
table which has nothing to keep track of the current state. Instead we have an order_events
table handled by an OrderEvent
model. This model belongs to Order
and has a state
field. In the Order
model we have a has_many
association with events. We define the various states in this model and delegate query versions of each state to a current_state
method.
class Order < ActiveRecord::Base has_many :events, class_name: "OrderEvent" STATES = %w[incomplete open canceled shipped] delegate :incomplete?, :open?, :canceled?, :shipped?, to: :current_state def self.open_orders joins(:events).merge OrderEvent.with_last_state("open") end def current_state (events.last.try(:state) || STATES.first).inquiry end def purchase(valid_payment = true) if incomplete? # process purchase ... events.create! state: "open" if valid_payment end end def cancel events.create! state: "canceled" if open? end def resume events.create! state: "open" if canceled? end def ship events.create! state: "shipped" if open? end end
This method determines the current state based on the latest event or, if there isn’t one it grabs the first state as the initial state, and calls inquiry
on it. This provides some additional query methods that we can call on the current state. ActiveSupport provides this and makes it possible. The events are simply method definitions which means that they can take arguments. We first check the state that we want to transition from then transition to the new state by creating a new event.
This approach means that there’s no need for a DSL or using callbacks. The only tricky part is querying the database. This is done is a class method called open_orders
where we join the events
table and merge some additional clauses from inside the OrderEvent
scope. This finds all the latest events that match a certain state. This is defined in OrderEvent
.
class OrderEvent < ActiveRecord::Base belongs_to :order attr_accessible :state validates_presence_of :order_id validates_inclusion_of :state, in: Order::STATES def self.with_last_state(state) order("id desc").group("order_id").having(state: state) end end
This class also has some validations to ensure that the state is in the list of valid states.
Overall this is about fifty lines of code and our custom solution keeps track of the history of events and timing of them all. While it doesn’t have all the conveniences of some of the gems we’ve looked at these can be added if necessary.