Accept map and list for embed_many

I have a simple schema with a structure like this:

defmodule Parent do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field(:name, :string)
    embeds_many(:children, Child)
  end

  def changeset(data, attrs) do
    data
    |> cast(attrs, [:name])
    |> cast_embed(:children)
  end
end

defmodule Child do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field(:key, :string)
    field(:name, :string)
  end

  def changeset(data, attrs) do
    data
    |> cast(attrs, [:name, :key])
  end
end

It works with data like this:

Parent.changeset(%Parent{}, %{
  name: "Parent name",
  children: [
    %{name: "child name", key: "child_key"}
  ]
})

But I’d like to make it work with this:

Parent.changeset(%Parent{}, %{
  name: "Parent name",
  children: %{
    child_key: %{name: "child name"}
  }
})

This does not crash, but child_key is lost in the conversion.

What is the best way to accept both format?

I could do something like:


  def changeset(data, attrs) do
    data
    |> cast(prepare_attributes(attrs), [:name])
    |> cast_embed(:children)
  end

But this means the prepare_attributes would need to do a lot of things that ecto does (accepting atom or string keys for attrs, handling missing keys…).

I do think this is the way to go. (Maybe someone else knows of an even better approach?)

In essence, this prepare_attributes needs to be able to only do the following:

  • Read the collection under the key :children or "children"
  • Map a function over this collection to change all {:key, child_map} into %{child_map | name: :key}
  • Write the result back under :children or "children".

This could be done using e.g. update_in. Something along the lines of


def prepare_attributes_for_children(attrs) do
  attrs
  |> Access.update_in([:children], &prepare_child_attributes/1)
 |> Access.update_in(["children"], &prepare_child_attributes/1)
end

def prepare_child_attributes({child_name, child_attributes}) do
  Map.put(child_attributes, :name, child_name)
end

I guess this works. But I was hopeful of some elegant solution involving Ecto magic.

For reference, my final implementation:

  def changeset(data, attrs) do
    attrs = accept_array_or_map_for_embed(attrs, :children)

    data
    |> cast(attrs, [:name])
    |> cast_embed(:children)
  end

  defp accept_array_or_map_for_embed(attrs, key) do
    skey = to_string(key)

    cond do
      Map.has_key?(attrs, key) ->
        update_in(attrs, [key], &put_key_in_child/1)

      Map.has_key?(attrs, skey) ->
        update_in(attrs, [skey], &put_key_in_child/1)

      true ->
        attrs
    end
  end

  defp put_key_in_child(vars) when is_map(vars) do
    vars
    |> Enum.map(fn {key, attrs} ->
      if is_atom(key) do
        Map.put(attrs, :name, key)
      else
        Map.put(attrs, "name", key)
      end
    end)
  end

  defp put_key_in_child(vars), do: vars

A few notes:

  • we do nothing if neither atom or string key is in attrs
  • we ensure that the :name attribute (which key will be mapped to) is in the same format as attrs (atom or string key, as ecto don’t accept mixed keys)
  • here :name is hardcoded but it could be passed along