Unique constraint error on a schema less many to many association table

I have a posts table which has many tags. The 2 are linked by a many to many association through an post_tags table. There is no schema for post_tags table. So, I use put_assoc/4 to insert data to post_tags table. Now, how can I add a unique constraint error to the Changeset when creating/editing a post?

I would set up an has many - through relation ship instead

post has_many post_tags
post has_many tags through
(https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3-has_many-has_one-through)

Then you can have a changeset on PostTag that validates unique constraint

I wrote a short tutorial about how I prefer to design this:

1 Like

In this tutorial, can we link multiple tags with a product at once? also, by adding cast_assoc in the taggings Changeset, it will try to create a new tag every time we try to link a tag with a product, right?

There is no batch insert of tags.

I see now that I forgot to add the unique constraint in Tag-module.

  def changeset(tag, attrs) do
    tag
    |> cast(attrs, [:name])
    |> validate_required([:name])
    |> unique_constraint(:name, name: :tags_name_index)
  end

That will result in an error Changeset that you need to chose how handle (ignore or validation error).

Specs look like this:

defmodule Tutorial.TaggableTest do
  use Tutorial.DataCase

  alias Tutorial.Taggable
  alias Tutorial.Taggable.Tag
  alias Tutorial.Taggable.Tagging
  alias Tutorial.Repo
  alias Tutorial.Products

  def product_fixture(attrs \\ %{}) do
    {:ok, product} =
      attrs
      |> Enum.into(%{description: "some description", name: "some name", price: 120.5, properties: %{}})
      |> Products.create_product()

    product
  end

  def tag_fixture() do
    product = product_fixture()

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

    tag
  end

  describe "tags and taggings" do
    test "list_tags/0 returns all tags" do
      tag = tag_fixture()
      assert Taggable.list_tags() == [tag]
    end

    test "tag_product/2 with valid data creates a tag" do
      product = product_fixture()
      assert {:ok, %Tagging{} = tagging} = Taggable.tag_product(product, %{tag: %{name: "Stout"}})
      assert tagging.tag.name == "Stout"
    end

    test "tag_product/2 with invalid data returns error changeset" do
      product = product_fixture()
      assert {:error, %Ecto.Changeset{}} = Taggable.tag_product(product, %{tag: %{name: nil}})
    end

    test "tag_product/2 with duplicate tag returns error changeset" do
      product = product_fixture()
      Taggable.tag_product(product, %{tag: %{name: "Stout"}})
      assert {:error, %Ecto.Changeset{}} = Taggable.tag_product(product, %{tag: %{name: "Stout"}})
    end

    test "delete_tag_from_product/2 deletes the tagging from product but not the tag" do
      product = product_fixture()
      {:ok, %Tagging{tag: %Tag{} = tag}} = Taggable.tag_product(product, %{tag: %{name: "Lager"}})

      assert %{tags: [^tag]} = product |> Repo.preload(:tags)
      assert {:ok, %Tagging{}} = Taggable.delete_tag_from_product(product, tag)
      assert %{tags: []} = product |> Repo.preload(:tags)
      assert [%Tag{name: "Lager"}] = Taggable.list_tags()
    end
  end
end

Then how do you create product with multiple tags? :roll_eyes: :grimacing:

I would loop through the tags and insert them one by one (can’t be that many). Otherwise I risk getting an error if I batch insert and one of them is a duplicate.

this is why I tried doing.

And why did you use that cast_assoc in the taggings Changeset?

Its actually through the Tagging I insert the tag.

  def tag_product(product, attrs \\ %{}) do
    product
    |> Ecto.build_assoc(:taggings)
    |> Tagging.changeset(attrs)
    |> Repo.insert()
  end

But I think there is an issue that only allows me to create and assign new tags. Will see if I can fix that.

exactly… so every time I tag something, it tries to create a new one, and that causes the unique constraint in tag table to raise

also, i keep getting this error,

* (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * taggings_product_id_fkey (foreign_key_constraint)

Why is that?

I am using Ecto.Multi to create the Product and then associate it with the tags

Either product_id is nil or wrong?

I doubt its because I am using Ecto.Multi. Because multi dont create the product until all the functions run perfectly, right? Correct me if I am wrong

Sorry for the late answer but I changed the code to:

  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

And test:

    test "tag_product/2 with valid data appends the tag if it exists" do
      product = product_fixture()
      tag = tag_fixture()
      Taggable.delete_tag_from_product(product, tag)

      assert {:ok, %Tagging{} = tagging} = Taggable.tag_product(product, %{tag: %{name: "Stout"}})
      assert tagging.tag.name == "Stout"
    end

I checked your GitHub code, and there is a cast_assoc in the taggings Changeset function, and in the tag_product function you have added another put_assoc too. Can you please explain me your code?

Also, this was because I used Task.async_stream to iterate over the list of tags and bind them to the product.
I changed it to Stream.map and now it works fine. Any idea, why Task.async_stream causes this error? @andreaseriksson

I tried to remove it but one of the test cases failed (raised) instead of returning an error changeset. But maybe that scenario would never happen if you design your UI correct

Im guessing since there are database constraints and you running the inserts async, the references are not persisted at this point? Or the current process doesn’t know about it.

@shijith.k Did everything work out for you?

I also added a second part where I have an interface for adding the tags:

Yes, everything worked out. But I didn’t use cast_assoc is taggings changeset.

I will check the second part of the tutorial.

If you dont mind, can we have a chat in slack or telegram? I have some doubts