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.