Skip to content
Episode 1 5:02

Your Rails uniqueness validation won't stop duplicates

Rails' uniqueness validation alone won't stop duplicate records. Learn how to stop bad data at the model with validates and normalizes, show the errors in the form, and back the rule with a unique database index.

Shownotes

After this episode you can stop bad data at the model with validates and normalizes, show the errors in the form, and back the rule with a database index.

Prerequisites: demo-shop on episode-001-before, Rails 8.1

Not covered: tests, custom error messages, client-side validation, i18n

Source code: webmatze/demo-shop (branches episode-001-before / episode-001-after)

Chapters

  • 0:00 The problem
  • 0:37 The solution at a glance
  • 0:55 Setup
  • 1:09 Building it
  • 2:46 Refactoring
  • 3:46 Gotchas
  • 4:22 Things to remember
  • 4:47 Wrap-up
Transcript

The problem

This is our shop. Three products, a form to add more. We open that form, leave every field empty, and hit Create. And Rails saves it. A product with no name and no price is now in the database. It gets worse. We add a second Ruby Mug and give it a price of minus five. Saved again. Back on the index we now have a duplicate and a negative price. Nothing stops this, because the model has no rules. That is what we fix in this episode.

The solution at a glance

The tool is Active Record validations. The Rails guide lists every built-in check. We need three of them: presence, numericality, and uniqueness. Each one is a single line in the model, and the form shows the errors without any extra code.

Setup

No gem to install. The model is an empty class, and the table has name, price and description. Before we add rules, we delete the broken rows we just created, so they cannot get in the way later.

Building it

We open the product model and add one line: validates name, presence true. From now on a product without a name is invalid, and save returns false instead of writing the row. Same form, same empty submit. This time Rails renders the form again and tells us why: name can't be blank. The scaffold already shows the errors object, we did not touch the view. Now the price. We add validates price, presence true, numericality greater than zero. Numericality checks that the value is a number, and the option sets the lower bound. Zero is out, minus five is out. We try a Sticker Pack for minus five. Name is fine now, so we only get one error: price must be greater than zero. Each rule reports on its own, and the form keeps what we typed. Duplicates next. We extend the name line with uniqueness true. Before saving, Active Record runs a query and looks for another product with the same name. We enter Ruby Mug again, this time with a proper price. Rails refuses: name has already been taken. Three lines, three rules, and the form explains every one of them. The same errors are available without a form. We build a product with a price of minus one, call valid, and print the full messages. That is exactly what the view rendered, so you can use it in jobs, imports, or the console.

Refactoring

This works, but uniqueness compares strings exactly. We type ruby mug in lower case, with spaces around it and a double space in the middle. It saves. On the index we now have two mugs that a human would call the same product. Two changes in the model. First, normalizes name: a lambda that strips the outer whitespace and squeezes double spaces into one. It runs before validation, so the rules see the clean value. Second, uniqueness gets case sensitive false, so ruby mug and Ruby Mug count as the same name. We remove the lower case mug we just saved, so the list is clean again. Same messy input as before. Now the name is normalized to ruby mug, the case insensitive check finds the existing product, and the save is rejected. The stored data stays clean no matter how the user types.

Gotchas

One gotcha you must know. The uniqueness validation is a query followed by an insert. Two requests at the same time can both pass the query and both insert. The only real guarantee is a unique index in the database. We write a migration that adds it. We run the migration. To prove the index works we bypass the model with insert all bang, which skips validations entirely. The database raises Record Not Unique. The validation gives the user a friendly message; the index makes the rule true.

Things to remember

A few more things. Insert all, update column and update all skip validations on purpose. Uniqueness without an index is a hint, not a guarantee. Case insensitive uniqueness does not match a case sensitive index, so normalize the value or use a citext column. And valid runs all validations, save runs them too, so you rarely call valid yourself.

Wrap-up

That is model validations: three rules, one normalizer, one index. The code is on GitHub, branches before and after. Next episode we move these rules into a form object.