Associative Forms

DANA 325 L M A D H F U Q E I T N G K O V R C W X S P B

Objectives

  • Correctly created the n:m table with a unique_index on both columns (together) and not nil guantee on both foreign keys. (2 Points)

  • tags can be added/removed via the page form directly. (3 Points)

  • tags show in form of badges when displaying pages (2 Points)

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 and tag_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 and tag_id a unique_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.

Copyright © 2025 Alexander Fuchsberger, Bucknell University. All rights reserved.