Skip to content

Instantly share code, notes, and snippets.

@thiagomajesk
Last active June 25, 2025 01:17
Show Gist options
  • Save thiagomajesk/559cad1cdad2bb508ca73c55b45648eb to your computer and use it in GitHub Desktop.
Save thiagomajesk/559cad1cdad2bb508ca73c55b45648eb to your computer and use it in GitHub Desktop.
An Ecto type that allows handling polymorphic embeddings
defmodule Dynamic do
@moduledoc """
A dynamic Ecto type that allows handling differently-shaped maps using structured data.
This module relies on embedded schemas to cast and validate data based on the provided mappings.
## Example
# Will load values from [%{type: "foo", ...}, %{type: "bar", ...}]
field :custom, Dynamic, mappings: [foo: Foo, bar: Bar]
# Will load values from [%{kind: "foo", ...}, %{kind: "bar", ...}]
field :custom, Dynamic, mappings: [foo: Foo, bar: Bar], key: :kind
"""
use Ecto.ParameterizedType
@impl true
def type(_), do: :map
@impl true
def init(opts) do
key = Keyword.get(opts, :key, :type)
mappings = Keyword.fetch!(opts, :mappings)
%{mappings: Enum.into(mappings, %{}), key: to_string(key)}
end
@impl true
def cast(nil, _), do: {:ok, nil}
def cast(data, %{mappings: mappings} = opts) when is_struct(data) do
with {:ok, data} <- infer_type(data, mappings), do: cast(data, opts)
end
def cast(data, opts) when is_map(data) do
%{key: key, mappings: mappings} = opts
case Map.new(data, &cast_key/1) do
%{^key => key} -> cast_embed(data, key, mappings)
other -> {:error, message: "Missing type on: \n #{inspect(other)}"}
end
end
@impl true
def load(nil, _, _), do: {:ok, nil}
def load(data, _loader, opts) when is_map(data) do
%{key: key, mappings: mappings} = opts
case Map.new(data, &cast_key/1) do
%{^key => key} -> load_embed(mappings, key, data)
other -> {:error, message: "Missing key #{key} on: \n #{inspect(other)}"}
end
end
def load(_, _, _), do: :error
@impl true
def dump(nil, _, _), do: {:ok, nil}
def dump(data, dumper, %{mappings: mappings} = opts) when is_struct(data) do
with {:ok, data} <- infer_type(data, mappings), do: dump(data, dumper, opts)
end
def dump(data, _dumper, %{key: key}) when is_map(data) do
case Map.new(data, &cast_key/1) do
%{^key => _key} = data -> {:ok, data}
other -> {:error, message: "Missing key #{key} on: \n #{inspect(other)}"}
end
end
def dump(_, _, _), do: :error
@impl true
def embed_as(_, _), do: :dump
defp infer_type(data, mappings) do
default = {:error, message: "Could not infer mapping for: #{inspect(data)}"}
Enum.find_value(mappings, default, fn {key, module} ->
if module == data.__struct__,
do: {:ok, Map.put(Map.from_struct(data), key, key)}
end)
end
defp cast_key({key, value}) when is_atom(key), do: {Atom.to_string(key), value}
defp cast_key({key, value}) when is_binary(key), do: {key, value}
defp cast_embed(data, key, types) do
case Map.fetch(types, String.to_existing_atom(key)) do
{:ok, module} -> to_embed(module, data)
:error -> {:error, message: "Unknown mapping: #{key}"}
end
end
defp to_embed(module, data) do
changeset = module.changeset(struct!(module), data)
case Ecto.Changeset.apply_action(changeset, :validate) do
{:ok, embed} -> {:ok, embed}
{:error, changeset} -> {:error, message: collect_errors(changeset)}
end
end
defp load_embed(mappings, key, data) do
case Map.fetch(mappings, String.to_existing_atom(key)) do
{:ok, module} -> {:ok, Ecto.embedded_load(module, data, :json)}
:error -> {:error, message: "Unknown mapping: #{key}"}
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment