Tutorial
Add Tags with Ecto has_many, through in Phoenix - Tagging part 1
This post was updated 01 May - 2020
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.