DEV Community

Cover image for Enumerated Types in Rails with Postgres
Jason Fleetwood-Boldt
Jason Fleetwood-Boldt

Posted on • Edited on

Enumerated Types in Rails with Postgres

MEET ENUMERATED TYPES

An enum is an enumerated type. In Rails, this means that you define a specific list of allowed values up front.
Historically, you did this with an enum defined on your model, like so:
enum status: [:pending, :in_progress: :finished]

You would then create an integer field on the model. Rails will map the :pending type to the integer 0, the :in_progress key to the integer 1, and :finished to the integer 2.
You can then refer in your code to the symbol instead of the integer, which removes the dependency on the underlying integer implementation from being sprinkled throughout your code.

You can write this in your code:

Conversation.where.not(status: :pending)
Enter fullscreen mode Exit fullscreen mode

Instead of writing this:

Conversation.where.not(status: 0)
Enter fullscreen mode Exit fullscreen mode

That's because by writing "0" into your code, you've now created a dependency on that part of the code knowing that 0 means "pending." You've also slowed down the next developer because now they have to remember that 0 means "pending."
Using the Ruby symbol instead (:pending), you're making your code less brittle and de-complected.

So it is preferred when you can to code this way.

WHAT DOES POSTGRES INTRODUCE?

Described above is historical way of creating enums in Rails - using an integer as the underlying database field type. However, with Postgres, we can now use the native Postgres enum type. That means that we need to define the enumerated type list in Postgres itself. (Unfortunately this is an extra thing to think about in the database migration which you will see below.)

Importantly, the underlying Postgres field type will be an enum type, and we will also use the Rails enum mechanism in our Ruby model definition. The two will be mapped together, but to do so we need just a couple of steps of special setup.
Natively, Rails doesn't know about the enum database field type. That's because Rails was written to be database agnostic and other databases don't have enum. For this reason, if we add enum field types to our database.
We can actually write a migration to do it, and the first Google result on this topic suggests you do such a thin. But in fact if you go down this route, you will have some more issues without this gem.

gem 'activerecord-pg_enum'
Enter fullscreen mode Exit fullscreen mode

This gem does two important things:
When Rails is building your schema file (db/schema.rb), it won't be able to create the database definition if it has enum types in it. Instead of outputting a proper schema file, it will not output the database with the enum types.

Why are we doing this?

The default integer field type is bigint, which takes up 8 bytes of memory. From the activerecord-pgenum docs:
As you will see, we're going to need to tell Postgres about our enumerated types (in schema migrations), which will be in addition to telling Rails in our model definitions. Importantly, our Rails enum definitions will no longer use arrays, instead they will use a hash. The above example will become

enum status: [:pending: 'pending', in_progress: 'in_progress', finished: 'finished']
Enter fullscreen mode Exit fullscreen mode

Although this seems strangely redundant, this is the most performant and preferred way to implement enums using Postgres & Rails
Let's get started. I assume you are starting from scratch, but if you already have an app with enums in it, you may have to migrate your data to add enums to Postgres.

STEP 1: INSTALL GEM ACTIVERECORD-POSTGRES

Add to your Gemfile

gem 'activerecord-pg_enum'
Enter fullscreen mode Exit fullscreen mode

Then run bundle install

STEP 2: CREATE YOUR FIRST POSTGRES ENUM

Option A: You are generating a new Model
Once the gem is installed, you can use enum as a first-class field type, like so:
rails generate model Conversation status:enum
In the migration file, add the content shown in bold below:

class CreateConversations < ActiveRecord::Migration[7.0]
  def change
    **create_enum "statuses", %w[pending in_progress finished]**
    create_table :conversations do |t|
      t.enum :status**, as: :statuses**
      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Note that if you fail to add
, as: :statuses

Your migration will note work.

As well, in the example above, note that the field name is status but the enumerated type itself is called statuses. Be careful when using plural/singular version of the same word to refer to similar things in your app.

Option B: Add to an Existing Model

Let's assume you already have a Conversation model. Now you would run this migration
rails generate migration AddStatusToConversation

Here, you'll edit the generate migration like so:

class AddStatusToConversation < ActiveRecord::Migration[7.0]
  def change
    create_enum "statuses", %w[pending in_progress finished]
    add_column :conversations, :status, :statuses
  end
end
Enter fullscreen mode Exit fullscreen mode

What's important to note here is that statuses, which is used as the 3rd argument in the add_column above, is used to specify the type of field. Normally here you might see :integer or :string. With enumerated types, we've defined our own "type" in Postgres's terminology, so we can now refer to this as a field type itself.
We can only do this because we created the enum using create_enum in the line above, of course.

STEP 3: DEFINE YOUR MODEL

class Conversation
  include PGEnum(statuses: %w[pending in_progress finished])
end
Enter fullscreen mode Exit fullscreen mode

This is the preferred way to setup your enums, although you can still use the old style hash syntax which is equivalent:
enum status: {pending: 'pending', in_process: 'in_process', finished: 'finished'}
Now, in our code we will refer to any place we have a status as a symbol (not an integer or a symbol).
To demonstrate, we can now refer to a status on a Conversation object as either :pending, :in_progress, or :finished. Rails will translate the symbols to the Postgres enums, which in turn provide the fastest and most performant database experience.

2.7.2 :001 > conv = Conversation.new
 => #<Conversation:0x000000011e9b62d8 id: nil, created_at: nil, updated_at: nil, status: nil> 
2.7.2 :002 > conv.status = :pending
 => :pending 
2.7.2 :003 > conv.save
  TRANSACTION (0.3ms)  BEGIN
  Conversation Create (0.9ms)  INSERT INTO "conversations" ("created_at", "updated_at", "status") VALUES ($1, $2, $3) RETURNING "id"  [["created_at", "2021-10-07 15:10:23.640882"], ["updated_at", "2021-10-07 15:10:23.640882"], ["status", "pending"]]
  TRANSACTION (6.5ms)  COMMIT
 => true
Enter fullscreen mode Exit fullscreen mode

RENAMING AN ENUM TYPE

There comes a time in every app’s life when you’ll want to make a change to the list itself. That is, you’re not going to change the values of the records, but rather, the name of the enum type. If you change abc to apple, for example, you want the records that pointed to abc before the change to point to apple after the change.

As you may have guessed, Postgres is keeping pointers to your enumerated values, so when you make an enumerated type name change you’ll run a Rails migration to make the change and also change references in your code at the same time (in the model files, explained in Step 3). Then, you won’t have to make any changes to your database records themselves. (You haven’t changed the values, only the names of enumerated types.)

A caveat to note is that you cannot remove all of the enums and replace them. If you want to rename an enum, do it value-by-value. For the example, the syntax would be as follows (assume that we are doing this on an enum called status_type):

rename_enum_value "status_type", {from: "abc", to: "apple"}
How to rename an enum value
Enter fullscreen mode Exit fullscreen mode

REMOVING THE ENUM COMPLETELY

drop_enum "status_list", %w[pending in_progress finished]
How to drop the enum entirely

You can also drop the entire enum, but this requires that there are no fields that depend on it!!!

This means Postgres is protecting you from making any records orphaned by your dropping the enum value. Instead of doing it, Postgres will give you this error:

Caused by:
ActiveRecord::StatementInvalid: PG::DependentObjectsStillExist: ERROR:  cannot drop type color because other objects depend on it
DETAIL:  column color of table alerts depends on type color
HINT:  Use DROP ... CASCADE to drop the dependent objects too.
Enter fullscreen mode Exit fullscreen mode

Choice #1) Remove all fields that point to any enum you want to drop. Set all of them to another enum or remove the fields themselves.

Choice #2) Unfortunately because the activerecord-pgenum gem does not have a way to pass the CASCADE flag to the migration if you really want to do that you’ll need to write the migration yourself. This is probably a good thing because it is enforcing good hygiene on you for your data maintenance. Go with choice #1 and avoid CASCADE.

REMOVING AN ENUM TYPE

You can’t! Sorry, you can only rename.

Example App here

Top comments (0)