Unexpected behaviour with `many_to_many` nested association casting

My Phoenix 1.3 app has two simple schemas Post and Tag. Since the two schemas have a many-to-many relationship, I associate them using a third join schema, PostTag, as suggested in the “What’s new with Ecto 2.0” book and this blog post about nested associations.

I was expecting to be able to create a new Post and add new and existing tags to it by using a single nested Post changeset, as described in the cast_assoc/4 documentation.

The test below describes my expectations:


  test "create Post with existing and new Tags" do
    tag = 
      tag_changeset(%Tag{}, %{name: "existing tag"}) 
      |> Repo.insert!
    
    tag_attrs = [
      %{name: "existing tag - updated", id: tag.id},
      %{name: "new tag"}
    ]
  
    post_attrs = %{title: "some title", tags: tag_attrs}
    
    assert {:ok, %Post{} = post} = 
      %Post{}
      |> post_changeset(post_attrs)
      |> Repo.insert()
    
    assert post.title == "some title"

    tags = 
      Ecto.assoc(post, :tags) 
      |> Repo.all
      |> Enum.map(fn (t) -> {t.name, t.id} end)
      |> Enum.into(%{})

    assert is_integer(tags["new tag"])
    assert tags["existing tag - updated"] == tag.id
  end

  defp post_changeset(%Post{} = post, attrs) do
    post
    |> cast(attrs, [:title])
    |> cast_assoc(:tags, with: &tag_changeset/2)
    |> validate_required([:title])
  end
  
  defp tag_changeset(%Tag{} = tag, attrs) do
    tag
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end

The test fails with, and inserts two new Tags instead of updating the name of one and adding a single new one.

    Assertion with == failed
     code:  tags["existing tag - updated"] == tag.id()
     left:  70
     right: 69

My question is: Is this expected behaviour?

The documentation for cast_assoc/4 suggests that it is not expected behaviour:

If the parameter contains an ID and there is an associated child with such ID, the parameter data will be passed to changeset/2 with the existing struct and become an update operation

I can only guess that the documentation only contemplated a belongs_to nesting, not a many_to_many one.

FWIW, I am aware I can use put_assoc to replace the entire association, but that’s not what I am trying to do here.

Here are the supporting schema modules; migrations omitted.

defmodule PhxSandbox.Blog.Post do
  use Ecto.Schema

  schema "blog_posts" do
    field :title, :string
    many_to_many :tags, PhxSandbox.Blog.Tag, join_through: PhxSandbox.Blog.PostTag
    timestamps()
  end
end

defmodule PhxSandbox.Blog.Tag do
  use Ecto.Schema
  schema "blog_tags" do
    field :name, :string
    many_to_many :posts, PhxSandbox.Blog.Post, join_through: PhxSandbox.Blog.PostTag
    timestamps()
  end
end

defmodule PhxSandbox.Blog.PostTag do
  use Ecto.Schema
  schema "blog_posts_tags" do
    belongs_to :post, PhxSandbox.Blog.Post
    belongs_to :tag, PhxSandbox.Blog.Tag
    timestamps()
  end
end
1 Like

In my recommendation and trying to find work-arounds for all sorts of cases, it is of my opinion that many_to_many helpers should only be used for selecting, to insert or update them you should always do that manually by setting the ID’s and such directly instead, that way it will always work as you expect with no surprises. :slight_smile:

The helpers are still great for selecting though. :slight_smile:

2 Likes

@OvermindDL1 The more I familiarise myself with associations, the more it is starting to look that way.

The documentation is confusing though. In “What’s new with Ecto 2.0”, in chapter 8, page 48 it actually says:

Differently from has_many :through , many_to_many associations are also writeable. This means we can send data through our forms exactly as we did at the beginning of this chapter

…and it goes on to build a nested association just like I do, but where the associated schema is not shared between many parent associations (TodoList and TodoItems)