How do you create records and it's associate and also check their constraint at once?

Below is some code snippet

  create(index("auths", [:email], unique: true))
   schema "users" do
    field(:name, :string)
    field(:deleted_at, :utc_datetime_usec)
    has_one(:auth, Auth)

    timestamps(type: :utc_datetime_usec)
  end

 schema "auths" do
    field(:email, :string)
    field(:deleted_at, :utc_datetime_usec)
    belongs_to(:user, User)

    timestamps(type: :utc_datetime_usec)
  end

  def create_changeset(auth, attrs) do
    auth
    |> cast(attrs, [:email])
    |> unique_constraint(:emaill)
  end
    Ecto.Multi.new()
    |> Ecto.Multi.insert(:user, user_schema)
    |> Ecto.Multi.merge(fn %{user: user_schema} ->
      Ecto.Multi.new()
            |> Ecto.Multi.insert(:auth, Ecto.build_assoc(user_schema, :auth, auth_schema))
    end)
    |> Repo.transaction()

I have changeset for checking email unique constraint but how do I apply it while doing multi insert with build_assoc?

The constraint is applied at the DB level, so you kind of don’t check.

Are you sure you need to use the multi (are we seeing reduced code or your real needs?).

I don’t really use the build assocs, but I believe ecto is smart enough to know the order an insertion should take.

Fake code (args probably wrong)

user = User.changeset(%{...}) |> put_assoc(:auth, %Auth{...})
{:ok, user} = Repo.insert(user)

But to use multi, can’t you also just chain them, no need for merges.

Ecto.Multi.new()
    |> Ecto.Multi.insert(:insert_user, %User{}_or_changeset)
    # docs are a bit obtuse, you get a map of previous actions,
    # so the user creation, given the multi name :insert_user, maps
    # to the user stuct returned from that insert.
    |> Ecto.Multi.insert(:insert_auth, fn %{insert_user: user} ->
      # insert will automatically insert what you return from the function
      Ecto.build_assoc(user, :auth, %{email: "..."})
    end)
    # run it all, check the multi docs but you will get something like
    # {:ok, %{insert_user: user, :insert_auth: auth}
    # {:error, :insert_auth, changeset_with_constraint_error, changes_that_worked_but_are_rolled_back_by_transaction}
    |> Repo.transaction()
1 Like

The constraint is applied at the DB level, so you kind of don’t check.

Yes, I could do at DB level but when it’s violated it will raise an error which then I should put try rescue but I prefer returning error like in this format {:error, reason} , the conventional elixir way of returning an error, which could only be done by changeset through unique_constraint, and I dont know how do you implement those unique_constraint with associated record.

Are you sure you need to use the multi (are we seeing reduced code or your real needs?).

Yes, it is for registration. user (profile), auth (email, password). So either both are created or not. not wanting a dangling record.

If you put unique_constraint in your changeset validations, the DB constraint failure will be converted from an exception to a changeset error, which means you do not neet the try/catch and are able to pattern match on {:error, changeset}.

Im not sure I understand your problem, re: “associated record”. If you try the multi I wrote, you are able to get the auth changeset back when it fails the unique check, and pattern match on that to alert the correct error.

Check the docs/IEX but I think ecto will save out child relationships if you build them off the parent, like you are trying to do (if you wanted to go without the multi).

See:

Create user changeset, put the auth assoc on that changeset:

iex(10)> user = User.changeset(%User{}, %{name: "xian"}) |> put_assoc(:auth, Auth.changeset(%Auth{}, %{email: "xian@"}))

#Ecto.Changeset<
  action: nil,
  changes: %{
    auth: #Ecto.Changeset<
      action: :insert,
      changes: %{email: "xian@"},
      errors: [],
      data: #RedGreens.Auths.Auth<>,
      valid?: true
    >,
    name: "xian"
  },
  errors: [],
  data: #RedGreens.Users.User<>,
  valid?: true
>

Insert user changeset, creates both user and nested auth:

iex(11)> user |> Repo.insert()                                                                    [debug] QUERY OK db=0.3ms idle=1783.1ms
begin []
[debug] QUERY OK db=0.2ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["xian", ~N[2021-02-24 06:02:12], ~N[2021-02-24 06:02:12]]
[debug] QUERY OK db=0.5ms
INSERT INTO "auths" ("email","user_id","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["xian@", 4, ~N[2021-02-24 06:02:12], ~N[2021-02-24 06:02:12]]
[debug] QUERY OK db=0.7ms
commit []
{:ok,
 %RedGreens.Users.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   auth: %RedGreens.Auths.Auth{
     __meta__: #Ecto.Schema.Metadata<:loaded, "auths">,
     email: "xian@",
     id: 4,
     inserted_at: ~N[2021-02-24 06:02:12],
     updated_at: ~N[2021-02-24 06:02:12],
     user: #Ecto.Association.NotLoaded<association :user is not loaded>,
     user_id: 4
   },
   id: 4,
   inserted_at: ~N[2021-02-24 06:02:12],
   name: "xian",
   updated_at: ~N[2021-02-24 06:02:12]
 }}

Create second user with same email:

iex(12)> user = User.changeset(%User{}, %{name: "another xian"}) |> put_assoc(:auth, Auth.changeset(%Auth{}, %{email: "xian@"}))
#Ecto.Changeset<
  action: nil,
  changes: %{
    auth: #Ecto.Changeset<
      action: :insert,
      changes: %{email: "xian@"},
      errors: [],
      data: #RedGreens.Auths.Auth<>,
      valid?: true
    >,
    name: "another xian"
  },
  errors: [],
  data: #RedGreens.Users.User<>,
  valid?: true
>
iex(13)> user |> Repo.insert()                                                                    [debug] QUERY OK db=0.1ms idle=1590.7ms
begin []
[debug] QUERY OK db=0.3ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["another xian", ~N[2021-02-24 06:02:24], ~N[2021-02-24 06:02:24]]
[debug] QUERY ERROR db=0.3ms
INSERT INTO "auths" ("email","user_id","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["xian@", 5, ~N[2021-02-24 06:02:24], ~N[2021-02-24 06:02:24]]
[debug] QUERY OK db=0.1ms
rollback []
{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{
     auth: #Ecto.Changeset<
       action: :insert,
       changes: %{email: "xian@", user_id: nil},
       errors: [
         email: {"has already been taken",
          [constraint: :unique, constraint_name: "auths_email_index"]}
       ],
       data: #RedGreens.Auths.Auth<>,
       valid?: false
     >,
     name: "another xian"
   },
   errors: [],
   data: #RedGreens.Users.User<>,
   valid?: false
 >}

User was never inserted because assoc failed:

iex(15)> from(u in User, where: u.name == "another xian") |> Repo.all()
[debug] QUERY OK source="users" db=0.2ms queue=0.5ms idle=1046.1ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."name" = 'another xian') []
[]
2 Likes

If you put unique_constraint in your changeset validations, the DB constraint failure will be converted from an exception to a changeset error, which means you do not neet the try/catch and are able to pattern match on {:error, changeset}.

Yes that’s what I want. But the problem is build_assoc cannot accept changeset? am i right?

Im not sure I understand your problem, re: “associated record”. If you try the multi I wrote, you are able to get the auth changeset back when it fails the unique check, and pattern match on that to alert the correct error.

If I pass in the changeset on the build_assoc 's args like this Ecto.build_assoc(user, :auth, auth_changeset), the changes such as email & password won’t get inserted, it only record the foreign_key_id (user_id)

Im not sure I understand your problem, re: “associated record”. If you try the multi I wrote, you are able to get the auth changeset back when it fails the unique check, and pattern match on that to alert the correct error.

I’ve tried your multi. When I insert the same email on the second times, it raise an exception error. Instead, I want {:error, changeset}

 |> Ecto.Multi.insert(:insert_user, user_schema)
 |> Ecto.Multi.insert(:insert_auth, fn %{insert_user: user} ->
      Ecto.build_assoc(user, :auth, auth_schema)
    end)
 |> Repo.transaction()```

```** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * auths_email_index (unique_constraint)

If you would like to stop this constraint violation from raising an
exception and instead add it as an error to your changeset, please
call `unique_constraint/3` on your changeset with the constraint
`:name` as an option.

The changeset has not defined any constraint.

Yes you are right, build_assoc takes a map, not a changeset Ecto — Ecto v3.5.8, my apologies I’ve never used it.

I see where you are having trouble. Because build_assoc takes a map, not a changeset, the changeset validations aren’t triggered.

You will have to try/catch, use the put_assoc style I outlined above (or cast_assoc) or simply set the id directly.

Ecto.Multi.new()
    |> Ecto.Multi.insert(:insert_user, %User{}_or_changeset)
    # docs are a bit obtuse, you get a map of previous actions,
    # so the user creation, given the multi name :insert_user, maps
    # to the user stuct returned from that insert.
    |> Ecto.Multi.insert(:insert_auth, fn %{insert_user: user} ->
      # insert will automatically insert what you return from the function
      Auth.changeset(%Auth{}, %{email: params.email, user_id: user.id})
      # OR
      Auth.changeset(%Auth{}, %{email: "rixile"}
      |> Ecto.Changeset.put_assoc(:user, user) #put_assoc needs changeset, so cant put_assoc(user...)
    end)
    # run it all, check the multi docs but you will get something like
    # {:ok, %{insert_user: user, :insert_auth: auth}
    # {:error, :insert_auth, changeset_with_constraint_error, changes_that_worked_but_are_rolled_back_by_transaction}
    |> Repo.transaction()

Your error literally tells you how to proceed though:

If you would like to stop this constraint violation from raising an
exception and instead add it as an error to your changeset, please
call `unique_constraint/3` on your changeset with the constraint
`:name` as an option.

The changeset has not defined any constraint.

Have you tried its suggestion?