defmodule Needle.Changesets do
require Logger
alias Needle.{ULID, Util}
alias Ecto.Association.{BelongsTo, Has, NotLoaded}
alias Ecto.{Changeset, Schema.Metadata}
@doc "Returns the schema object's current state."
def state(thing), do: thing.__meta__.state
@doc "True if the schema object's current state is `:built`"
def built?(%_{__meta__: %{state: :built}}), do: true
def built?(_), do: false
@doc "True if the schema object's current state is `:loaded`"
def loaded?(%_{__meta__: %{state: :loaded}}), do: true
def loaded?(_), do: false
@doc "True if the schema object's current state is `:deleted`"
def deleted?(%_{__meta__: %{state: :deleted}}), do: true
def deleted?(_), do: false
@doc "true if the provided changeset or list of changesets is valid."
def valid?(%Changeset{valid?: v}), do: v
def valid?(cs) when is_list(cs), do: Enum.all?(cs, &valid?/1)
def insert_verb(%_{} = thing),
do: if(built?(thing), do: :insert, else: :update)
# here be dragons
@doc false
def set_state(%_{__meta__: meta} = orig, to),
do: Map.put(orig, :__meta__, set_state(meta, to))
def set_state(%Metadata{state: _} = orig, to), do: Map.put(orig, :state, to)
@doc """
Like `Ecto.Changeset.cast` but for Pointables, Virtuals and Mixins.
If a pointable or virtual, Generates an ID if one is not present.
"""
def cast(changeset, params, cols) do
case changeset do
# for :loaded things, we just cast them as their ids already exist
%{__meta__: %{state: :loaded}} ->
Changeset.cast(changeset, params, cols)
%Changeset{data: %{__meta__: %{state: :loaded}}} ->
Changeset.cast(changeset, params, cols)
# for :built things, we should autogenerate an id if one is not
# present and it is a pointable or virtual type
%Changeset{data: %schema{__meta__: %{state: :built}}} ->
if Util.role(schema) not in [:pointable, :virtual] or
is_binary(get_field(changeset, :id)) do
Changeset.cast(changeset, params, cols)
else
changeset
|> Changeset.cast(params, cols)
|> put_new_id()
end
%schema{__meta__: %{state: :built}} ->
if Util.role(schema) in [:pointable, :virtual] do
changeset
|> Changeset.cast(params, cols)
|> put_new_id()
else
Changeset.cast(changeset, params, cols)
end
end
end
def put_new_id(changeset) do
if is_binary(get_field(changeset, :id)) do
changeset
else
changeset
|> Changeset.put_change(:id, ULID.generate())
end
end
@doc """
Like `Ecto.Changeset.put_assoc/3` but for Pointables, Virtuals and Mixins.
Copies across keys where possible.
"""
def put_assoc!(changeset, assoc_key, rels) do
{schema, changeset} = schema_and_changeset(changeset)
with {:error, e} <- maybe_put_assoc(schema, changeset, assoc_key, rels) do
raise RuntimeError, message: e
end
end
@doc """
Like `put_assoc!/3` but doesn't raise if the association doesn't exist
"""
def put_assoc(changeset, assoc_key, rels) do
{schema, changeset} = schema_and_changeset(changeset)
with {:error, e} <- maybe_put_assoc(schema, changeset, assoc_key, rels) do
Logger.error(e)
changeset
end
# rescue
# e in ArgumentError ->
# IO.warn("Needle.Changeset: Could not put_assoc #{inspect assoc_key}")
# Logger.error(e)
# changeset
end
defp schema_and_changeset(%Changeset{data: %schema{}} = changeset) do
{schema, changeset}
end
defp schema_and_changeset(%{__struct__: schema} = object) do
{schema, Changeset.change(object)}
end
defp schema_and_changeset(schema) when is_atom(schema) do
{schema, Changeset.change(schema)}
end
defp maybe_put_assoc(
schema,
changeset_or_object,
assoc_key,
rels
) do
assoc = schema.__schema__(:association, assoc_key)
case assoc do
%Has{cardinality: :one} ->
put_has_one(changeset_or_object, assoc_key, rels, assoc)
%Has{cardinality: :many} ->
put_has_many(changeset_or_object, assoc_key, rels, assoc)
%BelongsTo{} ->
put_belongs_to(changeset_or_object, assoc_key, rels, assoc)
%Ecto.Association.ManyToMany{} ->
# TODO: special handling like the ones above?
Changeset.put_assoc(changeset_or_object, assoc_key, rels)
_ ->
meta = "#{assoc_key} on %#{schema}{}"
Logger.error("Unrecognised assoc #{meta}: #{inspect(assoc)}")
{:error, "Cannot put unknown association: #{meta}"}
end
end
# put_assoc for a has_one. copies the owner's key across if one is present
defp put_has_one(changeset, assoc_key, rel, assoc) do
case get_field(changeset, assoc.owner_key) do
nil ->
Changeset.put_assoc(changeset, assoc_key, rel)
owner_key ->
rel = Map.put(rel, assoc.related_key, owner_key)
Changeset.put_assoc(changeset, assoc_key, rel)
end
end
# put_assoc for a has_many. copies the owner's key across if one is present
defp put_has_many(changeset, assoc_key, rels, assoc) do
case get_field(changeset, assoc.owner_key) do
nil ->
# Logger.info("put_assoc/put_has_many - assoc has no related key: #{assoc_key}")
Changeset.put_assoc(changeset, assoc_key, rels)
owner_key ->
# Logger.info("put_assoc/put_has_many - assoc related key - #{assoc_key}.#{assoc.related_key}: #{inspect owner_key}")
rels = Enum.map(rels, &Map.put(&1, assoc.related_key, owner_key))
# Logger.info("#{assoc_key}: #{inspect rels}")
Changeset.put_assoc(changeset, assoc_key, rels)
end
end
# put_assoc for a belongs to.
#
# * if the rel does not have the related key and that's the :id column, we generate it.
# * if the rel (now) has the related key, copies it to the parent changeset
defp put_belongs_to(changeset, assoc_key, rel, assoc) do
case Map.get(rel, assoc.related_key) do
nil ->
if Util.role(assoc.related) && assoc.related_key == :id do
# Autogenerate the id for them and copy it back
rel = Map.put(rel, assoc.related_key, ULID.generate())
changeset
|> Changeset.put_assoc(assoc_key, rel)
|> Changeset.put_change(
assoc.owner_key,
Map.get(rel, assoc.related_key)
)
else
# Not much we can do but leave it to ecto
Changeset.put_assoc(changeset, assoc_key, rel)
end
# copy it back
_related_key ->
Changeset.put_assoc(changeset, assoc_key, rel)
end
end
@doc "Like Ecto.build_assoc/3, but can work with a Changeset"
def build_assoc(%Changeset{data: %owner{}} = changeset, assoc_key, rel) do
assoc = owner.__schema__(:association, assoc_key)
case assoc do
%Has{cardinality: :one} ->
case Changeset.apply_action(changeset, :insert) do
{:ok, data} -> Ecto.build_assoc(data, assoc_key, rel)
_ -> nil
end
%Has{cardinality: :many} ->
case Changeset.apply_action(changeset, :insert) do
{:ok, data} -> Enum.map(rel, &Ecto.build_assoc(data, assoc_key, &1))
_ -> nil
end
%BelongsTo{} ->
raise RuntimeError,
message: "Expected `has` association in :#{assoc_key} on %#{owner}{}"
_ ->
raise RuntimeError,
message: "Unknown association :#{assoc_key} on %#{owner}{}"
end
end
def build_assoc(%_{} = schema, assoc_key, rel),
do: Ecto.build_assoc(schema, assoc_key, rel)
# cast_assoc but does the right thing over a put_assoc
def cast_assoc(%Changeset{data: %owner{}} = changeset, assoc_key, opts \\ []) do
assoc = owner.__schema__(:association, assoc_key)
case assoc do
%Has{cardinality: :one} ->
cast_has_one(changeset, assoc_key, assoc, opts)
%Has{cardinality: :many} ->
cast_has_many(changeset, assoc_key, assoc, opts)
%BelongsTo{} ->
cast_belongs_to(changeset, assoc_key, assoc, opts)
%Ecto.Association.ManyToMany{} ->
# TODO: special handling like the ones above?
Changeset.cast_assoc(changeset, assoc_key, opts)
_ ->
raise RuntimeError,
message: "Unknown association :#{assoc_key} on %#{owner}{}"
end
end
def cast_has_one(changeset, assoc_key, _assoc, opts) do
case Changeset.get_change(changeset, assoc_key) do
%Changeset{} ->
# Update the existing changeset
Changeset.update_change(changeset, assoc_key, fn change ->
attrs = Map.get(changeset.params, to_string(assoc_key), %{})
with_ = get_with(changeset, opts)
with_.(change, attrs)
end)
nil ->
changeset
|> Changeset.cast_assoc(assoc_key)
end
end
def cast_has_many(changeset, assoc_key, _assoc, opts) do
Changeset.cast_assoc(changeset, assoc_key, opts)
end
def cast_belongs_to(changeset, assoc_key, _assoc, opts) do
Changeset.cast_assoc(changeset, assoc_key, opts)
end
defp get_with(changeset, opts) do
case Keyword.get(opts, :with) do
nil -> Function.capture(changeset.data.__struct__, :changeset, 2)
other -> other
end
end
@doc false
def assoc_changeset(changeset, key, params, opts \\ [])
def assoc_changeset(%Changeset{data: %schema{} = data}, key, params, opts) do
case schema.__schema__(:association, key) do
%{related: related} ->
ac(insert_verb(data), data, key, related, params, opts)
_ ->
raise ArgumentError,
message: "Invalid relation: #{key} on #{schema}"
end
end
defp ac(:create, %_{}, _, related, params, opts),
do: ac_call(related, [struct(related), params], opts)
defp ac(:update, %_{} = data, key, related, params, opts) do
case Map.get(data, key) do
%NotLoaded{} ->
raise ArgumentError,
message:
"You must preload an assoc before casting to it (or set it to nil or the empty list depending on cardinality)."
%_{} = other ->
ac_call(related, [other, params], opts)
# [other | _] -> acc_call(related, [other, params]) # TODO
[] ->
ac_call(related, [struct(related), params], opts)
nil ->
ac_call(related, [struct(related), params], opts)
end
end
defp ac_call(schema, args, []), do: call(schema, :changeset, args)
defp ac_call(schema, args, opts),
do: call_extra(schema, :changeset, args, [opts])
# @doc "Like `Ecto.Changeset.put_assoc` but copies keys properly"
# def put_assoc(%Changeset{data: %struct{}}=changeset, key, value_or_values) do
# case struct.__schema__(
# end
# def put_assoc(%Changeset{}=cs, key, %Changeset{valid?: true}=mixin),
# do: Changeset.put_assoc(cs, key, mixin)
# def put_assoc(%Changeset{}=cs, _, %Changeset{valid?: false}=mixin),
# do: %{ cs | valid?: false, errors: cs.errors ++ mixin.errors }
# def cast_assoc(%Changeset{}=cs, key, params, opts \\ []),
# do: put_assoc(cs, key, assoc_changeset(cs, key, params, opts))
defp call_extra(module, func, args, extra) when is_list(extra) do
Code.ensure_loaded(module)
size = Enum.count(args)
function_exported?(module, func, size + Enum.count(extra))
|> ce(module, func, args, extra, size)
end
defp ce(true, module, func, args, extra, _),
do: apply(module, func, args ++ extra)
defp ce(false, module, func, args, _, size),
do: c2(function_exported?(module, func, size), module, func, args)
defp call(module, func, args) when is_list(args) do
Code.ensure_loaded(module)
size = Enum.count(args)
c2(function_exported?(module, func, size), module, func, args)
end
defp c2(true, module, func, args), do: apply(module, func, args)
defp c2(false, module, func, args) do
raise ArgumentError,
message: "Function not found: #{module}.#{func}, args: #{inspect(args)}"
end
@doc false
def rewrite_errors(%Changeset{errors: errors} = cs, options, config) do
errs = Keyword.get(options ++ config, :rename_params, [])
%{cs | errors: Util.rename(errors, Util.flip(errs))}
end
@doc false
def rewrite_child_errors(%Changeset{data: %what{}} = cs) do
rewrite_errors(cs, [], config_for(what))
end
@doc false
def rewrite_constraint_errors(%Changeset{} = c) do
changes = Enum.reduce(c.changes, c.changes, &rce_changes/2)
errors = c.errors ++ Enum.flat_map(changes, &rce_errors/1)
{:error, %{c | changes: changes, errors: errors}}
end
defp rce_changes({k, %Changeset{valid?: false} = v}, acc),
do: Map.put(acc, k, rewrite_child_errors(v))
defp rce_changes(_, acc), do: acc
defp rce_errors({_, %Changeset{valid?: false, errors: e}}), do: e
defp rce_errors(_), do: []
def merge_child_errors(%Changeset{} = cs),
do: Enum.reduce(cs.changes, cs, &merge_child_errors/2)
defp merge_child_errors({_k, %Changeset{} = cs}, acc), do: cs.errors ++ acc
defp merge_child_errors(_, acc), do: acc
@doc false
def default_id(changeset) do
case get_field(changeset, :id) do
id when is_binary(id) -> changeset
_ -> Changeset.put_change(changeset, :id, ULID.generate())
end
end
@doc false
def replicate_map_change(changeset, source_key, target_key, xform) do
case Changeset.fetch_change(changeset, source_key) do
{:ok, change} ->
Changeset.put_change(changeset, target_key, xform.(change))
_ ->
changeset
end
end
@doc false
def replicate_map_valid_change(
%Changeset{valid?: true} = changeset,
source_key,
target_key,
xform
) do
case Changeset.fetch_change(changeset, source_key) do
{:ok, change} ->
Changeset.put_change(changeset, target_key, xform.(change))
_ ->
changeset
end
end
def replicate_map_valid_change(changeset, _, _, _), do: changeset
@doc false
def config_for(module) do
otp_app = module.__pointers__(:otp_app)
conf = Application.get_env(otp_app, module, [])
conf
end
def config_for(module, key, default \\ []) do
conf = config_for(module)
val = Keyword.get(conf, key, default)
if is_list(conf) and is_list(val), do: val ++ conf, else: val
end
def update_data(%Changeset{data: data} = changeset, fun),
do: Map.put(changeset, :data, fun.(data))
# def update_change_in(data, path, transform)
# def update_change_in(data, [], transform), do: transform.(data)
# def update_change_in(%Changeset{}=data, [p | path], transform),
# do: Changeset.update_change(data, path, &update_change_in(&1, path, transform))
# def update_change_in(%{}=data, [p | path], transform),
# do: Map.update(data, p, nil, &update_change_in(&1, path, transform))
# def update_change_in(other), do: other
def put_id_on_mixins(attrs, mixin_names, %{id: pointable}) do
do_mixin_attrs(mixin_names, attrs, pointable)
end
def put_id_on_mixins(attrs, mixin_names, pointable) do
do_mixin_attrs(mixin_names, attrs, pointable)
end
defp do_mixin_attrs(mixin_names, attrs, id) when is_list(mixin_names) do
Enum.reduce(mixin_names, attrs, &do_mixin_attrs(&1, &2, id))
end
defp do_mixin_attrs(mixin_name, attrs, id) when is_atom(mixin_name) do
Map.update(attrs, mixin_name, nil, &Map.put(&1, :id, id))
end
def get_field(%Changeset{} = cs, key), do: Changeset.get_field(cs, key)
def get_field(%{} = map, key), do: Map.get(map, key)
def get_field(other, key), do: other[key]
end