improvement: add no_fields? relationships

This commit is contained in:
Zach Daniel 2022-05-03 18:56:37 -04:00
parent 98a7ac06b8
commit 904968b936
10 changed files with 163 additions and 258 deletions

View file

@ -97,6 +97,7 @@ locals_without_parens = [
modify_query: 1,
module: 1,
name: 1,
no_fields?: 1,
not_found_message: 1,
on: 1,
only_when_valid?: 1,

View file

@ -74,8 +74,15 @@ defmodule Ash.Actions.Load do
related_query.tenant
)
query =
if Map.get(relationship, :no_fields?) do
query
else
Ash.Query.ensure_selected(query, relationship.source_field)
end
{
Ash.Query.ensure_selected(query, relationship.source_field),
query,
requests ++
further_requests ++
do_requests(
@ -132,6 +139,12 @@ defmodule Ash.Actions.Load do
data
end
defp attach_to_many_loads(value, %{name: name, no_fields?: true}, data, lead_path) do
map_or_update(data, lead_path, fn record ->
Map.put(record, name, List.wrap(value))
end)
end
defp attach_to_many_loads(value, last_relationship, data, lead_path) when is_map(value) do
primary_key = Ash.Resource.Info.primary_key(last_relationship.source)
@ -150,6 +163,12 @@ defmodule Ash.Actions.Load do
end)
end
defp attach_to_one_loads(value, %{name: name, no_fields?: true}, data, lead_path) do
map_or_update(data, lead_path, fn record ->
Map.put(record, name, value |> List.wrap() |> Enum.at(0))
end)
end
defp attach_to_one_loads(value, last_relationship, data, lead_path) when is_map(value) do
primary_key = Ash.Resource.Info.primary_key(last_relationship.source)
@ -340,10 +359,7 @@ defmodule Ash.Actions.Load do
query:
load_query(
relationship,
related_query,
path,
root_query,
request_path
related_query
),
data:
data(
@ -601,10 +617,7 @@ defmodule Ash.Actions.Load do
query:
load_query(
join_relationship,
related_query,
Enum.reverse(join_relationship_path),
root_query,
request_path
related_query
),
data:
Request.resolve(dependencies, fn
@ -966,106 +979,25 @@ defmodule Ash.Actions.Load do
end
end
defp load_query_with_reverse_path(
root_query,
related_query,
reverse_path,
root_data_filter
) do
case Ash.Filter.parse(root_query.resource, root_query.filter, %{}, %{}) do
{:ok, nil} ->
related_query
|> Ash.Query.unset(:load)
|> Ash.Query.filter(
^put_nested_relationship(
[],
reverse_path,
root_data_filter,
false
)
)
|> Ash.Query.filter(^related_query.filter)
|> extract_errors()
{:ok, parsed} ->
filter =
put_nested_relationship(
[],
reverse_path,
root_data_filter,
false
)
related_query
|> Ash.Query.unset(:load)
|> Ash.Query.filter(^filter)
|> Ash.Query.filter(^put_nested_relationship([], reverse_path, parsed, false))
|> Ash.Query.filter(^related_query.filter)
|> extract_errors()
{:error, error} ->
{:error, error}
end
end
defp load_query(%{manual: manual}, related_query, _path, _root_query, _request_path)
defp load_query(%{manual: manual}, related_query)
when not is_nil(manual) do
related_query
end
defp load_query(
relationship,
related_query,
path,
root_query,
request_path
related_query
) do
Request.resolve([request_path ++ [:data]], fn context ->
data =
case get_in(context, request_path ++ [:data, :results]) do
%page{results: results} when page in [Ash.Page.Keyset, Ash.Page.Offset] ->
results
data ->
data
end
root_data_filter =
case data do
[] ->
false
[%resource{} | _] = items ->
pkey = Ash.Resource.Info.primary_key(resource)
[or: Enum.map(items, fn item -> item |> Map.take(pkey) |> Enum.to_list() end)]
end
path = Enum.reverse([relationship.name | Enum.map(path, & &1.name)])
case Ash.Resource.Info.reverse_relationship(
root_query.resource,
path
) do
nil ->
relationship.destination
|> Ash.Query.new(related_query.api)
|> Ash.Query.filter(^related_query.filter)
|> extract_errors()
reverse_path ->
load_query_with_reverse_path(
root_query,
related_query,
reverse_path,
root_data_filter
)
end
end)
if Map.get(relationship, :no_fields?) do
relationship.destination
|> Ash.Query.new(related_query.api)
else
relationship.destination
|> Ash.Query.new(related_query.api)
|> Ash.Query.filter(^related_query.filter)
end
end
defp extract_errors(%{errors: []} = item), do: {:ok, item}
defp extract_errors(%{errors: errors}), do: {:error, errors}
defp true_load_query(relationship, query, data, path, request_path) do
{source_field, path} =
if relationship.type == :many_to_many do
@ -1106,47 +1038,52 @@ defmodule Ash.Actions.Load do
defp get_query(query, relationship, source_data, source_field) do
{offset, limit} = offset_and_limit(query)
if lateral_join?(query, relationship, source_data) do
{:ok, Ash.Query.unset(query, :load)}
else
query =
if limit || offset do
Ash.Query.unset(query, [:limit, :offset])
else
query
end
cond do
lateral_join?(query, relationship, source_data) ->
{:ok, Ash.Query.unset(query, :load)}
related_data =
case source_data do
%page{results: results} when page in [Ash.Page.Keyset, Ash.Page.Offset] ->
results
Map.get(relationship, :no_fields?) ->
{:ok, query}
data ->
true ->
query =
if limit || offset do
Ash.Query.unset(query, [:limit, :offset])
else
query
end
related_data =
case source_data do
%page{results: results} when page in [Ash.Page.Keyset, Ash.Page.Offset] ->
results
data ->
data
end
ids =
Enum.flat_map(related_data, fn data ->
data
end
|> Map.get(source_field)
|> List.wrap()
end)
ids =
Enum.flat_map(related_data, fn data ->
data
|> Map.get(source_field)
|> List.wrap()
end)
filter_value =
case ids do
[id] ->
id
filter_value =
case ids do
[id] ->
id
ids ->
[in: ids]
end
ids ->
[in: ids]
end
new_query =
query
|> Ash.Query.filter(^[{relationship.destination_field, filter_value}])
|> Ash.Query.unset(:load)
new_query =
query
|> Ash.Query.filter(^[{relationship.destination_field, filter_value}])
|> Ash.Query.unset(:load)
{:ok, new_query}
{:ok, new_query}
end
end
@ -1238,30 +1175,4 @@ defmodule Ash.Actions.Load do
is_nil(destination_rel.context) &&
is_nil(rel.context)
end
defp put_nested_relationship(request_filter, path, value, records?) when not is_list(value) do
put_nested_relationship(request_filter, path, [value], records?)
end
defp put_nested_relationship(request_filter, [rel | rest], values, records?) do
[
{rel, put_nested_relationship(request_filter, rest, values, records?)}
]
end
defp put_nested_relationship(request_filter, [], [{field, value}], _) do
[{field, value} | request_filter]
end
defp put_nested_relationship(request_filter, [], [{field, _} | _] = keys, _) do
[{field, [{:in, Enum.map(keys, &elem(&1, 1))}]} | request_filter]
end
defp put_nested_relationship(request_filter, [], [values], _) do
List.wrap(request_filter) ++ List.wrap(values)
end
defp put_nested_relationship(request_filter, [], values, _) do
Keyword.update(request_filter, :or, values, &Kernel.++(&1, values))
end
end

View file

@ -102,7 +102,8 @@ defmodule Ash.Actions.ManagedRelationships do
changeset =
if input in [nil, []] && opts[:on_missing] != :ignore do
Ash.Changeset.force_change_attribute(changeset, relationship.source_field, nil)
changeset
|> maybe_force_change_attribute(relationship, :source_field, nil)
|> Ash.Changeset.after_action(fn _changeset, result ->
{:ok, Map.put(result, relationship.name, nil)}
end)
@ -173,8 +174,9 @@ defmodule Ash.Actions.ManagedRelationships do
belongs_to_manage_found: %{relationship.name => %{index => input}}
}
})
|> Ash.Changeset.force_change_attribute(
relationship.source_field,
|> maybe_force_change_attribute(
relationship,
:source_field,
Map.get(input, relationship.destination_field)
)
@ -216,8 +218,9 @@ defmodule Ash.Actions.ManagedRelationships do
}
}
})
|> Ash.Changeset.force_change_attribute(
relationship.source_field,
|> maybe_force_change_attribute(
relationship,
:source_field,
Map.get(found, relationship.destination_field)
)
@ -247,9 +250,10 @@ defmodule Ash.Actions.ManagedRelationships do
_value ->
if opts[:on_match] == :destroy do
changeset =
Ash.Changeset.force_change_attribute(
maybe_force_change_attribute(
changeset,
relationship.source_field,
relationship,
:source_field,
nil
)
@ -287,6 +291,16 @@ defmodule Ash.Actions.ManagedRelationships do
relationship.api || changeset.api
end
defp maybe_force_change_attribute(changeset, %{no_fields?: true}, _, _), do: changeset
defp maybe_force_change_attribute(changeset, relationship, key, value) do
Ash.Changeset.force_change_attribute(
changeset,
Map.get(relationship, key),
value
)
end
defp validate_required_belongs_to({:error, error}), do: {:error, error}
defp validate_required_belongs_to({changeset, instructions}) do
@ -413,8 +427,9 @@ defmodule Ash.Actions.ManagedRelationships do
belongs_to_manage_created: %{relationship.name => %{index => created}}
}
})
|> Ash.Changeset.force_change_attribute(
relationship.source_field,
|> maybe_force_change_attribute(
relationship,
:source_field,
Map.get(created, relationship.destination_field)
)
@ -859,12 +874,14 @@ defmodule Ash.Actions.ManagedRelationships do
relationship.through
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(create_or_update, join_input, actor: actor)
|> Ash.Changeset.force_change_attribute(
relationship.source_field_on_join_table,
|> maybe_force_change_attribute(
relationship,
:source_field_on_join_table,
Map.get(record, relationship.source_field)
)
|> Ash.Changeset.force_change_attribute(
relationship.destination_field_on_join_table,
|> maybe_force_change_attribute(
relationship,
:destination_field_on_join_table,
Map.get(found, relationship.destination_field)
)
|> Ash.Changeset.set_context(join_relationship.context)
@ -913,13 +930,13 @@ defmodule Ash.Actions.ManagedRelationships do
found
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_update(create_or_update, input,
relationships: opts[:relationships] || [],
actor: actor
)
|> Ash.Changeset.force_change_attribute(
relationship.destination_field,
|> maybe_force_change_attribute(
relationship,
:destination_field,
Map.get(record, relationship.source_field)
)
|> Ash.Changeset.set_context(relationship.context)
@ -968,14 +985,14 @@ defmodule Ash.Actions.ManagedRelationships do
else
relationship.destination
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_create(action_name, input,
require?: false,
actor: actor,
relationships: opts[:relationships]
)
|> Ash.Changeset.force_change_attribute(
relationship.destination_field,
|> maybe_force_change_attribute(
relationship,
:destination_field,
Map.get(record, relationship.source_field)
)
|> Ash.Changeset.set_context(relationship.context)
@ -1017,7 +1034,6 @@ defmodule Ash.Actions.ManagedRelationships do
else
relationship.destination
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_create(action_name, regular_params,
require?: false,
relationships: opts[:relationships],
@ -1043,12 +1059,14 @@ defmodule Ash.Actions.ManagedRelationships do
require?: false,
actor: actor
)
|> Ash.Changeset.force_change_attribute(
relationship.source_field_on_join_table,
|> maybe_force_change_attribute(
relationship,
:source_field_on_join_table,
Map.get(record, relationship.source_field)
)
|> Ash.Changeset.force_change_attribute(
relationship.destination_field_on_join_table,
|> maybe_force_change_attribute(
relationship,
:destination_field_on_join_table,
Map.get(created, relationship.destination_field)
)
|> Ash.Changeset.set_context(join_relationship.context)
@ -1114,8 +1132,7 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
changeset.tenant,
relationship,
changeset
relationship
) do
{:ok, notifications} ->
{:ok, current_value, notifications, []}
@ -1133,8 +1150,7 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
changeset.tenant,
relationship,
changeset
relationship
) do
{:ok, notifications} ->
{:ok, current_value, notifications, []}
@ -1153,7 +1169,6 @@ defmodule Ash.Actions.ManagedRelationships do
match
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_update(action_name, input,
actor: actor,
relationships: opts[:relationships] || []
@ -1184,7 +1199,6 @@ defmodule Ash.Actions.ManagedRelationships do
match
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_update(action_name, regular_params,
actor: actor,
relationships: opts[:relationships]
@ -1327,32 +1341,6 @@ defmodule Ash.Actions.ManagedRelationships do
end
end
defp set_source_context(changeset, {relationship, original_changeset}) do
case changeset.data.__metadata__[:manage_relationship_source] ||
original_changeset.context[:manage_relationship_source] do
nil ->
Ash.Changeset.set_context(changeset, %{
manage_relationship_source: [
{relationship.source, relationship.name, original_changeset}
]
})
value ->
Ash.Changeset.set_context(changeset, %{
manage_relationship_source:
value ++ [{relationship.source, relationship.name, original_changeset}]
})
end
|> Ash.Changeset.after_action(fn changeset, record ->
{:ok,
Ash.Resource.Info.put_metadata(
record,
:manage_relationship_source,
changeset.context[:manage_relationship_source]
)}
end)
end
defp delete_unused(
source_record,
original_value,
@ -1424,7 +1412,6 @@ defmodule Ash.Actions.ManagedRelationships do
record
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_destroy(action_name, %{}, actor: actor)
|> Ash.Changeset.set_context(relationship.context)
|> Ash.Changeset.set_tenant(changeset.tenant)
@ -1456,7 +1443,6 @@ defmodule Ash.Actions.ManagedRelationships do
{:destroy, action_name} ->
record
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_destroy(action_name, %{}, actor: actor)
|> Ash.Changeset.set_context(relationship.context)
|> Ash.Changeset.set_tenant(changeset.tenant)
@ -1490,8 +1476,7 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
changeset.tenant,
relationship,
changeset
relationship
) do
{:ok, notifications} ->
{:cont, {:ok, current_value, notifications}}
@ -1512,8 +1497,7 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
tenant,
%{type: :many_to_many} = relationship,
changeset
%{type: :many_to_many} = relationship
) do
action_name =
action_name || Ash.Resource.Info.primary_action(relationship.through, :destroy).name
@ -1531,7 +1515,6 @@ defmodule Ash.Actions.ManagedRelationships do
{:ok, result} ->
result
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_destroy(action_name, %{}, actor: actor)
|> Ash.Changeset.set_context(relationship.context)
|> Ash.Changeset.set_tenant(tenant)
@ -1561,8 +1544,7 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
tenant,
%{type: type} = relationship,
changeset
%{type: type} = relationship
)
when type in [:has_many, :has_one] do
action_name =
@ -1570,12 +1552,11 @@ defmodule Ash.Actions.ManagedRelationships do
record
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_update(action_name, %{},
relationships: opts[:relationships] || [],
actor: actor
)
|> Ash.Changeset.force_change_attribute(relationship.destination_field, nil)
|> maybe_force_change_attribute(relationship, :destination_field, nil)
|> Ash.Changeset.set_context(relationship.context)
|> Ash.Changeset.set_tenant(tenant)
|> api.update(return_notifications?: true, actor: actor, authorize?: opts[:authorize?])
@ -1596,8 +1577,7 @@ defmodule Ash.Actions.ManagedRelationships do
_opts,
_action_name,
_tenant,
%{type: :belongs_to},
_changeset
%{type: :belongs_to}
) do
{:ok, []}
end
@ -1610,8 +1590,7 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
tenant,
%{type: :many_to_many} = relationship,
changeset
%{type: :many_to_many} = relationship
) do
action_name =
action_name || Ash.Resource.Info.primary_action(relationship.through, :destroy).name
@ -1629,7 +1608,6 @@ defmodule Ash.Actions.ManagedRelationships do
{:ok, result} ->
result
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_destroy(action_name, %{}, actor: actor)
|> Ash.Changeset.set_context(relationship.context)
|> Ash.Changeset.set_tenant(tenant)
@ -1659,15 +1637,13 @@ defmodule Ash.Actions.ManagedRelationships do
opts,
action_name,
tenant,
relationship,
changeset
relationship
) do
action_name =
action_name || Ash.Resource.Info.primary_action(relationship.destination, :update).name
record
|> Ash.Changeset.new()
|> set_source_context({relationship, changeset})
|> Ash.Changeset.for_destroy(action_name, %{},
relationships: opts[:relationships] || [],
actor: actor

View file

@ -74,7 +74,7 @@ defmodule Ash.Changeset do
if context == %{} do
empty()
else
concat("context: ", to_doc(sanitize_context(context), opts))
concat("context: ", to_doc(context, opts))
end
tenant =
@ -112,23 +112,6 @@ defmodule Ash.Changeset do
)
end
defp sanitize_context(%{manage_relationship_source: manage_relationship_source} = context) do
sanitized_managed_relationship_source =
manage_relationship_source
|> Enum.reverse()
|> Enum.map_join(" -> ", fn {resource, rel, _} ->
"#{inspect(resource)}.#{rel}"
end)
%{
context
| manage_relationship_source:
"#manage_relationship_source<#{sanitized_managed_relationship_source}>"
}
end
defp sanitize_context(context), do: context
defp arguments(changeset, opts) do
if changeset.action do
if Enum.empty?(changeset.action.arguments) do

View file

@ -153,7 +153,8 @@ defmodule Ash.Engine.Request do
nil
other ->
raise "Got a weird thing #{inspect(other)}"
raise ArgumentError,
message: "Unexpected value passed to `Ash.Query.new/1`: #{inspect(other)}"
end
data =

View file

@ -17,6 +17,7 @@ defmodule Ash.Resource.Relationships.HasMany do
:violation_message,
:manual,
:api,
no_fields?: false,
could_be_related_at_creation?: false,
validate_destination_field?: true,
cardinality: :many,
@ -30,6 +31,7 @@ defmodule Ash.Resource.Relationships.HasMany do
writable?: boolean,
read_action: atom,
filter: Ash.Filter.t() | nil,
no_fields?: boolean,
name: atom,
type: Ash.Type.t(),
destination: Ash.Resource.t(),
@ -48,7 +50,8 @@ defmodule Ash.Resource.Relationships.HasMany do
@opt_schema Ash.OptionsHelpers.merge_schemas(
[
manual()
manual(),
no_fields()
],
@global_opts,
"Relationship Options"

View file

@ -19,6 +19,7 @@ defmodule Ash.Resource.Relationships.HasOne do
:not_found_message,
:violation_message,
:manual,
no_fields?: false,
could_be_related_at_creation?: false,
validate_destination_field?: true,
cardinality: :one,
@ -33,6 +34,7 @@ defmodule Ash.Resource.Relationships.HasOne do
writable?: boolean,
name: atom,
read_action: atom,
no_fields?: boolean,
type: Ash.Type.t(),
filter: Ash.Filter.t() | nil,
destination: Ash.Resource.t(),
@ -51,7 +53,7 @@ defmodule Ash.Resource.Relationships.HasOne do
|> OptionsHelpers.set_default!(:source_field, :id)
@opt_schema Ash.OptionsHelpers.merge_schemas(
[manual()] ++
[manual(), no_fields()] ++
[
required?: [
type: :boolean,

View file

@ -109,6 +109,32 @@ defmodule Ash.Resource.Relationships.SharedOptions do
@shared_options
end
def no_fields do
{:no_fields?,
[
type: :boolean,
doc: """
If true, all existing entities are considered related, i.e this relationship is not based on any fields, and `source_field` and
`destination_field` are ignored.
This can be very useful when combined with multitenancy. Specifically, if you have a tenant resource like `Organization`,
you can use `no_fields?` to do things like `has_many :employees, Employee, no_fields?: true`, which lets you avoid having an
unnecessary `organization_id` field on `Employee`. The same works in reverse: `has_one :organization, Organization, no_fields?: true`
allows relating the employee to their organization.
Some important caveats here:
1. You can still manage relationships from one to the other, but "relate" and "unrelate"
will have no effect, because there are no fields to change.
2. Loading the relationship on a list of resources will not behave as expected in all circumstances involving multitenancy. For example,
if you get a list of `Organization` and then try to load `employees`, you would need to set a single tenant on the load query, meaning
you'll get all organizations back with the set of employees from one tenant. This could eventually be solved, but for now it is considered an
edge case.
"""
]}
end
def manual do
{:manual,
type: {:ash_behaviour, Ash.Resource.ManualRelationship},

View file

@ -16,7 +16,9 @@ defmodule Ash.Resource.Transformers.ValidateRelationshipAttributes do
resource
|> Ash.Resource.Info.relationships()
|> Enum.reject(&Map.get(&1, :manual))
|> Enum.reject(fn relationship ->
Map.get(relationship, :manual) || Map.get(relationship, :no_fields?)
end)
|> Enum.filter(& &1.validate_destination_field?)
|> Enum.each(&validate_relationship(&1, attribute_names, resource))

View file

@ -51,7 +51,7 @@ defmodule Ash.Test.CalculationTest do
defmodule BestFriendsName do
use Ash.Calculation
def load(_query, opts, _) do
def load(_query, _opts, _) do
[best_friend: :full_name]
end