Delete a grandchild association in a Ecto.Changeset

I’m creating a complex form using LiveView. I’m using an Ecto.Changeset to render the form, so when the user changes something I update the changeset to re-render the form. I have most of the functionality working without problem, except for deleting a grandchild association.

I created a simplified example of my real code to show my case. I’m editing a Foo schema which has many Bar in :bars field. At the same time, a Bar struct has many Baz in :bazs field. The code is the following:

defmodule Project.Foo do
  use Ecto.Schema

  schema "foos" do
    field :foo, :string, default: "foo"
    has_many :bars, Project.Bar
  end

  def changeset(%__MODULE__{} = schema, params) do
    schema
    |> Ecto.Changeset.cast(params, [:foo])
    |> Ecto.Changeset.cast_assoc(:bars)
  end
end

defmodule Project.Bar do
  use Ecto.Schema

  schema "bars" do
    field :bar, :string, default: "bar"
    has_many :bazs, Project.Baz
  end

  def changeset(%__MODULE__{} = schema, params) do
    schema
    |> Ecto.Changeset.cast(params, [:bar])
    |> Ecto.Changeset.cast_assoc(:bazs)
  end
end

defmodule Project.Baz do
  use Ecto.Schema

  schema "bazs" do
    field :baz, :string, default: "baz"
  end

  def changeset(%__MODULE__{} = schema, params) do
    schema
    |> Ecto.cast(params, [:baz])
  end
end

defmodule Project.FooLive do
  use Phoenix.LiveView
  use Phoenix.HTML

  def mount(%{}, socket) do
    changeset = %Project.Foo{bars: [%Project.Bar{bazs: [%Project.Baz{}]}]} 
                |> Ecto.Changeset.change()

    {:ok, assign(socket, changeset: changeset)}
  end

  def render(assigns) do
    ~L"""
    <%= f = form_for assigns.changeset, "#", [] %>
      <%= label f, :foo %>
      <%= text_input f, :foo %>
      <%= inputs_for f, :bars, fn bar_f -> %>
        <%= label bar_f, :bar %>
        <%= text_input bar_f, :bar %>
        <%= inputs_for bar_f, :bazs, fn baz_f -> %>
          <%= label baz_f, :baz %>
          <%= text_input baz_f, :baz %>
          <button phx-click="delete_bar" phx-value="<%= "#{bar_f.index}-#{baz_f.index}" %>">Delete Bar</button>
        <% end %>
      <% end %>
    </form>
    """
  end

  def handle_event("delete_bar", indexes, socket) do
    changeset = socket.assigns.changeset
    [bar_index, baz_index] = indexes |> String.split("-") |> Enum.map(&String.to_integer/1)
    bars = Ecto.Changeset.get_field(changeset, :bars)
           |> IO.inspect(label: "pre change")
           |> List.update_at(bar_index, fn bar ->
             Map.update!(bar, :bazs, fn bazs ->
               List.delete_at(bazs, baz_index)
             end)
           end)
           # we can see here that `:bazs` == []
           |> IO.inspect(label: "post change")

    # This is false as I expected
    IO.inspect(bars == Ecto.Changeset.get_field(changeset, :bars), label: "Bars equals?")
    # I expected this to be false, but it is true because it doesn't update the bars relation
    IO.inspect(changeset == Ecto.Changeset.change(changeset, bars: bars), label: "Changeset equals?")
    # The same problem with put_assoc
    IO.inspect(changeset == Ecto.Changeset.put_assoc(changeset, :bars, bars), label: "Changeset 'put_assoc' equals?")

    changeset = Ecto.Changeset.change(changeset, bars: bars)

    {:noreply, assign(socket, changeset: changeset)}
  end
end

The output:

pre change: [
  %Project.Bar{
    __meta__: #Ecto.Schema.Metadata<:built, "bars">,
    bar: "bar",
    bazs: [
      %Project.Baz{
        __meta__: #Ecto.Schema.Metadata<:built, "bazs">,
        baz: "baz",
        id: nil
      }
    ],
    id: nil
  }
]
post change: [
  %Project.Bar{
    __meta__: #Ecto.Schema.Metadata<:built, "bars">,
    bar: "bar",
    bazs: [],
    id: nil
  }
]
Bars equals?: false
Changeset equals?: true
Changeset 'put_assoc' equals?: true

It seems like Ecto.Changeset.change does not detect that the :bars association changed. I use a similar approach to add a grandchild and it works perfectly, it detects the change and LiveView re-renders the form. I don’t know if it is a bug in Ecto or I’m not using the right function or approach—I admit the code it is quite complex just to delete a grandchild, if you have suggestion to make it easier I’m all ears :slight_smile:

Thanks in advance

Do you use phoenix context to decouple the application?

If not the following command will create one mix phx.gen.context Project Baz baz baz

This command will generate a module named Project with the model baz and a migration baz with a field of baz string.

You can find more info here https://hexdocs.pm/phoenix/contexts.html

and here https://www.youtube.com/watch?v=5MBGDM8xSQg

:wave:

I’d probably try to rerun the Foo.changeset/2 on foo (after manually deleting baz from it). You can get foo after every change by running apply_changes on the changeset.

  def mount(%{}, socket) do
    foo = %Project.Foo{bars: [%Project.Bar{bazs: [%Project.Baz{}]}]} 
    changeset = Foo.changeset(foo, %{})
    {:ok, assign(socket, foo: foo, changeset: changeset)}
  end

  def handle_event("deleve_bar", indexes, socket) do
    foo = delete_bar(socket.assigns.foo, indexes)
    changeset = Foo.changeset(foo, %{})
    {:noreply, assign(socket, changeset: changeset)}
  end 

  def handle_event("validate", %{"foo" => changes}, socket) do
    changeset = Foo.changeset(changeset, changes)
    foo = Ecto.Changeset.apply_changes(changeset)
    {:noreply, assign(socket, changeset: changeset, foo: foo)}
  end

  defp delete_bar(%{bars: bars} = foo, indexes) do
    [bar_index, baz_index] = indexes |> String.split("-") |> Enum.map(&String.to_integer/1)
    bars = List.update_at(bars, bar_index, fn bar ->
      Map.update!(bar, :bazs, fn bazs -> List.delete_at(bazs, baz_index) end)
    end)
    %{foo | bars: bars}
  end