Enumerted Types In Rails with Postgres

Enumerated Types in Rails and Postgres

Database

Beginner

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)

Instead of writing this:

Conversation.where.not(status: 0)

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 using the explicit symbol :pending (or string ‘pending‘), because it makes your code less coupled to the implementation details. (In this example, the integer 0, 1, and 2 can be considered “implementation details.”)

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. If we add enum field types to our database, we’ll need to use a gem special for this purpose

gem 'activerecord-pg_enum'

This gem does two important things:

  1. 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']

Although this seems strangely redundant, this is the most performant and preferred way to implement enums using Postgres & Rails. For the implementation below, we will use a special include from the Gem to make this less redundant so we can use a single array here again.

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'

Then run bundle install

Step 2: Create your First Postgres Enum

Option A: Use the Generator, then Modify your Migration File

Once the gem is installed, you can use enum as a first-class field type, like so:

rails generate model Conversation status:enum

In this syntax, take note that I am telling the generator to make a field call status using a type of enum, even though no actual status_type enum exists in my Postgres database yet. The generator creates a migration missing the :as keyword, which is needed to tell the migration what the enumerated type is for the field.

If you run rails db:migrate

We have to 1) define the enumerated type itself and 2) tell the field type to use it using :as as an argument in the generator.

To fix this, we’ll modify our generated migration file like so:

class CreateConversations < ActiveRecord::Migration[7.0]
  def change
    create_enum "status_type", %w[pending in_progress finished]

    create_table :conversations do |t|
      t.enum :status, as: :status_type

      t.timestamps
    end
  end
end

Note that if you fail to add as: :status_type your migration will not work. You don’t need to make your lists end with the characters _type, I just have done so here to demonstrate this is the status_type list (a list of 3 possible values), which is not the status itself (the field attached to one record, or tuple, in our database.)

Option B: Add an Enumerated Type 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 "status_list", %w[pending in_progress finished]
    add_column :conversations, :status, :status_list
  end
end

What’s important to note here is that status_list, 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(status_list: %w[pending in_progress finished])
end

This (above) 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 string (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 querying for our database interactions.

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 
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

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.

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 App1