From fdbc1999526a781fff4acb846c015d419f57c9f3 Mon Sep 17 00:00:00 2001 From: Rafal Piotrowski Date: Sat, 25 Jul 2026 13:42:56 +0200 Subject: [PATCH] Make QuerySizeValidator's combined-limit case reachable; add docs The depth && width case was ordered after the individual limit cases, so "Query is too complex" could never be reported. Reorder the cases, cover all three failure branches with tests, add Scaladoc, and add a "Compiler Phases" tutorial page showing how to enable the validator via compilerPhases. --- docs/tutorial/compiler-phases.md | 91 +++++++++++++++++++ docs/tutorial/directory.conf | 1 + modules/core/src/main/scala/compiler.scala | 22 ++++- .../test/scala/compiler/QuerySizeSuite.scala | 29 +++++- 4 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 docs/tutorial/compiler-phases.md diff --git a/docs/tutorial/compiler-phases.md b/docs/tutorial/compiler-phases.md new file mode 100644 index 00000000..2a08041a --- /dev/null +++ b/docs/tutorial/compiler-phases.md @@ -0,0 +1,91 @@ +# Compiler Phases + +Grackle compiles GraphQL queries into terms of a query algebra before interpreting them against a mapping. The +compiler runs a sequence of _phases_, each of which transforms the query algebra term, bringing it step by step into +a form which can be executed directly by the query interpreter. + +Several phases are built in and always run, among them the elaborators which substitute variables, apply `@skip` and +`@include` directives, inline fragments, and rewrite field selections according to the rules of a mapping's +`SelectElaborator`. In addition, mappings can install custom phases by overriding `compilerPhases`, + +```scala +override def compilerPhases: List[QueryCompiler.Phase] = + super.compilerPhases :+ new MyCustomPhase +``` + +A phase implements the `Phase` trait from `QueryCompiler`, + +```scala +trait Phase { + def transform(query: Query): Elab[Query] +} +``` + +`transform` receives the query algebra term produced by the preceding phase and yields a transformed term, or fails, +in the `Elab` monad. `Elab` gives phases access to the schema, the current context and the query's fragment +definitions, and allows compilation to be aborted with one or more GraphQL errors via `Elab.failure`. A phase which +fails prevents the query from executing at all — the client receives an error response with no data. + +This makes phases a natural place to enforce global policies on incoming queries. Grackle provides one such policy +phase out of the box: `QuerySizeValidator`. + +## Limiting query size with QuerySizeValidator + +A GraphQL server which accepts arbitrary queries from untrusted clients needs protection against queries which are +too expensive to execute — deeply nested or extremely broad queries can otherwise consume unbounded resources. The +`QuerySizeValidator` phase rejects queries exceeding a configurable maximum depth or width before they reach the +interpreter, + +```scala +override def compilerPhases: List[QueryCompiler.Phase] = + super.compilerPhases :+ new QuerySizeValidator(maxDepth = 5, maxWidth = 5) +``` + +_Depth_ is the number of nested selection levels in the query, and _width_ is the total number of leaf fields +selected. Both are computed after fragment spreads have been resolved, so a query cannot evade the limits by +factoring its selections into fragments. + +For example, with the Star Wars model from the previous chapter and the limits above, the query, + +```yaml +query { + character(id: "1000") { + friends { + friends { + friends { + friends { + friends { + friends { + name + } + } + } + } + } + } + } +} +``` + +is rejected with, + +```json +{ + "errors" : [ + { + "message" : "Query is too deep: depth is 8 levels, maximum is 5" + } + ] +} +``` + +and a query selecting too many leaf fields is similarly rejected with a `"Query is too wide"` error. A query +exceeding both limits at once is reported as `"Query is too complex"`. + +## Limitations + +Depth and width are syntactic measures: they are computed from the query text alone and know nothing about the size +of the underlying data. In particular, width does not account for list sizes — a field yielding a thousand elements +contributes to the width just once. `QuerySizeValidator` is therefore a coarse first line of defence rather than a +complete cost model. Guarding against expensive list expansions requires taking field cardinalities and arguments +into account, which can be implemented as a custom phase following the same pattern. diff --git a/docs/tutorial/directory.conf b/docs/tutorial/directory.conf index 0fdc7d61..ab798442 100644 --- a/docs/tutorial/directory.conf +++ b/docs/tutorial/directory.conf @@ -3,4 +3,5 @@ laika.navigationOrder = [ intro.md in-memory-model.md db-backed-model.md + compiler-phases.md ] diff --git a/modules/core/src/main/scala/compiler.scala b/modules/core/src/main/scala/compiler.scala index 3bdf6d3d..ef88aaca 100644 --- a/modules/core/src/main/scala/compiler.scala +++ b/modules/core/src/main/scala/compiler.scala @@ -1581,19 +1581,33 @@ object QueryCompiler { } /** - * A compiler phase which estimates the size of a query and applies width and depth limits. + * A compiler phase which rejects queries exceeding a given depth or width. + * + * Depth is the number of nested selection levels in the query, and width the + * total number of leaf fields selected, in both cases after resolving + * fragment spreads. Queries exceeding either limit fail compilation. + * + * Enable by adding an instance to a mapping's `compilerPhases`, + * + * {{{ + * override val compilerPhases = super.compilerPhases :+ new QuerySizeValidator(5, 5) + * }}} + * + * Note that width does not account for list sizes: a field yielding many + * elements contributes to width just once. Guarding against expensive list + * expansions requires a cost model beyond this phase. */ class QuerySizeValidator(maxDepth: Int, maxWidth: Int) extends Phase { override def transform(query: Query): Elab[Query] = Elab.fragments.flatMap { frags => querySize(query, frags) match { + case (depth, width) if depth > maxDepth && width > maxWidth => + Elab.failure( + s"Query is too complex: width/depth is $width/$depth leaves/levels, maximum is $maxWidth/$maxDepth") case (depth, _) if depth > maxDepth => Elab.failure(s"Query is too deep: depth is $depth levels, maximum is $maxDepth") case (_, width) if width > maxWidth => Elab.failure(s"Query is too wide: width is $width leaves, maximum is $maxWidth") - case (depth, width) if depth > maxDepth && width > maxWidth => - Elab.failure( - s"Query is too complex: width/depth is $width/$depth leaves/levels, maximum is $maxWidth/$maxDepth") case (_, _) => Elab.pure(query) } } diff --git a/modules/core/src/test/scala/compiler/QuerySizeSuite.scala b/modules/core/src/test/scala/compiler/QuerySizeSuite.scala index fb7b8d51..861a6d95 100644 --- a/modules/core/src/test/scala/compiler/QuerySizeSuite.scala +++ b/modules/core/src/test/scala/compiler/QuerySizeSuite.scala @@ -190,6 +190,33 @@ class QuerySizeSuite extends CatsEffectSuite { } test("query too deep") { + val query = """ + query { + character(id: "1000") { + friends { + friends { + friends { + friends { + friends { + friends { + name + } + } + } + } + } + } + } + } + """ + + val expected = Problem("Query is too deep: depth is 8 levels, maximum is 5") + + val res = StarWarsMapping.compiler.compile(query) + assertEquals(res, Result.Failure(NonEmptyChain(expected))) + } + + test("query too deep and too wide") { val query = """ query { character(id: "1000") { @@ -216,7 +243,7 @@ class QuerySizeSuite extends CatsEffectSuite { } """ - val expected = Problem("Query is too deep: depth is 8 levels, maximum is 5") + val expected = Problem("Query is too complex: width/depth is 7/8 leaves/levels, maximum is 5/5") val res = StarWarsMapping.compiler.compile(query) assertEquals(res, Result.Failure(NonEmptyChain(expected)))