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