In this short blog post I want to show you how easy it is to use Ecto validations together with PostgreSQL unique indexes to validate the uniqueness of an association.
First a little background on the problem (scroll down, if you just want the solution).
Today, I was working on a side project using Elixir and the Phoenix Framework. I wanted to know how to validate uniqueness on a association. For example, in an ecommerce application you would only want a user to be able to leave one review per product. You can’t validate uniqueness of a review on just
user_id or just
product_id, because a user should be able to review many different products, and a product should be able to have many reviews from different users. I needed something like a scoped validation. For example in rails something like this
validates_uniqueness_of :user_id, :scope => :product_id in the review model would work.
I googled around for a bit but wasn’t able to find what I needed. I thought I might have to do a custom validation, which runs a query to ensure uniqueness. But, I was hoping that there would be a better way so I asked in
#elixir-lang. Chris McCord answered me right away and pointed me in the right direction, he suggested using compound unique indexes and the ecto unique_constraint function.
Turns out using ecto migrations you can easily create multi-column or compound indexes in postgres, and then you can validate on that using Ecto validations.
Step 1: Add a new unique compound index to your Review table:
create index(:reviews, [:user_id, :product_id], unique: true)
Step 2: Call this new compound index in your Review model:
And that’s it, its that simple!!
I’ve included the full migration and model files below
#[timestamp]_add_unique_index_to_review_table.exs defmodule MyApp.Repo.Migrations.AddUniqueIndexToReviewTable do use Ecto.Migration def change do create index(:reviews, [:user_id, :product_id], unique: true) end end
#review.ex defmodule MyApp.Review do use MyApp.Web, :model alias MyApp.Review import Ecto.Query schema "reviews" do field :rating, :integer field :comment, :string belongs_to :user, MyApp.User belongs_to :product, MyApp.Product timestamps end .... def changeset(model, params \\ :empty) do model |> cast(params, @required_fields, @optional_fields) |> validate_number(:rating, less_than_or_equal_to: 5, greater_than_or_equal_to: 1) |> foreign_key_constraint(:user_id) |> foreign_key_constraint(:product_id) |> unique_constraint(:user_id_product_id) end end
Shoutout to Chris McCord and everyone over at #elixir-lang, for always being so helpful.