Current section
Files
Jump to
Current section
Files
lib/ash/actions/relationships.ex
defmodule Ash.Actions.Relationships do
@moduledoc false
alias Ash.Changeset
alias Ash.Engine.Request
def handle_relationship_changes(changeset) do
Enum.reduce(changeset.relationships, changeset, fn {name, data}, changeset ->
relationship = Ash.Resource.relationship(changeset.resource, name)
add_relationship_read_requests(changeset, relationship, data)
end)
end
defp add_relationship_read_requests(%{action_type: :update} = changeset, relationship, input) do
changeset
|> add_replace_requests(relationship, input)
|> add_remove_requests(relationship, input)
|> add_add_requests(relationship, input)
|> add_belongs_to_change(relationship, input)
end
defp add_relationship_read_requests(%{action_type: :create} = changeset, relationship, input) do
{input, key} =
if relationship.cardinality == :many do
case Map.fetch(input, :replace) do
{:ok, replacing} ->
{Map.update(input, :add, replacing, &Kernel.++(&1, replacing)), :add}
_ ->
{input, :add}
end
else
{input, :replace}
end
changeset
|> add_add_requests(relationship, Map.delete(input, :remove), key)
|> add_belongs_to_change(relationship, input)
end
defp add_belongs_to_change(
changeset,
%{type: :belongs_to, source_field: source_field, destination_field: destination_field},
input
) do
case Map.fetch(input, :replace) do
{:ok, nil} ->
changeset
:error ->
changeset
{:ok, replace} ->
add_belongs_to_change(changeset, replace, source_field, destination_field)
end
end
defp add_belongs_to_change(changeset, _, _), do: changeset
defp add_add_requests(changeset, relationship, input, key \\ :add) do
case Map.fetch(input, key) do
{:ok, identifiers} ->
do_add_relationship_read_requests(changeset, relationship, identifiers, key)
:error ->
changeset
end
end
defp add_belongs_to_change(changeset, identifiers, source_field, destination_field) do
case Map.get(identifiers, destination_field) do
{:ok, field_value} ->
Changeset.force_change_attribute(changeset, source_field, field_value)
_ ->
changeset
end
end
defp add_replace_requests(changeset, relationship, input) do
case Map.fetch(input, :replace) do
{:ok, identifiers} ->
changeset
|> do_add_relationship_read_requests(relationship, identifiers, :replace)
|> add_relationship_currently_related_request(relationship)
:error ->
changeset
end
end
defp add_remove_requests(changeset, relationship, input) do
case Map.fetch(input, :remove) do
{:ok, identifiers} ->
do_add_relationship_read_requests(
changeset,
relationship,
identifiers,
:remove
)
:error ->
changeset
end
end
defp do_add_relationship_read_requests(
changeset,
%{destination: destination} = relationship,
identifiers,
type
) do
relationship_name = relationship.name
{possible?, filter} =
case identifiers do
[{single_identifier, _changeset}] ->
{true, single_identifier}
[single_identifier] ->
{true, single_identifier}
[] ->
{false, []}
single when is_map(single) ->
{true, Map.to_list(single)}
many ->
case Ash.Resource.primary_key(relationship.destination) do
[field] ->
{true, [{field, in: get_many_field(many, field)}]}
_ ->
{true, [or: get_many_records(many)]}
end
end
query = Ash.Query.filter(destination, filter)
dependencies =
if possible? do
[[:relationships, relationship_name, type, :query]]
else
[]
end
request =
Request.new(
api: changeset.api,
resource: relationship.destination,
action: Ash.Resource.primary_action!(relationship.destination, :read),
query: query,
path: [:relationships, relationship_name, type],
authorize?: possible?,
data:
Request.resolve(dependencies, fn data ->
if possible? do
query = get_in(data, [:relationships, relationship_name, type, :query])
case changeset.api.read(query) do
{:ok, results} ->
{:ok, add_changes_to_results(changeset.resource, results, identifiers)}
{:error, error} ->
{:error, error}
end
else
{:ok, []}
end
end),
name: "read prior to write related #{relationship.name}"
)
changeset
|> Changeset.add_requests(request)
|> Changeset.changes_depend_on([:relationships, relationship_name, type, :data])
end
defp add_changes_to_results(resource, results, identifiers) do
pkey = Ash.Resource.primary_key(resource)
Enum.map(results, fn result ->
case find_changes(identifiers, result, pkey) do
nil ->
result
changes ->
{result, changes}
end
end)
end
defp find_changes(identifiers, result, pkey) do
Enum.find_value(identifiers, fn
{identifier, changes} ->
cond do
is_map(identifier) && Map.take(identifier, pkey) == Map.take(result, pkey) ->
changes
match?([_], pkey) && identifier == Map.get(result, List.first(pkey)) ->
changes
true ->
nil
end
_ ->
nil
end)
end
defp get_many_field(records, field) do
Enum.map(records, fn
{record, _changes} ->
Map.get(record, field)
record ->
Map.get(record, field)
end)
end
defp get_many_records(records) do
Enum.map(records, fn
{record, _changes} ->
record
record ->
record
end)
end
def changeset(changeset) do
if changeset.relationships == %{} do
changeset
else
Request.resolve(changeset.change_dependencies, fn data ->
new_changeset =
data
|> Map.get(:relationships, %{})
|> Enum.reduce(changeset, fn {relationship, relationship_data}, changeset ->
relationship = Ash.Resource.relationship(changeset.resource, relationship)
relationship_data =
relationship_data
|> Enum.into(%{}, fn {key, value} ->
{key, value.data}
end)
|> Map.put_new(:current, [])
add_relationship_to_changeset(changeset, relationship, relationship_data)
end)
{:ok, new_changeset}
end)
end
end
defp add_relationship_to_changeset(
changeset,
%{type: :has_one} = relationship,
relationship_data
) do
case relationship_data do
%{current: [current], replace: []} ->
changeset
|> unrelate_has_one(relationship, current)
|> relate_has_one(relationship, nil)
|> add_relationship_change_metadata(relationship.name, %{current: current, replace: nil})
%{current: [current], replace: [new]} ->
changeset
|> unrelate_has_one(relationship, current)
|> relate_has_one(relationship, new)
|> add_relationship_change_metadata(relationship.name, %{current: current, replace: new})
%{current: [], replace: [new]} ->
changeset
|> relate_has_one(relationship, new)
|> add_relationship_change_metadata(relationship.name, %{current: nil, replace: new})
end
end
defp add_relationship_to_changeset(
changeset,
%{type: :belongs_to} = relationship,
relationship_data
) do
relationship_data = Map.put_new(relationship_data, :current, [])
case relationship_data do
%{current: [current], replace: []} ->
changeset
|> relate_belongs_to(relationship, nil)
|> add_relationship_change_metadata(relationship.name, %{current: current, replace: nil})
%{current: [], replace: [new]} ->
changeset
|> relate_belongs_to(relationship, new)
|> add_relationship_change_metadata(relationship.name, %{current: nil, replace: new})
%{current: [current], replace: [new]} ->
changeset
|> relate_belongs_to(relationship, new)
|> add_relationship_change_metadata(relationship.name, %{
current: current,
replace: new
})
end
end
defp add_relationship_to_changeset(
changeset,
%{type: :has_many, destination: destination} = relationship,
relationship_data
) do
pkey = Ash.Resource.primary_key(destination)
relationship_data =
case relationship_data do
%{replace: values, current: current} ->
split_relationship_data(current, values, pkey)
other ->
other
end
changeset
|> set_relationship(relationship.name, Map.get(relationship_data, :current, []))
|> remove_has_many(relationship, relationship_data, pkey)
|> relate_has_many(relationship, relationship_data, pkey)
|> add_relationship_change_metadata(relationship.name, relationship_data)
end
defp add_relationship_to_changeset(
changeset,
%{type: :many_to_many, destination: destination} = relationship,
relationship_data
) do
pkey = Ash.Resource.primary_key(destination)
join_pkey = Ash.Resource.primary_key(relationship.through)
relationship_data =
case relationship_data do
%{replace: values, current: current} ->
split_relationship_data(current, values, pkey)
other ->
other
end
changeset
|> set_relationship(relationship.name, Map.get(relationship_data, :current, []))
|> remove_many_to_many(relationship, relationship_data, join_pkey, pkey)
|> relate_many_to_many(relationship, relationship_data, pkey)
|> add_relationship_change_metadata(relationship.name, relationship_data)
end
defp split_relationship_data(current, replace, pkey) do
adding =
Enum.reject(replace, fn
{_, _} ->
false
replacing ->
any_pkey_matches?(current, replacing, pkey)
end)
removing = Enum.reject(current, &any_pkey_matches?(replace, &1, pkey))
%{add: adding, remove: removing, current: current}
end
defp relate_many_to_many(
changeset,
relationship,
%{add: add, current: current},
pkey
)
when is_list(add) do
Enum.reduce(add, changeset, fn
{to_relate_record, join_changeset}, changeset ->
do_relate_many_to_many(changeset, relationship, to_relate_record, join_changeset)
to_relate_record, changeset ->
case find_pkey_match(current, to_relate_record, pkey) do
nil ->
do_relate_many_to_many(changeset, relationship, to_relate_record)
_record ->
changeset
end
end)
end
defp relate_many_to_many(changeset, _, _, _) do
changeset
end
defp do_relate_many_to_many(changeset, relationship, to_relate_record, join_changeset \\ nil) do
Changeset.after_action(changeset, fn changeset, record ->
join_attrs = %{
relationship.source_field_on_join_table() => Map.get(record, relationship.source_field),
relationship.destination_field_on_join_table() =>
Map.get(to_relate_record, relationship.destination_field)
}
join_changeset
|> Kernel.||(Ash.Changeset.new(relationship.through))
|> Ash.Changeset.force_change_attributes(join_attrs)
|> changeset.api.create(upsert?: true)
|> case do
{:ok, join_row} ->
{:ok,
record
|> remove_from_set_relationship(
relationship.name,
to_relate_record,
Ash.Resource.primary_key(relationship.destination)
)
|> add_to_set_relationship(relationship.name, to_relate_record)
|> remove_from_set_relationship(
relationship.join_relationship,
join_row,
Ash.Resource.primary_key(relationship.through)
)
|> add_to_set_relationship(relationship.join_relationship, join_row)}
{:error, error} ->
{:error, error}
end
end)
end
defp remove_many_to_many(
changeset,
relationship,
%{current: current, remove: remove},
join_pkey,
pkey
) do
Enum.reduce(remove, changeset, fn to_remove_record, changeset ->
case find_pkey_match(current, to_remove_record, pkey) do
nil ->
changeset
to_remove_record ->
do_remove_many_to_many(changeset, relationship, to_remove_record, join_pkey, pkey)
end
end)
end
defp remove_many_to_many(changeset, _, _, _, _) do
changeset
end
defp do_remove_many_to_many(changeset, relationship, to_remove_record, join_pkey, pkey) do
Changeset.after_action(changeset, fn changeset, record ->
filter = [
{relationship.source_field_on_join_table, Map.get(record, relationship.source_field)},
{
relationship.destination_field_on_join_table,
Map.get(to_remove_record, relationship.destination_field)
}
]
case changeset.api.get(relationship.through, filter) do
{:ok, nil} ->
changeset
{:error, error} ->
{:error, error}
{:ok, join_row} ->
destroy_and_remove(
changeset.api,
join_row,
to_remove_record,
record,
relationship,
join_pkey,
pkey
)
end
end)
end
defp destroy_and_remove(api, join_row, to_remove_record, record, relationship, join_pkey, pkey) do
case api.destroy(join_row) do
:ok ->
{:ok,
record
|> remove_from_set_relationship(relationship.join_relationship, join_row, join_pkey)
|> remove_from_set_relationship(relationship.name, to_remove_record, pkey)}
{:error, error} ->
{:error, error}
end
end
defp relate_has_many(changeset, relationship, %{add: add, current: current}, pkey)
when is_list(add) do
Enum.reduce(add, changeset, fn to_relate_record, changeset ->
if any_pkey_matches?(current, to_relate_record, pkey) do
changeset
else
Changeset.after_action(changeset, fn changeset, record ->
to_relate_record
|> Ash.Changeset.new()
|> Ash.Changeset.force_change_attribute(
relationship.destination_field,
Map.get(record, relationship.source_field)
)
|> changeset.api.update()
|> case do
{:ok, related} ->
{:ok, add_to_set_relationship(record, relationship.name, related)}
{:error, error} ->
{:error, error}
end
end)
end
end)
end
defp relate_has_many(changeset, _, _, _) do
changeset
end
defp remove_has_many(changeset, relationship, %{current: current, remove: remove}, pkey) do
Enum.reduce(remove, changeset, fn to_relate_record, changeset ->
if any_pkey_matches?(current, to_relate_record, pkey) do
Changeset.after_action(changeset, fn changeset, record ->
to_relate_record
|> Ash.Changeset.new()
|> Ash.Changeset.force_change_attribute(relationship.destination_field, nil)
|> changeset.api.update()
|> case do
{:ok, related} ->
{:ok, remove_from_set_relationship(record, relationship.name, related, pkey)}
{:error, error} ->
{:error, error}
end
end)
else
changeset
end
end)
end
defp remove_has_many(changeset, _, _, _) do
changeset
end
defp find_pkey_match(records, to_relate_record, pkey) do
search_pkey = Map.take(to_relate_record, pkey)
Enum.find(records, fn
{record, _changes} ->
Map.take(record, pkey) == search_pkey
record ->
Map.take(record, pkey) == search_pkey
end)
end
defp any_pkey_matches?(records, to_relate_record, pkey) do
not is_nil(find_pkey_match(records, to_relate_record, pkey))
end
defp set_relationship(changeset, relationship_name, value) do
Map.update!(changeset, :data, fn data ->
case value do
values when is_list(values) ->
Map.put(data, relationship_name, Enum.map(values, &clear_relationships/1))
value ->
Map.put(data, relationship_name, clear_relationships(value))
end
end)
end
defp add_to_set_relationship(record, relationship_name, to_relate) do
Map.update!(record, relationship_name, fn
%Ecto.Association.NotLoaded{} -> [clear_relationships(to_relate)]
set_relationship -> [clear_relationships(to_relate) | set_relationship]
end)
end
defp remove_from_set_relationship(record, relationship_name, to_remove, pkey) do
Map.update!(record, relationship_name, fn
%Ecto.Association.NotLoaded{} ->
[]
set_relationship ->
search_pkey = Map.take(to_remove, pkey)
Enum.reject(set_relationship, fn set -> Map.take(set, pkey) == search_pkey end)
end)
end
defp relate_belongs_to(changeset, relationship, new) do
changeset =
if new do
Changeset.force_change_attribute(
changeset,
relationship.source_field,
Map.get(new, relationship.destination_field)
)
else
Changeset.force_change_attribute(changeset, relationship.source_field, nil)
end
Changeset.after_action(changeset, fn _changeset, result ->
if new do
{:ok, Map.put(result, relationship.name, clear_relationships(new))}
else
{:ok, Map.put(result, relationship.name, nil)}
end
end)
end
defp relate_has_one(changeset, relationship, to_relate_record) do
Changeset.after_action(changeset, fn _changeset, record ->
if to_relate_record do
to_relate_record
|> Ash.Changeset.new()
|> Ash.Changeset.force_change_attribute(
relationship.destination_field,
Map.get(record, relationship.source_field)
)
|> changeset.api.update()
|> case do
{:ok, related} ->
{:ok, Map.put(record, relationship.name, clear_relationships(related))}
{:error, error} ->
{:error, error}
end
else
{:ok, Map.put(record, relationship.name, nil)}
end
end)
end
defp unrelate_has_one(changeset, relationship, to_relate_record) do
Changeset.after_action(changeset, fn changeset, record ->
to_relate_record
|> Changeset.new()
|> Changeset.force_change_attribute(relationship.destination_field, nil)
|> changeset.api.update()
|> case do
{:ok, _related} ->
{:ok, record}
{:error, error} ->
{:error, error}
end
end)
end
defp add_relationship_change_metadata(changeset, relationship_name, data) do
Map.update(
changeset,
:relationships,
%{relationship_name => data},
&Map.put(&1, relationship_name, data)
)
end
defp add_relationship_currently_related_request(
changeset,
%{type: :many_to_many} = relationship
) do
join_through_request = many_to_many_join_resource_request(changeset, relationship)
destination_request = many_to_many_destination_request(changeset.api, relationship)
requests = [join_through_request, destination_request]
changeset
|> Changeset.add_requests(requests)
|> Changeset.changes_depend_on([:relationships, relationship.name, :current, :data])
end
defp add_relationship_currently_related_request(
changeset,
%{destination: destination} = relationship
) do
value = Changeset.get_attribute(changeset, relationship.source_field)
filter_statement = [{relationship.destination_field, value}]
request =
Request.new(
api: changeset.api,
resource: destination,
action: Ash.Resource.primary_action!(relationship.destination, :read),
path: [:relationships, relationship.name, :current],
query: Ash.Query.filter(destination, filter_statement),
data:
Request.resolve([[:relationships, relationship.name, :current, :query]], fn data ->
query = get_in(data, [:relationships, relationship.name, :current, :query])
changeset.api.read(query)
end),
name: "Read related #{relationship.name} before replace"
)
changeset
|> Changeset.add_requests(request)
|> Changeset.changes_depend_on([:relationships, relationship.name, :current, :data])
end
defp many_to_many_join_resource_request(
changeset,
%{through: through} = relationship
) do
value = Changeset.get_attribute(changeset, relationship.source_field)
filter_statement = [{relationship.source_field_on_join_table, value}]
Request.new(
api: changeset.api,
resource: through,
action: Ash.Resource.primary_action!(relationship.destination, :read),
path: [:relationships, relationship.name, :current_join],
query: Ash.Query.filter(through, filter_statement),
data:
Request.resolve([[:relationships, relationship.name, :current_join, :query]], fn data ->
query = get_in(data, [:relationships, relationship.name, :current_join, :query])
changeset.api.read(query)
end),
name: "Read related join for #{relationship.name} before replace"
)
end
defp many_to_many_destination_request(
api,
%{destination: destination, name: name} = relationship
) do
Request.new(
api: api,
resource: destination,
action: Ash.Resource.primary_action!(relationship.destination, :read),
path: [:relationships, name, :current],
query:
Request.resolve(
[[:relationships, name, :current_join, :data]],
fn %{relationships: %{^name => %{current_join: %{data: current_join}}}} ->
field_values =
Enum.map(current_join, &Map.get(&1, relationship.destination_field_on_join_table))
filter_statement = [{relationship.destination_field, in: field_values}]
{:ok,
relationship.destination
|> Ash.Query.new(api)
|> Ash.Query.filter(filter_statement)}
end
),
data:
Request.resolve(
[[:relationships, name, :current, :query]],
fn %{
relationships: %{
^name => %{current: %{query: query}}
}
} ->
api.read(query)
end
),
name: "Read related join for #{name} before replace"
)
end
defp clear_relationships(%resource{} = record) do
resource
|> Ash.Resource.relationships()
|> Enum.reduce(record, fn relationship, record ->
not_loaded = %Ecto.Association.NotLoaded{
__cardinality__: relationship.cardinality,
__field__: relationship.name,
__owner__: relationship.source
}
Map.put(record, relationship.name, not_loaded)
end)
end
end