Adding an element to embeds_many association via cast_embed

I have an ecto schema with an embeds_many association like this:

defmodule Foo do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "foo" do
    field :name, :string

    embeds_many :addresses, Address do
      field :city, :string
      field :country, :string
      field :house_number, :string
      field :street, :string
      field :zip, :integer

      def changeset(address, attrs) do
        address
        |> cast(attrs, [:city, :zip, :street, :country, :house_number, :coordinates, :description])
        |> validate_required([:city, :zip, :street, :country])
      end
    end

    timestamps()
  end

  @doc false
  def changeset(foo, attrs) do
    foo
    |> cast(attrs, [:name])
    |> cast_embed(:addresses)
  end
end

Now I am struggling to manipulate the associated address embedding from user data. I was able to add the first entry and can also update this one because the id is present, but I can’t find a way to insert multiple addresses because I am fighting with this error:

** (RuntimeError) you are attempting to change relation :addresses of
Foo but the `:on_replace` option of this relation
is set to `:raise`.

By default it is not possible to replace or delete embeds and
associations during `cast`. Therefore Ecto requires the parameters
given to `cast` to have IDs matching the data currently associated
to Foo. Failing to do so results in this error message.

If you want to replace data or automatically delete any data
not sent to `cast`, please set the appropriate `:on_replace`
option when defining the relation. The docs for `Ecto.Changeset`
covers the supported options in the "Associations, embeds and on
replace" section.

However, if you don't want to allow data to be replaced or
deleted, only updated, make sure that:

  * If you are attempting to update an existing entry, you
    are including the entry primary key (ID) in the data.

  * If you have a relationship with many children, all children
    must be given on update.

I think I understand the error and have also read the documentation how ecto handles these casts here: Ecto.Changeset — Ecto v3.12.5

As I understand it, ecto needs not only the new values that I want to insert but also all the children of the association on the attrs side, to determine which changes occurred and what should be inserted/updated.

But I can’t figure out a way to provide the user data map together with the already present addresses to satisfy the requirement from ecto.

If I use Foo.update_foo(foo, %{"addresses" => [address_params]}) it fails with the above error. If I try to prepend the value like so Foo.update_foo(foo, %{"addresses" => [address_params | foo.addresses]}) it fails with ** (Ecto.CastError) expected params to be a :map because I mix structs with the user provided maps.

What is the solution to add an element to the association?

Get the old collection of values, add to it, call put_embed?

You need to Repo.preload(foo, :addresses) before casting.

EDIT, weird, I did not see @dimitarvp's answer even though it was 25 mins ago. Maybe Discourse does have bugs :thinking: Or I'm blind.

Preloading does not work as this is not an association but an embedded schema.

When trying it like

foo = Repo.prelaod(foo, :addresses)

foo
    |> cast(attrs, [:name])
    |> cast_embed(:addresses)

The error is produced: cannot preload embedded field :addresses without also preloading one of its associations as it has no effect

The old values are an ecto struct and the new values are user provided data as a string map that is not yet cast. If I mix the two, even when using put_embed, I get an error.

Foo.change_foo(foo)
|> Changeset.put_embed(:addresses, [address_params | foo.addresses])

field names given to change/put_change must be atoms, got: "city"

Alright, i got it to work even if I don’t know if that is the most elegant solution. Rather than adding the user input map to the existing list of address structs, I first map the existing addresses to maps using Enum.map(&Map.from_struct/1). Then I have an array of maps that I can easily add the user input to and that ecto understands

existing_addresses = foo.addresses
      |> Enum.map(&Map.from_struct/1)

updated_addresses = existing_addresses ++ [address_params]
Foo.update_foo(foo, %{"addresses" => updated_addresses})
1 Like

Hmm, are the existing embedded addresses also being displayed? If so, take a look at the example in the LiveView docs for inputs_for on how to dynamically add and remove inputs.

I used inputs_for in other cases but sadly in this one we don’t display all addresses but want to show a modal that allows the editing of one.

Haven’t tried if myself, but have you tried wrapping a hidden input modal with inputs_for alongside an edit button that toggles visibility? It should simplify things on the backend for Ecto ¯\_(ツ)_/¯