Packages

A toolkit for data mapping and language integrated query for Elixir

Current section

Files

Jump to
ecto_tc lib ecto changeset relation.ex
Raw

lib/ecto/changeset/relation.ex

defmodule Ecto.Changeset.Relation do
@moduledoc false
require Logger
alias Ecto.Changeset
alias Ecto.Association.NotLoaded
@type t :: %{required(:__struct__) => atom(),
required(:cardinality) => :one | :many,
required(:on_replace) => :raise | :mark_as_invalid | atom,
required(:relationship) => :parent | :child,
required(:ordered) => boolean,
required(:owner) => atom,
required(:related) => atom,
required(:field) => atom,
optional(atom()) => any()}
@doc """
Builds the related data.
"""
@callback build(t, owner :: Ecto.Schema.t) :: Ecto.Schema.t
@doc """
Returns empty container for relation.
"""
def empty(%{cardinality: cardinality}), do: cardinality_to_empty(cardinality)
defp cardinality_to_empty(:one), do: nil
defp cardinality_to_empty(:many), do: []
@doc """
Checks if the container can be considered empty.
"""
def empty?(%{cardinality: _}, %NotLoaded{}), do: true
def empty?(%{cardinality: :many}, []), do: true
def empty?(%{cardinality: :many}, changes), do: filter_empty(changes) == []
def empty?(%{cardinality: :one}, nil), do: true
def empty?(%{}, _), do: false
@doc """
Filter empty changes
"""
def filter_empty(changes) do
Enum.filter(changes, fn
%Changeset{action: action} when action in [:replace, :delete] -> false
_ -> true
end)
end
@doc """
Applies related changeset changes
"""
def apply_changes(%{cardinality: :one}, nil) do
nil
end
def apply_changes(%{cardinality: :one}, changeset) do
apply_changes(changeset)
end
def apply_changes(%{cardinality: :many}, changesets) do
for changeset <- changesets,
struct = apply_changes(changeset),
do: struct
end
defp apply_changes(%Changeset{action: :delete}), do: nil
defp apply_changes(%Changeset{action: :replace}), do: nil
defp apply_changes(changeset), do: Changeset.apply_changes(changeset)
@doc """
Loads the relation with the given struct.
Loading will fail if the association is not loaded but the struct is.
"""
def load!(%{__meta__: %{state: :built}}, %NotLoaded{__cardinality__: cardinality}) do
cardinality_to_empty(cardinality)
end
def load!(struct, %NotLoaded{__field__: field}) do
raise "attempting to cast or change association `#{field}` " <>
"from `#{inspect struct.__struct__}` that was not loaded. Please preload your " <>
"associations before manipulating them through changesets"
end
def load!(_struct, loaded), do: loaded
@doc """
Casts related according to the `on_cast` function.
"""
def cast(%{cardinality: :one} = relation, _owner, nil, current, _on_cast) do
case current && on_replace(relation, current) do
:error -> {:error, {"is invalid", [type: expected_type(relation)]}}
_ -> {:ok, nil, true}
end
end
def cast(%{cardinality: :one} = relation, owner, params, current, on_cast) when is_list(params) do
if Keyword.keyword?(params) do
cast(relation, owner, Map.new(params), current, on_cast)
else
{:error, {"is invalid", [type: expected_type(relation)]}}
end
end
def cast(%{cardinality: :many} = relation, owner, params, current, on_cast) when is_map(params) do
params =
params
|> Enum.map(&key_as_int/1)
|> Enum.sort
|> Enum.map(&elem(&1, 1))
cast(relation, owner, params, current, on_cast)
end
def cast(%{related: mod} = relation, owner, params, current, on_cast) do
pks = mod.__schema__(:primary_key)
fun = &do_cast(relation, owner, &1, &2, &3, on_cast)
data_pk = data_pk(pks)
param_pk = param_pk(mod, pks)
with :error <- cast_or_change(relation, params, current, data_pk, param_pk, fun) do
{:error, {"is invalid", [type: expected_type(relation)]}}
end
end
defp do_cast(meta, owner, params, struct, allowed_actions, {module, fun, args})
when is_atom(module) and is_atom(fun) and is_list(args) do
on_cast = fn changeset, attrs ->
apply(module, fun, [changeset, attrs | args])
end
do_cast(meta, owner, params, struct, allowed_actions, on_cast)
end
defp do_cast(meta, owner, params, nil = _struct, allowed_actions, on_cast) do
{:ok,
on_cast.(meta.__struct__.build(meta, owner), params)
|> put_new_action(:insert)
|> check_action!(allowed_actions)}
end
defp do_cast(relation, _owner, nil = _params, current, _allowed_actions, _on_cast) do
on_replace(relation, current)
end
defp do_cast(_meta, _owner, params, struct, allowed_actions, on_cast) do
{:ok,
on_cast.(struct, params)
|> put_new_action(:update)
|> check_action!(allowed_actions)}
end
@doc """
Wraps related structs in changesets.
"""
def change(%{cardinality: :one} = relation, nil, current) do
case current && on_replace(relation, current) do
:error -> {:error, {"is invalid", [type: expected_type(relation)]}}
_ -> {:ok, nil, true}
end
end
def change(%{related: mod} = relation, value, current) do
get_pks = data_pk(mod.__schema__(:primary_key))
with :error <- cast_or_change(relation, value, current, get_pks, get_pks,
&do_change(relation, &1, &2, &3)) do
{:error, {"is invalid", [type: expected_type(relation)]}}
end
end
# This may be an insert or an update, get all fields.
defp do_change(relation, %{__struct__: _} = changeset_or_struct, nil, _allowed_actions) do
changeset = Changeset.change(changeset_or_struct)
{:ok,
changeset
|> assert_changeset_struct!(relation)
|> put_new_action(action_from_changeset(changeset))}
end
defp do_change(relation, nil, current, _allowed_actions) do
on_replace(relation, current)
end
defp do_change(relation, %Changeset{} = changeset, _current, allowed_actions) do
{:ok,
changeset
|> assert_changeset_struct!(relation)
|> put_new_action(:update)
|> check_action!(allowed_actions)}
end
defp do_change(_relation, %{__struct__: _} = struct, _current, allowed_actions) do
{:ok,
struct
|> Ecto.Changeset.change
|> put_new_action(:update)
|> check_action!(allowed_actions)}
end
defp do_change(relation, changes, current, allowed_actions)
when is_list(changes) or is_map(changes) do
changeset = Ecto.Changeset.change(current || relation.__struct__.build(relation, nil), changes)
changeset = put_new_action(changeset, action_from_changeset(changeset))
do_change(relation, changeset, current, allowed_actions)
end
defp action_from_changeset(%{data: %{__meta__: %{state: state}}}) do
case state do
:built -> :insert
:loaded -> :update
:deleted -> :delete
end
end
defp action_from_changeset(_) do
:insert # We don't care if it is insert/update for embeds (no meta)
end
defp assert_changeset_struct!(%{data: %{__struct__: mod}} = changeset, %{related: mod}) do
changeset
end
defp assert_changeset_struct!(%{data: data}, %{related: mod}) do
raise ArgumentError, "expected changeset data to be a #{mod} struct, got: #{inspect data}"
end
@doc """
Handles the changeset or struct when being replaced.
"""
def on_replace(%{on_replace: :mark_as_invalid}, _changeset_or_struct) do
:error
end
def on_replace(%{on_replace: :raise, field: name, owner: owner}, _) do
raise """
you are attempting to change relation #{inspect name} of
#{inspect owner} but the `:on_replace` option of this relation
is set to `:raise`.
By default it is not possible to replace or delete embeds and
associations during `cast`. Therefore Ecto requires the parameters
given to `cast` to have IDs matching the data currently associated
to #{inspect owner}. Failing to do so results in this error message.
If you want to replace data or automatically delete any data
not sent to `cast`, please set the appropriate `:on_replace`
option when defining the relation. The docs for `Ecto.Changeset`
covers the supported options in the "Associations, embeds and on
replace" section.
However, if you don't want to allow data to be replaced or
deleted, only updated, make sure that:
* If you are attempting to update an existing entry, you
are including the entry primary key (ID) in the data.
* If you have a relationship with many children, all children
must be given on update.
"""
end
def on_replace(_relation, changeset_or_struct) do
{:ok, Changeset.change(changeset_or_struct) |> put_new_action(:replace)}
end
defp raise_if_updating_with_struct!(%{field: name, owner: owner}, %{__struct__: _} = new) do
raise """
you have set that the relation #{inspect name} of #{inspect owner}
has `:on_replace` set to `:update` but you are giving it a struct/
changeset to put_assoc/put_change.
Since you have set `:on_replace` to `:update`, you are only allowed
to update the existing entry by giving updated fields as a map or
keyword list or set it to nil.
If you indeed want to replace the existing #{inspect name}, you have
to change the foreign key field directly.
Got: #{inspect new}
"""
end
defp raise_if_updating_with_struct!(_, _) do
true
end
defp cast_or_change(%{cardinality: :one} = relation, value, current, current_pks_fun, new_pks_fun, fun)
when is_map(value) or is_list(value) or is_nil(value) do
single_change(relation, value, current_pks_fun, new_pks_fun, fun, current)
end
defp cast_or_change(%{cardinality: :many}, [], [], _current_pks, _new_pks, _fun) do
{:ok, [], true}
end
defp cast_or_change(%{cardinality: :many} = relation, value, current, current_pks_fun, new_pks_fun, fun)
when is_list(value) do
{current_pks, current_map} = process_current(current, current_pks_fun, relation)
%{unique: unique, ordered: ordered} = relation
ordered = if ordered, do: current_pks, else: []
map_changes(value, new_pks_fun, fun, current_map, [], true, true, unique && %{}, ordered)
end
defp cast_or_change(_, _, _, _, _, _), do: :error
# single change
defp single_change(_relation, nil, _current_pks_fun, _new_pks_fun, fun, current) do
single_change(nil, current, fun, [:update, :delete], false)
end
defp single_change(_relation, new, _current_pks_fun, _new_pks_fun, fun, nil) do
single_change(new, nil, fun, [:insert], false)
end
defp single_change(%{on_replace: on_replace} = relation, new, current_pks_fun, new_pks_fun, fun, current) do
pk_values = new_pks_fun.(new)
if (pk_values == current_pks_fun.(current) and pk_values != []) or
(on_replace == :update and raise_if_updating_with_struct!(relation, new)) do
single_change(new, current, fun, allowed_actions(pk_values), true)
else
case on_replace(relation, current) do
{:ok, _changeset} -> single_change(new, nil, fun, [:insert], false)
:error -> :error
end
end
end
defp single_change(new, current, fun, allowed_actions, skippable?) do
case fun.(new, current, allowed_actions) do
{:ok, %{action: :ignore}} ->
:ignore
{:ok, changeset} ->
if skippable? and skip?(changeset) do
:ignore
else
{:ok, changeset, changeset.valid?}
end
:error ->
:error
end
end
# map changes
defp map_changes([changes | rest], new_pks, fun, current, acc, valid?, skip?, unique, ordered)
when is_map(changes) or is_list(changes) do
pk_values = new_pks.(changes)
{struct, current, allowed_actions} = pop_current(current, pk_values)
case fun.(changes, struct, allowed_actions) do
{:ok, %{action: :ignore}} ->
ordered = pop_ordered(pk_values, ordered)
map_changes(rest, new_pks, fun, current, acc, valid?, skip?, unique, ordered)
{:ok, changeset} ->
changeset = maybe_add_error_on_pk(changeset, pk_values, unique)
acc = [changeset | acc]
valid? = valid? and changeset.valid?
skip? = (struct != nil) and skip? and skip?(changeset)
unique = unique && Map.put(unique, pk_values, true)
ordered = pop_ordered(pk_values, ordered)
map_changes(rest, new_pks, fun, current, acc, valid?, skip?, unique, ordered)
:error ->
:error
end
end
defp map_changes([], _new_pks, fun, current, acc, valid?, skip?, _unique, ordered) do
current_structs = Enum.map(current, &elem(&1, 1))
skip? = skip? and ordered == []
reduce_delete_changesets(current_structs, fun, Enum.reverse(acc), valid?, skip?)
end
defp map_changes(_params, _new_pks, _fun, _current, _acc, _valid?, _skip?, _unique, _ordered) do
:error
end
defp pop_ordered(pk_values, [pk_values | tail]), do: tail
defp pop_ordered(_pk_values, tail), do: tail
defp maybe_add_error_on_pk(%{data: %{__struct__: schema}} = changeset, pk_values, unique) do
if is_map(unique) and not missing_pks?(pk_values) and Map.has_key?(unique, pk_values) do
Enum.reduce(schema.__schema__(:primary_key), changeset, fn pk, acc ->
Changeset.add_error(acc, pk, "has already been taken")
end)
else
changeset
end
end
defp missing_pks?(pk_values) do
pk_values == [] or Enum.any?(pk_values, &is_nil/1)
end
defp allowed_actions(pk_values) do
if Enum.all?(pk_values, &is_nil/1) do
[:insert, :update, :delete]
else
[:update, :delete]
end
end
defp reduce_delete_changesets([struct | rest], fun, acc, valid?, _skip?) do
case fun.(nil, struct, [:update, :delete]) do
{:ok, changeset} ->
valid? = valid? and changeset.valid?
reduce_delete_changesets(rest, fun, [changeset | acc], valid?, false)
:error ->
:error
end
end
defp reduce_delete_changesets([], _fun, _acc, _valid?, true), do: :ignore
defp reduce_delete_changesets([], _fun, acc, valid?, false), do: {:ok, acc, valid?}
# helpers
defp check_action!(changeset, allowed_actions) do
action = changeset.action
cond do
action in allowed_actions ->
changeset
action == :ignore ->
changeset
action == :insert ->
raise "cannot insert related #{inspect changeset.data} " <>
"because it is already associated with the given struct"
action == :replace ->
raise "cannot replace related #{inspect changeset.data}. " <>
"This typically happens when you are calling put_assoc/put_embed " <>
"with the results of a previous put_assoc/put_embed/cast_assoc/cast_embed " <>
"operation, which is not supported. You must call such operations only once " <>
"per embed/assoc, in order for Ecto to track changes effeciently"
true ->
raise "cannot #{action} related #{inspect changeset.data} because " <>
"it already exists and it is not currently associated with the " <>
"given struct. Ecto forbids casting existing records through " <>
"the association field for security reasons. Instead, set " <>
"the foreign key value accordingly"
end
end
defp key_as_int({key, val}) when is_binary(key) do
case Integer.parse(key) do
{key, ""} -> {key, val}
_ -> {key, val}
end
end
defp key_as_int(key_val), do: key_val
defp process_current(nil, _get_pks, _relation),
do: {[], %{}}
defp process_current(current, get_pks, relation) do
{pks, {map, counter}} =
Enum.map_reduce(current, {%{}, 0}, fn struct, {acc, counter} ->
pks = get_pks.(struct)
key = if pks == [], do: map_size(acc), else: pks
{pks, {Map.put(acc, key, struct), counter+ 1}}
end)
if map_size(map) != counter do
Logger.warn """
found duplicate primary keys for association/embed `#{inspect(relation.field)}` \
in `#{inspect(relation.owner)}`. In case of duplicate IDs, only the last entry \
with the same ID will be kept. Make sure that all entries in `#{inspect(relation.field)}` \
have an ID and the IDs are unique between them
"""
end
{pks, map}
end
defp pop_current(current, pk_values) do
case Map.pop(current, pk_values) do
{nil, current} -> {nil, current, [:insert]}
{struct, current} -> {struct, current, allowed_actions(pk_values)}
end
end
defp data_pk(pks) do
fn
%Changeset{data: data} -> Enum.map(pks, &Map.get(data, &1))
map when is_map(map) -> Enum.map(pks, &Map.get(map, &1))
list when is_list(list) -> Enum.map(pks, &Keyword.get(list, &1))
end
end
defp param_pk(mod, pks) do
pks = Enum.map(pks, &{&1, Atom.to_string(&1), mod.__schema__(:type, &1)})
fn params ->
Enum.map pks, fn {atom_key, string_key, type} ->
original = Map.get(params, string_key) || Map.get(params, atom_key)
case Ecto.Type.cast(type, original) do
{:ok, value} -> value
_ -> original
end
end
end
end
defp put_new_action(%{action: action} = changeset, new_action) when is_nil(action),
do: Map.put(changeset, :action, new_action)
defp put_new_action(changeset, _new_action),
do: changeset
defp skip?(%{valid?: true, changes: empty, action: :update}) when empty == %{},
do: true
defp skip?(_changeset),
do: false
defp expected_type(%{cardinality: :one}), do: :map
defp expected_type(%{cardinality: :many}), do: {:array, :map}
## Surface changes on insert
def surface_changes(%{changes: changes, types: types} = changeset, struct, fields) do
{changes, errors} =
Enum.reduce fields, {changes, []}, fn field, {changes, errors} ->
case {struct, changes, types} do
# User has explicitly changed it
{_, %{^field => _}, _} ->
{changes, errors}
# Handle associations specially
{_, _, %{^field => {tag, embed_or_assoc}}} when tag in [:assoc, :embed] ->
# This is partly reimplementing the logic behind put_relation
# in Ecto.Changeset but we need to do it in a way where we have
# control over the current value.
value = load!(struct, Map.get(struct, field))
empty = empty(embed_or_assoc)
case change(embed_or_assoc, value, empty) do
{:ok, change, _} when change != empty ->
{Map.put(changes, field, change), errors}
{:error, error} ->
{changes, [{field, error}]}
_ -> # :ignore or ok with change == empty
{changes, errors}
end
# Struct has a non nil value
{%{^field => value}, _, %{^field => _}} when value != nil ->
{Map.put(changes, field, value), errors}
{_, _, _} ->
{changes, errors}
end
end
case errors do
[] -> %{changeset | changes: changes}
_ -> %{changeset | errors: errors ++ changeset.errors, valid?: false, changes: changes}
end
end
end