Cast_assoc issue with many_to_many

cast_assoc doesn’t seem to work when I create or update with an existing item in the many_to_many relationship and the item has a required field. See below for example:

defmodule EctoTest.Repo.Migrations.AddTodoListsAndItems do
  use Ecto.Migration

  def change do
    create table("todo_lists") do
      add(:title, :string, null: false)
      timestamps()
    end

    create table("todo_items") do
      add(:description, :string, null: false)
      timestamps()
    end

    create table("todo_lists_items", primary_key: false) do
      add(:todo_item_id, references(:todo_items), null: true)
      add(:todo_list_id, references(:todo_lists), null: true)
    end
  end
end
defmodule EctoTest.Todos.TodoList do
  use Ecto.Schema
  alias Ecto.Changeset
  alias EctoTest.Todos.TodoItem

  schema "todo_lists" do
    field :title
    many_to_many :todo_items, TodoItem, join_through: "todo_lists_items", on_replace: :delete
    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> Changeset.cast(params, [:title])
    |> Changeset.cast_assoc(:todo_items, required: true)
  end
end
defmodule EctoTest.Todos.TodoItem do
  use Ecto.Schema
  alias Ecto.Changeset

  schema "todo_items" do
    field :description
    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> Changeset.cast(params, [:description])
    |> Changeset.validate_required([:description])
  end
end
defmodule EctoTest.Todos do
  alias EctoTest.Repo
  alias EctoTest.Todos.TodoItem
  alias EctoTest.Todos.TodoList

  def create_todo_item(attrs \\ %{}) do
    %TodoItem{}
    |> TodoItem.changeset(attrs)
    |> Repo.insert()
  end

  def create_todo_list(attrs \\ %{}) do
    %TodoList{}
    |> TodoList.changeset(attrs)
    |> Repo.insert()
  end

  def update_todo_list(%TodoList{} = todo_list, attrs \\ %{}) do
    todo_list
    |> Repo.preload([:todo_items])
    |> TodoList.changeset(attrs)
    |> Repo.update()
  end
end

Both the following tests fail with similar errors

defmodule EctoTest.Todos.TodoListTest do
  use EctoTest.DataCase
  alias EctoTest.Todos

  describe "create_todo_list/2" do
    test "creates todo_list and casts todo_items" do
      {:ok, todo_item} = Todos.create_todo_item(%{description: "Test description"})
      todo_list_params = %{title: "Test title", todo_items: [%{id: todo_item.id}]}

      assert {:ok, todo_list} = Todos.create_todo_list(todo_list_params)
    end
  end

  describe "update_todo_list/2" do
    test "updates todo_list and casts todo_items" do
      {:ok, todo_item} = Todos.create_todo_item(%{description: "Test description"})
      todo_list_params = %{title: "Test title", todo_items: [%{id: todo_item.id}]}

      assert {:ok, todo_list} = Todos.create_todo_list(todo_list_params)

      {:ok, new_todo_item} = Todos.create_todo_item(%{description: "New todo item"})
      todo_list_params = %{todo_items: [%{id: new_todo_item.id}]}

      assert {:ok, todo_list} = Todos.update_todo_list(todo_list, todo_list_params)
    end
  end
end

These are the errors I get

1) test update_todo_list/2 updates todo_list and casts todo_items (EctoTest.Todos.TodoListTest)
     test/ecto_test/todos/todo_list_test.exs:15
     match (=) failed
     code:  assert {:ok, todo_list} = Todos.create_todo_list(todo_list_params)
     right: {:error,
             #Ecto.Changeset<
               action: :insert,
               changes: %{
                 title: "Test title",
                 todo_items: [
                   #Ecto.Changeset<
                     action: :insert,
                     changes: %{},
                     errors: [
                       description: {"can't be blank", [validation: :required]}
                     ],
                     data: #EctoTest.Todos.TodoItem<>,
                     valid?: false
                   >
                 ]
               },
               errors: [],
               data: #EctoTest.Todos.TodoList<>,
               valid?: false
             >}
     stacktrace:
       test/ecto_test/todos/todo_list_test.exs:19: (test)



  2) test create_todo_list/2 creates todo_list and casts todo_items (EctoTest.Todos.TodoListTest)
     test/ecto_test/todos/todo_list_test.exs:6
     match (=) failed
     code:  assert {:ok, todo_list} = Todos.create_todo_list(todo_list_params)
     right: {:error,
             #Ecto.Changeset<
               action: :insert,
               changes: %{
                 title: "Test title",
                 todo_items: [
                   #Ecto.Changeset<
                     action: :insert,
                     changes: %{},
                     errors: [
                       description: {"can't be blank", [validation: :required]}
                     ],
                     data: #EctoTest.Todos.TodoItem<>,
                     valid?: false
                   >
                 ]
               },
               errors: [],
               data: #EctoTest.Todos.TodoList<>,
               valid?: false
             >}
     stacktrace:
       test/ecto_test/todos/todo_list_test.exs:10: (test)

Is this expected behavior? If so, what’s the best way for me to make this work?

Please check out this example. I am currently in my mobile device:

Please let me know if it helped.

Best regards,

We don’t run into this problem with has_many associations, it seems unique to many_to_many.

So, just to get the error out out the way, they are because the TodoItem changeset has a required field :description

  def changeset(struct, params \\ %{}) do
    struct
    |> Changeset.cast(params, [:description])
    |> Changeset.validate_required([:description])
  end

… and you are calling it with just the “id”

 todo_list_params = %{title: "Test title", todo_items: [%{id: todo_item.id}]}

But even if you fix that, you are still going to run into trouble. cast_assoc requires you to handle both parent and child association at once. You should have a params structure similar to:

Please note that in your test, you are first saving the TodoItem and then adding it to a TodoList and this will not work because cast_assoc works with both at one.

%{ "title" => "Your Title",
   "todo_items" => [ 
                     %{"id" => "1", "description" => "Your description 1"},
                     %{"id" => "2", "description" => "Your description 2"},
                   ]
}

Your actual TodoList.changeset should work when you are creating the record but if you are updating it you have preload the TodoItem association first.

You can also reference the documentation for cast_assoc:

https://hexdocs.pm/ecto/Ecto.Changeset.html#cast_assoc/3

Hope this helps. Best regards,

1 Like

He’s calling it with just the id, because there is already a description in the record. Since he preloads that association, we’re expecting a preloaded struct to be passed to the changeset like we’d have in a has_many relationship, but that is not happening.

On the update, it is preloaded:

Oh,

Focusing only on the changeset errors:

As you can see in the errors, the function that is been called is the Todos.create_todo_list(todo_list_params) so there is no preload inside this function.

code:  assert {:ok, todo_list} = Todos.create_todo_list(todo_list_params)

Looks like the code is ok but not the test code.

So, if I wanted to test the creation of the parent and child at the same time with none of them existing, I would try the following code as my test

  describe "create_todo_list/2" do
    test "creates todo_list and casts todo_items" do
      todo_list_params = %{title: "Test title",
         todo_items: [%{description: "My Test Description"}]
      }

      assert {:ok, todo_list} = Todos.create_todo_list(todo_list_params)
    end
  end

Creating a new todo_list with a existing todo_item is not possible because you can’t preload a child associations of a non existing parent.

Hope this clarifies what I was trying to explain.

Best regards,

Yes I agree the create can’t work the way he has it. What about the update though? Shouldn’t the preloaded struct be passed to changeset function?