defmodule Versioned.Helpers do
@moduledoc "Tools shared between modules. (For internal use.)"
alias Ecto.{Changeset, Schema}
@doc "Wrap a line of AST in a block if it isn't already wrapped."
@spec normalize_block(Macro.t()) :: Macro.t()
def normalize_block({x, m, _} = line) when x != :__block__,
do: {:__block__, m, [line]}
def normalize_block(block), do: block
@doc """
Create a `version_mod` struct to insert from a new instance of the record.
## Options
* `:deleted` - If `true`, records will marked as deleted.
* `:change` - For updates, an `t:Ecto.Changeset.t/0` should be provided to
inform which records were inserted vs updated vs deleted.
"""
@spec build_version(Schema.t(), keyword) :: Changeset.t() | nil
def build_version(%mod{} = struct, opts) do
with %{} = params <- build_params(struct, opts) do
mod
|> Versioned.version_mod()
|> struct()
|> Changeset.change(params)
end
end
@doc """
Recursively crawl changeset and compile a list of version structs with
is_deleted set to true.
"""
@spec deleted_versions(Changeset.t(), keyword) :: [Ecto.Schema.t()]
def deleted_versions(%{action: action, data: %mod{}} = changeset, opts) do
deletes =
if action == :replace do
changeset
|> Changeset.apply_changes()
|> maybe_build_version_params(Keyword.put(opts, :deleted, true))
|> case do
nil -> []
params -> [struct(Versioned.version_mod(mod), params)]
end
else
[]
end
Enum.reduce(mod.__schema__(:associations), deletes, fn assoc, acc ->
%{cardinality: cardinality} = mod.__schema__(:association, assoc)
change = Changeset.get_change(changeset, assoc)
case {cardinality, change} do
{_, nil} -> acc
{:one, change} -> acc ++ deleted_versions(change, opts)
{:many, changes} -> acc ++ Enum.flat_map(changes, &deleted_versions(&1, opts))
end
end)
end
@spec build_params(Schema.t(), keyword) :: map | nil
defp build_params(%mod{} = struct, opts) do
with %{} = params <- maybe_build_version_params(struct, opts) do
Enum.reduce(mod.__schema__(:associations), params, fn assoc_name, acc ->
:association |> mod.__schema__(assoc_name) |> do_build_params(struct, opts, acc)
end)
end
end
@spec do_build_params(Ecto.Association.t(), Schema.t(), keyword, map) :: map
defp do_build_params(
%{cardinality: cardinality, field: field, owner: owner, queryable: queryable} =
assoc_info,
struct,
opts,
acc
) do
child = Map.get(struct, field)
v? = Versioned.versioned?(queryable)
finish = fn
_, params, false -> Map.put(acc, field, params)
ver_key, params, true -> Map.put(acc, ver_key, params)
end
case {cardinality, build_assoc_params(assoc_info, child, opts)} do
{_, nil} -> acc
{:one, params} -> finish.(:"#{field}_version", params, v?)
{:many, list} -> finish.(owner.__versioned__(:has_many_field, field), list, v?)
end
end
defp do_build_params(_, _, _, acc), do: acc
# If the struct is versioned, build parameters for the corresponding version
# record to insert. nil otherwise.
@spec maybe_build_version_params(Schema.t(), keyword) :: map | nil
defp maybe_build_version_params(%mod{} = struct, opts) do
change = opts[:change]
if Versioned.versioned?(mod) and (not is_map(change) or 0 < map_size(change.changes)) do
:fields
|> mod.__schema__()
|> Enum.reject(&(&1 in [:id, :inserted_at, :updated_at]))
|> Enum.filter(&(&1 in Versioned.version_mod(mod).__schema__(:fields)))
|> Map.new(&{&1, Map.get(struct, &1)})
|> Map.put(:"#{mod.__versioned__(:source_singular)}_id", struct.id)
|> Map.put(:is_deleted, Keyword.get(opts, :deleted, false))
|> Map.put(:inserted_at, opts[:inserted_at])
else
nil
end
end
# Build parameters for an association if data is present.
@spec build_assoc_params(Ecto.Association.t(), Schema.t() | [Schema.t()], keyword) :: list | nil
defp build_assoc_params(_, %Ecto.Association.NotLoaded{}, _) do
nil
end
defp build_assoc_params(%{cardinality: :one, field: field}, data, opts) do
change =
with %{} = cs <- opts[:change] do
Changeset.get_change(cs, field)
end
build_params(data, Keyword.put(opts, :change, change))
end
defp build_assoc_params(%{cardinality: :many, field: field}, data, opts)
when is_list(data) do
{inserted_params_list, change_fn} =
with %{} = change <- opts[:change],
cs_list when is_list(cs_list) <- Changeset.get_change(change, field) do
change_fn = fn record ->
Enum.find(cs_list, &(Changeset.get_field(&1, :id) == record.id))
end
inserted_css = Enum.filter(cs_list, &(&1.action == :insert))
inserted_params_list =
inserted_css |> Enum.map(&build_params(&1, opts)) |> Enum.filter(& &1)
{inserted_params_list, change_fn}
else
true_or_nil -> {[], fn _ -> true_or_nil end}
end
Enum.reduce(data, inserted_params_list, fn record, acc ->
case build_params(record, Keyword.put(opts, :change, change_fn.(record))) do
nil -> acc
params -> [params | acc]
end
end)
end
end