Registration embedded_schema with errors from a Multi.new transaction

Scenario
I created a Registration schema embedded_schema that is tied to a form.
Accounts.Registration.register_user() will validate the registration form which then converts
it to a Accounts.User.changeset() and then does a Repo.insert() wrapped in a Multi.new()

defmodule Try.Accounts.Registration do
  # ...
  @primary_key false
  embedded_schema do
    field :name, :string
    field :email, :string
    field :password, :string
  end

  def changeset(struct, params) do
    struct
    |> cast(params, [:password, :name, :email])
    |> validate_required([:password, :name, :email])
    |> validate_length(:password, min: 3, max: 10)
  end

def register_user(params) do
  chset = changeset(%Registration{}, params)

  Multi.new()
  |> Multi.run(:registration, &apply_registration(&1, chset))
  |> Multi.run(:user, fn ops ->
    Repo.insert(to_user_changeset(%User{}, ops.registration))
  end)
  |> Repo.transaction()

end

defp apply_registration(_changes, changeset) do
  if changeset.valid? do
    {:ok, apply_changes(changeset)}
  else
    {:error, changeset}
  end
end

defp to_user_changeset(user, %Registration{} = reg) do
  User.changeset(user, Map.take(reg, [:email, :name, :password]))
end

end

My Problem
In my RegisterController.create() I will get an error on the unique_constraint(:email) for the User and what gets returned is a User.changeset()and not a Registration.changeset(). Which makes sense because that validation is tied to the User schema.

By using Multi.new I get an error tuple that looks like this

{:error, failed_operation, failed_value, changes_so_far}

If you review the last error tuple {:error, :user, failed_value, changes_so_far} you can pattern match and you will see what returned back from the DB. I commented it out

defmodule TryWeb.RegisterController do
#...
  def create(conn, %{"registration" => registration_params}) do
    case Registration.register_user(registration_params) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "Welcome!")
        |> redirect(to: admin_home_path(conn, :index))
      {:error, :registration, failed_value, _changes_so_far} ->
        changeset = %{failed_value | action: :insert}
        render(conn, "new.html", changeset: changeset)
      {:error, :user, failed_value, changes_so_far} ->
        IO.inspect failed_value
        #------------
        # failed_value
        #------------
        # Ecto.Changeset<action: :insert, changes: %{email: "foo@gmail.com", name: "foo",
        # password_hash: "$2b$12$aauR/LLVhrirvd4P61EyB.US3mUTWP3zVMGlHVMBRhYPXgPQte6Ca"},
        # errors: [email: {"has already been taken", []}], data: #Try.Accounts.User<>,
        # valid?: false>
        #------------
        IO.inspect changes_so_far
        # -----------------
        # changes_so_far
        # -----------------
        # %{registration: %Try.Accounts.Registration{email: "foo@gmail.com", name: "foo", password: "pass"}}
        # -----------------
        render(conn, "new.html", changeset: changeset)
    end
  end

end

My Working Solution
In order to get my form.html to work correctly I need to create a customized changeset for that error tuple on :user. I have to create a Registration.changeset() that has an :error of email taken even though the Registration embed_schema does not support that validation. And remove the :password_hash because the Registration module does not contain it either. This seems to be the only way to do it. See solution below:

  {:error, :user, failed_value, changes_so_far} ->
    changeset = %{ failed_value | data: Map.get(changes_so_far, :registration)}
    changeset = Ecto.Changeset.delete_change(changeset, :password_hash)
    # -----------------
    # Merging failed_value changeset and changes_so_far into a Registration
    # -----------------
    IO.inspect changeset
    # Ecto.Changeset<action: :insert, changes: %{email: "foo@gmail.com", name: "foo"},
    #  errors: [email: {"has already been taken", []}], data: #Try.Accounts.Registration<>, valid?: false>
    render(conn, "new.html", changeset: changeset)

Looking for Feedback
My solution works. But something tells me its not the right way of doing things. I’m returning a Registration.changeset with validation errors and attributes that do not belong to it but it’s the only way I can get the form.html to show the errors and work correctly. I hope I’m making sense here. Looking for feedback.

I think I found a solution to my problem in this article

https://medium.com/@abitdodgy/building-many-to-many-associations-with-embedded-schemas-in-ecto-and-phoenix-e420abc4c6ea

This statement below outlines the problem I’m having. The author did a much better job at explaining what I was trying to solve above.

3.If the transaction succeeds, we’re done. Otherwise, we copy errors from the failing changeset back to the registration changeset in order to display them in the UI.

If you are wondering why we would have errors in step three given that we validate the registration changeset in step one, the answer is simple: Errors that come from association and uniqueness constraints only appear after the application makes a trip to the database. Since the registration schema isn’t backed by a database table, it has no way of knowing that an email isn’t unique. When a transaction fails, we copy any errors that result from such constraints from the failing changeset to the registration changeset.

I’m going to spend some review the concept in the medium post and then update my code above to reflect the changes.

1 Like