Ecto - embeds_many as single map with unique keys

I have a Parent model, which embeds_many() children models. Children are then stored in a jsonb column as a List (Array in JSON) of simple, two-key maps (Objects), where values of one of the keys of those maps need to be unique. Something like:

[
	{"name":"name0", "amount":"10.00"},
	{"name":"name1", "amount":"12.20"},
	{"name":"name2", "amount":"10.00"},
	[…]
	{"name":"nameN", "amount":"56.00"}
]

Value of name has to be unique. I handled the uniqueness on create/update and this works. But then I continuously spend cycles all around the application converting the retrieved list into a single map like:

%{
	"name0" => "10.00",
	"name1" => "12.20",
	"name2" => "10.00",
	[…]
	"nameN" => "56.00"
}

Any suggestions for a “proper” way to get the data stored as a single map (JSON object), while retaining the possibility to use Phoenix form helpers / inputs_for, validations and other goodies?

Custom Ecto type? Or? Some examples, maybe?

This is full of caveats:

  • inputs_for only works for related assocs or embeds, which are one or a list of related records. There’s nothing like a map for related records.
  • The :map or {:map, value_type} native types of ecto are not supported by phoenix form handling natively. You can make things work by manually creating input names and assigning values to inputs, but it’s not covered ootb. Also using an ecto type over a relationship means you forego a bunch of their features like e.g. per item errors, sort/drop support, likely a few more…
  • How do you even model a “map” using form inputs in an abstract manner in the first place. There’s surely many specific examples of how to do that, but I’m not sure there’s a generic version phoenix could implement.

So really I’d treat the data like you do and let the goal of it becoming a map be an implementation detail. You could consider separating the “write model” from the “read model” here and let the read model read the data from the db in the correct format / into a :map field.

1 Like

Thank you, Benjamin. I spent good part of the day trying to figure something reasonable out and I ran into basically all the caveats you listed with no apparent solution. At least none that would not make things even more smelly than they are now.

One thing made me curious though. Since I have the write part working, and the main problem is with the fact that I read the data in the form it is stored (i. e. as List of Maps rather than a Map), then – maybe – the separation of models you mentioned is worth checking.

How does one do this? Any examples or links to documentation?

Something like this:

defmodule MapFromArray do
  use Ecto.Type

  def type, do: {:array, :map}

  def load(data) do
    with {:ok, list} <- Ecto.Type.load(type(), data) do
      {:ok, Map.new(list, &{&1["name"], &1["amount"]})}
    end
  end

  def embed_as(_), do: :dump

  […]
end

defmodule ReadParent do
  use Ecto.Schema

  schema "parents" do
    field :children, MapFromArray
  end
end
  
  
Repo.insert_all("parents", [
  %{children: [%{name: "abc", amount: 2}, %{name: "def", amount: 5}]}
])

[%{children: %{"abc" => 2, "def" => 5}}] = Repo.all(ReadParent)

Could even consider Ecto.ParameterizedType if you don’t like hardcoding key/value keys.

1 Like