How to achieve `put_assoc_new` behaviour?

first I put_assoc in the changeset to insert new item, but when I edit this item put_assoc runs again and inserts a new item.

I don’t want this behavior I want to put_assoc only if there is none,

something like the difference between Map.put and Map.put_new is there a way to achieve that behavior like put_assoc_new?

You didn’t put your code so here’s an example which you can easily refactor to use in your own code. This function is from projects.ex file and not from project.ex which contains the schema.

def create_project(attrs, user) do
    %Project{}
    |> Project.changeset(attrs)
    |> Changeset.put_assoc(:user, user)
    |> Repo.insert()
end

This function creates a project, and to update it, you just have to create update function without put_assoc.

def update_project(attrs, user) do
    %Project{}
    |> Project.changeset(attrs)
    |> Repo.update()
end
3 Likes

Single Ecto.changeset.t/1 type defines changes as a public key we can simply use it as a normal Map which means Map.has_key?/2 would work without any problem.

Here is a minimal working example:

Mix.install([:ecto])

defmodule Blog do
  defmodule Post do
    use Ecto.Schema

    alias Ecto.Changeset

    schema "posts" do
      field(:author, :string)
      field(:content, :string)
      field(:title, :string)
      has_many(:comments, Blog.Comment)
    end

    def changeset(post, attrs) do
      Changeset.cast(post, attrs, ~w[author content title]a)
    end
  end

  defmodule Comment do
    use Ecto.Schema

    alias Ecto.Changeset

    schema "comments" do
      belongs_to(:post, Blog.Post)
      field(:author, :string)
      field(:content, :string)
    end

    def changeset(comment, attrs) do
      comment
      |> Changeset.cast(attrs, ~w[author content]a)
      |> put_new_assoc(:post, attrs[:post])
    end

    def put_new_assoc(%Changeset{changes: changes} = changeset, key, value) do
      if Map.has_key?(changes, key) do
        changeset
      else
        Changeset.put_assoc(changeset, key, value)
      end
    end
  end
end

defmodule Example do
  alias Blog.{Comment, Post}
  alias Ecto.Changeset

  def sample do
    post =
      %Post{}
      |> Post.changeset(%{author: "Foo", content: "Foo's content", title: "Foo's title"})
      |> Changeset.apply_changes()

    comment =
      %Comment{}
      |> Comment.changeset(%{author: "Bar", content: "Bar's content", post: post})
      |> Changeset.apply_changes()

    updated_post =
      post
      |> Post.changeset(%{content: "Updated Foo's content"})
      |> Changeset.apply_changes()

    updated_comment =
      %Comment{}
      |> Comment.changeset(%{author: "Bar", content: "Bar's content", post: post})
      |> Comment.changeset(%{post: updated_post})
      |> Changeset.apply_changes()

    comment.post.content == updated_comment.post.content
  end
end

iex> Example.sample()
# true

Helpful resources:

  1. Ecto.Changeset.t/1
  2. Map.has_key?/2
1 Like

If you only want the put_assoc to happen when making an Ecto.Changeset for a new record, consider splitting your single changeset function into specific functions for create vs update.

1 Like

Thanks for everybody’s response I think this is the best function so far

def put_assoc_new(changeset, key, key_id, value) do
  if changeset |> get_field(key_id) |> is_nil do
    put_assoc(changeset, key, value)
  else
    changeset
  end
end