The other day I was helping a friend set up a phoenix app which required the use of tags on products, we all have used tags in our day to day to add information about notes, images, and other stuff. Tags are just labels/chunks-of-text which are used to associated with an entity like a product, blog post, image, etc. This blog post has a few tags too (Ecto, Elixir, Phoenix, etc.). Tags help us organize information by annotating records with useful fragments of information. And modeling these in a database is pretty straightforward, it is usually implemented like the following design.

As you can see, we have a many-to-many relation between the products and tags
tables via a products_tags table which has just 2 columns the product_id
and
the tag_id
and it has a composite primary key (while also having an index on
the tag_id
to make lookups faster). The use of a join table is required,
however, you usually want the join table to be invisible in your domain, as you
don’t want to deal with a ProductTag model, it doesn’t serve any purpose other
than helping you bridge the object model with the relational model. Anyway, here
is how we ended up building the many-to-many relationship in Phoenix and Ecto.
Scaffolding the models
We use a nondescript Core
context for our Product
model by running the
following scaffold code:
1 | mix phx.gen.html Core Product products name:string description:text |
This generates the following migration (I’ve omitted the boilerplate to make reading the relevant code easier):
1 | create table(:products) do |
Don’t forget to add the following to your router.ex
1 | resources "/products", ProductController |
Then, we add the Tag
in the same context by running the following scaffold
generator:
1 | mix phx.gen.html Core Tag tags name:string:unique |
This generates the following migration, note the unique index on name
, as we
don’t want tags with duplicate names, you might have separate tags per user in
which case you would have a unique index on [:user_id, :name]
.
1 | create table(:tags) do |
Finally, we generate the migration for the join table products_tags
(by
convention it uses the pluralized names of both entities joined by an underscore
so products
and tags
joined by an _
gives us the name products_tags
).
1 | mix phx.gen.schema Core.ProductTag products_tags product_id:references:products tag_id:references:tags |
This scaffolded migration requires a few tweaks to make it look like the following:
1 | create table(:products_tags, primary_key: false) do |
Note the following:
- We added a
primary_key: false
declaration to thetable()
function call to avoid creating a wastedid
column. - We got rid of the
timestamps()
declaration as we don’t want to trackinserts
andupdates
on the joins. You might want to track inserts if you want to know when a product was tagged with a specific tag which makes things a little more complex, so, we’ll avoid it for now. - We added a
, primary_key: true
to the:product_id
and:tag_id
lines to make[:product_id, :tag_id]
a composite primary key
Now our database is set up nicely for our many-to-many relationship. Here is how our tables look in the database:
1 | product_tags_demo_dev=# \d products |
Getting tags to work!
Now comes the fun part, modifying our controllers and contexts to get our tags working!
The first thing we need to do is add a many_to_many relationship on the Product
schema like so:
1 | schema "products" do |
(Note, that we don’t need to add this relationship on the other side, i.e., Tag
to get this working)
Now, we need to modify our Product
form to show an input mechanism for tags,
the easy way to do this is to ask the users to provide a comma-separated list of
tags in an input textbox. A nicer way is to use a javascript library like
select2.
For us, a text box with comma-separated tags will suffice.
The easiest way to do this is to add a text field like so:
1
2
3<%= label f, :tags %>
<%= text_input f, :tags %>
<%= error_tag f, :tags %>
However, as soon as you wire this up you’ll get an error on the /products/new
page like below:
1
protocol Phoenix.HTML.Safe not implemented for #Ecto.Association.NotLoaded<association :tags is not loaded> of type Ecto.Association.NotLoaded (a struct).
to_string
function can’t convert an
Ecto.Association.NotLoaded
struct into a string, When you have a relation like
a belongs_to
or has_one
or many_to_many
that isn’t loaded on a struct, it
has this default value. This is coming from our controller, we can remedy this
by changing our action to the following:
1 | def new(conn, _params) do |
Notice the tags: []
, we are creating a new product with an empty tags
collection so that it renders properly in the form.
Now that we have fixed our form, we can try submitting some tags through this
form, However, when you enter any tags and hit Save
it doesn’t do anything
which is not surprising because we haven’t set up the handling of these tags on
the backend yet.
We know that the tags
field has comma-separated tags, so we need to do the
following to be able to save a product.
- Split tags on a comma.
- Strip them of whitespace.
- Lowercase them to get them to be homogeneous (If you want your tag names to
be persisted using the input casing and still treat the uppercased version
the same as the lowercased or capitalized versions, you can use
:citext
(short for case insensitive text) read more about how to set up:citext
columns in my blog post about storing username/email in a case insensitive fashion). - Once we have all the tag
names
we can insert any new tags and then fetch the existing tags, combine them, and useput_assoc
to put them on the product.
Step #4 creates a race condition in your code which can happen when 2 requests
try to create tags with the same name at the same time. An easy way to work
around this is to treat all the tags as new and do an upsert using
Repo.insert_all
with an on_conflict: :nothing
option which adds the fragment
ON CONFLICT DO NOTHING
to your SQL making your query run successfully even if
there are tags with the same name in the database, it just doesn’t insert new
tags. Also, note that this function inserts all the tags in a single query doing
a bulk insert of all the input tags. Once you upsert
all the tags, you can
then find them and use a put_assoc
to create an association.
This is what ended up as the final Core.create_product
function:
1 | def create_product(attrs \\ %{}) do |
It does the following:
- Normalize our tags
- Ensure that all the tags are in our database using
Repo.insert_all
withon_conflict: :nothing
in a single SQL query. - Load all the tag structs using the names.
- Use
put_assoc
to associate the tags with the newly created product. - From here
Ecto
takes over and makes sure that our product has the right association records in theproducts_tags
table
Notice, how through all of our code we haven’t used the products_tags
table
except for defining the many_to_many
relationship in the Product
schema.
This is all you need to insert a product with multiple tags, However, we still want to show the tags of a product on the product details page. We can do this by tweaking our action and the Core module like so:
1 | defmodule Core do |
Here we are preloading the tags with the product and we can use it in the view like below to show all the tags for a product:
1 | Tags: <%= (for tag <- @product.tags, do: tag.name) |> Enum.join(", ") %> |
This takes care of creating and showing a product with tags, However, if we try to edit a product we are greeted with the following error:
1 | protocol Phoenix.HTML.Safe not implemented for #Ecto.Association.NotLoaded<association :tags is not loaded> of type Ecto.Association.NotLoaded (a struct). |
Hmmm, we have seen this before when we rendered a new Product without tags,
However, in this case, our product does have tags but they haven’t been
loaded/preloaded. We can remedy that easily by tweaking our edit
action to the
following:
1 | def edit(conn, %{"id" => id}) do |
This gives us a new error:
1 | lists in Phoenix.HTML and templates may only contain integers representing bytes, binaries or other lists, got invalid entry: %ProductTagsDemo.Core.Tag{__meta__: #Ecto.Schema.Metadata<:loaded, "tags">, id: 1, inserted_at: ~N[2020-05-04 05:20:45], name: "phone", updated_at: ~N[2020-05-04 05:20:45]} |
This is because we are using a text_input
for a collection of tags and when
phoenix tries to convert the list of tags into a string it fails. This is a good
place to add a custom input function:
1 | defmodule ProductTagsDemoWeb.ProductView do |
With this helper we can tweak our form to:
1
2
3
4<%= label f, :tags %>
<%= tag_input f, :tags %>
<%= error_tag f, :tags %>
<small class="help-text">tags separated by commas</small>text_input
has been changed to tag_input
.
Now, when we go to edit a product, it should render the form with the tags
separated by commas. However, updating the product by changing tags still
doesn’t work because we haven’t updated our backend code to handle this. To
complete this, we need to tweak the controller and the Core
context like so:
1 | defmodule ProductTagsDemoWeb.ProductController do |
Note that in the controller we are using get_product_with_tags!
and in the
context, we inserted a line to put_assoc
similar to the create_product
function which does the same things as create_product
.
Astute readers will observe that our create and update product implementation
doesn’t rollback newly created tags, when create_product
or update_product
fails. Let us handle this case and wrap our post!
Ecto provides Ecto.Multi
to allow easy database transaction handling. This
just needs changes to our context and our view like so:
1 | defmodule ProductTagsDemo.Core do |
Whew, that was long, but hopefully, this gives you a comprehensive understanding
of how to handle many_to_many
relationships in Ecto and Phoenix.
The source code associated with this blog post can be found at https://github.com/minhajuddin/product_tags_demo
P.S. There is a lot of duplication in our final create_product
and
update_product
functions, try removing the duplication in an elegant way! I’ll
share my take on it in the next post!