Put_assoc can update column in case of the same pk?

The associated data may be given in different formats:

* a map or a keyword list representing changes to be applied to the
  associated data. A map or keyword list can be given to update the
  associated data as long as they have matching primary keys.
  For example, `put_assoc(changeset, :comments, [%{id: 1, title: "changed"}])`
  will locate the comment with `:id` of 1 and update its title.
  If no comment with such id exists, one is created on the fly.
  Since only a single comment was given, any other associated comment
  will be replaced. On all cases, it is expected the keys to be atoms.
  Opposite to `cast_assoc` and `embed_assoc`, the given map (or struct)
  is not validated in any way and will be inserted as is.
  This API is mostly used in scripts and tests, to make it straight-
  forward to create schemas with associations at once, such as:

      Ecto.Changeset.change(
        %Post{},
        title: "foo",
        comments: [
          %{body: "first"},
          %{body: "second"}
        ]
      )

* changesets - when changesets are given, they are treated as the canonical
  data and the associated data currently stored in the association is either
  updated or replaced. For example, if you call
  `put_assoc(post_changeset, :comments, [list_of_comments_changesets])`,
  all comments with matching IDs will be updated according to the changesets.
  New comments or comments not associated to any post will be correctly
  associated. Currently associated comments that do not have a matching ID
  in the list of changesets will act according to the `:on_replace` association
  configuration (you can chose to raise, ignore the operation, update or delete
  them). If there are changes in any of the changesets, they will be
  persisted too.

* structs - when structs are given, they are treated as the canonical data
  and the associated data currently stored in the association is replaced.
  For example, if you call
  `put_assoc(post_changeset, :comments, [list_of_comments_structs])`,
  all comments with matching IDs will be replaced by the new structs.
  New comments or comments not associated to any post will be correctly
  associated. Currently associated comments that do not have a matching ID
  in the list of changesets will act according to the `:on_replace`
  association configuration (you can chose to raise, ignore the operation,
  update or delete them). Different to passing changesets, structs are not
  change tracked in any fashion. In other words, if you change a comment
  struct and give it to `put_assoc/4`, the updates in the struct won't be
  persisted. You must use changesets instead. `put_assoc/4` with structs
  only takes care of guaranteeing that the comments and the parent data
  are associated. This is extremely useful when associating existing data,
  as we will see in the "Example: Adding tags to a post" section.

according to above.

I can update specific column even if there is a changeset or map that I put in the second parameter of put_assoc with the same pk of the parent table.

so, I tested it.

defmodule ConstraintTest.Schema.Post do
  use Ecto.Schema
  import Ecto.Changeset
  alias ConstraintTest.Schema.Content

  schema "posts" do
    field :title, :string
    field :content, :string

    has_many :contents, Content

    timestamps()
  end

  def changeset(struct_or_changeset, attrs) do
    struct_or_changeset
    |> cast(attrs, [:title, :content])
  end
end
defmodule ConstraintTest.Schema.Content do
  use Ecto.Schema
  import Ecto.Changeset
  alias ConstraintTest.Schema.Post
  @timestamps_opts [type: :utc_datetime]

  schema "contents" do
    field :reply, :string
    field :nick, :string
    field :age, :integer
    field :email, :string

    belongs_to :post, Post

    timestamps()
  end

  def changeset(struct_or_changeset, attrs) do
    struct_or_changeset
    |> cast(attrs, [:reply, :nick, :age, :email])
    |> validate_required([:reply, :nick])
  end
end
defmodule Context do 
import Ecto.Query, warn: false

  alias ConstraintTest.Repo
  alias ConstraintTest.Schema.{Post, Content}

  def create_content(post, attrs) do
    # 1. map
    # contents = attrs
    # 2. changeset
    contents =
      Enum.map(attrs, fn attr ->
        Ecto.Changeset.change(%Content{}, attr)
      end)
    post = Repo.preload(post, :contents)

    post
    |> Ecto.Changeset.change()
    |> Ecto.Changeset.put_assoc(:contents, contents ++ post.contents)
    |> Repo.update()
  end
end
post = %{
  "title" => "My first post",
  "content" => "Lorem ipsum dolor sit amet consectetur adipisicing elit. Natus, aliquam voluptatibus modi sunt similique aspernatur veritatis maiores autem alias aut consectetur sint illum accusamus blanditiis eos quidem tempore excepturi veniam."
}

{:ok, post_struct} = Context.create_post(post)
contents = [
  %{
    reply: "first comment",
    nick: "follower",
    age: 30,
    email: "follower@fan.com"
  }, %{
    reply: "second comment",
    nick: "enemy",
    age: 33,
    email: "enemy@fan.com"
  }, %{
    reply: "third comment",
    nick: "none",
    age: 10,
    email: "none@business.com"
  }
]

{:ok, post_struct} = Context.create_content(post_struct, contents)
list_of_comments = [
  %{
    id: 1,
    reply: "fourth comment",
    nick: "follower1"
  }, %{
    id: 2,
    reply: "fifth comment",
    nick: "enemy1"
  }, %{
    id: 3,
    reply: "sixth comment",
    nick: "none1"
  }
]

Context.create_content(updated_post, list_of_comments)

result that i expect. becase they are updated.

%{
id: 1,
reply: “fourth comment”,
nick: “follower1”,
age: 30,
email: “follower@fan.com
}, %{
id: 2,
reply: “fifth comment”,
nick: “enemy1”,
age: 33,
email: “enemy@fan.com
}, %{
id: 3,
reply: “sixth comment”,
nick: “none1”
age: 10,
email: “none@business.com
}

but got

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

* contents_pkey (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.

(ecto 3.9.4) lib/ecto/repo/schema.ex:795: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
(elixir 1.14.3) lib/enum.ex:1658: Enum."-map/2-lists^map/1-0-"/2
(ecto 3.9.4) lib/ecto/repo/schema.ex:780: Ecto.Repo.Schema.constraints_to_errors/3
(ecto 3.9.4) lib/ecto/repo/schema.ex:761: Ecto.Repo.Schema.apply/4
(ecto 3.9.4) lib/ecto/repo/schema.ex:369: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
(ecto 3.9.4) lib/ecto/association.ex:815: Ecto.Association.Has.on_repo_change/5
(ecto 3.9.4) lib/ecto/association.ex:573: anonymous fn/8 in Ecto.Association.on_repo_change/7
(elixir 1.14.3) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3

here is my question

what if i am using
|> Ecto.Changeset.put_assoc(:contents, contents)
instead of
|> Ecto.Changeset.put_assoc(:contents, contents ++ post.contents)

→ on_replace option is needed!
I want to use :update option but can’t cause this is has_many relation.
so, nilify or delete is available but the result is not what i expect neither.

Any other way i can update?
How can I update columns with the same pk by using put_assoc?

Ecto’s put_assoc as well as cast_assoc always work with a set of associations.

When you supply a list of associations to those functions it’ll

  • Create any new items (no match for their passed id or no id available)
  • Update any existing (preloaded) items (matched by their id)
  • Delete any existing (preloaded) items, which are no longer in the passed list
|> Ecto.Changeset.put_assoc(:contents, contents ++ post.contents)

This is therefore problematic, because you’re passing the existing contents + the ones you passed into, which might also share their primary key. That’s expected to break.

I’m wondering why you’re not using cast_assoc here, which would resolve you of the need to manually combine existing contents with the passed in contents.

def upsert_content(post, attrs) do
    post = Repo.preload(post, :contents)

    post
    |> Ecto.Changeset.cast(%{contents: attrs})
    |> Ecto.Changeset.cast_assoc(:contents)
    |> Repo.update()
  end

Thank you for reply.

alternatives,
I am using Ecto.Changeset.put_assoc(:contents, contents) instead of Ecto.Changeset.put_assoc(:contents, contents ++ post.contents)
because as you mentioned, these are going to be broken due to PK.

even in case Ecto.Changeset.put_assoc(:contents, contents),
I can’t update any items with same PK among any previously preloaded item.
either insert as a new item or delete previous item and insert new 3 items.

what I am wondering in this sample source,
How can I update items which is the same PK as previous items just mentioned in hexdocs I quoted.

* Update any existing (preloaded) items (matched by their id)

can you have a sample or something like that?
cause I tried but every try i did gives me the either insert new or delete and insert by on_replace options I put in.