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