Tutorial

Add Tags with Ecto has_many, through in Phoenix - Tagging part 1

This post was updated 01 May - 2020

has_many phoenix ecto belongs_to

I want to add tags to products. And as usual there are a situation where a product can have many tags and a tag can belong to many products.

However, in my opinion, the best option is to use a has_many :through relationship. So for this, I need a table called tags and a join table called taggings.

In this tutorial, I will go through how would do this.

STEP 1 - Generate the code

So, lets generate these with these commands:

mix phx.gen.context Taggable Tag tags name
mix phx.gen.context Taggable Tagging taggings tag_id:references:tags product_id:references:products

Since I use the same context for these, I will get asked if I really want to use the same context. And in this case, this is exactly what I want. I will delete most of the boilerplate code anyway. So I will answer Y here:

Would you like to proceed? [Yn] Y

With the code is created, the first thing I always to is to edit the migration files. Lets take them in order. Add null: false and create unique_index(:tags, [:name]).

# priv/repo/migrations/20200211160754_create_tags.exs
defmodule Tutorial.Repo.Migrations.CreateTags do
use Ecto.Migration
def change do
create table(:tags) do
add :name, :string, null: false
timestamps()
end
create unique_index(:tags, [:name])
end
end

And the same thing in the other migration. But also, make sure to change to ondelete: :deleteall so we dont get orphan records in the database.

# priv/repo/migrations/20200211160936_create_taggings.exs
defmodule Tutorial.Repo.Migrations.CreateTaggings do
use Ecto.Migration
def change do
create table(:taggings) do
add :tag_id, references(:tags, on_delete: :delete_all)
add :product_id, references(:products, on_delete: :delete_all)
timestamps()
end
create index(:taggings, [:tag_id])
create index(:taggings, [:product_id])
create unique_index(:taggings, [:tag_id, :product_id])
end
end

When the migrations are edited I can run:

mix ecto.migrate

STEP 2 - Setup the relations

So, to iterate, we created a module called Tag that has many :products through the module Tagging. So Tagging belongs to both Tag and Product.

Lets set that up:

# lib/tutorial/taggable/tagging.ex
defmodule Tutorial.Taggable.Tagging do
use Ecto.Schema
import Ecto.Changeset
schema "taggings" do
belongs_to :tag, Tutorial.Taggable.Tag
belongs_to :product, Tutorial.Products.Product
timestamps()
end
@doc false
def changeset(tagging, attrs) do
tagging
|> cast(attrs, [])
|> unique_constraint(:name, name: :taggings_tag_id_product_id_index)
|> cast_assoc(:tag)
end
end

Note that we have the unique constraint setup and also cast_assoc(:tag)

Next step is to setup the has_many :through in the Tag module.

Pay attention to that this is how we set that up:

has_many :taggings, Tutorial.Taggable.Tagging
has_many :products, through: [:taggings, :post]

So the entire module look like:

# lib/tutorial/taggable/tag.ex
defmodule Tutorial.Taggable.Tag do
use Ecto.Schema
import Ecto.Changeset
schema "tags" do
field :name, :string
has_many :taggings, Tutorial.Taggable.Tagging
has_many :products, through: [:taggings, :post]
timestamps()
end
@doc false
def changeset(tag, attrs) do
tag
|> cast(attrs, [:name])
|> validate_required([:name])
|> unique_constraint(:name, name: :tags_name_index)
end
end

We also want this to be reflected in the Product. Add this inside the schema definition:

# lib/tutorial/products/product.ex
schema "products" do
...
has_many :tags, through: [:taggings, :tag]
timestamps()
end

The last step of the code changes is to basically delete everything in the Taggable context and just have 3 functions. listtags/0, tagproduct/2 and deletetagfrom_product/2:

# lib/tutorial/taggable.ex
defmodule Tutorial.Taggable do
@moduledoc """
The Taggable context.
"""
import Ecto.Query, warn: false
alias Tutorial.Repo
alias Tutorial.Taggable.Tag
alias Tutorial.Taggable.Tagging
def list_tags do
Repo.all(Tag)
end
def tag_product(product, %{tag: tag_attrs} = attrs) do
tag = create_or_find_tag(tag_attrs)
product
|> Ecto.build_assoc(:taggings)
|> Tagging.changeset(attrs)
|> Ecto.Changeset.put_assoc(:tag, tag)
|> Repo.insert()
end
defp create_or_find_tag(%{name: "" <> name} = attrs) do
%Tag{}
|> Tag.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, tag} -> tag
_ -> Repo.get_by(Tag, name: name)
end
end
defp create_or_find_tag(_), do: nil
def delete_tag_from_product(product, tag) do
Repo.find_by(Tagging, product_id: product.id, tag_id: tag.id)
|> case do
%Tagging{} = tagging -> Repo.delete(tagging)
nil -> {:ok, %Tagging{}}
end
end
end

STEP 3 - Test the functionality in IEX

Now we are ready to test this in a the console. Open IEX:

iex -S mix

And for convenience att some aliases:

alias Tutorial.Repo
alias Tutorial.Products
alias Tutorial.Taggable

Start with getting one of the products. I already have hundred products generated through the seeds file. Assign one of them to product

product = Products.get_product!(100)

Then create a tag for that product:

Taggable.tag_product(product, %{tag: %{name: "Stout"}})

If we did everything correct, we should see it saved like this:

And I can see that its saved correctly if I run:

product |> Repo.preload(:tags)

Next thing to dry is the delete the product. For that, I need both the tag and the product. Grab the tag we just created by:

%{tags: [tag]} = product |> Repo.preload(:tags)

And the delete it by:

Taggable.delete_tag_from_product(product, tag)

And again, the correct outcome is:

NEXT STEP

When this is working, the next part of this tutorial is to make a dynamic interface with LiveView so we can add tags to products.

Related Tutorials

Published 03 Feb - 2020
Updated 01 May - 2020

Setup a has_many / belongs_to in Phoenix

Something I do in EVERY project is to setup some sort of relation between resources. And even though Phoenix comes with generators for migrations an..

Published 06 May - 2022

Teams Feature with Phx.Gen.Auth

A very common feature in web applications, especially SAAS applications are the concept of teams where a user can have and belong to multiple teams...