Ruby on Rails: Active Record Enum

Gregory Bryant
QuarkWorks, Inc.
Published in
5 min readFeb 7, 2020
Design by Joy Park

Writing software from scratch can be a satisfying journey full of planning, implementing, and revising. While in the development phase, decisions can be temporary and easily undone, allowing development to take new paths.

Coming into an existing live codebase is an entirely different task since decisions have already been made, and they are not easily changed. A good starting point is to make a small, low-risk change that can have good returns.

A recent project I worked on included an order model and order status model, both backed by database tables and developed using Ruby on Rails. The code utilizing the models involved magic numbers as a means of checking and setting statuses.

For example:

order.status = 2

or

if order.status == 3 then
<insert complex operations here>
end

The numbers could be swapped out with constants, but ActiveRecord has built-in functionality for enums. The ActiveRecord implementation of enums seems intended for models with an integer field for the enumerated value, a situation that does not match my project.

Here is the assumption for implementing ActiveRecord enums:

class CreateOrders < ActiveRecord::Migration[5.2]
def change
create_table :orders do |t|
t.date :created_at
t.decimal :total
t.integer :order_status
t.timestamps
end
end
end

Where my existing project is structured as:

class CreateOrderStatuses < ActiveRecord::Migration[5.2]
def change
create_table :order_statuses do |t|
t.string :order_status
t.timestamps
end
end
end
class CreateOrders < ActiveRecord::Migration[5.2]
def change
create_table :orders do |t|
t.date :created_at
t.decimal :total
t.belongs_to :order_status, foreign_key: true
t.timestamps
end
end
end

Through some trial and error, I was able to implement a solution that makes use of the built-in functionality that ActiveRecord enums provide while not having to make any changes to my database schema or breaking changes to my existing production codebase.

To follow along with me you can use the following instructions.

Navigate to the folder that will hold your project files.

cd ~/Projects/Rails/

Create the file seeds.rb

~/Projects/Rails/seeds.rb

and paste in the following:

OrderStatus.create(order_status:'open')
OrderStatus.create(order_status:'hold')
OrderStatus.create(order_status:'closed')
(1..100).each {|i| Order.create(total:rand(10000),created_at:rand(Time.now-2.years..Time.now),order_status_id:rand(1..3))}

Then, execute the following:

rails new enum
cd enum
spring stop
cp ../seeds.rb db/seeds.rb
rails generate model OrderStatus order_status
rails generate model Order created_at:datetime total:decimal order_status:references
rake db:migrate
rake db:seed

Excellent! Now we can get to work. Let’s begin by firing up a rails console (rails c) and see what we are starting with.

To test if the first order in our database is closed here is what some of the current options look like:

Order.first.order_status==OrderStatus.where(order_status:"open").first

Order.first.order_status==1

The first way is not convenient and is error-prone and the second is what we are trying to avoid. To improve the situation, we will add some enum notation to our models.

Modify the following files accordingly:

order_status.rb:

class OrderStatus < ApplicationRecord
enum status: {'open':1,'hold':2,'closed':3}
end

order.rb:

class Order < ApplicationRecord
has_one :order_status
enum order_status_id: OrderStatus.statuses
end

We have now gained the following functionality:

> o = Order.first
> o.open?
=> false
o.order_status_id=1
> o.open?
=> true
o.close!
> o.open?
=> false
Order.open
=> <list of open orders>

but also some problems:

> o = Order.first
> o.hold!
> o.hold?
=> true
> o.order_status_id==OrderStatus.where(order_status:'hold').first.id
=> false
> o.order_status_id
=> "hold"
> Order.where(order_status:'open')
=> #<ActiveRecord::Relation []>

If we want to add this functionality to the order_status field, it will require some more work.

Create the file order_status_enum.rb in the model directory and paste in the following code:

module OrderStatusEnum

def order_status
self.method(:order_status_id).super_method.call.to_s
end

def order_status=(value)
if value.is_a? String
self.method(:order_status_id=).super_method.call(value)
elsif value.is_a? Symbol
self.method(:order_status_id=).super_method.call(value.to_s)
else
raise ArgumentError.new "#{value} is not a valid order_status"
end
end

def order_status_id
OrderStatus.statuses[self.method(:order_status_id).super_method.call]
end

def order_status_id=(value)
if value.is_a? Integer
self.method(:order_status_id=).super_method.call(value)
else
raise ArgumentError.new "#{value} is not a valid order_status_id"
end
end

end

To include this new module in your model, make the following change to order.rb:

class Order < ApplicationRecord
has_one :order_status
prepend OrderStatusEnum
enum order_status_id: OrderStatus.statuses
end

This change forces order_status and order_status_id to behave as expected. Trying to use the Active Record query interface will still produce an error, to fix this we need to make an alias linking order_status to order_status_id. Make the following change to order.rb:

class Order < ApplicationRecord
has_one :order_status
prepend OrderStatusEnum
enum order_status_id: OrderStatus.statuses
alias_attribute :order_status, :order_status_id
end

Now everything is working as expected, but we are still comparing status against hardcoded strings, which can be fixed by making the following change.

order_status.rb:

class OrderStatus < ApplicationRecord 
enum status: {'open':1,'hold':2,'closed':3}
OPEN='open'
HOLD='hold'
CLOSED='closed'
end

This allows us to test for status as follows:

> o.order_status == OrderStatus::OPEN
=> true

All is looking well, except I have neglected to mention the harmless informational messages:

Creating scope :open. Overwriting existing method OrderStatus.open.Creating scope :open. Overwriting existing method Order.open.

All Ruby objects inherit from Object, which includes the Kernel module, which in turn, has an open method. In my situation, I do not use the Kernel module’s open method and I need to keep the naming consistent with the existing project. To silence these messages we can make the following change to order_status.rb:

class OrderStatus < ApplicationRecord 
class << self
undef open
end
enum status: {'open':1,'hold':2,'closed':3}
OPEN='open'
HOLD='hold'
CLOSED='closed'
end

By adding enumerations to the project, we have added clarity by making comparisons using descriptive language instead of numbers, and we have the safety of only the defined order states being valid for assignment and comparison. We have also added convenience through the extra query and assignment methods that Active Record provides with enum types.

As a software engineer, I have to weigh early decisions against their effect in the near and long term. Saving an hour today can cost a project countless hours in the future to undo or accommodate that decision. Besides raw engineering hours, there is also the learning curve for new team members coming into a codebase. Having code be self-documenting by using the functionality within your stack will also help future engineers coming onto the project.

As ever, QuarkWorks is available to help with any software application project — web, mobile, and more! If you are interested in our services you can check out our website. We would love to answer any questions you have! Just reach out to us on our Twitter, Facebook, LinkedIn, or Instagram.

For monthly updates, subscribe to our general newsletter here.

--

--