improvement: support parent/1 in relationships

This commit is contained in:
Zach Daniel 2023-07-26 16:46:22 -04:00
parent b9abe2aa72
commit bda7c56543
17 changed files with 625 additions and 171 deletions

View file

@ -125,7 +125,7 @@
{Credo.Check.Refactor.MatchInCondition, false},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, [max_nesting: 6]},
{Credo.Check.Refactor.Nesting, [max_nesting: 7]},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},

View file

@ -1,2 +1,2 @@
erlang 26.0.1
elixir 1.15.0
erlang 26.0.2
elixir 1.15.4

View file

@ -52,7 +52,7 @@ The following functions are built in:
- `exists/2` | `exists(foo.bar, name == "fred")` takes an expression scoped to the destination resource, and checks if any related entry matches. See the section on `exists` below.
- `path.exists/2` | Same as `exists` but the source of the relationship is itself a nested relationship. See the section on `exists` below.
- `parent/1` | Allows an expression scoped to a resource to refer to the "outer" context.
- `parent/1` | Allows an expression scoped to a resource to refer to the "outer" context. Used in relationship filters and `exists`
## DateTime Functions
@ -151,6 +151,19 @@ Ash.Query.filter(Post, author.exists(roles, name == :admin) and author.active)
While the above is not common, it can be useful in some specific circumstances, and is used under the hood by the policy authorizer when combining the filters of various resources to create a single filter.
## Relationship Filters
When filtering relationships, you can use the `parent/1` function to scope a part of the expression to "source" of the join. This allows for very expressive relationships! Keep in mind, however, that if you want to update and/or manage these relationships, you'll have to make sure that any attributes that make these things actually related are properly set.
```elixir
has_many :descendents, __MODULE__ do
description "All descendents in the same tree"
no_attributes? true # this says that there is no matching source_attribute and destination_attribute on this relationship
# This is an example using postgres' ltree extension.
filter expr(tree_id == parent(tree_id) and fragment("? @> ?", parent(path), path))
end
```
## Portability
Ash expressions being portable is more important than it sounds. For example, if you were using AshPostgres and had the following calculation, which is an expression capable of being run in elixir or translated to SQL:

View file

@ -142,7 +142,7 @@ defmodule Ash.Actions.Load do
defp attach_to_many_loads(
value,
%{name: name, no_attributes?: true},
%{name: name, no_attributes?: true} = relationship,
data,
lead_path
) do
@ -158,9 +158,55 @@ defmodule Ash.Actions.Load do
value |> Map.values() |> List.flatten()
end
map_or_update(data, lead_path, fn record ->
Map.put(record, name, value)
end)
if has_parent_expr?(relationship) do
case relationship_type_with_non_simple_equality(relationship) do
:error ->
map_or_update(data, lead_path, fn record ->
source_value = Map.get(record, relationship.source_attribute)
Map.put(
record,
name,
Enum.filter(value, fn
%{__lateral_join_source__: destination_value}
when not is_nil(destination_value) ->
destination_value == source_value
_ ->
# handle backwards compatibility on join
# data layers should now all be setting `__lateral_join_source__` for any
# lateral joins
true
end)
)
end)
{:ok, type} ->
map_or_update(data, lead_path, fn record ->
source_value = Map.get(record, relationship.source_attribute)
Map.put(
record,
name,
Enum.filter(value, fn
%{__lateral_join_source__: destination_value}
when not is_nil(destination_value) ->
Ash.Type.equal?(type, destination_value, source_value)
_ ->
# handle backwards compatibility on join
# data layers should now all be setting `__lateral_join_source__` for any
# lateral joins
true
end)
)
end)
end
else
map_or_update(data, lead_path, fn record ->
Map.put(record, name, value)
end)
end
end
defp attach_to_many_loads(value, last_relationship, data, lead_path)
@ -222,43 +268,75 @@ defmodule Ash.Actions.Load do
end
defp attach_to_many_loads(value, last_relationship, data, lead_path) do
if Ash.Resource.Info.primary_key_simple_equality?(last_relationship.destination) do
values = Enum.group_by(value, &Map.get(&1, last_relationship.destination_attribute))
case relationship_type_with_non_simple_equality(last_relationship) do
:error ->
values =
Enum.group_by(value, fn
%{__lateral_join_source__: lateral_join_source}
when not is_nil(lateral_join_source) ->
lateral_join_source
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
related_records = Map.get(values, source_key, [])
Map.put(record, last_relationship.name, related_records)
end)
else
destination_attribute = last_relationship.destination_attribute
type =
Ash.Resource.Info.attribute(last_relationship.destination, destination_attribute).type
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
related_records =
Enum.filter(value, fn maybe_related ->
Ash.Type.equal?(
type,
Map.get(maybe_related, destination_attribute),
source_key
)
record ->
Map.get(record, last_relationship.destination_attribute)
end)
Map.put(record, last_relationship.name, related_records)
end)
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
related_records = Map.get(values, source_key, [])
Map.put(record, last_relationship.name, related_records)
end)
{:ok, type} ->
destination_attribute = last_relationship.destination_attribute
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
related_records =
Enum.filter(value, fn
%{__lateral_join_source__: lateral_join_source}
when not is_nil(lateral_join_source) ->
lateral_join_source
maybe_related ->
Ash.Type.equal?(
type,
Map.get(maybe_related, destination_attribute),
source_key
)
end)
Map.put(record, last_relationship.name, related_records)
end)
end
end
defp attach_to_one_loads(value, %{name: name, no_attributes?: true}, data, lead_path) do
defp attach_to_one_loads(
value,
%{name: name, no_attributes?: true} = relationship,
data,
lead_path
) do
map_or_update(data, lead_path, fn record ->
if is_map(data) do
Map.put(record, name, value |> List.wrap() |> Enum.at(0) |> elem(1))
else
Map.put(record, name, value |> List.wrap() |> Enum.at(0))
source_value = Map.get(record, relationship.source_attribute)
case relationship_type_with_non_simple_equality(relationship) do
{:ok, type} ->
case value do
%{__lateral_join_source__: destination_value} when not is_nil(destination_value) ->
Ash.Type.equal?(
type,
source_value,
destination_value
)
value ->
Map.put(record, name, value |> List.wrap() |> Enum.at(0))
end
end
end
end)
end
@ -324,20 +402,35 @@ defmodule Ash.Actions.Load do
Map.put(record, last_relationship.name, related_record)
end)
else
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
case relationship_type_with_non_simple_equality(last_relationship) do
:error ->
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
related_record =
Enum.find(value, fn maybe_related ->
Ash.Type.equal?(
type,
Map.get(maybe_related, destination_attribute),
source_key
)
related_record =
Enum.find(value, fn maybe_related ->
Map.get(maybe_related, destination_attribute) == source_key
end)
Map.put(record, last_relationship.name, related_record)
end)
Map.put(record, last_relationship.name, related_record)
end)
{:ok, type} ->
map_or_update(data, lead_path, fn record ->
source_key = Map.get(record, last_relationship.source_attribute)
related_record =
Enum.find(value, fn maybe_related ->
Ash.Type.equal?(
type,
Map.get(maybe_related, destination_attribute),
source_key
)
end)
Map.put(record, last_relationship.name, related_record)
end)
end
end
end
@ -349,53 +442,75 @@ defmodule Ash.Actions.Load do
|> Map.get(join_path, %{})
|> Map.get(:data, [])
source_attribute_on_join_resource_type =
Ash.Resource.Info.attribute(
last_relationship.through,
last_relationship.source_attribute_on_join_resource
).type
destination_attribute_on_join_resource_type =
Ash.Resource.Info.attribute(
last_relationship.through,
last_relationship.destination_attribute_on_join_resource
).type
join_relationship =
Ash.Resource.Info.relationship(
last_relationship.source,
last_relationship.join_relationship
)
map_or_update(data, lead_path, fn record ->
source_value = Map.get(record, last_relationship.source_attribute)
join_values =
join_data
|> Enum.filter(fn join_row ->
Ash.Type.equal?(
source_attribute_on_join_resource_type,
Map.get(join_row, last_relationship.source_attribute_on_join_resource),
source_value
)
end)
|> Enum.map(&Map.get(&1, last_relationship.destination_attribute_on_join_resource))
related_records =
value
|> Enum.filter(fn
%{__lateral_join_source__: join_value} ->
Ash.Type.equal?(
destination_attribute_on_join_resource_type,
source_value,
join_value
)
value ->
destination_value = Map.get(value, last_relationship.destination_attribute)
Enum.any?(join_values, fn join_value ->
case relationship_type_with_non_simple_equality(join_relationship) do
{:ok, type} ->
join_data
|> Enum.filter(fn join_row ->
Ash.Type.equal?(
destination_attribute_on_join_resource_type,
destination_value,
join_value
type,
Map.get(join_row, last_relationship.source_attribute_on_join_resource),
source_value
)
end)
end)
|> Enum.map(&Map.get(&1, last_relationship.destination_attribute_on_join_resource))
:error ->
join_data
|> Enum.filter(fn join_row ->
Map.get(join_row, last_relationship.source_attribute_on_join_resource) ==
source_value
end)
|> Enum.map(&Map.get(&1, last_relationship.destination_attribute_on_join_resource))
end
related_records =
case relationship_type_with_non_simple_equality(last_relationship) do
{:ok, type} ->
value
|> Enum.filter(fn
%{__lateral_join_source__: join_value} when not is_nil(join_value) ->
Ash.Type.equal?(
type,
source_value,
join_value
)
value ->
destination_value = Map.get(value, last_relationship.destination_attribute)
Enum.any?(join_values, fn join_value ->
Ash.Type.equal?(
type,
destination_value,
join_value
)
end)
end)
:error ->
value
|> Enum.filter(fn
%{__lateral_join_source__: join_value} when not is_nil(join_value) ->
source_value == join_value
value ->
destination_value = Map.get(value, last_relationship.destination_attribute)
Enum.any?(join_values, fn join_value ->
join_value == destination_value
end)
end)
end
Map.put(record, last_relationship.name, related_records)
end)
@ -1297,7 +1412,7 @@ defmodule Ash.Actions.Load do
authorization_filter ->
base_query
|> Ash.Query.do_filter(authorization_filter)
|> Ash.Query.do_filter(authorization_filter, parent_stack: relationship.source)
end
source_data = get_in(data, parent_data_path)
@ -1388,6 +1503,7 @@ defmodule Ash.Actions.Load do
end
if action.manual do
raise_if_parent_expr!(relationship, "manual actions")
false
else
{offset, limit} = offset_and_limit(query)
@ -1398,29 +1514,72 @@ defmodule Ash.Actions.Load do
cond do
is_many_to_many_not_unique_on_join?(relationship) ->
raise_if_parent_expr!(
relationship,
"many to many relationships that don't have unique constraints on their join resource attributes"
)
false
limit == 1 && is_nil(relationship.context) && is_nil(relationship.filter) &&
is_nil(relationship.sort) && relationship.type != :many_to_many ->
false
has_parent_expr?(relationship)
limit == 1 && (source_data == :unknown || Enum.count_until(source_data, 2) == 1) &&
relationship.type != :many_to_many ->
false
has_parent_expr?(relationship)
true ->
lateral_join =
(limit || offset || relationship.type == :many_to_many) &&
Ash.DataLayer.data_layer_can?(
relationship.source,
{:lateral_join, resources}
)
if has_parent_expr?(relationship) do
true
else
lateral_join =
(limit || offset || relationship.type == :many_to_many) &&
Ash.DataLayer.data_layer_can?(
relationship.source,
{:lateral_join, resources}
)
!!lateral_join
Ash.DataLayer.prefer_lateral_join_for_many_to_many?(
Ash.DataLayer.data_layer(relationship.source)
) and !!lateral_join
end
end
end
end
defp raise_if_parent_expr!(relationship, reason) do
if has_parent_expr?(relationship) do
raise ArgumentError, "Found `parent_expr` in unsupported context: #{reason}"
end
end
defp has_parent_expr?(%{filter: filter}, depth \\ 0) do
not is_nil(
Ash.Filter.find(filter, fn
%Ash.Query.Call{name: :parent, args: [expr]} ->
if depth == 0 do
true
else
has_parent_expr?(expr, depth - 1)
end
%Ash.Query.Exists{expr: expr} ->
has_parent_expr?(expr, depth + 1)
%Ash.Query.Parent{expr: expr} ->
if depth == 0 do
true
else
has_parent_expr?(expr, depth - 1)
end
_ ->
false
end)
)
end
defp is_many_to_many_not_unique_on_join?(%{type: :many_to_many} = relationship) do
join_keys =
Enum.sort([
@ -1510,12 +1669,13 @@ defmodule Ash.Actions.Load do
}
})
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> remove_relationships_from_load()
|> read(relationship.read_action, request_opts)
lateral_join?(query, relationship, source_data) && (limit || offset) ->
lateral_join?(query, relationship, source_data) &&
(limit || offset || has_parent_expr?(relationship)) ->
query
|> Ash.Query.set_context(%{
data_layer: %{
@ -1528,7 +1688,7 @@ defmodule Ash.Actions.Load do
}
})
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> remove_relationships_from_load()
|> read(relationship.read_action, request_opts)
@ -1549,7 +1709,7 @@ defmodule Ash.Actions.Load do
true ->
query
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> remove_relationships_from_load()
|> read(
@ -1582,7 +1742,7 @@ defmodule Ash.Actions.Load do
) do
query
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> remove_relationships_from_load()
|> read(relationship.read_action, request_opts)
@ -1594,11 +1754,14 @@ defmodule Ash.Actions.Load do
join_data = get_in(data, join_request_path ++ [:data])
destination_attribute_on_join_resource_type =
Ash.Resource.Info.attribute(
relationship.through,
relationship.destination_attribute_on_join_resource
).type
equality =
case relationship_type_with_non_simple_equality(relationship) do
{:ok, type} ->
&equal_by_type(type, &1, &2)
:error ->
&(&1 == &2)
end
join_data
|> Enum.uniq_by(
@ -1612,8 +1775,7 @@ defmodule Ash.Actions.Load do
group =
Enum.map(group, fn join_row ->
Enum.find_value(results, fn {record, index} ->
if Ash.Type.equal?(
destination_attribute_on_join_resource_type,
if equality.(
Map.get(join_row, relationship.destination_attribute_on_join_resource),
Map.get(record, relationship.destination_attribute)
) do
@ -1639,6 +1801,7 @@ defmodule Ash.Actions.Load do
end
end)
else
# TODO: This needs to support `Ash.Type.equal?`
results
|> Enum.with_index()
|> Enum.group_by(fn {record, _i} ->
@ -1689,6 +1852,31 @@ defmodule Ash.Actions.Load do
end
end
defp relationship_type_with_non_simple_equality(relationship) do
with :error <-
type_without_simple_equality(relationship.source, relationship.source_attribute) do
type_without_simple_equality(relationship.destination, relationship.destination_attribute)
end
end
defp type_without_simple_equality(resource, attribute) do
case Ash.Resource.Info.attribute(resource, attribute) do
%{type: type} ->
if Ash.Type.simple_equality?(type) do
:error
else
{:ok, type}
end
_ ->
:error
end
end
defp equal_by_type(type, l, r) do
Ash.Type.equal?(type, l, r)
end
defp remove_relationships_from_load(query) do
case query.load do
empty when empty in [nil, []] ->

View file

@ -238,7 +238,9 @@ defmodule Ash.Actions.ManagedRelationships do
authorize?: opts[:authorize?]
)
|> Ash.Query.filter(^keys)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter,
parent_stack: relationship.source
)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.set_tenant(changeset.tenant)
@ -879,7 +881,7 @@ defmodule Ash.Actions.ManagedRelationships do
})
|> Ash.Query.for_read(read, input, actor: actor, authorize?: opts[:authorize?])
|> Ash.Query.filter(^keys)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.set_tenant(changeset.tenant)
@ -1530,7 +1532,7 @@ defmodule Ash.Actions.ManagedRelationships do
|> Ash.Query.limit(1)
|> Ash.Query.set_tenant(changeset.tenant)
|> Ash.Query.set_context(join_relationship.context)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> api(changeset, join_relationship).read_one(
authorize?: opts[:authorize?],

View file

@ -1362,11 +1362,7 @@ defmodule Ash.Actions.Read do
calculations_in_query
),
{:ok, query} <-
Ash.DataLayer.filter(
query,
filter,
ash_query.resource
),
data_layer_filter(query, filter, ash_query),
{:ok, query} <-
Ash.DataLayer.sort(query, ash_query.sort, ash_query.resource),
{:ok, query} <-
@ -1444,6 +1440,14 @@ defmodule Ash.Actions.Read do
)
end
defp data_layer_filter(query, filter, ash_query) do
Ash.DataLayer.filter(
query,
filter,
ash_query.resource
)
end
defp loaded_query(query, calculations_at_runtime) do
query
|> load_calc_requirements(calculations_at_runtime)

View file

@ -85,6 +85,7 @@ defmodule Ash.DataLayer do
{:ok, data_layer_query()} | {:error, term}
@callback distinct(data_layer_query(), list(atom), resource :: Ash.Resource.t()) ::
{:ok, data_layer_query()} | {:error, term}
@callback prefer_lateral_join_for_many_to_many?() :: boolean
@callback limit(
data_layer_query(),
limit :: non_neg_integer(),
@ -217,6 +218,7 @@ defmodule Ash.DataLayer do
upsert: 3,
functions: 1,
in_transaction?: 1,
prefer_lateral_join_for_many_to_many?: 0,
add_aggregate: 3,
add_aggregates: 3,
add_calculation: 4,
@ -232,6 +234,16 @@ defmodule Ash.DataLayer do
Extension.get_persisted(resource, :data_layer)
end
@doc "Wether or not lateral joins should be used for many to many relationships by default"
@spec prefer_lateral_join_for_many_to_many?(Ash.DataLayer.t()) :: boolean
def prefer_lateral_join_for_many_to_many?(data_layer) do
if function_exported?(data_layer, :prefer_lateral_join_for_many_to_many?, 0) do
data_layer.prefer_lateral_join_for_many_to_many?()
else
true
end
end
@doc "Whether or not the data layer supports a specific feature"
@spec data_layer_can?(Ash.Resource.t() | Spark.Dsl.t(), Ash.DataLayer.feature()) :: boolean
def data_layer_can?(resource, feature) do

View file

@ -1,5 +1,6 @@
defmodule Ash.DataLayer.Ets do
@behaviour Ash.DataLayer
require Ash.Query
@ets %Spark.Dsl.Section{
name: :ets,
@ -188,11 +189,9 @@ defmodule Ash.DataLayer.Ets do
@doc false
@impl true
def can?(resource, :async_engine) do
not private?(resource)
end
def can?(_, :distinct_sort), do: true
def can?(resource, :async_engine), do: not private?(resource)
def can?(_, {:lateral_join, _}), do: true
def can?(_, :bulk_create), do: true
def can?(_, :composite_primary_key), do: true
def can?(_, :expression_calculation), do: true
@ -360,11 +359,12 @@ defmodule Ash.DataLayer.Ets do
aggregates: aggregates,
api: api
},
_resource
_resource,
parent \\ nil
) do
with {:ok, records} <- get_records(resource, tenant),
{:ok, records} <-
filter_matches(records, filter, api),
filter_matches(records, filter, api, parent),
records <- Sort.runtime_sort(records, distinct_sort || sort, api: api),
records <- Sort.runtime_distinct(records, distinct, api: api),
records <- Sort.runtime_sort(records, sort, api: api),
@ -383,6 +383,151 @@ defmodule Ash.DataLayer.Ets do
defp do_limit(records, nil), do: records
defp do_limit(records, limit), do: Enum.take(records, limit)
@impl true
def prefer_lateral_join_for_many_to_many?, do: false
@impl true
def run_query_with_lateral_join(
query,
root_data,
_destination_resource,
[{source_query, source_attribute, destination_attribute, relationship}]
) do
source_attributes = Enum.map(root_data, &Map.get(&1, source_attribute))
source_query
|> Ash.Query.filter(ref(^source_attribute) in ^source_attributes)
|> Ash.Query.set_context(%{private: %{internal?: true}})
|> Ash.Query.unset(:load)
|> Ash.Query.unset(:select)
|> query.api.read(authorize?: false)
|> case do
{:error, error} ->
{:error, error}
{:ok, root_data} ->
root_data
|> Enum.reduce_while({:ok, []}, fn parent, {:ok, results} ->
new_filter =
if Map.get(relationship, :no_attributes?) do
query.filter
else
Ash.Filter.add_to_filter(query.filter, [
{destination_attribute, Map.get(parent, source_attribute)}
])
end
query = %{query | filter: new_filter}
case run_query(query, relationship.source, parent) do
{:ok, new_results} ->
new_results =
Enum.map(
new_results,
&Map.put(&1, :__lateral_join_source__, Map.get(parent, source_attribute))
)
{:cont, {:ok, new_results ++ results}}
{:error, error} ->
{:halt, {:error, error}}
end
end)
|> case do
{:ok, results} ->
{:ok, results}
{:error, error} ->
{:error, error}
end
end
end
def run_query_with_lateral_join(query, root_data, _destination_resource, [
{source_query, source_attribute, source_attribute_on_join_resource, relationship},
{through_query, destination_attribute_on_join_resource, destination_attribute,
_through_relationship}
]) do
source_attributes = Enum.map(root_data, &Map.get(&1, source_attribute))
source_query
|> Ash.Query.unset(:load)
|> Ash.Query.filter(ref(^source_attribute) in ^source_attributes)
|> Ash.Query.set_context(%{private: %{internal?: true}})
|> query.api.read(authorize?: false)
|> case do
{:error, error} ->
{:error, error}
{:ok, root_data} ->
root_data
|> Enum.reduce_while({:ok, []}, fn parent, {:ok, results} ->
through_query
|> Ash.Query.filter(
ref(^source_attribute_on_join_resource) ==
^Map.get(parent, source_attribute)
)
|> Ash.Query.set_context(%{private: %{internal?: true}})
|> query.api.read(authorize?: false)
|> case do
{:ok, join_data} ->
join_attrs =
Enum.map(join_data, &Map.get(&1, destination_attribute_on_join_resource))
new_filter =
if is_nil(query.filter) do
Ash.Filter.parse!(query.resource, [
{destination_attribute, [in: join_attrs]}
])
else
Ash.Filter.add_to_filter(query.filter, [
{destination_attribute, [in: join_attrs]}
])
end
query = %{query | filter: new_filter}
case run_query(query, relationship.source, parent) do
{:ok, new_results} ->
new_results =
Enum.flat_map(new_results, fn result ->
join_data
|> Enum.flat_map(fn join_row ->
if Map.get(join_row, destination_attribute_on_join_resource) ==
Map.get(result, destination_attribute) do
[
Map.put(
result,
:__lateral_join_source__,
Map.get(join_row, source_attribute_on_join_resource)
)
]
else
[]
end
end)
end)
{:cont, {:ok, new_results ++ results}}
{:error, error} ->
{:halt, {:error, error}}
end
{:error, error} ->
{:halt, {:error, error}}
end
end)
|> case do
{:ok, results} ->
{:ok, results}
{:error, error} ->
{:error, error}
end
end
end
def do_add_calculations(records, _resource, [], _api), do: {:ok, records}
def do_add_calculations(records, resource, calculations, api) do
@ -679,10 +824,11 @@ defmodule Ash.DataLayer.Ets do
end
end
defp filter_matches(records, nil, _api), do: {:ok, records}
defp filter_matches(records, filter, api, parent \\ nil)
defp filter_matches(records, nil, _api, _parent), do: {:ok, records}
defp filter_matches(records, filter, api) do
Ash.Filter.Runtime.filter_matches(api, records, filter)
defp filter_matches(records, filter, api, parent) do
Ash.Filter.Runtime.filter_matches(api, records, filter, parent: parent)
end
@doc false

View file

@ -1126,7 +1126,7 @@ defmodule Ash.Filter do
relationship.destination
|> Ash.Query.set_context(relationship.context)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.for_read(action, %{},
actor: actor,
authorize?: true,
@ -1664,7 +1664,7 @@ defmodule Ash.Filter do
relationship.destination
|> Ash.Query.new(api)
|> Ash.Query.do_filter(filter)
|> Ash.Query.do_filter(relationship.filter)
|> Ash.Query.do_filter(relationship.filter, parent_stack: relationship.source)
|> Ash.Query.sort(relationship.sort, prepend?: true)
|> Ash.Query.set_context(relationship.context)
|> filter_related_in(relationship, :lists.droplast(path), api, data)
@ -2724,6 +2724,13 @@ defmodule Ash.Filter do
end
end
defp resolve_call(
%Call{name: :parent, args: [arg], relationship_path: []},
context
) do
do_hydrate_refs(%Ash.Query.Parent{expr: arg}, context)
end
defp resolve_call(%Call{name: name, args: args} = call, context) do
could_be_calculation? = Enum.count(args) == 1 && Keyword.keyword?(Enum.at(args, 0))
@ -3017,6 +3024,10 @@ defmodule Ash.Filter do
end
def do_hydrate_refs(%Ash.Query.Parent{expr: expr} = this, context) do
if !Map.has_key?(context, :parent_stack) || context.parent_stack in [[], nil] do
raise "Attempted to use parent expression without a known parent: #{inspect(this)}"
end
context =
%{
context
@ -3173,25 +3184,14 @@ defmodule Ash.Filter do
%{__function__?: true, arguments: args} = func ->
%{func | arguments: Enum.map(args, &move_to_relationship_path(&1, relationship_path))}
%Ash.Query.Exists{expr: expr} = exists ->
%{
exists
| at_path: relationship_path ++ exists.at_path,
expr:
map(expr, fn
%Ash.Query.Parent{} = this ->
move_to_relationship_path(this, relationship_path)
%Ash.Query.Exists{} = exists ->
%{exists | at_path: relationship_path ++ exists.at_path}
other ->
other
end)
}
# %Ash.Query.Parent{expr: expr} = this ->
# %{this | expr: move_to_relationship_path(expr, relationship_path)}
%Ash.Query.Parent{expr: expr} = this ->
%{this | expr: move_to_relationship_path(expr, relationship_path)}
%Call{args: args} = call ->
%{call | args: Enum.map(args, &move_to_relationship_path(&1, relationship_path))}
%Call{name: :exists, relationship_path: call_path} = call ->
%{call | relationship_path: relationship_path ++ call_path}
%__MODULE__{expression: expression} = filter ->
%{filter | expression: move_to_relationship_path(expression, relationship_path)}

View file

@ -281,14 +281,16 @@ defmodule Ash.Filter.Runtime do
%resource{} ->
Ash.Filter.hydrate_refs(expression, %{
resource: resource,
public?: false
public?: false,
parent_stack: parent_stack(parent)
})
_ ->
if resource do
Ash.Filter.hydrate_refs(expression, %{
resource: resource,
public?: false
public?: false,
parent_stack: parent_stack(parent)
})
else
{:ok, expression}
@ -657,14 +659,16 @@ defmodule Ash.Filter.Runtime do
%resource{} ->
Ash.Filter.hydrate_refs(expression, %{
resource: resource,
public?: false
public?: false,
parent_stack: parent_stack(parent)
})
_ ->
if resource do
Ash.Filter.hydrate_refs(expression, %{
resource: resource,
public?: false
public?: false,
parent_stack: parent_stack(parent)
})
else
{:ok, expression}
@ -941,4 +945,7 @@ defmodule Ash.Filter.Runtime do
end
end
end
defp parent_stack(nil), do: []
defp parent_stack(%resource{}), do: [resource]
end

View file

@ -47,6 +47,9 @@ defmodule Ash.Filter.TemplateHelpers do
@doc "A helper for creating a reference"
def ref(name), do: {:_ref, [], name}
@doc "A helper for creating a parent reference"
def parent(expr), do: {:_parent, [], expr}
@doc "A helper for creating a reference to a related path"
def ref(path, name), do: {:_ref, path, name}

View file

@ -87,7 +87,8 @@ defmodule Ash.Helpers do
end
@doc false
def deep_merge_maps(left, right) when is_map(left) and is_map(right) do
def deep_merge_maps(left, right)
when is_map(left) and is_map(right) and not is_struct(left) and not is_struct(right) do
Map.merge(left, right, fn _, left, right ->
deep_merge_maps(left, right)
end)

View file

@ -2137,14 +2137,22 @@ defmodule Ash.Query do
end
@doc false
def do_filter(query, %Ash.Filter{} = filter) do
def do_filter(query, filter, opts \\ [])
def do_filter(query, %Ash.Filter{} = filter, opts) do
query = to_query(query)
if Ash.DataLayer.data_layer_can?(query.resource, :filter) do
new_filter =
case query.filter do
nil ->
Ash.Filter.parse(query.resource, filter, query.aggregates, query.calculations)
Ash.Filter.parse(
query.resource,
filter,
query.aggregates,
query.calculations,
with_parent_stack(%{}, opts)
)
existing_filter ->
Ash.Filter.add_to_filter(
@ -2152,16 +2160,21 @@ defmodule Ash.Query do
filter,
:and,
query.aggregates,
query.calculations
query.calculations,
with_parent_stack(%{}, opts)
)
end
case new_filter do
{:ok, filter} ->
case Ash.Filter.hydrate_refs(filter, %{
resource: query.resource,
public?: false
}) do
case Ash.Filter.hydrate_refs(
filter,
%{
resource: query.resource,
public?: false
}
|> with_parent_stack(opts)
) do
{:ok, result} ->
%{query | filter: result}
@ -2177,10 +2190,10 @@ defmodule Ash.Query do
end
end
def do_filter(query, nil), do: to_query(query)
def do_filter(query, []), do: to_query(query)
def do_filter(query, nil, _opts), do: to_query(query)
def do_filter(query, [], _opts), do: to_query(query)
def do_filter(query, statement) do
def do_filter(query, statement, opts) do
query = to_query(query)
if Ash.DataLayer.data_layer_can?(query.resource, :filter) do
@ -2191,23 +2204,29 @@ defmodule Ash.Query do
statement,
:and,
query.aggregates,
query.calculations
query.calculations,
with_parent_stack(%{}, opts)
)
else
Ash.Filter.parse(
query.resource,
statement,
query.aggregates,
query.calculations
query.calculations,
with_parent_stack(%{}, opts)
)
end
case filter do
{:ok, filter} ->
case Ash.Filter.hydrate_refs(filter, %{
resource: query.resource,
public?: false
}) do
case Ash.Filter.hydrate_refs(
filter,
%{
resource: query.resource,
public?: false
}
|> with_parent_stack(opts)
) do
{:ok, result} ->
%{query | filter: result}
@ -2223,6 +2242,16 @@ defmodule Ash.Query do
end
end
defp with_parent_stack(context, opts) do
if opts[:parent_stack] do
parent_stack = List.wrap(opts[:parent_stack])
Map.update(context, :parent_stack, parent_stack, &(parent_stack ++ &1))
else
context
end
end
@doc """
Lock the query results.

View file

@ -417,6 +417,7 @@ defmodule Ash.Resource do
:__meta__,
:__metadata__,
:__order__,
:__lateral_join_source__,
:*,
:calculations,
:aggregates,

View file

@ -98,6 +98,12 @@ defmodule Ash.Schema do
)
end
Module.put_attribute(
__MODULE__,
:ash_struct_fields,
{:__lateral_join_source__, nil}
)
struct_fields = Module.get_attribute(__MODULE__, :ash_struct_fields)
Module.delete_attribute(__MODULE__, struct_fields_name)
Module.register_attribute(__MODULE__, struct_fields_name, accumulate: true)
@ -242,6 +248,12 @@ defmodule Ash.Schema do
)
end
Module.put_attribute(
__MODULE__,
:ash_struct_fields,
{:__lateral_join_source__, nil}
)
struct_fields = Module.get_attribute(__MODULE__, :ash_struct_fields)
Module.delete_attribute(__MODULE__, struct_fields_name)

View file

@ -189,6 +189,11 @@ defmodule Ash.Test.Actions.LoadTest do
api(Ash.Test.Actions.LoadTest.Api2)
end
has_many :posts_with_same_title, __MODULE__ do
no_attributes? true
filter expr(parent(title) == title and parent(id) != id)
end
many_to_many(:categories, Ash.Test.Actions.LoadTest.Category,
through: Ash.Test.Actions.LoadTest.PostCategory,
destination_attribute_on_join_resource: :category_id,
@ -344,6 +349,29 @@ defmodule Ash.Test.Actions.LoadTest do
|> Map.get(:posts_in_same_category)
end
test "parent expressions can be used for complex constraints" do
post1 =
Post
|> new(%{title: "post1", category: "foo"})
|> Api.create!()
post1_same =
Post
|> new(%{title: "post1", category: "bar"})
|> Api.create!()
Post
|> new(%{title: "post2", category: "baz"})
|> Api.create!()
post1_same_id = post1_same.id
assert [%{id: ^post1_same_id}] =
post1
|> Api.load!(:posts_with_same_title)
|> Map.get(:posts_with_same_title)
end
test "it allows loading through manual relationships" do
post1 =
Post

View file

@ -258,13 +258,21 @@ defmodule Ash.Test.NotifierTest do
|> Ash.Changeset.new(%{name: "foo"})
|> Api.create!()
Post
|> Ash.Changeset.new(%{name: "foo"})
|> Ash.Changeset.manage_relationship(:related_posts, [post], type: :append_and_remove)
|> Api.create!()
|> Ash.Changeset.new(%{})
|> Ash.Changeset.manage_relationship(:related_posts, [], type: :append_and_remove)
|> Api.update!()
assert %{related_posts: [_]} =
post =
Post
|> Ash.Changeset.new(%{name: "foo"})
|> Ash.Changeset.manage_relationship(:related_posts, [post],
type: :append_and_remove
)
|> Api.create!()
|> Api.load!(:related_posts)
assert %{related_posts: []} =
post
|> Ash.Changeset.new(%{})
|> Ash.Changeset.manage_relationship(:related_posts, [], type: :append_and_remove)
|> Api.update!()
assert_receive {:notification, %{action: %{type: :destroy}, resource: PostLink}}
end