Skip to content

Commit 471274d

Browse files
authored
improvement: support "unrelated" aggregates (#2240)
* improvement: support "unrelated" aggregates the terminology here is a bit confusing, but aggregates were originally designed over relationships. This allows to use a Resource, instead of a relationship.path in aggregates. * docs: document unrelated aggregates * chore: update usage rules w/ unrelated aggregate information * WIP * improvement: add unrelated exists expressions * use `related?` instead of `unrelated?` * add missed stuff when changing unrelated? -> related? * fix: don't skip authorization for unrelated aggregates * get build passing & make tests suck less * update docs * cleanup comments
1 parent 60dc935 commit 471274d

File tree

23 files changed

+2666
-339
lines changed

23 files changed

+2666
-339
lines changed

documentation/dsls/DSL-Ash.Resource.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2823,6 +2823,8 @@ Declares a named count aggregate on the resource
28232823

28242824
Supports `filter`, but not `sort` (because that wouldn't affect the count)
28252825

2826+
Can aggregate over relationships using a relationship path, or directly over another resource.
2827+
28262828
See the [aggregates guide](/documentation/topics/resources/aggregates.md) for more.
28272829

28282830

@@ -2838,14 +2840,21 @@ end
28382840
28392841
```
28402842

2843+
```
2844+
count :matching_profiles_count, Profile do
2845+
filter expr(name == parent(name))
2846+
end
2847+
2848+
```
2849+
28412850

28422851

28432852
### Arguments
28442853

28452854
| Name | Type | Default | Docs |
28462855
|------|------|---------|------|
28472856
| [`name`](#aggregates-count-name){: #aggregates-count-name .spark-required} | `atom` | | The field to place the aggregate in |
2848-
| [`relationship_path`](#aggregates-count-relationship_path){: #aggregates-count-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
2857+
| [`relationship_path`](#aggregates-count-relationship_path){: #aggregates-count-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
28492858
### Options
28502859

28512860
| Name | Type | Default | Docs |
@@ -2935,7 +2944,7 @@ exists :has_ticket, :assigned_tickets
29352944
| Name | Type | Default | Docs |
29362945
|------|------|---------|------|
29372946
| [`name`](#aggregates-exists-name){: #aggregates-exists-name .spark-required} | `atom` | | The field to place the aggregate in |
2938-
| [`relationship_path`](#aggregates-exists-relationship_path){: #aggregates-exists-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
2947+
| [`relationship_path`](#aggregates-exists-relationship_path){: #aggregates-exists-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
29392948
### Options
29402949

29412950
| Name | Type | Default | Docs |
@@ -3027,7 +3036,7 @@ end
30273036
| Name | Type | Default | Docs |
30283037
|------|------|---------|------|
30293038
| [`name`](#aggregates-first-name){: #aggregates-first-name .spark-required} | `atom` | | The field to place the aggregate in |
3030-
| [`relationship_path`](#aggregates-first-relationship_path){: #aggregates-first-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3039+
| [`relationship_path`](#aggregates-first-relationship_path){: #aggregates-first-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
30313040
| [`field`](#aggregates-first-field){: #aggregates-first-field } | `atom` | | The field to aggregate. Defaults to the first field in the primary key of the resource |
30323041
### Options
30333042

@@ -3120,7 +3129,7 @@ end
31203129
| Name | Type | Default | Docs |
31213130
|------|------|---------|------|
31223131
| [`name`](#aggregates-sum-name){: #aggregates-sum-name .spark-required} | `atom` | | The field to place the aggregate in |
3123-
| [`relationship_path`](#aggregates-sum-relationship_path){: #aggregates-sum-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3132+
| [`relationship_path`](#aggregates-sum-relationship_path){: #aggregates-sum-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
31243133
| [`field`](#aggregates-sum-field){: #aggregates-sum-field } | `atom` | | The field to aggregate. Defaults to the first field in the primary key of the resource |
31253134
### Options
31263135

@@ -3212,7 +3221,7 @@ end
32123221
| Name | Type | Default | Docs |
32133222
|------|------|---------|------|
32143223
| [`name`](#aggregates-list-name){: #aggregates-list-name .spark-required} | `atom` | | The field to place the aggregate in |
3215-
| [`relationship_path`](#aggregates-list-relationship_path){: #aggregates-list-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3224+
| [`relationship_path`](#aggregates-list-relationship_path){: #aggregates-list-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
32163225
| [`field`](#aggregates-list-field){: #aggregates-list-field } | `atom` | | The field to aggregate. Defaults to the first field in the primary key of the resource |
32173226
### Options
32183227

@@ -3306,7 +3315,7 @@ end
33063315
| Name | Type | Default | Docs |
33073316
|------|------|---------|------|
33083317
| [`name`](#aggregates-max-name){: #aggregates-max-name .spark-required} | `atom` | | The field to place the aggregate in |
3309-
| [`relationship_path`](#aggregates-max-relationship_path){: #aggregates-max-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3318+
| [`relationship_path`](#aggregates-max-relationship_path){: #aggregates-max-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
33103319
| [`field`](#aggregates-max-field){: #aggregates-max-field } | `atom` | | The field to aggregate. Defaults to the first field in the primary key of the resource |
33113320
### Options
33123321

@@ -3397,7 +3406,7 @@ end
33973406
| Name | Type | Default | Docs |
33983407
|------|------|---------|------|
33993408
| [`name`](#aggregates-min-name){: #aggregates-min-name .spark-required} | `atom` | | The field to place the aggregate in |
3400-
| [`relationship_path`](#aggregates-min-relationship_path){: #aggregates-min-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3409+
| [`relationship_path`](#aggregates-min-relationship_path){: #aggregates-min-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
34013410
| [`field`](#aggregates-min-field){: #aggregates-min-field } | `atom` | | The field to aggregate. Defaults to the first field in the primary key of the resource |
34023411
### Options
34033412

@@ -3488,7 +3497,7 @@ end
34883497
| Name | Type | Default | Docs |
34893498
|------|------|---------|------|
34903499
| [`name`](#aggregates-avg-name){: #aggregates-avg-name .spark-required} | `atom` | | The field to place the aggregate in |
3491-
| [`relationship_path`](#aggregates-avg-relationship_path){: #aggregates-avg-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3500+
| [`relationship_path`](#aggregates-avg-relationship_path){: #aggregates-avg-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
34923501
| [`field`](#aggregates-avg-field){: #aggregates-avg-field } | `atom` | | The field to aggregate. Defaults to the first field in the primary key of the resource |
34933502
### Options
34943503

@@ -3581,7 +3590,7 @@ end
35813590
| Name | Type | Default | Docs |
35823591
|------|------|---------|------|
35833592
| [`name`](#aggregates-custom-name){: #aggregates-custom-name .spark-required} | `atom` | | The field to place the aggregate in |
3584-
| [`relationship_path`](#aggregates-custom-relationship_path){: #aggregates-custom-relationship_path .spark-required} | `atom \| list(atom)` | | The relationship or relationship path to use for the aggregate |
3593+
| [`relationship_path`](#aggregates-custom-relationship_path){: #aggregates-custom-relationship_path .spark-required} | `list(atom) \| atom` | | The relationship or relationship path to use for the aggregate, or a resource module for resource-based aggregates |
35853594
| [`type`](#aggregates-custom-type){: #aggregates-custom-type .spark-required} | `module` | | The type of the value returned by the aggregate |
35863595
### Options
35873596

documentation/topics/reference/expressions.md

Lines changed: 58 additions & 2 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 directly: `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

@@ -121,7 +121,11 @@ For elixir-backed data layers, they will be a function or an MFA that will be ca
121121

122122
## Inline Aggregates
123123

124-
Aggregates can be referenced in-line, with their relationship path specified and any options provided that match the options given to `Ash.Query.Aggregate.new/4`. For example:
124+
Aggregates can be referenced in-line, with their relationship path specified and any options provided that match the options given to `Ash.Query.Aggregate.new/4`.
125+
126+
### Relationship-based Inline Aggregates
127+
128+
For aggregating over related data through relationships:
125129

126130
```elixir
127131
calculate :grade, :decimal, expr(
@@ -130,6 +134,36 @@ calculate :grade, :decimal, expr(
130134
)
131135
```
132136

137+
### Resource-based Inline Aggregates
138+
139+
For aggregating over any resource without a relationship:
140+
141+
```elixir
142+
# Count profiles matching the user's name
143+
calculate :matching_profiles, :integer,
144+
expr(count(Profile, filter: expr(name == parent(name))))
145+
146+
# Get the latest report title by the user
147+
calculate :latest_report, :string,
148+
expr(first(Report,
149+
field: :title,
150+
query: [
151+
filter: expr(author_name == parent(name)),
152+
sort: [inserted_at: :desc]
153+
]
154+
))
155+
156+
# Complex calculation with multiple resource-based aggregates and exists
157+
calculate :stats, :map, expr(%{
158+
profile_count: count(Profile, filter: expr(name == parent(name))),
159+
total_score: sum(Report, field: :score, query: [filter: expr(author_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))
162+
})
163+
```
164+
165+
The `parent/1` function allows referencing fields from the source resource within resource-based aggregate filters.
166+
133167
The available aggregate kinds can also be seen in the `Ash.Query.Aggregate` module documentation.
134168

135169
## Templates
@@ -235,6 +269,28 @@ Ash.Query.filter(Post, author.exists(roles, name == :admin) and author.active)
235269

236270
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.
237271

272+
### Resource-based Exists
273+
274+
Sometimes you want to check for the existence of records in any resource, not just through relationships. Resource-based 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 resource-based exists expressions using the target resource's primary read action.
293+
238294
## Portability
239295

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

documentation/topics/resources/aggregates.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22

33
Aggregates in Ash allow for retrieving summary information over groups of related data. A simple example might be to show the "count of published posts for a user". Aggregates allow us quick and performant access to this data, in a way that supports being filtered/sorted on automatically. More aggregate types can be added, but you will be restricted to only the supported types. In cases where aggregates don't suffice, use [Calculations](/documentation/topics/resources/calculations.md), which are intended to be much more flexible.
44

5+
Aggregates can work in two ways:
6+
1. **Relationship-based aggregates** - aggregate over data through relationships (the traditional approach)
7+
2. **Resource-based aggregates** - aggregate over any resource directly without requiring a relationship
8+
59
## Declaring aggregates on a resource
610

711
Aggregates are defined in an `aggregates` section. For all possible types, see below.
812
For a full reference, see `d:Ash.Resource.Dsl.aggregates`.
913

14+
### Relationship-based Aggregates
15+
1016
```elixir
1117
aggregates do
1218
count :count_of_posts, :posts do
@@ -15,6 +21,31 @@ aggregates do
1521
end
1622
```
1723

24+
### Resource-based Aggregates
25+
26+
Resource-based aggregates allow you to aggregate over any resource without needing a relationship. Instead of providing a relationship path, you provide the target resource module directly:
27+
28+
```elixir
29+
aggregates do
30+
# Count profiles with matching name
31+
count :matching_profiles_count, Profile do
32+
filter expr(name == parent(name))
33+
end
34+
35+
# Sum scores from reports where author matches user's name
36+
sum :total_report_score, Report, :score do
37+
filter expr(author_name == parent(name))
38+
end
39+
40+
# Check if any active profile exists (no parent filter needed)
41+
exists :has_active_profile, Profile do
42+
filter expr(active == true)
43+
end
44+
end
45+
```
46+
47+
The `parent/1` function allows you to reference fields from the source resource within the aggregate's filter expression.
48+
1849
## Using an aggregate
1950

2051
Aggregates are loaded and filtered on in the same way that calculations are. Lets look at some examples:
@@ -71,7 +102,7 @@ See the docs on `d:Ash.Resource.Dsl.aggregates` for more information.
71102

72103
Custom aggregates can be added to the query and will be placed in the `aggregates` key of the results. This is an escape hatch, and is not the primary way that you should be using aggregates. It does, however, allow for dynamism, i.e if you are accepting user input that determines what the filter and/or field should be, that kind of thing.
73104

74-
Example:
105+
### Relationship-based aggregate example:
75106

76107
```elixir
77108
User
@@ -85,6 +116,20 @@ User
85116
)
86117
```
87118

119+
### Resource-based aggregate example:
120+
121+
```elixir
122+
User
123+
|> Ash.Query.aggregate(
124+
:matching_profiles,
125+
:count,
126+
Profile,
127+
query: [
128+
filter: expr(name == parent(name))
129+
]
130+
)
131+
```
132+
88133
See the documentation for `Ash.Query.aggregate/4` for more information.
89134

90135
## Join Filters
@@ -112,11 +157,20 @@ Join filters allows for more complex aggregate queries, including joining with p
112157

113158
Aggregates can be created in-line in expressions, with their relationship path specified and any options provided that match the options given to `Ash.Query.Aggregate.new/4`. For example:
114159

160+
### Relationship-based inline aggregates
115161
```elixir
116162
calculate :grade, :decimal, expr(
117163
count(answers, query: [filter: expr(correct == true)]) /
118164
count(answers, query: [filter: expr(correct == false)])
119165
)
120166
```
121167

168+
### Resource-based inline aggregates
169+
```elixir
170+
calculate :profile_summary, :map, expr(%{
171+
matching_profiles: count(Profile, filter: expr(name == parent(name))),
172+
total_reports: count(Report, filter: expr(author_name == parent(name)))
173+
})
174+
```
175+
122176
See the [Expressions guide](/documentation/topics/reference/expressions.md#inline-aggregates) for more.

lib/ash/actions/aggregate.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ defmodule Ash.Actions.Aggregate do
8888
limit: query.limit,
8989
offset: query.offset,
9090
distinct: query.distinct,
91+
distinct_sort: query.distinct_sort,
92+
sort: query.sort,
9193
domain: query.domain,
9294
tenant: query.tenant,
9395
filter: query.filter,

0 commit comments

Comments
 (0)