Why doesn't put_assoc update the parent entity?

Hello,

Why doesn’t put_assoc in the following test code update the parent entity?

# existing parent record
parent = Repo.get(Parent, 1) ||
  Repo.insert!(%Parent{
    :id => 1,
    :text => "parent text"
  })

# external data coming from a form to add a new "child"
child_attrs = %{
  "text" => "child text",
  "parent_id" => 1
}

changeset =
  %Child{}
  |> Child.changeset(child_attrs)
  |> Ecto.Changeset.put_assoc(
     :parent,
     %{parent | text: "updated parent text"} # <- why doesn't the text get updated?
   )
  |> Repo.insert()

As you can see above, I am inserting a new entity and want to update the parent entity as well. However, in DB, the parent’s text field will remain as “parent text” and will not update to “updated parent text”.

The schema are as follow:

defmodule MyApp.Parent do
  use Ecto.Schema
  import Ecto.Changeset

  schema "parent" do
    field :text, :string
    has_many :children, MyApp.Child
  end

  def changeset(collection, attrs) do
    collection
    |> cast(attrs, [:text])
    |> validate_required([:text])
  end
end

defmodule MyApp.Child do
  use Ecto.Schema
  import Ecto.Changeset

  schema "child" do
    field :text, :string
    belongs_to :parent, MyApp.Parent
  end

  def changeset(collection, attrs) do
    collection
    |> cast(attrs, [:text, :parent_id])
    |> validate_required([:text, :parent_id])
  end
end

I tried to change
belongs_to :parent, MyApp.Parent
to
belongs_to :parent, MyApp.Parent, on_replace: :update
but the text still didn’t get updated.

Thank you for any help!

It won’t work like this.

You could probably use Ecto.Multi, or use an explicit update to parent. There are no callbacks in Ecto.

Why do You think a Repo.insert can update another record?

Repo.update() can insert another record. For example, the following code in the doc using Repo.update() will update and insert:

post
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_assoc(:comments, [%Comment{body: "so-so example!"} | post.comments])
|> Repo.update!()

So in the same way, I thought that Repo.insert() could potentially update a record through functions such as put_assoc(). Also you there is an option you can set for belongs_to named on_replace. So why not?

What do you mean by that?

I mean no callback, as seen in Rails/Active Record callback…

Have you tried:

%{text: "updated parent text"}

or

%{id: child_attrs.parent_id , text: "updated parent text"}

Instead?

I usually do belongs_to :parent, MyApp.Parent, on_replace: :delete but this is for a many-to-many relationship.

Also I do my put_assoc in my changesets.

  53   def changeset(perfume_approval, attrs) do
  54     company_records = id_records(:company_id, attrs["company_id"])
  55     accord_records = id_records(:accord_id, attrs["accord_id"])
  56     note_records = get_all_note_records(attrs)
  57
  58     perfume_approval
  59     |> cast(attrs, [:perfume_name, :concentration, :gender, :perfume_description, :picture_url, :year_released, :month_released, :day_released, :submitter_user_id, :approved])
  60     |> validate_required([:perfume_name, :gender, :concentration, :perfume_description, :submitter_user_id])
  61     |> check_companies(company_records)
  62     |> put_assoc(:perfume_approval_company_joins, company_records)
  63     |> put_assoc?(:perfume_approval_accord_joins, accord_records)
  64     |> put_assoc?(:perfume_approval_note_joins, note_records)
  65   end

   5   def put_assoc?(changeset, _atom, nil), do: changeset
   6   def put_assoc?(changeset, atom, records) do
   7     changeset
   8     |> put_assoc(atom, records)
   9   end

Have you tried:

%{text: "updated parent text"}

This will insert instead of an update. A new child and parent is created.

or

%{id: child_attrs.parent_id , text: "updated parent text"}

Will try to insert and will result in a constraint violation of the primary key.

But I don’t understand why; from the docs for the on_replace option:

:update - updates the association, available only for has_one and belongs_to . This option will update all the fields given to the changeset including the id for the association

and in the schema, I have set the option to “update”:

belongs_to :parent, MyApp.Parent, on_replace: :update

I come from PHP, never used Ruby. That’s why I didn’t get it:-)

Nevermind. That doesn’t work either. I understand what you’re trying to do now… yeah sorry good luck!

Try this:

I added in a list like the doc and insert! instead.

changeset =
  %Child{}
  |> Child.changeset(child_attrs)
  |> Ecto.Changeset.put_assoc(
     :parent,
     [%{parent | text: "updated parent text"}] 
   )
  |> Repo.insert!()

The example https://hexdocs.pm/ecto/Ecto.Changeset.html#put_assoc/4-example-adding-a-comment-to-a-post

have two ways it update! and insert!

Thank you anyway for having a look, it’s really appreciated. I’ll let you know if I find something out.

1 Like

Solved.

So the problem is that the attempts above such as:

|> Ecto.Changeset.put_assoc(
  :parent,
  %{parent | text: "updated parent text"}
)

don’t add any data into the changes of the changeset. Whatever you want to update, you must put it in the changeset’s changes. If you just pass a Schema structure, or a map with an ID, Ecto doesn’t know if this data exists in the database or not.

So something that I learned here, is that - if I’m not wrong! - Ecto knows whether it has to perform an insert or update for the assoc according to presence of data in changeset’s changes.

So the code needs to be something like:

parent_changeset =
  Ecto.Changeset.change(parent, %{text: "updated parent text"})

%Child{}
|> Child.changeset(child_attrs)
|> Ecto.Changeset.put_assoc(:parent, parent_changeset)
|> Repo.insert!()

Use Ecto.Changeset.change() for internal/trusted data and Ecto.Changeset.cast() for external/untrusted data.

5 Likes

I want to add something here:

If I create a new Child (%Child{} |> Child.changeset(child_attrs) |> ...), and want to update the associated parent, I need to pass a parent changeset as shown above (|> put_assoc(:parent, parent_changeset)).

However, if I want to update an existing Child (child |> Child.changeset(child_attrs) |> ...), and want to update the associated parent, I must first preload the parent (child |> Repo.preload(:parent) |> Child.changeset(child_attrs) |> ...), and then in put_assoc I must not pass a changeset, but I can simply pass a map (|> put_assoc(:parent, %{text: "updated parent text"}), because the parent has been preloaded.

1 Like

Little correction to the last reply. To update the associated parent in the second case:

|> put_assoc(:parent, %{text: "updated parent text"}

should be:

|> put_assoc(:parent, %{id: 1, text: "updated parent text"}

Without passing the primary key (id), Ecto might think that you want to replace the parent by creating a new parent I guess.