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)))