DEV Community

Cover image for Uniqueness validation does not work since the beginning of Ruby on Rails.
Evgeniy Demin
Evgeniy Demin

Posted on • Originally published at Medium

Uniqueness validation does not work since the beginning of Ruby on Rails.

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
Enter fullscreen mode Exit fullscreen mode

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 = ?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or, you may skip validations in some cases, therefore accidentally breaking the expectations.

user.save!(validate: false)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It will be executed then as the following SQL statement.

CREATE UNIQUE INDEX index_users_on_email ON users(email);
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)