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, modify_query: 1,
module: 1, module: 1,
name: 1, name: 1,
no_fields?: 1,
not_found_message: 1, not_found_message: 1,
on: 1, on: 1,
only_when_valid?: 1, only_when_valid?: 1,

View file

@ -74,8 +74,15 @@ defmodule Ash.Actions.Load do
related_query.tenant 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 ++ requests ++
further_requests ++ further_requests ++
do_requests( do_requests(
@ -132,6 +139,12 @@ defmodule Ash.Actions.Load do
data data
end 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 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) primary_key = Ash.Resource.Info.primary_key(last_relationship.source)
@ -150,6 +163,12 @@ defmodule Ash.Actions.Load do
end) end)
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 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) primary_key = Ash.Resource.Info.primary_key(last_relationship.source)
@ -340,10 +359,7 @@ defmodule Ash.Actions.Load do
query: query:
load_query( load_query(
relationship, relationship,
related_query, related_query
path,
root_query,
request_path
), ),
data: data:
data( data(
@ -601,10 +617,7 @@ defmodule Ash.Actions.Load do
query: query:
load_query( load_query(
join_relationship, join_relationship,
related_query, related_query
Enum.reverse(join_relationship_path),
root_query,
request_path
), ),
data: data:
Request.resolve(dependencies, fn Request.resolve(dependencies, fn
@ -966,106 +979,25 @@ defmodule Ash.Actions.Load do
end end
end end
defp load_query_with_reverse_path( defp load_query(%{manual: manual}, related_query)
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)
when not is_nil(manual) do when not is_nil(manual) do
related_query related_query
end end
defp load_query( defp load_query(
relationship, relationship,
related_query, related_query
path,
root_query,
request_path
) do ) do
Request.resolve([request_path ++ [:data]], fn context -> if Map.get(relationship, :no_fields?) do
data = relationship.destination
case get_in(context, request_path ++ [:data, :results]) do |> Ash.Query.new(related_query.api)
%page{results: results} when page in [Ash.Page.Keyset, Ash.Page.Offset] -> else
results relationship.destination
|> Ash.Query.new(related_query.api)
data -> |> Ash.Query.filter(^related_query.filter)
data end
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)
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 defp true_load_query(relationship, query, data, path, request_path) do
{source_field, path} = {source_field, path} =
if relationship.type == :many_to_many do 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 defp get_query(query, relationship, source_data, source_field) do
{offset, limit} = offset_and_limit(query) {offset, limit} = offset_and_limit(query)
if lateral_join?(query, relationship, source_data) do cond do
{:ok, Ash.Query.unset(query, :load)} lateral_join?(query, relationship, source_data) ->
else {:ok, Ash.Query.unset(query, :load)}
query =
if limit || offset do
Ash.Query.unset(query, [:limit, :offset])
else
query
end
related_data = Map.get(relationship, :no_fields?) ->
case source_data do {:ok, query}
%page{results: results} when page in [Ash.Page.Keyset, Ash.Page.Offset] ->
results
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 data
end |> Map.get(source_field)
|> List.wrap()
end)
ids = filter_value =
Enum.flat_map(related_data, fn data -> case ids do
data [id] ->
|> Map.get(source_field) id
|> List.wrap()
end)
filter_value = ids ->
case ids do [in: ids]
[id] -> end
id
ids -> new_query =
[in: ids] query
end |> Ash.Query.filter(^[{relationship.destination_field, filter_value}])
|> Ash.Query.unset(:load)
new_query = {:ok, new_query}
query
|> Ash.Query.filter(^[{relationship.destination_field, filter_value}])
|> Ash.Query.unset(:load)
{:ok, new_query}
end end
end end
@ -1238,30 +1175,4 @@ defmodule Ash.Actions.Load do
is_nil(destination_rel.context) && is_nil(destination_rel.context) &&
is_nil(rel.context) is_nil(rel.context)
end 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 end

View file

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

View file

@ -74,7 +74,7 @@ defmodule Ash.Changeset do
if context == %{} do if context == %{} do
empty() empty()
else else
concat("context: ", to_doc(sanitize_context(context), opts)) concat("context: ", to_doc(context, opts))
end end
tenant = tenant =
@ -112,23 +112,6 @@ defmodule Ash.Changeset do
) )
end 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 defp arguments(changeset, opts) do
if changeset.action do if changeset.action do
if Enum.empty?(changeset.action.arguments) do if Enum.empty?(changeset.action.arguments) do

View file

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

View file

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

View file

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

View file

@ -109,6 +109,32 @@ defmodule Ash.Resource.Relationships.SharedOptions do
@shared_options @shared_options
end 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 def manual do
{:manual, {:manual,
type: {:ash_behaviour, Ash.Resource.ManualRelationship}, type: {:ash_behaviour, Ash.Resource.ManualRelationship},

View file

@ -16,7 +16,9 @@ defmodule Ash.Resource.Transformers.ValidateRelationshipAttributes do
resource resource
|> Ash.Resource.Info.relationships() |> 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.filter(& &1.validate_destination_field?)
|> Enum.each(&validate_relationship(&1, attribute_names, resource)) |> Enum.each(&validate_relationship(&1, attribute_names, resource))

View file

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