Database
Beginner
IMPORTANT: Enumerated types are now built into Rails 7 so you no longer need a gem to do the extension. This blog post applies to Rails 6 and prior only.
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 reference 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 the historical (no longer preferred) 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 we need to define the enumerated type list in Postgres. (Unfortunately this is an extra thing to think about in the database migration which you will see below.)
Notably, 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 unique 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:
- 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.
The Magic of Enums in Rails
Ruby on Rails has special under-the-hood magic that will patch four methods onto your models for each enum value you specify. That means if you specify an enum with 3 values, Rails gives you 12 magic methods associated with names based on these values.
Take the example above:
def Invoice < ActiveResource
enum status: [:pending: 'pending', in_progress: 'in_progress', finished:
'finished']
Rails will put 4 methods for each enum:
IN this example invoice
represents an instance of a since invoice and Invoice
represents the Invoice class.
for our Invoice example… | ||
Bang (!) | Tells the object to become that status. | invoice.pending! will tell the invoice to turn its status into pending |
Interrogative (?) | Asks the object if it is in that status | invoice.pending? returns true if the invoice is in pending status; false if not |
Scope (…) | When used on the class, provides a scope that will get all of the object in this state | Invoice.pending will return a scope that can be used to find all of the pending invoices |
Negative scope (not_ *) | Invoice.not_pending will return a scope that can be used to find all of the invoices not in pending status |
Remember, the scopes are ActiveRecord chainable scopes, so they can be used in conjunction with other valid scopes too. (Invoices.pending.not_paid
for example).