Ecto.Changeset.unique_constraint

You're seeing just the function unique_constraint, go back to Ecto.Changeset module for more information.
Link to this function

unique_constraint(changeset, field_or_fields, opts \\ [])

View Source

Specs

unique_constraint(t(), atom() | [atom(), ...], Keyword.t()) :: t()

Checks for a unique constraint in the given field or list of fields.

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.

In order to use the uniqueness constraint, the first step is to define the unique index in a migration:

create unique_index(:users, [:email])

Now that a constraint exists, when modifying users, we could annotate the changeset with a unique constraint so Ecto knows how to convert it into an error message:

cast(user, params, [:email])
|> unique_constraint(:email)

Now, when invoking Repo.insert/2 or Repo.update/2, if the email already exists, it will be converted into an error and {:error, changeset} returned by the repository. Note that the error will occur only after hitting the database so it will not be visible until all other validations pass.

Options

  • :message - the message in case the constraint check fails, defaults to "has already been taken"

  • :name - the constraint name. By default, the constraint name is inferred from the table + field(s). May be required explicitly for complex cases

  • :match - how the changeset constraint name is matched against the repo constraint, may be :exact or :suffix. Defaults to :exact. :suffix matches any repo constraint which ends_with? :name to this changeset constraint.

Complex constraints

Because the constraint logic is in the database, we can leverage all the database functionality when defining them. For example, let's suppose the e-mails are scoped by company id:

# In migration
create unique_index(:users, [:email, :company_id])

# In the changeset function
cast(user, params, [:email])
|> unique_constraint([:email, :company_id])

The first field name, :email in this case, will be used as the error key to the changeset errors keyword list. For example, the above unique_constraint/3 would generate something like:

Repo.insert!(%User{email: "john@elixir.org", company_id: 1})
changeset = User.changeset(%User{}, %{email: "john@elixir.org", company_id: 1})
{:error, changeset} = Repo.insert(changeset)
changeset.errors #=> [email: {"has already been taken", []}]

In complex cases, instead of relying on name inference, it may be best to set the constraint name explicitly:

# In the migration
create unique_index(:users, [:email, :company_id], name: :users_email_company_id_index)

# In the changeset function
cast(user, params, [:email])
|> unique_constraint(:email, name: :users_email_company_id_index)

Partitioning

If your table is partitioned, then your unique index might look different per partition, e.g. Postgres adds p<number> to the middle of your key, like:

users_p0_email_key
users_p1_email_key
...
users_p99_email_key

In this case you can use the name and suffix options together to match on these dynamic indexes, like:

cast(user, params, [:email])
|> unique_constraint(:email, name: :email_key, match: :suffix)

Case sensitivity

Unfortunately, different databases provide different guarantees when it comes to case-sensitiveness. For example, in MySQL, comparisons are case-insensitive by default. In Postgres, users can define case insensitive column by using the :citext type/extension. In your migration:

execute "CREATE EXTENSION IF NOT EXISTS citext"
create table(:users) do
  ...
  add :email, :citext
  ...
end

If for some reason your database does not support case insensitive columns, you can explicitly downcase values before inserting/updating them:

cast(data, params, [:email])
|> update_change(:email, &String.downcase/1)
|> unique_constraint(:email)