Ecto's uniqueness constraint vs Rails' uniqueness validation
I really like how closely Ecto integrates with our database. That was a surprising difference when I first came from Rails.
Ecto’s constraints are a great example of that. Like Rails, Ecto has validations. But unlike Rails, Ecto also has constraints. Validating uniqueness is the example that most easily comes to my mind.
Rails uniqueness validation
With Rails, we can validate uniqueness through the validate method (with a uniqueness
option) or through the
validates_uniqueness_of method. Both do the same:
validates that the attribute’s value is unique right before the object gets saved.
class Account < ApplicationRecord
validates :email, uniqueness: true
end
# OR
class Account < ApplicationRecord
validates_uniqueness_of :email
end
But as the Rails guides state:
It does not create a uniqueness constraint in the database, so it may happen that two different database connections create two records with the same value for a column that you intend to be unique.
Because the validation happens before records are inserted into the database, we always have the possibility of a race condition — for example, if one user quickly submits the same form twice or two users submit the form at the same time.
The Rails guides go on to suggest a solution:
To avoid that, you must create a unique index on that column in your database.
Unfortunately, Rails does nothing out of the box when the database’s unique index raises an error:
You can either choose to let this error propagate (which will result in the default Rails exception page being shown), or you can catch it and restart the transaction
So, even though Rails suggests using a unique index at the database level to ensure data integrity, we have to roll our own exception-catching and error-handling if we want a good experience for our users.
Ecto’s uniqueness constraint
Ecto also allows us to validate the uniqueness of an attribute through unique_constraint/3:
defmodule Account
import Ecto.Changeset
def changeset(account, params) do
account
|> cast(params, [:email])
|> unique_constraint(:email)
end
end
But unlike Rails’ uniqueness validation, Ecto’s unique_constraint/2
requires
we create a unique index in our database:
to use the uniqueness constraint, the first step is to define the unique index in a migration
Then, Ecto integrates with it, rescuing the exception and turning it into a nice error for our users:
The unique constraint works by relying on the database to check if the unique constraint has been violated or not and, if so, Ecto converts it into a changeset error.
So, both Rails and Ecto recommend creating a unique index in our database for data integrity. But only Ecto takes an extra step and integrates with the database, turning the exception into a nice error message for our users – blending the strengths of our application with those of our database.
Ecto’s unsafe validations
Of course, the downside of relying on a database constraint is that we do not know if the record is unique until we try to insert it into the database. And there are times when we want to validate a record’s uniqueness (subject to race conditions) before we have to persist it. For those situations, Ecto has a function called unsafe_validate_unique/4.
By explicitly labeling the function as “unsafe”, Ecto suggests that we shouldn’t
trust it for data integrity. The function can help as a first layer of errors
for our customers, but ultimately, we should rely on the unique_constraint/3
:
This function exists to provide quick feedback to users of your application. It should not be relied on for any data guarantee as it has race conditions and is inherently unsafe. For example, if this check happens twice in the same time interval (because the user submitted a form twice), both checks may pass and you may end-up with duplicate entries in the database. Therefore, a unique_constraint/3 should also be used to ensure your data won’t get corrupted
Thus, by integrating more closely with the database, Ecto offers the best of both worlds: data integrity as the recommended default, and an unsafe way to get quick feedback when needed.