Sorting and Deleting many-to-many assocs with Ecto and LiveView

Image by Annie Ruygt

In this post, we’ll use Ecto’s :sort_param and :delete_param options, along with LiveView, to sort and delete elements within a many-to-many association. Fly.io is a great place to run your Phoenix LiveView applications! Check out how to get started!

Ecto enables us to effortlessly work with different types of associations. In a many-to-many relationship, we can easily insert, modify, or delete elements. However, what if we want to sort the elements in a specific order? How can we remove specific records from an association?

Thankfully, Ecto has two new options to make that easier! When dealing with associations or embeds using cast_assoc/3 and cast_embed/3, respectively, we can use the :sort_param and :drop_param options. These options enable us to save associations in a specific order or delete associations based on their position.

But wait, there’s more! Passing these new parameters from LiveView is incredibly straightforward. In this post, we will leverage the power of LiveView and Ecto to sort and delete elements in a many-to-many relationship. Here’s what we’ll do:

  1. Define the necessary Ecto.Schemas.
  2. Define the changesets that enable us to use the :sort_param and :drop_param options.
  3. Set up a form with the required inputs to populate a many-to-many association.
  4. Incorporate the magic of checkboxes into our form to sort and delete elements.

By the end, we will have achieved something like this:

Defining the Ecto Schemas

Let’s begin by defining our example. We’ll take inspiration from a bookstore scenario, where a book can have one or many authors, and an author can write one or many books. To represent this, we’ll define three schemas: books, authors, and author_books.

First, we define the author_books schema, which serves as the intermediate table in our many-to-many relationship:

defmodule ComponentsExamples.Library.AuthorBook do
  use Ecto.Schema
  import Ecto.Changeset

  alias ComponentsExamples.Library.{Author, Book}

  schema "author_books" do
    field :position, :integer
    belongs_to :author, Author, primary_key: true
    belongs_to :book, Book, primary_key: true
    timestamps()
  end
end

This schema is straightforward. It has two relations, :author and :book, to preload information about the author and book, respectively. Additionally, note the :position field. This field helps us store the author’s position, enabling us to preload the authors of a book in the order specified by their positions. We’ll explore this in more detail shortly.

Now we define the books schema:

defmodule ComponentsExamples.Library.Book do
  use Ecto.Schema
  import Ecto.Changeset

  alias ComponentsExamples.Library.AuthorBook

  schema "books" do
    field :price, :decimal
    field :publication_date, :utc_datetime
    field :title, :string

    has_many :book_authors, AuthorBook, 
      preload_order: [asc: :position], 
      on_replace: :delete

    has_many :authors, through: [:book_authors, :author]
    timestamps()
  end
end

The books schema consists of three fields: title, price, and publication_date. We have also defined two associations that work neatly together.

The first association, has_many: book_authors, enables us to save and query the relationship between books and authors using the AuthorBook schema. Two essential options have been included:

  1. The :preload_order option ensures authors are sorted based on the earlier mentioned :position field, in ascending order, when preloaded.
  2. The on_replace: :delete setting ensures that any association element not included in the parameters sent will be deleted.

It’s important to note that the option on_replace: :delete is required for the sort and delete operations, and here’s why it makes sense: Since the client is modifying and deleting children, it becomes necessary for them to provide the full listing. Any elements missing from the listing are considered discarded because there is no way to know if the “old” ones should be preserved or not.

The second association, has_many: authors, enables us to conveniently preload the authors of a book. We achieve this by using the associations we mentioned earlier: [:book_authors, :author]. The :book_authors association guarantees that the authors are already listed according to their positions. It’s a good trick, isn’t it?

Next, we need to define the author schema, which, for this example, does not have any specific constraints:

defmodule ComponentsExamples.Library.Author do
  use Ecto.Schema
  import Ecto.Changeset

  alias ComponentsExamples.Library.AuthorBook

  schema "authors" do
    field :bio, :string
    field :birth_date, :string
    field :gender, :string
    field :name, :string
    timestamps()

    many_to_many :books, AuthorBook, join_through: "author_books"
  end
end

With this, we have defined the necessary schemas to model the bookstore scenario.

Using :drop_param and :sort_param

Let’s start by defining the Book.changeset/3 function, to create or modify a book and its associated authors:

  def changeset(book, attrs) do
    book
    |> cast(attrs, [:title, :publication_date, :price])
    |> validate_required([:title, :publication_date, :price])
    |> cast_assoc(:book_authors,
      with: &AuthorBook.changeset/3,
      sort_param: :authors_order,
      drop_param: :authors_delete
    )
  end

Within this function, we specify the names of the parameters used for sorting and deleting elements within the :book_authors association: :authors_order and :authors_delete. Additionally, we use the :with option, which accepts a function with arity 3 to create the child records. The third argument passed to the function contains the position of each child element.

Warning: Make sure that you are using Ecto v3.10.2 or a newer version. If you attempt to send a function with arity 3 in the :with option on older versions, you may encounter an error.

Now, let’s save the position in our :author_books table. To accomplish this, we define the AuthorBook.changeset/3 function:

  def changeset(author_book, attrs, position) do
    author_book
    |> cast(attrs, [:author_id, :book_id])
    |> change(position: position)
    |> unique_constraint([:author, :book], name: "author_books_author_id_book_id_index")
  end

In this function, we ensure to handle the position passed as the third argument to our changeset function. We cast the attributes :author_id and :book_id, then modify the changeset to include the position. Finally, we enforce a unique constraint on the combination of :author_id and :book_id using the unique_constraint function.

With this, our work with Ecto.Changeset and Ecto.Schema is complete. Now let’s explore how to send :authors_order and :authors_delete from a form.

Using inputs_for to populate a has_many association

In this example, we assume that there are existing authors in our database, and we need to provide the user with options to choose the author(s) of a book. To achieve this, we first create a book changeset with at least one book_author in the :book_authors association. We then build a form using this changeset.

Let’s prepare our assigns:

  def update(%{book: book} = assigns, socket) do
    book_changeset = Library.change_book(book)

    socket =
      socket
      |> assign(assigns)
      |> assign_form(book_changeset)
      |> assign_authors()

    {:ok, socket}
  end

The crucial part is the assign_form/2 function, where we build a form for the Book. If the book does not have any author in the :book_authors association, we create an empty %AuthorBook{} and include it as a single author in the book’s :book_authors association —this allows us to render at least one input to fill in the author information. Finally, we convert this modified changeset into a form:

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    if Ecto.Changeset.get_field(changeset, :book_authors) == [] do
      book_author = %AuthorBook{}
      changeset = Ecto.Changeset.put_change(changeset, :book_authors, [book_author])
      assign(socket, :form, to_form(changeset))
    else
      assign(socket, :form, to_form(changeset))
    end
  end

To provide the user with a selection of authors from the database, we’ll use a select input. The select input expects a list of options in the format {label, value}. We assign these options using the assign_authors/1 function:

defp assign_authors(socket) do
  authors =
    Library.list_authors()
    |> Enum.map(&{&1.name, &1.id})

  assign(socket, :authors, authors)
end

In this function, we fetch the authors from the database using Library.list_authors(). We then transform each author into a {label, value} tuple, where the label represents the author’s name and the value corresponds to the author’s ID. Finally, we assign the authors list to the socket for use in the template.

With our assigns prepared, we can now define the inputs to send the attributes of the :book_authors association.

def render(assigns) do
  ~H"""
  <div>
    <.simple_form 
      for={@form} 
      phx-change="validate" 
      phx-submit="save" 
      phx-target={@myself}
    >
      ...
      <div id="authors" phx-hook="SortableInputsFor" class="space-y-2">
        <.inputs_for :let={b_author} field={@form[:book_authors]}>
          <div class="flex space-x-2 drag-item">
            <.icon name="hero-bars-3" data-handle />
            <.input
              type="select"
              field={b_author[:author_id]}
              placeholder="Author"
              options={@authors}
            />
          </div>
        </.inputs_for>
      </div>
      <:actions>
        <.button phx-disable-with="Saving...">
          Save
        </.button>
      </:actions>
    </.simple_form>
  </div>
  """
end

We use the function component <.inputs_for> to populate the @form[:book_authors] association. Within this component, we access the attributes of each individual :book_authors using the b_author variable.

We create a select input to populate the b_author[:author_id] field, passing in the list of authors as available options.

With this, we are now able to send information about books and authors. However, we still need to make one final modification to send our :sort_param and :delete_param parameters.

Checkboxes magic

:drop_param and :delete_param require a list of numerical indexes to identify the elements that need to be reordered or deleted. To send this list of indexes, we need to include the position of each b_author element.

To do this, we add a hidden input inside the <.inputs_for> component for each b_author. This hidden input will send the value of the element’s position using value={b_author.index}:

  def render(assigns) do
    ~H"""
    <div>
      <.simple_form 
        for={@form} 
        phx-change="validate" 
        phx-submit="save" 
        phx-target={@myself}
      >
        ...
        <div id="authors" phx-hook="SortableInputsFor" class="space-y-2">
          <.inputs_for :let={b_author} field={@form[:book_authors]}>
            <div class="flex space-x-2 drag-item">
              <.icon name="hero-bars-3" data-handle />
+             <input 
+               type="hidden" 
+               name="book[authors_order][]" 
+               value={b_author.index} 
+             />
              <.input
                type="select"
                field={b_author[:author_id]}
                placeholder="Author"
                options={@authors}
              />
            </div>
          </.inputs_for>
        </div>
        <:actions>
          <.button phx-disable-with="Saving...">
            Save
          </.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

The naming convention of this input is crucial. As the form is built from a Book changeset, the input names reflect this structure name. For our example, each input name starts with “book” followed by square brackets and the attribute name being filled, such as name="book[publication_date]".

It’s important to note that this convention holds true unless you have specifically set up the :as option of the to_form/2 function and used a different prefix for the form inputs. For example: to_form(changeset, as: "my_form")

In the case of :authors_order, which involves multiple inputs, each element is uniquely identified by appending an index. For instance, the name attribute would be represented as name="book[authors_order][]".

With this additional input, we can now manage the position of each element. Let’s see how we can add and remove elements from the assoc:

  def render(assigns) do
    ~H"""
    <div>
      <.simple_form 
        for={@form} 
        phx-change="validate" 
        phx-submit="save" 
        phx-target={@myself}
      >
        ...
        <div id="authors" phx-hook="SortableInputsFor" class="space-y-2">
          <.inputs_for :let={b_author} field={@form[:book_authors]}>
            <div class="flex space-x-2 drag-item">
              <.icon name="hero-bars-3" data-handle />
              <input type="hidden" name="book[authors_order][]" value={b_author.index} />
              <input 
                type="hidden" 
                name="book[authors_order][]" 
                value={b_author.index} 
              />
+             <label>
+               <input
+                 type="checkbox"
+                 name="book[authors_delete][]"
+                 value={b_author.index}
+                 class="hidden"
+               />
+               <.icon name="hero-x-mark" />
+             </label>
            </div>
          </.inputs_for>
        </div>
        <:actions>
          <.button phx-disable-with="Saving...">
            Save
          </.button>

+          <label class="block cursor-pointer">
+            <input 
+              type="checkbox" 
+              name="book[authors_order][]" 
+              class="hidden" 
+            />
+            <.icon name="hero-plus-circle" /> add more
+          </label>
        </:actions>
      </.simple_form>
    </div>
    """
  end

To add elements to the book[authors_order][] association, we use another checkbox input hidden inside a label with a plus icon. This input is essential to append new elements to the end of the list. It is placed outside the <.inputs_for> function and after it. These checkboxes send the index of each b_author element as the value.

For deleting elements, we need to send the index of the element to be removed. Inside the <.inputs_for> function, we add another checkbox input hidden inside a label with a hero-x-mark icon. These checkboxes also send the index of each b_author element as the value.

Let’s see how the information of these checkboxes is sent when the validate or submit events are triggered:

[debug] HANDLE EVENT "validate" in ComponentsExamplesWeb.LibraryLive
  Component: ComponentsExamplesWeb.MulfiFormComponent
  Parameters: %{"_target" => ["book", "authors_order"], "book" => %{"authors_order" => ["0", "on"], "book_authors" => %{"0" => %{"_persistent_id" => "0", "author_id" => "2", "book_id" => "2", "id" => "88"}}, "price" => "2323", "publication_date" => "2023-07-13T19:37", "title" => "Libro prueba"}}
[debug] Replied in 982µs

If we focus on the authors_order attribute, we can see that a list ["0", "on"] is sent. This list indicates that there are two authors, with indexes "0" and "on"wait, what? Well, this weird “on” index is used to identify when we’ve just added a new item using our “add more” checkbox.

What if we have another checkbox, similar to the one we used to add new authors, but placed before the <.inputs_for> component? In that case, when adding an element, the "on" parameter would be at the beginning of the list: ["on", "0"].

Let’s now see what happens when we delete an element:

[debug] HANDLE EVENT "validate" in ComponentsExamplesWeb.LibraryLive
  Component: ComponentsExamplesWeb.MulfiFormComponent
  Parameters: %{"_target" => ["book", "authors_delete"], "book" => %{"authors_delete" => ["1"], "authors_order" => ["0", "1"], "book_authors" => %{"0" => %{"_persistent_id" => "1", "author_id" => "1"}, "1" => %{"_persistent_id" => "0", "author_id" => "2", "book_id" => "2", "id" => "88"}}, "price" => "2323", "publication_date" => "2023-07-13T19:37", "title" => "Libro prueba"}}
[debug] Replied in 944µs

In this example, the authors_delete attribute is sent along with a list of indexes representing the elements to be deleted.

You may have noticed that both example logs include a _persistent_id. This identifier serves as internal Phoenix book keeping to track our inputs effectively.

Awesome! We’ve got it all covered now! Adding and deleting elements from the association is a breeze. Just send the required info from our form, and our changesets and schemas will take care of the rest. Way to go!

(For a complete view of the code, please refer to this PR.)

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!

Some Things to Keep in Mind

When using this approach to sort and delete items in a association, there are a few considerations worth noting:

  1. Make sure to preload the association elements before performing any updates. This ensures that the data is correctly ordered.
  2. It’s important to be aware that this approach relies on preloaded elements. If you want to order associations in a relationship but haven’t preloaded all the elements, manual handling of reordering will be necessary.
  3. While this example demonstrates the manipulation of a many-to-many relationship, you can easily adapt these concepts to work with simpler relationships like has-many.