Best practice for pattern matching ecto changeset error constraints?

Hello!

I have a user table where I don’t allow two verified users with the same email to exist. This is enforced by a unique constraint on the table and a corresponding unique constraint on the changeset. When this case occurs I will get back a changeset that looks like:

#Ecto.Changeset<
  action: :insert,
  changes: %{...},
  errors: [
    email: {"has already been taken",
     [constraint: :unique, constraint_name: "users_verified_email_index"]}
  ],
  data: #Scribe.Accounts.User<>,
  valid?: false
>

I want to pattern match on this particular error constraint however this is a bit of a full on pattern match and it involves keyword list which means I have to get the number and order of items to match, i.e.

{:error, %Ecto.Changeset{ errors: [email: {_, [constraint: :unique, constraint_name: _]}] }} ->

Is there a better way to pattern match on these error constraints? Or is there an alternative way of approaching the problem?

Perhaps instead of trying to insert a user and acting on the error I should be checking if the user exists first in the user context function and if it does just return that user object and don’t try to insert anything. So to anyone using this new get_or_insert function they can assume that any error indicates that something bad happened instead of having to make sense of the error.

Thanks in advance for the help!

2 Likes

I usually do almost the same as you here, with the exception of making a function to search for a specific error in the list of changeset errors:

defp email_taken?({:email, {_, [constraint: :unique, constraint_name: _]}}), do: true
defp email_taken?(_), do: false

def do_stuff() do
  cs = insert_stuff()
  email_is_taken = Enum.any?(cs.errors, &email_taken?/1)
end

The advantage of this approach is that you can also check for other errors, the code looks a bit cleaner and more readable (IMO), and the whole thing is composable and the approach can be reused (you just need to add new checker functions).

4 Likes

I like how you have abstracted that functionality, definitely cleaner.

I’m currently looking to avoid the situation all together though and use upserts to my advantage so I can just pretend it was inserted and role with it. Having a few issues getting upserts to work with unique partial indexes though…

I am not saying you should not chase upserts but there should be nothing stopping you just putting an unique constraint both in the DB and in the Ecto schema and just catch that, and react to it.

Don’t spend too much time on such minutiae. Put a TODO somewhere after you make it work in a lame manner (namely like I suggested :003:) and just move on.

I’m doing it this way:

  def create_changeset(...) do
    ...
    |> unique_constraint(:some_id, message: "already_taken")
  end

  # in the same module
  def already_taken?(%Ecto.Changeset{} = changeset) do
    case changeset.errors[:some_id] do
      {"already_taken", _} -> true
      _ -> false
    end
  end

I would advise against doing that, since you’re defeating the purpose of RDBMS-based validations that Ecto embraces. If you were to choose that approach, you’d still need to keep the code for handling failed constraint to make sure nobody inserts the user between your SELECT and INSERT, so you’re back where you started.

1 Like

Unfortunately that code logic won’t work if you wanna use it in a transaction.

** (Postgrex.Error) ERROR 25P02 (in_failed_sql_transaction) current transaction is aborted, commands ignored until end of transaction block

Looks like once you got a constraint error, the transaction is aborted. Seems like you have to call get, and if nil then create the record. It’s less safe if another process created the record between the get and create call, but I don’t see any easy safer way.