Enum validations and database constraints in Rails 7.1

Rails 7.1 added a validation option for enum, so it’s now straightforward to validate that the value of an enum attribute is an allowed value and, optionally, that the value is not nil in an Active Record model class. This has made user feedback for enum attributes simpler, but it can get tricky when we want to define the enum in the database so we can prevent bad data from being written directly to the database AND have a validation in the model so that users get helpful error messages. If you’re not sure whether you need a database constraint, validation, or both, check out Validation, Database Constraint, or Both?

What’s wrong with just validating with Active Record?

Nothing! If you’re not worried about writes directly to the database containing bad data, this might be all you need. In this case, you can back the enum with an integer or string column and leave the validation to the model:

class AddRoleToUsers < ActiveRecord::Migration[7.1]
  def change
    change_table :users do |t|
      t.integer :role
    end
  end
end

class User < ApplicationRecord
  enum :role, [:counter, :admin, :finance], default: :counter, validate: true
end

Do I have to add an Active Record validation if I have constraints in the database?

Nope, not at all. Active Record validations are helpful for data errors that users can fix themselves. If the data can’t be fixed by a user, or will never be created or updated by a user, you don’t need to validate your enum-backed attribute. You can still validate presence of the attribute without using enum validation.

class AddRoleToUsers < ActiveRecord::Migration[7.1]
  def change
    create_enum :user_role, ["student", "teacher", "admin"]

    change_table :users do |t|
      t.enum :role, enum_type: "user_role", null: false, default: :student
    end
  end
end


class User < ApplicationRecord
  # No validation for the role attribute is necessary here.
end

How do I combine a validation with a database constraint?

To combine the two, the syntax is just different enough to be confusing. The migration will be the same as when you have database constraints without Active Record validation

class AddRoleToUsers < ActiveRecord::Migration[7.1]
  def change
    create_enum :user_role, ["student", "teacher", "admin"]

    change_table :users do |t|
      t.enum :role, enum_type: "user_role", null: false, default: :student
    end
  end
end

If you keep the enum definition in the model the same as when we used an integer colum in the database:

class User < ApplicationRecord
# This is the how not to do this example. Don't copy and paste this one into your app!
  enum :role, [:counter, :admin, :finance], default: :counter, validate: true
end

you’ll get this confusing error:

PG::InvalidTextRepresentation: ERROR:  invalid input value for enum user_role: "0"

This is because we’re defining the enum in the model with an array of symbols. Rails will helfpully map these to integer values in the database. But we’ve defined the column in the database as an enum column and created an enum type to associate with it. So, when Rails tries to write the integer 0 to the role column, the database correctly responds that this is the incorrect data type, since it’s not one of the options defined in the user_role enum. (If we had defined the column in the database as a string column, we wouldn’t get an error here, but the values would not be persisted to the db.)

The fix for this is pretty simple, though! We just need to tell Rails explicitly what values our enum maps to. To do this, we’ll replace our array of symbols with a hash:

class User < ApplicationRecord
  enum :role, {student: "student", teacher: "teacher", admin: "admin"},
    default: :student, validate: true
end

How do I know this works?

If you’re using Shoulda Matchers in your model specs, there’s a matcher for this: define_enum_for. For our example, we can test it like this:

RSpec.describe User, type: :model do
  describe "validations" do
    subject { build(:user) }

    it {
      is_expected.to define_enum_for(:role).with_values({
        student: "student", teacher: "teacher", admin: "admin"
      }).backed_by_column_of_type(:enum)
    }
  end
end

Now you can carry on, confident that bad data won’t be written to your enum column and your users will get helpful feedback for invalid data.