Associate many-to-many records with Ecto

Associate many-to-many records with Ecto

Learn how to associate two existing records for a many-to-many relationship using Ecto.

Ecto and its Changeset module are so powerful and flexible! Sometimes, even too much... I was wondering, how to associate two existing records for many-to-many in the simplest way and it took me a while. But finally, I got this! And wanted to share it with you.

Goal

Let's assume we want to some tags to a blog post. Tags are already available in the database. We have a checkbox list of the tags. All we need is to bind them in the database.

Since one post may have many tags and a tag can belong to many posts, it's a classic many-to-many relationship. But we certainly don't want to create a joint record for each post-tag association... Let's look at what Ecto has for us.

Strategy

Our strategy is to apply changes to a post, fetch tags, and "put" them into the post. Getting this in the Elixir-ish way...

attrs_from_form
|> post_changeset
|> fetch_tags_by_ids
|> put_tags_to_post
|> insert_to_db

As you can see, we have two additional steps due to the association of the tags: fetch_tags_by_ids and put_tags_to_post. They appear to be strongly coupled, so we should combine them into one function. Let's proceed with put_tags and have it perform both fetching tags and adding them to the post.

Now let's translate this to real Code.

Code

@spec create_post(map()) :: {:ok, %Post{}} | {:error, %Ecto.Changeset{}}
def create_post(attrs \\ %{}) do
  %Post{tags: []}
  |> Post.changeset(attrs)
  |> put_tags(attrs)
  |> Repo.insert()
end

@spec put_tags(%Ecto.Changeset{}, map()) :: %Ecto.Changeset{}
defp put_tags(changeset, %{"tag_ids" => tag_ids}) do
  tags = get_tags(tag_ids)

  changeset
  |> Ecto.Changeset.put_assoc(:tags, tags)
end

@spec get_tags([binary()]) :: [%Tag{}]
def get_tags(ids) do
  from(t in Tag, where: t.id in ^ids)
  |> Repo.all
end

The key function here is Ecto.Changeset.put_assoc. It takes post changeset, name of the association, and collection of tags we want to put to the post. Keep in mind that put_assoc works on the whole collection. In other words, it would completely replace the old collection with a new one! By default, it won't make any change though, and raise an error.

To make this work, we need to set on_replace on the parent (Post) schema to :delete, since we want to have the ability to remove tags from the post.

schema "posts" do
  ...

  many_to_many :tags, Blog.Content.Tag, join_through: Blog.Content.PostTag, on_replace: :delete
end

Now it works! And we're... almost done. Almost? There's one more issue. What if the post doesn't have any tags? In such a case, the current implementation of put_tags won't work. It even doesn't get pattern matched, because of the lack of "tag_ids" argument.

Fortunately, we can easily fix this. The following one-liner function does the work. Remember, to put this below the previous implementation of the function, in the other case it'll always get pattern-matched!

defp put_tags(changeset, _), do: changeset

Hmm... put_tags name doesn't fit too much to this particular case. It's a matter of style, but let's do the last step, and rename it to maybe_put_tags to make it more clear.

And that's it! The final implementation looks like this. Just remember about on_replace: :delete option on your tags association in Post schema.

@spec create_post(map()) :: {:ok, %Post{}} | {:error, %Ecto.Changeset{}}
def create_post(attrs \\ %{}) do
  %Post{tags: []}
  |> Post.changeset(attrs)
  |> maybe_put_tags(attrs)
  |> Repo.insert
end

@spec maybe_put_tags(%Ecto.Changeset{}, map()) :: %Ecto.Changeset{}
defp maybe_put_tags(changeset, %{"tag_ids" => tag_ids}) do
  tags = get_tags(tag_ids)
  changeset
  |> Ecto.Changeset.put_assoc(:tags, tags)
end

defp maybe_put_tags(changeset, _), do: changeset

@spec get_tags([binary()]) :: [%Tag{}]
def get_tags(ids) do
  from(t in Tag, where: t.id in ^ids)
  |> Repo.all
end