# Associate many-to-many records with 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...

```elixir
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

```elixir
@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](https://hexdocs.pm/ecto/Ecto.Changeset.html#put_assoc/4). 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](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-the-on_replace-option) on the parent (Post) schema to `:delete`, since we want to have the ability to remove tags from the post.

```elixir
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!

```elixir
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.

```elixir
@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
```
