Some people say “e-noom”. Others say “e-numb”. But for some reason, Rails calls its enums “integer”.
That decision—dating back to the feature’s introduction in Rails 4.1—has caused no end of problems for developers and teams that aren’t hyper-vigilant about managing the impedance mismatch between ActiveRecord objects that marshal enum attributes as strings and the underlying database schema that represents enum columns as integers.
If you’ve been using Rails enum
attributes and have never had an issue with them, then feel free to stop reading and go about your day. But if you’ve ever run into a tricky gotcha when trying to populate an enum field in a test fixture or when submitting a form, then this post is for you. (Oh, and if you’ve heard horror stories about enums and thus avoided them until now, this post can be for you too.)
Postgres, the best database
As has been the case for several years now, the prescription to today’s ActiveRecord frustration is, yet again, a Postgres feature most people don’t know about.
Going back until at least version 8, Postgres has offered its own mechanism for declaring enumerated types as string constants:
create type rate_type as enum ('hourly', 'weekly');
When you set or read an enum column over a database adapter like ActiveRecord, "hourly"
and "weekly"
values are represented as strings. As luck would have it, shortly after enum’s initial release, Rails added support for enum attributes backed by strings. Therefore, there’s nothing preventing us from defining a Postgres enum column and declaring a matching ActiveRecord enum attribute by explicitly setting its string value counterparts.
The approach outlined below has several best-of-both-worlds-y benefits:
- Postgres will store the values internally as constants, requiring just 4 bytes each
- Postgres will validate that only valid enum values are set, and correctly error if you try to send it a string that doesn’t match a declared enum value
- Postgres can compare values held in columns of the same enum type across multiple tables, which is quite neat
- ActiveRecord will still provide convenience methods for enum attributes, like
rate_types
,weekly?
andhourly!
- Best of all, there will no longer be any awkward translation between strings and ordinal integers, so an entire category of gotchas can be safely erased from our collective memories
So, let’s dive into how to set up ActiveRecord enum attributes backed by Postgres enum columns, for each of a few different scenarios.
Adding an enum to an app using schema.rb
If your app’s migration tasks generate a db/schema.rb
file (as is the default), you probably want them to continue to do so. There’s just one problem to deal with first.
See, Rails migrations don’t offer native support for creating enum types, so any enum declarations and any tables containing enum type columns present in your migrations will not be dumped without so much as an error or a warning. (That seemed suboptimal, so I opened this Rails issue for discussion.)
In fact, if you start using PG enums in your migrations, you’ll see entire tables in your schema.rb
file replaced with:
# Could not dump table "projects" because of following StandardError
# Unknown type 'rate_type' for column 'rate_type'
This is not ideal. And it might be weeks or months before anyone notices that the schema.rb
is now broken.
Fear not! There is a solution! The activerecord-postgres_enum gem adds awareness of PG enums to Rails migrations, and therefore allows you to write tidier migrations while continuing to be able to rely on your schema.rb
file.
With that gem installed, you can write a pretty straightforward migration like this:
class AddDefaultRateTypeToClients < ActiveRecord::Migration[5.2]
def change
create_enum :rate_type, ["hourly", "weekly"]
change_table :clients do |t|
t.enum :default_rate_type, enum_name: :rate_type, null: false, default: "hourly"
end
end
end
And a model attribute like this:
class Client < ActiveRecord::Base
enum default_rate_type: {
hourly: "hourly",
weekly: "weekly",
}
end
And you’re off to the races! As best as I can tell from a few days of working with them, everything basically just works, and all the edge cases I’d spent the last few years dancing around seem to have gone away.
Adding an enum to an app using structure.sql
Of course, not every app uses the default schema.rb
file. If your app’s migrations do anything that ActiveRecord migrations don’t support, someone will have likely added a configuration like this to your config/application.rb
at some point:
config.active_record.schema_format = :sql
Which tells Rails to persist a raw db/structure.sql
dump instead of the more readable and portable db/schema.rb
file.
If you’re already using structure.sql
, adding an extra gem just for cuter migration semantics is probably not worth the expense. But that’s okay, because you can write a migration like this without it:
class AddDefaultRateTypeToClients < ActiveRecord::Migration[5.2]
def change
reversible do |migrate|
migrate.up do
execute "create type rate_type as enum ('hourly', 'weekly')"
end
migrate.down do
execute "drop type rate_type"
end
end
change_table :clients do |t|
t.column :default_rate_type, :rate_type, null: false, default: "weekly"
end
end
end
(If you’re not familiar with reversible
, check out the Rails guide on it.)
With this approach, apart from losing the convenience methods create_enum
and t.enum
, everything else is the same as if we’d used the activerecord-postgres_enum
gem.
Converting existing columns from integers to enums
Now, the real fun comes when attempting to migrate your existing ActiveRecord enum
attributes that are backed by integer columns to a Postgres enum type.
The first step is understanding which numeric values need to be converted to which strings. If your enum attribute is already defined by setting explicit integers like this:
class Project < ApplicationRecord
enum rate_type: {
hourly: 0,
weekly: 1,
}
end
Then this will be easy. You know that hourly
and weekly
will have been stored as 0
and 1
, respectively, across every environment.
However, if the enum is defined using a simple array, the ordinal values will be derived implicitly by ActiveRecord:
class Project < ApplicationRecord
enum rate_type: [:hourly, :weekly]
end
And you may want to be a little more careful. Though I’m 99% sure there aren’t any versions of Rails or any Postgres adapters that would transliterate this to anything other than the same 0
and 1
, it’s easy enough to ask Rails for the value when converting the column that we can just as well do that in our migration.
In either case, your migration will need to drop down into executing SQL, even if you’ve adopted the aforementioned enum gem. Below, we’ll also conservatively ask ActiveRecord for the enum’s integer values rather than hard-code any magic numbers. Finally, we’ll locally redefine the Project
model in order to future-proof the migration against changes in app/
later. (If you haven’t added good_migrations to your Gemfile
yet, you really should!)
class EnumerateProjectRateType < ActiveRecord::Migration[5.2]
# 1.) locally define the model you're working with
class Project < ActiveRecord::Base
enum rate_type: [:hourly, :weekly]
end
def change
# 2.) create the enum type if it hasn't been created
reversible do |migrate|
migrate.up do
execute "create type rate_type as enum ('hourly', 'weekly')"
end
migrate.down do
execute "drop type rate_type"
end
end
# 3.) ask Rails for the integer equivalents of our two enum values
hourly_int = Project.rate_types[:hourly]
weekly_int = Project.rate_types[:weekly]
# 4.) change the column type and migrate its data up and down
reversible do |migrate|
migrate.up do
execute <<~SQL
alter table projects
alter column rate_type drop default,
alter column rate_type set data type rate_type using case
when rate_type = #{hourly_int} then 'hourly'::rate_type
when rate_type = #{weekly_int} then 'weekly'::rate_type
end,
alter column rate_type set default 'hourly';
SQL
end
migrate.down do
execute <<~SQL
alter table projects
alter column rate_type drop default,
alter column rate_type set data type integer using case
when rate_type = 'hourly' then #{hourly_int}
when rate_type = 'weekly' then #{weekly_int}
end,
alter column rate_type set default #{hourly_int};
SQL
end
end
end
end
And then, finally, update the model:
class Project < ActiveRecord::Base
enum rate_type: {
hourly: "hourly",
weekly: "weekly",
}
end
And that’s pretty much all you should need to do, unless you’re hard-coding integer values somewhere or otherwise have application code that depended on the old integer values. (A good idea might be to grep for any methods that would have returned those integers, like Project.rate_types
).
Conclusion
This post didn’t cover what enums are, whether they’re worth using, or why you should care, but hopefully it presents a useful path forward for anyone on Postgres already using (or hoping to use) ActiveRecord enum attributes, but not yet familiar with Postgres enum types.
Happy migrating!