Last active
June 25, 2025 01:17
-
-
Save thiagomajesk/559cad1cdad2bb508ca73c55b45648eb to your computer and use it in GitHub Desktop.
An Ecto type that allows handling polymorphic embeddings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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