Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions docs/tutorial/compiler-phases.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/tutorial/directory.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ laika.navigationOrder = [
intro.md
in-memory-model.md
db-backed-model.md
compiler-phases.md
]
22 changes: 18 additions & 4 deletions modules/core/src/main/scala/compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
29 changes: 28 additions & 1 deletion modules/core/src/test/scala/compiler/QuerySizeSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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)))
Expand Down
Loading