Using Postgres Enum in Rails ActiveRecord

  • Rails
  • Postgres

In this post, I will provide some code to make working with an Enum data type in Postgres easier within your ActiveRecord models. Skip to the end for the code, or stick around for some verbose pontificating.

ActiveRecord Enums

ActiveRecord comes with a handy feature to specify a certain field is an Enum, or "Enumerated set of values". However, the documentation emphasizes the simple way to use it that is fraught with peril. They even note the danger in the docs:

Note that when an array is used, the implicit mapping from the values to database integers is derived from the order the values appear in the array. In the example, :active is mapped to 0 as it's the first element, and :archived is mapped to 1. In general, the i-th element is mapped to i-1 in the database.

Therefore, once a value is added to the enum array, its position in the array must be maintained, and new values should only be added to the end of the array. To remove unused values, the explicit hash syntax should be used.

If you use the Array form in your model, like this, it implicitly uses the position of the item in that array for the integer value for the column in the database:

class Message
  enum state: [
    :queued,     # 0
    :dispatched, # 1
    :delivered   # 2
  ]
end

This writes rows to the DB that look like this:

 id |  state   |         created_at
----+----------+----------------------------
  1 |        0 | 2020-10-14 21:34:40.036597
  2 |        2 | 2020-10-14 21:34:40.056437

However, if you make any change to the enum aside from adding a new value to the end of the Array, then the integer values of the fields change as well.

class Message
  enum state: [
    :queued,     # 0
    :dispatched, # 1
    :failed,     # 2
    :delivered   # 3
  ]
end

By inserting :failed in the middle of the Array, ActiveRecord will now consider 2 to be "failed" where previously it was "delivered", and so Message id:2 in our table that used to be "delivered" is now "failed". 💣

However, not all is lost! ActiveRecord Enums may also be defined as a Hash instead of an Array. That looks like this:

class Message
  enum state: {
    queued:     0,
    dispatched: 1,
    delivered:  2
  }
end

Now, when we add a new value to the enum, we can put it wherever we want in that hash, as long as we don't change the numbers.

Its still kinda tedious and annoying, though, that we have to track these numbers ourselves. It would be nice if we could just store those values directly as strings in the DB, but that would result in a much larger table, wasting storage on all those same strings over and over again.

Leveraging Postgres

The reason why Rails chooses to use Integers as values for its enums is because it has to support the lowest-common feature set of the databases it supports, and not all of them support Enums natively. Postgres, however, is one that does, and so if your app will only ever talk to Postgres, then you can take advantage of them.

Here's a simple migration to add an enum, we have to drop to raw SQL to accomplish it:

class CreateMessagingTables < ActiveRecord::Migration[6.0]
  reversible do |dir|
    dir.up do
      execute "CREATE TYPE message_state_type AS ENUM ('queued', 'dispatched', 'delivered')"

      create_table :messages do |t|
        t.column :state, :message_state_type, null: false
      end
    end

    dir.down do
      drop_table :messages
      execute "DROP TYPE message_state_type"
    end
  end
end

That's gross and annoying, though, so lets extract a helper:

# lib/migration_utils.rb

module MigrationUtils
  module CreateEnum
    def create_enum(name, values)
      reversible do |dir|
        dir.up do
          say_with_time "create_enum(:#{name})" do
            suppress_messages do
              execute "CREATE TYPE #{name} AS ENUM (#{values.map{ |v| quote(v) }.join(', ')})"
            end
          end
        end

        dir.down do
          say_with_time "drop_enum(:#{name})" do
            execute "DROP TYPE #{name}"
          end
        end
      end
    end
  end
end

# Then use it in a migration
#
# db/migrations/0000000000_create_messages.rb
class CreateMessagingTables < ActiveRecord::Migration[6.0]
  include MigrationUtils::CreateEnum

  change do
    create_enum :message_state_type, %w[queued dispatched delivered]

    create_table :messages do |t|
      t.column :state, :message_state_type, null: false
    end
  end
end

Now we can use Postgres Enum in Rails!

Instead of Integers, Postgres will expose the enum values as Strings, so we need to update the Hash in our model:

class Message
  enum state: {
    queued:     :queued,
    dispatched: :dispatched,
    delivered:  :delivered
  }
end

The values of the hash must match those of the postgres enum, but the keys can be whatever you like (but why would you do that to yourself?). Since for our app, the keys always match the values, we wrote a little helper to remove some boilerplate:

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  # Provides a bit of syntactic sugar around Rails' built-in enums to map
  # them to postgres enums which expect string values instead of integer
  # values. Basically this saves you from having to pass in:
  # {
  #   foo: "foo",
  #   bar: "bar",
  #   baz: "baz"
  # }
  # to the Rails enum DSL method.
  def self.pg_enum(attribute, values, options = {})
    enum({ attribute => Hash[values.map{ |value| [value.to_sym, value.to_s] }] }.merge(options))
  end
end

Now our model looks like this:

class Message
  pg_enum state: %i[ queued dispatched delivered ]
end

You can find all the code for this, along with helpers to add and remove fields in the migration, at this gist