Skip to content

Commit 9501c5f

Browse files
committed
improvement: add unrelated exists expressions
1 parent d254f20 commit 9501c5f

File tree

11 files changed

+1175
-75
lines changed

11 files changed

+1175
-75
lines changed

documentation/topics/reference/expressions.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ For elixir-backed data layers, they will be a function or an MFA that will be ca
9696

9797
## Sub-expressions
9898

99-
- `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.
99+
- `exists/2` | `exists(foo.bar, name == "fred")` takes an expression scoped to the destination resource, and checks if any related entry matches. Can also be used with resource modules for unrelated exists: `exists(SomeResource, name == "fred")`. See the section on `exists` below.
100100
- `path.exists/2` | Same as `exists` but the source of the relationship is itself a nested relationship. See the section on `exists` below.
101101
- `parent/1` | Allows an expression scoped to a resource to refer to the "outer" context. Used in relationship filters and `exists`
102102

@@ -153,11 +153,12 @@ calculate :latest_report, :string,
153153
]
154154
))
155155

156-
# Complex calculation with multiple unrelated aggregates
156+
# Complex calculation with multiple unrelated aggregates and exists
157157
calculate :stats, :map, expr(%{
158158
profile_count: count(Profile, filter: expr(name == parent(name))),
159159
total_score: sum(Report, field: :score, query: [filter: expr(author_name == parent(name))]),
160-
has_active_profile: exists(Profile, filter: expr(active == true and name == parent(name)))
160+
has_active_profile: exists(Profile, active == true and name == parent(name)),
161+
has_recent_reports: exists(Report, author_name == parent(name) and inserted_at > ago(1, :week))
161162
})
162163
```
163164

@@ -268,6 +269,28 @@ Ash.Query.filter(Post, author.exists(roles, name == :admin) and author.active)
268269

269270
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.
270271

272+
### Unrelated Exists
273+
274+
Sometimes you want to check for the existence of records in any resource, not just through relationships. Unrelated exists allows you to query any resource directly:
275+
276+
```elixir
277+
# Check if there are any profiles with the same name as the user
278+
Ash.Query.filter(User, exists(Profile, name == parent(name)))
279+
280+
# Check if user has reports (without needing a relationship)
281+
Ash.Query.filter(User, exists(Report, author_name == parent(name)))
282+
283+
# Check existence with complex conditions
284+
Ash.Query.filter(User, exists(Profile, active == true and age > 25))
285+
286+
# Combine with other filters
287+
Ash.Query.filter(User,
288+
active == true and exists(Profile, name == parent(name))
289+
)
290+
```
291+
292+
The `parent/1` function allows you to reference fields from the source resource within the exists expression. Authorization is automatically applied to unrelated exists expressions using the target resource's primary read action.
293+
271294
## Portability
272295

273296
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:

lib/ash/actions/read/read.ex

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4686,16 +4686,61 @@ defmodule Ash.Actions.Read do
46864686
{:ok,
46874687
filter
46884688
|> Ash.Filter.map(fn
4689-
%Ash.Query.Exists{at_path: at_path, path: exists_path, expr: exists_expr} = exists ->
4690-
{:ok, new_expr} =
4691-
do_filter_with_related(
4692-
resource,
4693-
exists_expr,
4694-
path_filters,
4695-
prefix ++ at_path ++ exists_path
4696-
)
4697-
4698-
{:halt, %{exists | expr: new_expr}}
4689+
%Ash.Query.Exists{
4690+
at_path: at_path,
4691+
path: exists_path,
4692+
expr: exists_expr,
4693+
unrelated?: unrelated?,
4694+
resource: unrelated_resource
4695+
} = exists ->
4696+
if unrelated? do
4697+
primary_read_action = Ash.Resource.Info.primary_action!(unrelated_resource, :read)
4698+
filter_key = {:unrelated_exists, unrelated_resource, primary_read_action.name}
4699+
4700+
case Map.get(path_filters, filter_key) do
4701+
nil ->
4702+
{:ok, new_expr} =
4703+
do_filter_with_related(
4704+
unrelated_resource,
4705+
exists_expr,
4706+
path_filters,
4707+
[]
4708+
)
4709+
4710+
{:halt, %{exists | expr: new_expr}}
4711+
4712+
%Ash.Filter{expression: false} ->
4713+
{:halt, false}
4714+
4715+
auth_filter ->
4716+
{:ok, new_expr} =
4717+
do_filter_with_related(
4718+
unrelated_resource,
4719+
exists_expr,
4720+
path_filters,
4721+
[]
4722+
)
4723+
4724+
combined_expr =
4725+
Ash.Query.BooleanExpression.optimized_new(
4726+
:and,
4727+
new_expr,
4728+
auth_filter.expression
4729+
)
4730+
4731+
{:halt, %{exists | expr: combined_expr}}
4732+
end
4733+
else
4734+
{:ok, new_expr} =
4735+
do_filter_with_related(
4736+
resource,
4737+
exists_expr,
4738+
path_filters,
4739+
prefix ++ at_path ++ exists_path
4740+
)
4741+
4742+
{:halt, %{exists | expr: new_expr}}
4743+
end
46994744

47004745
other ->
47014746
other

lib/ash/data_layer/data_layer.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ defmodule Ash.DataLayer do
7474
| {:combine, combination_type}
7575
| {:atomic, :update}
7676
| {:atomic, :upsert}
77+
| {:exists, :unrelated}
7778
| {:lateral_join, list(Ash.Resource.t())}
7879
| {:join, Ash.Resource.t()}
7980
| {:aggregate, :unrelated}

lib/ash/data_layer/ets/ets.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ defmodule Ash.DataLayer.Ets do
201201
def can?(_, {:aggregate, :avg}), do: true
202202
def can?(_, {:aggregate, :exists}), do: true
203203
def can?(_, {:aggregate, :unrelated}), do: true
204+
def can?(_, {:exists, :unrelated}), do: true
204205
def can?(_, :changeset_filter), do: true
205206
def can?(_, :update_query), do: true
206207
def can?(_, :destroy_query), do: true

lib/ash/data_layer/mnesia/mnesia.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ defmodule Ash.DataLayer.Mnesia do
9292
def can?(_, :sort), do: true
9393
def can?(_, :filter), do: true
9494
def can?(_, {:filter_relationship, _}), do: true
95-
def can?(_, {:query_aggregate, :count}), do: true
9695
def can?(_, :expression_calculation), do: true
9796
def can?(_, :expression_calculation_sort), do: true
9897
def can?(_, :limit), do: true
@@ -111,6 +110,8 @@ defmodule Ash.DataLayer.Mnesia do
111110
def can?(_, {:aggregate, :min}), do: true
112111
def can?(_, {:aggregate, :avg}), do: true
113112
def can?(_, {:aggregate, :exists}), do: true
113+
def can?(_, {:aggregate, :unrelated}), do: true
114+
def can?(_, {:exists, :unrelated}), do: true
114115
def can?(resource, {:query_aggregate, kind}), do: can?(resource, {:aggregate, kind})
115116

116117
case Application.compile_env(:ash, :no_join_mnesia_ets) || false do

lib/ash/expr/expr.ex

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ defmodule Ash.Expr do
721721
when is_atom(op) and op in @operator_symbols do
722722
args = Enum.map(args, &do_expr(&1, false))
723723

724-
if op == :== do
724+
if op in [:==, :!=, :>, :<, :>=, :<=] do
725725
soft_escape(
726726
quote do
727727
args = unquote(args)
@@ -765,6 +765,80 @@ defmodule Ash.Expr do
765765
)
766766
end
767767

768+
def do_expr({:exists, _, [{:__aliases__, _, _parts} = alias_ast, original_expr]}, escape?) do
769+
processed_expr = do_expr(original_expr, false)
770+
771+
soft_escape(
772+
quote do
773+
%Ash.Query.Exists{
774+
path: [],
775+
resource: Macro.escape(unquote(alias_ast)),
776+
expr: unquote(processed_expr),
777+
at_path: [],
778+
unrelated?: true
779+
}
780+
end,
781+
escape?
782+
)
783+
end
784+
785+
def do_expr({:exists, _, [{:__aliases__, _, _parts} = alias_ast]}, escape?) do
786+
soft_escape(
787+
quote do
788+
%Ash.Query.Exists{
789+
path: [],
790+
resource: Macro.escape(unquote(alias_ast)),
791+
expr: true,
792+
at_path: [],
793+
unrelated?: true
794+
}
795+
end,
796+
escape?
797+
)
798+
end
799+
800+
def do_expr({:exists, _, [module_atom, original_expr]}, escape?) when is_atom(module_atom) do
801+
module_string = Atom.to_string(module_atom)
802+
803+
if String.match?(module_string, ~r/^[A-Z].*/) do
804+
processed_expr = do_expr(original_expr, false)
805+
806+
soft_escape(
807+
quote do
808+
%Ash.Query.Exists{
809+
path: [],
810+
resource: unquote(module_atom),
811+
expr: unquote(processed_expr),
812+
at_path: [],
813+
unrelated?: true
814+
}
815+
end,
816+
escape?
817+
)
818+
else
819+
expr_with_at_path(module_atom, [], original_expr, Ash.Query.Exists, escape?)
820+
end
821+
end
822+
823+
def do_expr({:exists, _, [module_atom]}, escape?) when is_atom(module_atom) do
824+
module_string = Atom.to_string(module_atom)
825+
826+
if String.match?(module_string, ~r/^[A-Z].*/) do
827+
soft_escape(
828+
%Ash.Query.Exists{
829+
path: [],
830+
resource: module_atom,
831+
expr: true,
832+
at_path: [],
833+
unrelated?: true
834+
},
835+
escape?
836+
)
837+
else
838+
expr_with_at_path(module_atom, [], true, Ash.Query.Exists, escape?)
839+
end
840+
end
841+
768842
def do_expr({:exists, _, [path, original_expr]}, escape?) do
769843
expr_with_at_path(path, [], original_expr, Ash.Query.Exists, escape?)
770844
end

0 commit comments

Comments
 (0)