In this lecture we will learn more about n:m relationships (with a reference schema) and associative forms.
Lets start by adding a new table tags
and expanding our Content
context:
mix phx.gen.context Content Tag tags name:string
This will most likely warn you that you have already stuff in the context and recommend you to create a new one instead. We don't heed the advice here as tags
are closely related to the pages
and topics
. Thus we confirm y
that we indeed want to create this resource.
Before we migrate, let's
- do some clean up (remove timestamps)
- Further we want to create a unique index (each tag can only exist once)
- prepare a
n:m
table to associate pages with tags - add some instructions so we can directly create tags with the migration file
From the newly created schema remove the timestamps:
schema "tags" do
field :name, :string
# remove the following line:
# timestamps(type: :utc_datetime)
end
Tags are timeless and storing datetime information when they were created is unnecessary.
Also add a unique_constraint for the name field in the changeset
function.
Do something similar in the new migration file:
defmodule App.Repo.Migrations.CreateTags do
use Ecto.Migration
alias App.{Content, Repo}
def change do
create table(:tags) do
add :name, :string
# remove the following
# timestamps(type: :utc_datetime)
end
unique_index(:tags, :name)
end
end
We can further instruct the migration file to auto-create some tags. Since we only want to add tags when we migrate up (not down) we will split the change function into an up and a down function:
defmodule App.Repo.Migrations.CreateTags do
use Ecto.Migration
def up do
create table(:tags) do
add :name, :string
end
create unique_index(:tags, :name)
end
def down do
drop unique_index(:tags, :name)
drop table(:tags)
end
end
Notice that we need to undo database changes in the reverse order. I can only create the index for a table if the table was created first. Similarily i need to drop the index before i can drop the table.
Test this by migrating followed by rolling back your latest change:
mix ecto.migrate
mix ecto.rollback
You should see a similar output:
❯ mix ecto.migrate
20:05:46.931 [info] == Running 20250316234608 App.Repo.Migrations.CreateTags.up/0 forward
20:05:46.932 [info] create table tags
20:05:46.946 [info] create index tags_name_index
20:05:46.949 [info] == Migrated 20250316234608 in 0.0s
❯ mix ecto.rollback
20:05:52.978 [info] == Running 20250316234608 App.Repo.Migrations.CreateTags.down/0 forward
20:05:52.979 [info] drop index tags_name_index
20:05:52.980 [info] drop table tags
20:05:52.982 [info] == Migrated 20250316234608 in 0.0s
Now we are not done yet with the migration. Next we would like to create some tags when migrating up.
def up do
create table(:tags) do
add :name, :string
end
create unique_index(:tags, :name)
flush()
~w(Bucknell Sports Photograpy Nature)
|> Enum.each(&App.Content.create_tag(%{name: &1}))
end
A few new things here:
flush()
allows us perform all the currently cached instructions because we need the table completed before we can add entries.~w(A B C)
is a convenient sigil to quickly create a list of strings. The example would create["A", "B", "C"]
. We can use this to streamline the creation of tags.Enum.each()
you probably have encountered before. It simply allows us to execute a function for each element in an enumerable.
Go ahead test your migration and rollback afterwards like before. You should see your Tags being created in the sql log.
Looking at the bigger picture, this is nice way to populate a database on both dev and production server without having to run a seed file manually.
Objective 1: n:m table (2 points)
Now before we can move on from the migration file there is one last task. We need to create a n:m table so we can associate pages with tags.
I will let you work out the specifics with your partner but ensure the specifications below:
- table name should be
:pages_tags
- table should only contain two columns:
page_id
andtag_id
. Both should be foreign keys referencing to the correct tables with an instruction to delete all related records if an associated post or tag is deleted. I strongly suggest consulting the reference page. - If you are an acolyt of SQL you might be tempted to modify the autogenerated (hidden) primary key (id) into a composite key of page_id and tag_id but don't bother. This is not a sql optimization that really brings you any benefit and keeping integer primary keys is more compatible with references and the ECTO eco system. What you must do however is make the combination of
page_id
andtag_id
aunique_index
. Also the fields must not be nil. - although a matter of debate in a social media platform, we likely won't need timestamps here as well so we should get rid of them too.
Once you are confident about your migration you can go ahead and finally migrate the database into its latest stage.
You might be wondering at this point, don't we need a schema file for the posts_tags?
** Update: The following section has been updated since the assignment was released**
In most cases I would agree but lets understand the current scenario:
- A page can have multipe tags
- A tag can be present on multiple pages.
There is no additional information here. The two are either linked or not. When there is no additional information (or in other words additional columns in the n:m table) Ecto allows to directly link assocations skipping the n:m table when constructing queries.
This is shown in an example in the Ecto Docs. Notice how Movie and Actore directly connect via join_through
rather than a join schema.
Add the many_to_many
references to both your page and tag schemas. Also add a PageTags join schema:
defmodule App.Content.PageTag do
use Ecto.Schema
import Ecto.Changeset
schema "pages_tags" do
belongs_to :page, App.Content.Page
belongs_to :tag, App.Content.Tag
end
@doc false
@required ~w(page_id tag_id)a
def changeset(page, attrs) do
page
|> cast(attrs, @required)
|> validate_required(@required)
|> foreign_key_constraint(:page_id)
|> foreign_key_constraint(:topic_id)
|> unique_constraint([:page_id, :tag_id])
end
end
In your Page schema you will want to have both Tags and PageTags linked:
many_to_many :tags, App.Content.Tag, join_through: "pages_tags"
has_many :page_tags, App.Content.PageTag,
on_delete: :delete_all,
on_replace: :delete
Associative Forms (3 points)
Next we will modify our form (and in extension the page changeset) so that we can directly add tags to the page via the same form. This section I want you to explore independently with your partner. I may add some more hints on Friday but try to solve this on your own (or with AI/your partner) to learn most from the exercise.
Start by reading with great care the section <inputs_for>
in LiveView documenetation:
https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#inputs_for/1
In your form_component.ex
you will likely have something like this:
<.inputs_for :let={tag_form} field={@form[:page_tags]}>
...
<.input type="hidden" field={tag_form[:page_id]} value={@page.id} />
<.input type="select" field={tag_form[:tag_id]} options={@tag_options} />
...
</.inputs_for>
Notice that we are allowing to change page_tags, not tags directly.
Since new pages don't have a page_id yet (its nil
), we can't add page_tags on creating the page with this approach.
Thus make the Add Tag
button conditional:
<button
:if={@action == :edit}
type="button"
name="page[page_tags_sort][]"
value="new"
phx-click={JS.dispatch("change")}
>
Add Tag
</button>
If this code looks new to you you may have not read the documentation on <inputs_for> relationships completely.
Styling and Showing of Tags (2 point)
Your Tags appear as colored Badges
below the heading when visiting the page show path.
You will need to preload
the tags when loading your post from the database. Consult the Ecto documentation on the Query
section.