Skip to content

Instantly share code, notes, and snippets.

@AndrewDryga
Last active November 6, 2022 11:20
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AndrewDryga/72f2cdd366265afb4c0528935fa96927 to your computer and use it in GitHub Desktop.
Save AndrewDryga/72f2cdd366265afb4c0528935fa96927 to your computer and use it in GitHub Desktop.
Dynamic embeds with Ecto
defmodule DynamicChangeset do
@moduledoc """
This module provides helper functions to extend `Ecto.Changeset` to support
dynamic embeds.
"""
alias Ecto.Changeset
@doc """
Casts the given embed with the embedded changeset and field which is used to define it's type.
If embedded changeset was valid, changes would be put as map in embed field. Otherwise the
parent changeset would be invalidated and embed is put for embed field.
No embedded validation is performed if there was an error on `type_field`
or `attrs_field` has no changes.
No validation is performed if `type_field` is not set either in data or in changes. If it
set in both places - values from changes would be used.
## Options
* `:with` - callback that accepts type and attributes as arguments and returns a changeset
for embedded field. Function signature: `(type, current_attrs, attrs) -> Ecto.Changeset.t()`.
* `:required` - if the embed is a required field, default - `false`. Only applies on
non-list embeds.
"""
def cast_dynamic_embed(changeset, type_field, attrs_field, opts) do
with false <- Keyword.has_key?(changeset.errors, type_field),
{_from, type} <- Changeset.fetch_field(changeset, type_field) do
opts = Keyword.update!(opts, :with, fn on_cast -> &on_cast.(type, &1, &2) end)
cast_schemaless_embed(changeset, attrs_field, opts)
else
true -> changeset
:error -> changeset
end
end
def cast_type_dependent_embed(changeset, type_field, attrs_field, opts) do
with false <- Keyword.has_key?(changeset.errors, type_field),
{_from, type} <- Changeset.fetch_field(changeset, type_field) do
opts = Keyword.update!(opts, :with, fn on_cast -> &on_cast.(type, &1, &2) end)
Changeset.cast_embed(changeset, attrs_field, opts)
else
true -> changeset
:error -> changeset
end
end
@doc """
Casts an embedded schemaless changeset. It can be used where data in map field do not depend
on type but also do not have schema (eg. list of user-provided tracking attributes).
For more information see `cast_dynamic_embed/4`.
"""
def cast_schemaless_embed(changeset, attrs_field, opts) do
on_cast = Keyword.fetch!(opts, :with)
case Map.get(changeset.types, attrs_field) do
{:array, :map} ->
cast_embeds(changeset, attrs_field, on_cast)
# TODO: set to :map and make sure tests do not fail
_other ->
required? = Keyword.get(opts, :required, false)
cast_embed(changeset, attrs_field, on_cast, required?)
end
end
defp cast_embed(changeset, attrs_field, on_cast, required?) when is_function(on_cast, 2) do
with {:ok, attrs} <- Changeset.fetch_change(changeset, attrs_field),
# TODO: we can have a better way to define type information and load schema automatically
current_attrs = Map.get(changeset.data, attrs_field) || %{},
%Changeset{} = attrs_changeset = on_cast.(current_attrs, attrs),
false <- not has_changes?(attrs_changeset) and current_attrs != %{},
{:ok, valid_attrs} <- dump_embed_data(attrs_changeset) do
changeset = Changeset.put_change(changeset, attrs_field, valid_attrs)
%{changeset | constraints: changeset.constraints ++ attrs_changeset.constraints}
else
:error ->
if required? do
Changeset.add_error(changeset, attrs_field, "can't be blank", validation: :required)
else
changeset
end
true ->
Changeset.delete_change(changeset, attrs_field)
{:error, %{valid?: false} = attrs_changeset} ->
put_embedded_error(changeset, attrs_field, attrs_changeset)
end
end
defp has_changes?(%{valid?: true, changes: changes}) when changes == %{}, do: false
defp has_changes?(_changeset), do: true
defp cast_embeds(changeset, attrs_field, on_cast) do
with {:ok, attrs_list} when is_list(attrs_list) <-
Changeset.fetch_change(changeset, attrs_field) do
changeset = %{changeset | changes: Map.put(changeset.changes, attrs_field, [])}
changeset =
Enum.reduce(attrs_list, changeset, fn attrs, changeset ->
%Changeset{} = attrs_changeset = on_cast.(%{}, attrs)
if attrs_changeset.valid? do
Changeset.update_change(changeset, attrs_field, &(&1 ++ [attrs_changeset]))
else
put_embedded_error(changeset, attrs_field, attrs_changeset)
end
end)
embedded_changesets = Map.get(changeset.changes, attrs_field, [])
embedded_changesets_valid? = Enum.all?(embedded_changesets, & &1.valid?)
embedded_changesets_or_attrs =
if embedded_changesets_valid? do
Enum.map(embedded_changesets, &apply_embed_changes/1)
else
embedded_changesets
end
%{
changeset
| changes: Map.put(changeset.changes, attrs_field, embedded_changesets_or_attrs)
}
else
:error ->
changeset
{:ok, _not_a_list} ->
Changeset.add_error(changeset, attrs_field, "is invalid", validation: :cast)
end
end
def dump_embed_data(%Changeset{valid?: false} = changeset) do
{:error, changeset}
end
def dump_embed_data(%Changeset{} = changeset) do
{:ok, apply_embed_changes(changeset)}
end
def apply_embed_changes(%Changeset{changes: changes, data: data}) when changes == %{} do
dump_dynamic_embed(data)
end
def apply_embed_changes(%Changeset{changes: changes, data: data, types: types}) do
Enum.reduce(changes, dump_dynamic_embed(data), fn {key, value}, acc ->
case Map.fetch(types, key) do
{:ok, {:embed, relation}} ->
Map.put(acc, to_string(key), apply_relation_changes(relation, value))
{:ok, _type} ->
Map.put(acc, to_string(key), value)
:error ->
acc
end
end)
end
def apply_relation_changes(%{cardinality: :one}, nil) do
nil
end
def apply_relation_changes(%{cardinality: :one}, changeset) do
apply_embed_changes(changeset)
end
def apply_relation_changes(%{cardinality: :many, on_replace: on_replace}, changesets) do
for changeset <- changesets,
not relation_deleted?(changeset, on_replace),
struct = apply_embed_changes(changeset),
do: struct
end
defp relation_deleted?(%{action: :delete}, _on_replace), do: true
defp relation_deleted?(%{action: :replace}, :delete), do: true
defp relation_deleted?(%{}, _on_replace), do: false
defp dump_dynamic_embed(%{__struct__: _struct} = schema) do
Ecto.embedded_dump(schema, :json)
|> Enum.reduce(%{}, fn {key, value}, acc -> Map.put(acc, to_string(key), value) end)
end
defp dump_dynamic_embed(%{} = map) do
map
|> Map.drop([:__entries_type__])
|> Enum.reduce(%{}, fn
{key, value}, acc when is_map(value) ->
Map.put(acc, to_string(key), dump_dynamic_embed(value))
{key, value}, acc when is_list(value) ->
Map.put(acc, to_string(key), Enum.map(value, &dump_dynamic_embed/1))
{key, value}, acc ->
Map.put(acc, to_string(key), value)
end)
end
defp dump_dynamic_embed(other) do
other
end
# Make changeset invalid and put embedded changeset with proper type information in it.
#
# This is to make sure that `traverse_errors/2` would properly render embedded changeset
# errors.
defp put_embedded_error(changeset, embed_field, embedded_changeset) do
case Map.get(changeset.types, embed_field) do
{:embed, %{cardinality: :many}} ->
changes = Map.get(changeset.changes, embed_field, [])
changes = changes ++ [embedded_changeset]
%{
changeset
| changes: Map.put(changeset.changes, embed_field, changes),
valid?: false
}
{:array, :map} ->
embedded_type =
{:embed,
%Ecto.Embedded{
cardinality: :many,
field: embed_field,
on_cast: nil,
on_replace: :delete,
owner: %{},
related: Map.get(changeset.data, :__struct__),
unique: false
}}
changes = Map.get(changeset.changes, embed_field, [])
changes = changes ++ [embedded_changeset]
%{
changeset
| changes: Map.put(changeset.changes, embed_field, changes),
types: Map.put(changeset.types, embed_field, embedded_type),
valid?: false
}
_other ->
embedded_type =
{:embed,
%Ecto.Embedded{
cardinality: :one,
field: embed_field,
on_cast: nil,
on_replace: :update,
owner: %{},
related: Map.get(changeset.data, :__struct__),
unique: true
}}
%{
changeset
| changes: Map.put(changeset.changes, embed_field, embedded_changeset),
types: Map.put(changeset.types, embed_field, embedded_type),
valid?: false
}
end
end
@doc """
This functions allows to traverse changes in an embedded changeset via callback function, which
accept field name of a change, previous value from changeset data and changed value from
changeset changes.
"""
def traverse_embedded_changes(changeset, field, func) when is_function(func, 3) do
with {:ok, embedded_changes} <- Changeset.fetch_change(changeset, field) do
Enum.reduce(embedded_changes, [], fn {key, value}, acc ->
previous_value = changeset.data |> Map.fetch!(field) |> Map.get(key)
if value == previous_value do
acc
else
acc ++ [func.(key, previous_value, value)]
end
end)
else
:error -> changeset
end
end
def load_dynamic_embed(schema, data) do
if struct = load_data_to_struct(schema, data) do
Enum.reduce(schema.__schema__(:embeds), struct, fn embed, struct ->
data = data || %{}
embed_data = Map.get(data, embed) || Map.get(data, to_string(embed))
case schema.__schema__(:embed, embed) do
%{related: embed_schema, cardinality: :one} ->
Map.put(struct, embed, load_dynamic_embed(embed_schema, embed_data))
%{related: embed_schema, cardinality: :many} ->
if not is_nil(embed_data) do
Map.put(struct, embed, Enum.map(embed_data, &load_dynamic_embed(embed_schema, &1)))
else
Map.put(struct, embed, [])
end
end
end)
end
end
defp load_data_to_struct(_schema, nil), do: nil
defp load_data_to_struct(schema, data), do: Ecto.embedded_load(schema, data, :json)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment