You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: documentation/dsls/DSL-Ash.Resource.md
+18-9Lines changed: 18 additions & 9 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2823,6 +2823,8 @@ Declares a named count aggregate on the resource
2823
2823
2824
2824
Supports `filter`, but not `sort` (because that wouldn't affect the count)
2825
2825
2826
+
Can aggregate over relationships using a relationship path, or directly over another resource.
2827
+
2826
2828
See the [aggregates guide](/documentation/topics/resources/aggregates.md) for more.
2827
2829
2828
2830
@@ -2838,14 +2840,21 @@ end
2838
2840
2839
2841
```
2840
2842
2843
+
```
2844
+
count :matching_profiles_count, Profile do
2845
+
filter expr(name == parent(name))
2846
+
end
2847
+
2848
+
```
2849
+
2841
2850
2842
2851
2843
2852
### Arguments
2844
2853
2845
2854
| Name | Type | Default | Docs |
2846
2855
|------|------|---------|------|
2847
2856
|[`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|
|[`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|
2939
2948
### Options
2940
2949
2941
2950
| Name | Type | Default | Docs |
@@ -3027,7 +3036,7 @@ end
3027
3036
| Name | Type | Default | Docs |
3028
3037
|------|------|---------|------|
3029
3038
|[`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|
3031
3040
|[`field`](#aggregates-first-field){: #aggregates-first-field } |`atom`|| The field to aggregate. Defaults to the first field in the primary key of the resource |
3032
3041
### Options
3033
3042
@@ -3120,7 +3129,7 @@ end
3120
3129
| Name | Type | Default | Docs |
3121
3130
|------|------|---------|------|
3122
3131
|[`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|
3124
3133
|[`field`](#aggregates-sum-field){: #aggregates-sum-field } |`atom`|| The field to aggregate. Defaults to the first field in the primary key of the resource |
3125
3134
### Options
3126
3135
@@ -3212,7 +3221,7 @@ end
3212
3221
| Name | Type | Default | Docs |
3213
3222
|------|------|---------|------|
3214
3223
|[`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|
3216
3225
|[`field`](#aggregates-list-field){: #aggregates-list-field } |`atom`|| The field to aggregate. Defaults to the first field in the primary key of the resource |
3217
3226
### Options
3218
3227
@@ -3306,7 +3315,7 @@ end
3306
3315
| Name | Type | Default | Docs |
3307
3316
|------|------|---------|------|
3308
3317
|[`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|
3310
3319
|[`field`](#aggregates-max-field){: #aggregates-max-field } |`atom`|| The field to aggregate. Defaults to the first field in the primary key of the resource |
3311
3320
### Options
3312
3321
@@ -3397,7 +3406,7 @@ end
3397
3406
| Name | Type | Default | Docs |
3398
3407
|------|------|---------|------|
3399
3408
|[`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|
3401
3410
|[`field`](#aggregates-min-field){: #aggregates-min-field } |`atom`|| The field to aggregate. Defaults to the first field in the primary key of the resource |
3402
3411
### Options
3403
3412
@@ -3488,7 +3497,7 @@ end
3488
3497
| Name | Type | Default | Docs |
3489
3498
|------|------|---------|------|
3490
3499
|[`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|
3492
3501
|[`field`](#aggregates-avg-field){: #aggregates-avg-field } |`atom`|| The field to aggregate. Defaults to the first field in the primary key of the resource |
3493
3502
### Options
3494
3503
@@ -3581,7 +3590,7 @@ end
3581
3590
| Name | Type | Default | Docs |
3582
3591
|------|------|---------|------|
3583
3592
|[`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|
3585
3594
|[`type`](#aggregates-custom-type){: #aggregates-custom-type .spark-required} |`module`|| The type of the value returned by the aggregate |
Copy file name to clipboardExpand all lines: documentation/topics/reference/expressions.md
+58-2Lines changed: 58 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -96,7 +96,7 @@ For elixir-backed data layers, they will be a function or an MFA that will be ca
96
96
97
97
## Sub-expressions
98
98
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.
100
100
-`path.exists/2` | Same as `exists` but the source of the relationship is itself a nested relationship. See the section on `exists` below.
101
101
-`parent/1` | Allows an expression scoped to a resource to refer to the "outer" context. Used in relationship filters and `exists`
102
102
@@ -121,7 +121,11 @@ For elixir-backed data layers, they will be a function or an MFA that will be ca
121
121
122
122
## Inline Aggregates
123
123
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:
has_active_profile:exists(Profile, active ==trueand 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
+
133
167
The available aggregate kinds can also be seen in the `Ash.Query.Aggregate` module documentation.
134
168
135
169
## Templates
@@ -235,6 +269,28 @@ Ash.Query.filter(Post, author.exists(roles, name == :admin) and author.active)
235
269
236
270
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.
237
271
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)
Ash.Query.filter(User, exists(Profile, active ==trueand age >25))
285
+
286
+
# Combine with other filters
287
+
Ash.Query.filter(User,
288
+
active ==trueandexists(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
+
238
294
## Portability
239
295
240
296
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:
Copy file name to clipboardExpand all lines: documentation/topics/resources/aggregates.md
+55-1Lines changed: 55 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,11 +2,17 @@
2
2
3
3
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.
4
4
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
+
5
9
## Declaring aggregates on a resource
6
10
7
11
Aggregates are defined in an `aggregates` section. For all possible types, see below.
8
12
For a full reference, see `d:Ash.Resource.Dsl.aggregates`.
9
13
14
+
### Relationship-based Aggregates
15
+
10
16
```elixir
11
17
aggregates do
12
18
count :count_of_posts, :postsdo
@@ -15,6 +21,31 @@ aggregates do
15
21
end
16
22
```
17
23
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, Profiledo
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, :scoredo
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, Profiledo
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
+
18
49
## Using an aggregate
19
50
20
51
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.
71
102
72
103
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.
73
104
74
-
Example:
105
+
### Relationship-based aggregate example:
75
106
76
107
```elixir
77
108
User
@@ -85,6 +116,20 @@ User
85
116
)
86
117
```
87
118
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
+
88
133
See the documentation for `Ash.Query.aggregate/4` for more information.
89
134
90
135
## Join Filters
@@ -112,11 +157,20 @@ Join filters allows for more complex aggregate queries, including joining with p
112
157
113
158
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:
0 commit comments