Last active
July 22, 2024 03:46
-
-
Save AndrewDryga/72f2cdd366265afb4c0528935fa96927 to your computer and use it in GitHub Desktop.
Dynamic embeds with Ecto
This file contains 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 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