defmodule Ash.EmbeddableType do
@moduledoc false
@embedded_resource_array_constraints [
sort: [
type: :any,
doc: """
A sort to be applied when casting the data.
Only relevant for a type of {:array, `EmbeddedResource}`
The sort is not applied when reading the data, so if the sort changes you will
need to fix it in your database or wait for the data to be written again, at which
point it will be sorted when casting.
"""
],
load: [
type: {:list, :atom},
doc: """
A list of calculations to load on the resource.
Only relevant for a type of {:array, `EmbeddedResource}`
Aggregates are not supported on embedded resources.
"""
],
create_action: [
type: :atom,
doc:
"The action to use on the resource when creating an embed. The primary is used by default."
],
update_action: [
type: :atom,
doc:
"The action to use on the resource when updating an embed. The primary is used by default."
],
destroy_action: [
type: :atom,
doc:
"The action to use on the resource when destroying an embed. The primary is used by default."
],
read_action: [
type: :atom,
doc: "The read action to use when reading the embed. The primary is used by default."
],
__source__: [
type: :any,
hide: true,
doc:
"This is hidden in the documentation, but this is used to add the `__source__` context to the changeset."
]
]
defmodule ShadowApi do
@moduledoc false
use Ash.Api
resources do
allow_unregistered? true
end
execution do
timeout :infinity
end
end
@doc false
def embedded_resource_array_constraints, do: @embedded_resource_array_constraints
@doc false
def handle_errors(errors) do
errors
|> do_handle_errors()
|> List.wrap()
|> Ash.Error.flatten_preserving_keywords()
end
defp do_handle_errors(errors) when is_list(errors) do
if Keyword.keyword?(errors) do
main_fields = Keyword.take(errors, [:message, :field, :fields, :path])
vars = Keyword.merge(main_fields, Keyword.get(errors, :vars, []))
main_fields
|> Keyword.put(:vars, vars)
|> Enum.into(%{})
|> do_handle_errors()
else
Enum.map(errors, &do_handle_errors/1)
end
end
defp do_handle_errors(%{errors: errors}) do
errors
|> List.wrap()
|> do_handle_errors()
end
defp do_handle_errors(%Ash.Error.Changes.InvalidAttribute{
message: message,
field: field,
vars: vars
}) do
vars
|> Keyword.put(:field, field)
|> Keyword.put(:message, message)
|> add_index()
end
defp do_handle_errors(%{message: message, vars: vars, field: field}) do
vars
|> Keyword.put(:message, message)
|> Keyword.put(:field, field)
|> add_index()
end
defp do_handle_errors(%{message: message, vars: vars}) do
vars
|> Keyword.put(:message, message)
|> add_index()
end
defp do_handle_errors(%{field: field} = exception) do
[field: field, message: Exception.message(exception)]
end
defp do_handle_errors(error) when is_binary(error) do
[message: error]
end
defp do_handle_errors(error) when is_exception(error) do
[message: Exception.message(error)]
end
defp do_handle_errors(_error) do
[message: "Something went wrong"]
end
defp add_index(opts) do
opts
# cond do
# opts[:index] && opts[:field] ->
# Keyword.put(opts, :field, "#{opts[:field]}[#{opts[:index]}]")
# opts[:index] ->
# Keyword.put(opts, :field, "[#{opts[:index]}]")
# true ->
# opts
# end
end
defmacro single_embed_implementation do
# credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks
quote location: :keep do
alias Ash.EmbeddableType.ShadowApi
def storage_type, do: :map
def cast_input(%{__struct__: __MODULE__} = input, _constraints), do: {:ok, input}
def cast_input(value, constraints) when is_map(value) do
action =
constraints[:create_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :create).name
__MODULE__
|> Ash.Changeset.new()
|> Ash.Changeset.set_context(%{__source__: constraints[:__source__]})
|> Ash.Changeset.for_create(action, value)
|> ShadowApi.create()
|> case do
{:ok, result} ->
{:ok, result}
{:error, error} ->
{:error, Ash.EmbeddableType.handle_errors(error)}
end
end
def cast_input(_, _), do: :error
def cast_stored(value, constraints) when is_map(value) do
__MODULE__
|> Ash.Resource.Info.attributes()
|> Enum.reduce_while({:ok, struct(__MODULE__)}, fn attr, {:ok, struct} ->
with {:fetch, {:ok, value}} <- {:fetch, fetch_key(value, attr.name)},
{:ok, casted} <-
Ash.Type.cast_stored(attr.type, value, attr.constraints) do
{:cont, {:ok, Map.put(struct, attr.name, casted)}}
else
{:fetch, :error} ->
{:cont, {:ok, struct}}
other ->
{:halt, other}
end
end)
|> case do
{:ok, casted} ->
case constraints[:load] do
empty when empty in [nil, []] ->
{:ok, casted}
load ->
action =
constraints[:read_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :read).name
__MODULE__
|> Ash.DataLayer.Simple.set_data([casted])
|> Ash.Query.load(load)
|> Ash.Query.for_read(action)
|> ShadowApi.read()
|> case do
{:ok, [casted]} ->
{:ok, casted}
{:error, errors} ->
{:error, Ash.EmbeddableType.handle_errors(errors)}
end
end
other ->
other
end
end
def cast_stored(nil, _), do: {:ok, nil}
def cast_stored(_other, _) do
:error
end
def fetch_key(map, atom) do
case Map.fetch(map, atom) do
{:ok, value} ->
{:ok, value}
:error ->
Map.fetch(map, to_string(atom))
end
end
def dump_to_native(value, _) when is_map(value) do
attributes = Ash.Resource.Info.attributes(__MODULE__)
calculations = Ash.Resource.Info.calculations(__MODULE__)
Enum.reduce_while(attributes ++ calculations, {:ok, %{}}, fn attribute, {:ok, acc} ->
case Map.fetch(value, attribute.name) do
:error ->
{:cont, {:ok, acc}}
{:ok, value} ->
case Ash.Type.dump_to_embedded(
attribute.type,
value,
Map.get(attribute, :constraints) || []
) do
:error ->
{:halt, :error}
{:ok, dumped} ->
{:cont, {:ok, Map.put(acc, attribute.name, dumped)}}
end
end
end)
end
def dump_to_native(nil, _), do: {:ok, nil}
def dump_to_native(_, _), do: :error
def constraints,
do:
Keyword.take(array_constraints(), [
:load,
:create_action,
:destroy_action,
:update_action,
:__source__
])
def apply_constraints(nil, _), do: {:ok, nil}
def apply_constraints(term, constraints) do
ShadowApi.load(term, constraints[:load] || [])
end
def handle_change(nil, new_value, _constraints) do
{:ok, new_value}
end
def handle_change(old_value, nil, constraints) do
action =
constraints[:destroy_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :destroy).name
case ShadowApi.destroy(old_value, action: action) do
:ok -> {:ok, nil}
{:error, error} -> {:error, Ash.EmbeddableType.handle_errors(error)}
end
end
def handle_change(old_value, new_value, constraints) do
pkey_fields = Ash.Resource.Info.primary_key(__MODULE__)
if Enum.all?(pkey_fields, fn pkey_field ->
Ash.Resource.Info.attribute(__MODULE__, pkey_field).private?
end) do
{:ok, new_value}
else
pkey = Map.take(old_value, pkey_fields)
if Map.take(new_value, pkey_fields) == pkey do
{:ok, new_value}
else
action =
constraints[:destroy_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :destroy).name
case ShadowApi.destroy(old_value, action: action) do
:ok ->
{:ok, new_value}
{:error, error} ->
{:error, Ash.EmbeddableType.handle_errors(error)}
end
end
end
end
def prepare_change(old_value, "", constraints) do
prepare_change(old_value, nil, constraints)
end
def prepare_change(_old_value, nil, _constraints) do
{:ok, nil}
end
def prepare_change(_old_value, %{__struct__: __MODULE__} = new_value, _constraints) do
{:ok, new_value}
end
def prepare_change(nil, new_value, _constraints) do
{:ok, new_value}
end
def prepare_change(old_value, new_uncasted_value, constraints) do
pkey_fields = Ash.Resource.Info.primary_key(__MODULE__)
if Enum.all?(pkey_fields, fn pkey_field ->
Ash.Resource.Info.attribute(__MODULE__, pkey_field).private?
end) do
action =
constraints[:update_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :update).name
old_value
|> Ash.Changeset.new()
|> Ash.Changeset.set_context(%{__source__: constraints[:__source__]})
|> Ash.Changeset.for_update(action, new_uncasted_value)
|> ShadowApi.update()
|> case do
{:ok, value} -> {:ok, value}
{:error, error} -> {:error, Ash.EmbeddableType.handle_errors(error)}
end
else
pkey =
Enum.into(pkey_fields, %{}, fn pkey_field ->
case fetch_key(new_uncasted_value, pkey_field) do
:error ->
{pkey_field, :error}
{:ok, value} ->
attribute = Ash.Resource.Info.attribute(__MODULE__, pkey_field)
case Ash.Type.cast_input(attribute.type, value, attribute.constraints) do
{:ok, casted} ->
{pkey_field, casted}
_ ->
{pkey_field, :error}
end
end
end)
if Enum.any?(Map.values(pkey), &(&1 == :error)) do
{:ok, new_uncasted_value}
else
old_pkey = Map.take(old_value, pkey_fields)
if old_pkey == pkey do
action =
constraints[:update_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :update).name
old_value
|> Ash.Changeset.new()
|> Ash.Changeset.set_context(%{__source__: constraints[:__source__]})
|> Ash.Changeset.for_update(action, new_uncasted_value)
|> ShadowApi.update()
|> case do
{:ok, value} -> {:ok, value}
{:error, error} -> {:error, Ash.EmbeddableType.handle_errors(error)}
end
else
{:ok, new_uncasted_value}
end
end
end
end
end
end
defmacro array_embed_implementation do
# credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks
quote location: :keep do
alias Ash.EmbeddableType.ShadowApi
def array_constraints, do: Ash.EmbeddableType.embedded_resource_array_constraints()
def apply_constraints_array([], _constraints), do: {:ok, []}
def apply_constraints_array(term, constraints) do
pkey = Ash.Resource.Info.primary_key(__MODULE__)
unique_keys = Enum.map(Ash.Resource.Info.identities(__MODULE__), & &1.keys) ++ [pkey]
case find_duplicates(term, unique_keys) do
nil ->
query =
__MODULE__
|> Ash.DataLayer.Simple.set_data(term)
|> Ash.Query.load(constraints[:load] || [])
query =
if constraints[:sort] do
Ash.Query.sort(query, constraints[:sort])
else
query
end
ShadowApi.read(query)
keys ->
{:error, message: "items must be unique on keys %{keys}", keys: Enum.join(keys, ",")}
end
end
defp find_duplicates(term, unique_keys) do
Enum.find(unique_keys, fn unique_key ->
has_duplicates?(term, __MODULE__, fn item ->
Enum.reduce(unique_key, %{}, fn key, acc ->
if Map.has_key?(item, key) || Map.has_key?(item, to_string(key)) do
attribute = Ash.Resource.Info.attribute(__MODULE__, key)
case Ash.Type.cast_input(
attribute.type,
Map.get(item, key) || Map.get(item, to_string(key)),
attribute.constraints
) do
{:ok, value} ->
Map.put(acc, key, value)
_ ->
acc
end
end
end)
end)
end)
end
defp has_duplicates?(list, resource, func) do
list
|> Enum.reduce_while(MapSet.new(), fn x, acc ->
x = func.(x)
acc
|> Enum.any?(fn item ->
Enum.all?(x, fn {key, value} ->
attr = Ash.Resource.Info.attribute(resource, key)
Enum.any?(acc, fn other ->
Ash.Type.equal?(attr.type, Map.get(other, key), value)
end)
end)
end)
|> case do
true ->
{:halt, 0}
false ->
{:cont, MapSet.put(acc, x)}
end
end)
|> is_integer()
end
def handle_change_array(nil, new_values, constraints) do
handle_change_array([], new_values, constraints)
end
def handle_change_array(old_values, nil, constraints) do
handle_change_array(old_values, [], constraints)
end
def handle_change_array(old_values, new_values, constraints) do
pkey_fields = Ash.Resource.Info.primary_key(__MODULE__)
destroy_action =
constraints[:destroy_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :destroy).name
old_values
|> Enum.with_index()
|> Enum.reject(fn {old_value, _} ->
pkey = Map.take(old_value, pkey_fields)
Enum.any?(new_values, fn new_value ->
Map.take(new_value, pkey_fields) == pkey
end)
end)
|> Enum.reduce_while(:ok, fn {record, index}, :ok ->
case ShadowApi.destroy(record, action: destroy_action) do
:ok ->
{:cont, :ok}
{:error, error} ->
errors =
error
|> Ash.EmbeddableType.handle_errors()
|> Enum.map(fn keyword ->
Keyword.put(keyword, :index, index)
end)
{:halt, {:error, errors}}
end
end)
|> case do
:ok ->
{:ok, new_values}
{:error, error} ->
{:error, error}
end
end
def prepare_change_array(old_values, new_uncasted_values, constraints) do
pkey_fields = Ash.Resource.Info.primary_key(__MODULE__)
if Enum.all?(pkey_fields, fn pkey_field ->
Ash.Resource.Info.attribute(__MODULE__, pkey_field).private?
end) do
{:ok, new_uncasted_values}
else
pkey_attributes =
Enum.into(pkey_fields, %{}, fn field ->
{field, Ash.Resource.Info.attribute(__MODULE__, field)}
end)
action =
constraints[:update_action] ||
Ash.Resource.Info.primary_action!(__MODULE__, :update).name
new_uncasted_values
|> Enum.with_index()
|> Enum.reduce_while(
{:ok, []},
fn
{new, _index}, {:ok, new_uncasted_values} when is_struct(new, __MODULE__) ->
{:cont, {:ok, [new | new_uncasted_values]}}
{new, index}, {:ok, new_uncasted_values} ->
pkey =
Enum.into(pkey_fields, %{}, fn pkey_field ->
case fetch_key(new, pkey_field) do
:error ->
{pkey_field, :error}
{:ok, value} ->
attr = Map.get(pkey_attributes, pkey_field)
case Ash.Type.cast_input(attr.type, value, attr.constraints) do
{:ok, casted} ->
{pkey_field, casted}
_ ->
{pkey_field, :error}
end
end
end)
if Enum.any?(Map.values(pkey), &(&1 == :error)) do
{:cont, {:ok, [new | new_uncasted_values]}}
else
value_updating_from =
Enum.find(old_values, fn old_value ->
Map.take(old_value, pkey_fields) == pkey
end)
if value_updating_from do
value_updating_from
|> Ash.Changeset.new()
|> Ash.Changeset.set_context(%{__source__: constraints[:__source__]})
|> Ash.Changeset.for_update(action, new)
|> ShadowApi.update()
|> case do
{:ok, value} ->
{:cont, {:ok, [value | new_uncasted_values]}}
{:error, error} ->
errors =
error
|> Ash.EmbeddableType.handle_errors()
|> Enum.map(fn keyword ->
Keyword.put(keyword, :index, index)
end)
{:halt, {:error, errors}}
end
else
{:cont, {:ok, [new | new_uncasted_values]}}
end
end
end
)
|> case do
{:ok, values} -> {:ok, Enum.reverse(values)}
{:error, error} -> {:error, error}
end
end
end
end
end
defmacro define_embeddable_type do
quote location: :keep do
use Ash.Type, embedded?: true
Ash.EmbeddableType.single_embed_implementation()
Ash.EmbeddableType.array_embed_implementation()
end
end
end