Skip to content

Commit 7906c4e

Browse files
authored
feat: Type and Query complexity callbacks (#273)
* feat: Type and Query complexity callbacks A custom complexity callback can be provided for a type to be used when that type is resolved through a relationship. A query-level complexity callback applies when the resource has a top-level query in the schema.
1 parent 6a1d032 commit 7906c4e

File tree

8 files changed

+106
-5
lines changed

8 files changed

+106
-5
lines changed

.formatter.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ spark_locals_without_parens = [
1313
attribute_types: 1,
1414
authorize?: 1,
1515
auto?: 1,
16+
complexity: 1,
1617
create: 2,
1718
create: 3,
1819
create: 4,

documentation/dsls/DSL-AshGraphql.Domain.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ get :get_post, :read
110110
| [`as_mutation?`](#graphql-queries-get-as_mutation?){: #graphql-queries-get-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. |
111111
| [`relay_id_translations`](#graphql-queries-get-relay_id_translations){: #graphql-queries-get-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. |
112112
| [`hide_inputs`](#graphql-queries-get-hide_inputs){: #graphql-queries-get-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. |
113+
| [`complexity`](#graphql-queries-get-complexity){: #graphql-queries-get-complexity } | `{module, list(any)}` | `{AshGraphql.Graphql.Resolver, :query_complexity}` | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
113114

114115

115116

@@ -156,6 +157,7 @@ read_one :current_user, :current_user
156157
| [`as_mutation?`](#graphql-queries-read_one-as_mutation?){: #graphql-queries-read_one-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. |
157158
| [`relay_id_translations`](#graphql-queries-read_one-relay_id_translations){: #graphql-queries-read_one-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. |
158159
| [`hide_inputs`](#graphql-queries-read_one-hide_inputs){: #graphql-queries-read_one-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. |
160+
| [`complexity`](#graphql-queries-read_one-complexity){: #graphql-queries-read_one-complexity } | `{module, list(any)}` | `{AshGraphql.Graphql.Resolver, :query_complexity}` | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
159161

160162

161163

@@ -207,6 +209,7 @@ list :list_posts_paginated, :read, relay?: true
207209
| [`as_mutation?`](#graphql-queries-list-as_mutation?){: #graphql-queries-list-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. |
208210
| [`relay_id_translations`](#graphql-queries-list-relay_id_translations){: #graphql-queries-list-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. |
209211
| [`hide_inputs`](#graphql-queries-list-hide_inputs){: #graphql-queries-list-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. |
212+
| [`complexity`](#graphql-queries-list-complexity){: #graphql-queries-list-complexity } | `{module, list(any)}` | `{AshGraphql.Graphql.Resolver, :query_complexity}` | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
210213

211214

212215

documentation/dsls/DSL-AshGraphql.Resource.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ end
6969
| [`argument_input_types`](#graphql-argument_input_types){: #graphql-argument_input_types } | `keyword` | | A keyword list of actions and their input type overrides for arguments. The type overrides should refer to types available in the graphql (absinthe) schema. `list_of/1` and `non_null/1` helpers can be used. |
7070
| [`primary_key_delimiter`](#graphql-primary_key_delimiter){: #graphql-primary_key_delimiter } | `String.t` | `"~"` | If a composite primary key exists, this can be set to determine delimiter used in the `id` field value. |
7171
| [`depth_limit`](#graphql-depth_limit){: #graphql-depth_limit } | `integer` | | A simple way to prevent massive queries. |
72+
| [`complexity`](#graphql-complexity){: #graphql-complexity } | `{module, list(any)}` | | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
7273
| [`generate_object?`](#graphql-generate_object?){: #graphql-generate_object? } | `boolean` | `true` | Whether or not to create the GraphQL object, this allows you to manually create the GraphQL object. |
7374
| [`filterable_fields`](#graphql-filterable_fields){: #graphql-filterable_fields } | `list(atom)` | | A list of fields that are allowed to be filtered on. Defaults to all filterable fields for which a GraphQL type can be created. |
7475
| [`nullable_fields`](#graphql-nullable_fields){: #graphql-nullable_fields } | `atom \| list(atom)` | | Mark fields as nullable even if they are required. This is useful when using field policies. See the authorization guide for more. |
@@ -137,6 +138,7 @@ get :get_post, :read
137138
| [`as_mutation?`](#graphql-queries-get-as_mutation?){: #graphql-queries-get-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. |
138139
| [`relay_id_translations`](#graphql-queries-get-relay_id_translations){: #graphql-queries-get-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. |
139140
| [`hide_inputs`](#graphql-queries-get-hide_inputs){: #graphql-queries-get-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. |
141+
| [`complexity`](#graphql-queries-get-complexity){: #graphql-queries-get-complexity } | `{module, list(any)}` | `{AshGraphql.Graphql.Resolver, :query_complexity}` | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
140142

141143

142144

@@ -182,6 +184,7 @@ read_one :current_user, :current_user
182184
| [`as_mutation?`](#graphql-queries-read_one-as_mutation?){: #graphql-queries-read_one-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. |
183185
| [`relay_id_translations`](#graphql-queries-read_one-relay_id_translations){: #graphql-queries-read_one-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. |
184186
| [`hide_inputs`](#graphql-queries-read_one-hide_inputs){: #graphql-queries-read_one-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. |
187+
| [`complexity`](#graphql-queries-read_one-complexity){: #graphql-queries-read_one-complexity } | `{module, list(any)}` | `{AshGraphql.Graphql.Resolver, :query_complexity}` | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
185188

186189

187190

@@ -232,6 +235,7 @@ list :list_posts_paginated, :read, relay?: true
232235
| [`as_mutation?`](#graphql-queries-list-as_mutation?){: #graphql-queries-list-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. |
233236
| [`relay_id_translations`](#graphql-queries-list-relay_id_translations){: #graphql-queries-list-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. |
234237
| [`hide_inputs`](#graphql-queries-list-hide_inputs){: #graphql-queries-list-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. |
238+
| [`complexity`](#graphql-queries-list-complexity){: #graphql-queries-list-complexity } | `{module, list(any)}` | `{AshGraphql.Graphql.Resolver, :query_complexity}` | An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query. |
235239

236240

237241

@@ -582,7 +586,7 @@ managed_relationship action, argument
582586

583587
Configures the behavior of a given managed_relationship for a given action.
584588

585-
If there are type conflicts (for example, if the input could create or update a record, and the
589+
If there are type conflicts (for example, if the input could create or update a record, and the
586590
create and update actions have an argument of the same name but with a different type),
587591
a warning is emitted at compile time and the first one is used. If that is insufficient, you will need to do one of the following:
588592

lib/resource/info.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,9 @@ defmodule AshGraphql.Resource.Info do
229229
true
230230
)
231231
end
232+
233+
@doc "The complexity callback `{mod, fun}` for this type"
234+
def complexity(resource) do
235+
Extension.get_opt(resource, [:graphql], :complexity, nil)
236+
end
232237
end

lib/resource/query.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule AshGraphql.Resource.Query do
1010
:modify_resolution,
1111
:relay_id_translations,
1212
:description,
13+
:complexity,
1314
as_mutation?: false,
1415
hide_inputs: [],
1516
metadata_names: [],
@@ -74,6 +75,13 @@ defmodule AshGraphql.Resource.Query do
7475
type: {:list, :atom},
7576
doc: "A list of inputs to hide from the mutation.",
7677
default: []
78+
],
79+
complexity: [
80+
type: :mod_arg,
81+
doc: """
82+
An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query.
83+
""",
84+
default: {AshGraphql.Graphql.Resolver, :query_complexity}
7785
]
7886
]
7987

lib/resource/resource.ex

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ defmodule AshGraphql.Resource do
192192
describe: """
193193
Configures the behavior of a given managed_relationship for a given action.
194194
195-
If there are type conflicts (for example, if the input could create or update a record, and the
195+
If there are type conflicts (for example, if the input could create or update a record, and the
196196
create and update actions have an argument of the same name but with a different type),
197197
a warning is emitted at compile time and the first one is used. If that is insufficient, you will need to do one of the following:
198198
@@ -419,6 +419,12 @@ defmodule AshGraphql.Resource do
419419
A simple way to prevent massive queries.
420420
"""
421421
],
422+
complexity: [
423+
type: :mod_arg,
424+
doc: """
425+
An {module, function} that will be called with the arguments and complexity value of the child fields query. It should return the complexity of this query.
426+
"""
427+
],
422428
generate_object?: [
423429
type: :boolean,
424430
doc:
@@ -688,7 +694,7 @@ defmodule AshGraphql.Resource do
688694
[
689695
{{AshGraphql.Graphql.Resolver, :resolve}, {domain, resource, query, relay_ids?}}
690696
],
691-
complexity: {AshGraphql.Graphql.Resolver, :query_complexity},
697+
complexity: query.complexity || {AshGraphql.Graphql.Resolver, :query_complexity},
692698
module: schema,
693699
name: to_string(query.name),
694700
description: query.description || query_action.description,
@@ -4303,6 +4309,7 @@ defmodule AshGraphql.Resource do
43034309
end
43044310

43054311
type = AshGraphql.Resource.Info.type(relationship.destination)
4312+
type_complexity = AshGraphql.Resource.Info.complexity(relationship.destination)
43064313

43074314
pagination_strategy =
43084315
relationship_pagination_strategy(resource, relationship.name, read_action)
@@ -4314,7 +4321,7 @@ defmodule AshGraphql.Resource do
43144321
module: schema,
43154322
name: to_string(name),
43164323
description: relationship.description,
4317-
complexity: {AshGraphql.Graphql.Resolver, :query_complexity},
4324+
complexity: type_complexity || {AshGraphql.Graphql.Resolver, :query_complexity},
43184325
middleware: [
43194326
{{AshGraphql.Graphql.Resolver, :resolve_assoc_many},
43204327
{domain, relationship, pagination_strategy}}

test/read_test.exs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,69 @@ defmodule AshGraphql.ReadTest do
421421
]
422422
end
423423

424+
test "custom complexity calculation" do
425+
query = """
426+
query PostLibrary {
427+
paginatedPosts(limit: 2) {
428+
results{
429+
text
430+
sponsoredComments(limit: 5) {
431+
text
432+
}
433+
}
434+
}
435+
}
436+
"""
437+
438+
resp =
439+
query
440+
|> Absinthe.run(AshGraphql.Test.Schema,
441+
analyze_complexity: true,
442+
max_complexity: 300
443+
)
444+
445+
assert {:ok, %{errors: errors}} = resp
446+
447+
assert errors |> Enum.map(& &1.message) |> Enum.sort() == [
448+
"Field paginatedPosts is too complex: complexity is 1014 and maximum is 300",
449+
"Field results is too complex: complexity is 507 and maximum is 300",
450+
"Field sponsoredComments is too complex: complexity is 505 and maximum is 300",
451+
"Operation PostLibrary is too complex: complexity is 1014 and maximum is 300"
452+
]
453+
end
454+
455+
test "complexity calculation for aliased top-level queries" do
456+
query = """
457+
query SponsoredComments {
458+
c1: getSponsoredComment(id: "abc-123") {
459+
text
460+
}
461+
c2: getSponsoredComment(id: "def-456") {
462+
text
463+
}
464+
c3: getSponsoredComment(id: "fed-789") {
465+
text
466+
}
467+
c4: getSponsoredComment(id: "cba-012") {
468+
text
469+
}
470+
}
471+
"""
472+
473+
resp =
474+
query
475+
|> Absinthe.run(AshGraphql.Test.Schema,
476+
analyze_complexity: true,
477+
max_complexity: 300
478+
)
479+
480+
assert {:ok, %{errors: errors}} = resp
481+
482+
assert errors |> Enum.map(& &1.message) |> Enum.sort() == [
483+
"Operation SponsoredComments is too complex: complexity is 404 and maximum is 300"
484+
]
485+
end
486+
424487
test "a read with a loaded field works" do
425488
AshGraphql.Test.Post
426489
|> Ash.Changeset.for_create(:create, text: "bar", published: true)

test/support/resources/sponsored_comment.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ defmodule AshGraphql.Test.SponsoredComment do
88

99
graphql do
1010
type :sponsored_comment
11+
complexity {__MODULE__, :query_complexity}
1112

1213
queries do
13-
get :get_sponsored_comment, :read
14+
get :get_sponsored_comment, :read, complexity: {__MODULE__, :query_complexity}
1415
end
1516

1617
mutations do
@@ -45,4 +46,13 @@ defmodule AshGraphql.Test.SponsoredComment do
4546
relationships do
4647
belongs_to(:post, AshGraphql.Test.Post, public?: true)
4748
end
49+
50+
@doc "Sponsored comments are complex to serve, add 100 to the cost per comment"
51+
def query_complexity(%{limit: n}, child_complexity, _resolution) do
52+
n * (child_complexity + 100)
53+
end
54+
55+
def query_complexity(_args, child_complexity, _resolution) do
56+
child_complexity + 100
57+
end
4858
end

0 commit comments

Comments
 (0)