This story is about a problem of uniqueness validation in ActiveRecord (Ruby on Rails default ORM). Even though the issue is quite common, most available guidelines provide incomplete solutions. That is why I want to share how to deal with the problem and ensure it does not come back.
Feel free to skip to the last section if you know the topic well. I believe you will find helpful information there.
What is wrong?
Before answering the question, let us recall how the validation works. Assume we have the following model.
class User < ActiveRecord::Base
validates :email, uniqueness: true
end
Every method call that triggers validations will query a database to know if the given email is already taken.
SELECT 1 FROM users WHERE email = ?
In real-life applications, multiple running instances may process users’ requests simultaneously. That means two SELECT
queries for the same email may run in parallel and result in passed validations. This issue is called “race condition,” which can break your model’s expectations.
We can break the model’s expectations even easier.
# given array of User
users
# Run the validations on every User instance
# It also saves the state for the sequential calls
if users.all?(&:valid?)
# No validation will be triggered
users.each(&:save!)
else
p “Something goes wrong”
end
Or, you may skip validations in some cases, therefore accidentally breaking the expectations.
user.save!(validate: false)
Now you understand the problem and probably know the solution, but stay with me for a little longer; you won’t regret it.
Incomplete solution
The ultimate solution that most guides speak about is adding a database constraint, a unique index to be precise, to ensure database consistency.
You can achieve that with the following migration.
class AddUsersEmailUniqueIndex < ActiveRecord::Migration
def change
add_index :users, :email, unique: true
end
end
It will be executed then as the following SQL statement.
CREATE UNIQUE INDEX index_users_on_email ON users(email);
By having this constraint, you are safe from breaking your model’s expectations by either of the described situations in the section above.
So far, we have briefly discussed what is provided in many sources. So let me explain what is wrong with this solution.
You have to remember about the index!
We are all human-being. We must remember every detail, especially during hard work toward achieving the product’s goals.
There multiple things to remember to keep things consistent:
- adding a new unique validation means adding a new unique index
- removing means removing both
- adjusting means aligning both
Understandably, this is very error-prone.
It does not work out of the box!
One day your team decided to add a unique validation to an existing model widely used across the application. Gladly, you remembered to cover the unique validation with the corresponding unique index.
Unfortunately, as soon as you released the new validation on production, your team started getting bugs with 500 errors. After a short investigation, you find that all of them are ActiveRecord::RecordNotUnique
.
Now you might correctly assume that the race conditioning is happening on the production, but still, why it fails with 500 errors instead of 422 errors like it would with any other validation?
And the answer is the need for more support from ActiveRecord to recognize the database error and map it to the validation error if it matches. To avoid that, you must add rescue
blocks to your application in appropriate places and do the mapping manually. Another way would be to change the calls with “safe” methods such as create_or_find_by!()
.
Postpone this decision until you read the final section of this article.
Full solution
The critical issues I mentioned early are solved by these two open-source gems (both libraries can do even more!):
- DatabaseConsistency — finds inconsistent indexes and validations
- DatabaseValidations — turns unique index violations into validation errors
DatabaseConsistency is a tool to avoid various issues due to inconsistencies and inefficiencies between a database schema and application models.
Run the simple command below, and it will find inconsistencies for you. The following output is for missing unique indexes.
$ bundle exec database_consistency
MissingUniqueIndexChecker fail User email model should have proper unique index in the database
The following one is for cases when an index is present, but the corresponding unique validation does not cover it.
$ bundle exec database_consistency
UniqueIndexChecker fail User index_users_on_email index is unique in the database but do not have uniqueness validator
DatabaseValidations provides database-driven validations for ActiveRecord.
In our case, we should update the model to specify db_uniqueness instead of uniqueness
class User < ActiveRecord::Base
validates :email, db_uniqueness: true
end
With this simple change, it will:
- ensure during run-time that the appropriate unique index covers the validation in the database
- seamlessly transform
ActiveRecord::RecordNotUnique
errors to validation errors (turns 500 errors to 422)
That is it about pitfalls of uniqueness validations in ActiveRecord.
I hope the article was helpful for you. Subscribe for the upcoming topics:
Top comments (0)