Skip to content

Commit bfcc3f1

Browse files
committed
Add section on "How complexity scoring works"
1 parent 51e8a29 commit bfcc3f1

File tree

1 file changed

+184
-27
lines changed

1 file changed

+184
-27
lines changed

guides/queries/complexity_and_depth.md

Lines changed: 184 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,46 @@ index: 4
1010

1111
GraphQL-Ruby ships with some validations based on {% internal_link "query analysis", "/queries/ast_analysis" %}. You can customize them as-needed, too.
1212

13+
## Prevent deeply-nested queries
14+
15+
You can also reject queries based on the depth of their nesting. You can define `max_depth` at schema-level or query-level:
16+
17+
```ruby
18+
# Schema-level:
19+
class MySchema < GraphQL::Schema
20+
# ...
21+
max_depth 15
22+
end
23+
24+
# Query-level, which overrides the schema-level setting:
25+
MySchema.execute(query_string, max_depth: 20)
26+
```
27+
28+
By default, **introspection fields are counted**. The default introspection query requires at least `max_depth 13`. You can also configure your schema not to count introspection fields with `max_depth ..., count_introspection_fields: false`.
29+
30+
You can use `nil` to disable the validation:
31+
32+
```ruby
33+
# This query won't be validated:
34+
MySchema.execute(query_string, max_depth: nil)
35+
```
36+
37+
To get a feeling for depth of queries in your system, you can extend {{ "GraphQL::Analysis::AST::QueryDepth" | api_doc }}. Hook it up to log out values from each query:
38+
39+
```ruby
40+
class LogQueryDepth < GraphQL::Analysis::AST::QueryDepth
41+
def result
42+
query_depth = super
43+
message = "[GraphQL Query Depth] #{query_depth} || staff? #{query.context[:current_user].staff?}"
44+
Rails.logger.info(message)
45+
end
46+
end
47+
48+
class MySchema < GraphQL::Schema
49+
query_analyzer(LogQueryDepth)
50+
end
51+
```
52+
1353
## Prevent complex queries
1454

1555
Fields have a "complexity" value which can be configured in their definition. It can be a constant (numeric) value, or a proc. If no `complexity` is defined for a field, it will default to a value of `1`. It can be defined as a keyword _or_ inside the configuration block. For example:
@@ -121,42 +161,159 @@ class Types::BaseField < GraphQL::Schema::Field
121161
end
122162
```
123163

124-
## Prevent deeply-nested queries
164+
## How complexity scoring works
125165

126-
You can also reject queries based on the depth of their nesting. You can define `max_depth` at schema-level or query-level:
166+
GraphQL Ruby's complexity scoring algorithm is biased towards selection fairness. While highly accurate, its results are not always intuitive. Here's an example query performed on the [Shopify Admin API](https://shopify.dev/docs/api/admin-graphql):
127167

128-
```ruby
129-
# Schema-level:
130-
class MySchema < GraphQL::Schema
131-
# ...
132-
max_depth 15
133-
end
134-
135-
# Query-level, which overrides the schema-level setting:
136-
MySchema.execute(query_string, max_depth: 20)
168+
```graphql
169+
query {
170+
node(id: "123") { # interface Node
171+
id
172+
...on HasMetafields { # interface HasMetafields
173+
metafield(key: "a") {
174+
value
175+
}
176+
metafields(first: 10) {
177+
nodes {
178+
value
179+
}
180+
}
181+
}
182+
...on Product { # implements HasMetafields
183+
title
184+
metafield(key: "a") {
185+
definition {
186+
description
187+
}
188+
}
189+
}
190+
...on PriceList {
191+
name
192+
catalog {
193+
id
194+
}
195+
}
196+
}
197+
}
137198
```
138199

139-
By default, **introspection fields are counted**. The default introspection query requires at least `max_depth 13`. You can also configure your schema not to count introspection fields with `max_depth ..., count_introspection_fields: false`.
200+
First, GraphQL Ruby allows field definitions to specify a `complexity` attribute that provides a complexity score (or a proc that computes a score) for each field. Let's say that this schema defines a system where:
140201

141-
You can use `nil` to disable the validation:
202+
- Leaf fields cost `0`
203+
- Composite fields cost `1`
204+
- Connection fields cost `children * input size`
142205

143-
```ruby
144-
# This query won't be validated:
145-
MySchema.execute(query_string, max_depth: nil)
206+
Given these parameters, we get an itemized scoring distribution of:
207+
208+
```graphql
209+
query {
210+
node(id: "123") { # 1, composite
211+
id # 0, leaf
212+
...on HasMetafields {
213+
metafield(key: "a") { # 1, composite
214+
value # 0, leaf
215+
}
216+
metafields(first: 10) { # 1 * 10, connection
217+
nodes { # 1, composite
218+
value # 0, leaf
219+
}
220+
}
221+
}
222+
...on Product {
223+
title # 0, leaf
224+
metafield(key: "a") { # 1, composite
225+
definition { # 1, composite
226+
description # 0, leaf
227+
}
228+
}
229+
}
230+
...on PriceList {
231+
name # 0, leaf
232+
catalog { # 1, composite
233+
id # 0, leaf
234+
}
235+
}
236+
}
237+
}
146238
```
147239

148-
To get a feeling for depth of queries in your system, you can extend {{ "GraphQL::Analysis::AST::QueryDepth" | api_doc }}. Hook it up to log out values from each query:
240+
However, we cannot naively tally these itemized scores without over-costing the query. Consider:
241+
242+
- The `node` scope makes many _possible_ selections on an abstract type, so we need the maximum among concrete possibilities for a fair representation.
243+
- A `node.metafield` selection path is duplicated across the `HasMetafields` and `Product` selection scopes. This path will only resolve once, so should also only cost once.
244+
245+
To reconcile these possibilities, the [complexity algorithm](https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/analysis/ast/query_complexity.rb) breaks the selection down into a tree of types mapped to possible selections, across which lexical selections can be coalesced and deduplicated (pseudocode):
149246

150247
```ruby
151-
class LogQueryDepth < GraphQL::Analysis::AST::QueryDepth
152-
def result
153-
query_depth = super
154-
message = "[GraphQL Query Depth] #{query_depth} || staff? #{query.context[:current_user].staff?}"
155-
Rails.logger.info(message)
156-
end
157-
end
248+
{
249+
Schema::Query => {
250+
"node" => {
251+
Schema::Node => {
252+
"id" => nil,
253+
},
254+
Schema::HasMetafields => {
255+
"metafield" => {
256+
Schema::Metafield => {
257+
"value" => nil,
258+
},
259+
},
260+
"metafields" => {
261+
Schema::Metafield => {
262+
"nodes" => { ... },
263+
},
264+
},
265+
},
266+
Schema::Product => {
267+
"title" => nil,
268+
"metafield" => {
269+
Schema::Metafield => {
270+
"definition" => { ... },
271+
},
272+
},
273+
},
274+
Schema::PriceList => {
275+
"name" => nil,
276+
"catalog" => {
277+
Schema::Catalog => {
278+
"id" => nil,
279+
},
280+
},
281+
},
282+
},
283+
},
284+
}
285+
```
158286

159-
class MySchema < GraphQL::Schema
160-
query_analyzer(LogQueryDepth)
161-
end
287+
This aggregation provides a new perspective on the scoring where _possible typed selections_ have costs rather than individual fields. In this normalized view, `Product` acquires the `HasMetafields` interface costs, and ignores a duplicated path. Ultimately the maximum of possible typed costs is used, making this query cost `12`:
288+
289+
```graphql
290+
query {
291+
node(id: "123") { # max(11, 12, 1) = 12
292+
id
293+
...on HasMetafields { # 1 + 10 = 11
294+
metafield(key: "a") { # 1
295+
value
296+
}
297+
metafields(first: 10) { # 10
298+
nodes {
299+
value
300+
}
301+
}
302+
}
303+
...on Product { # 1 + 11 from HasMetafields = 12
304+
title
305+
metafield(key: "a") { # duplicated in HasMetafields
306+
definition { # 1
307+
description
308+
}
309+
}
310+
}
311+
...on PriceList { # 1 = 1
312+
name
313+
catalog { # 1
314+
id
315+
}
316+
}
317+
}
318+
}
162319
```

0 commit comments

Comments
 (0)