Tutorial

View on Github

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

This post was updated 01 May - 2020

belongs_toectohas_manyphoenix

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 on_delete: :delete_all 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)

Nest 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. list_tags/0, tag_product/2 and delete_tag_from_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.

Phoenix Boilerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Try now

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Learn More

Related Tutorials

Published 15 Feb - 2020

Create ghost loading cards in Phoenix LiveView

ectoliveviewphoenix

Unless you already didn't know, when a LieView component is mounted on a page, it runs the mount/2 function twice. One when the page is rendered fro..

Published 03 Feb - 2020

Setup a has_many / belongs_to in Phoenix

belongs_toectohas_manyphoenix

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