Ruby on Rails: Active Record Enum
By: Gregory Bryant
Tags: Coding, Startup, Development, Technology, Education

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: