From d236e3700907033fd354681c3954119f8fee6699 Mon Sep 17 00:00:00 2001 From: Luke Matthews Date: Tue, 16 Jun 2026 20:25:27 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20Di=C3=A1taxis=20documentation=20overhau?= =?UTF-8?q?l=20(compile-verified)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the documentation from 4 tutorial pages into a full Diátaxis information architecture: Getting Started, Tutorials, How-to guides, Concepts, and Reference (45 pages). Highlights: - Getting Started: overview, install, and a 5-minute quick start. - Tutorials: existing in-memory (Star Wars) and DB-backed (World) tutorials revised with cross-links; new mutations & subscriptions tutorial. - 14 how-to guides: filtering/ordering/paging, composing mappings, SQL backends, interfaces/unions, jsonb, custom scalars/enums, schema & query directives, effects/batching, errors, validation, generic derivation, circe, and serving over HTTP. - 9 concept pages explaining the compiler/algebra/interpreter architecture, mappings & cursors, elaboration, the schema model, nullability, effects, composition and introspection. - 14 reference pages covering schemas/SDL, the mapping catalog, the query algebra, predicates, Result/Problem, the SQL/circe/generic backends and running operations. All code examples are compile-verified. Most are snipped from real compiled sources (demo + test mappings) via the existing `Output.snip` mechanism — sources are tagged with comment-only `// #tag` markers. Three small in-memory example mappings live in modules/docs (the docs module now depends on core/circe/generic so they and the inline `mdoc` blocks compile). The whole site builds clean with `sbt docs/tlSite` (mdoc + Laika strict link validation). Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 1 + docs/concepts/architecture.md | 106 ++++++ docs/concepts/compiler-elaboration.md | 146 ++++++++ docs/concepts/composition.md | 154 +++++++++ docs/concepts/directory.conf | 12 + docs/concepts/effects-batching.md | 112 +++++++ .../introspection-fragments-variables.md | 238 +++++++++++++ docs/concepts/mappings-cursors.md | 96 ++++++ docs/concepts/nullability-lists.md | 134 ++++++++ docs/concepts/query-interpreter.md | 168 ++++++++++ docs/concepts/schema-model.md | 158 +++++++++ docs/directory.conf | 5 + docs/getting-started/directory.conf | 6 + docs/getting-started/install.md | 88 +++++ docs/getting-started/overview.md | 101 ++++++ docs/getting-started/quick-start.md | 167 ++++++++++ docs/how-to/circe-backend.md | 167 ++++++++++ docs/how-to/compose-mappings.md | 135 ++++++++ docs/how-to/custom-scalars-enums.md | 135 ++++++++ docs/how-to/directory.conf | 17 + docs/how-to/effects-batching.md | 144 ++++++++ docs/how-to/errors.md | 201 +++++++++++ docs/how-to/filtering-ordering-paging.md | 176 ++++++++++ docs/how-to/generic-derivation.md | 159 +++++++++ docs/how-to/interfaces-unions.md | 123 +++++++ docs/how-to/jsonb-columns.md | 112 +++++++ docs/how-to/query-directives.md | 88 +++++ docs/how-to/schema-directives.md | 201 +++++++++++ docs/how-to/serve-over-http.md | 129 +++++++ docs/how-to/sql-backends.md | 158 +++++++++ docs/how-to/validate-mappings.md | 111 ++++++ docs/index.md | 26 +- docs/reference/circe-mapping.md | 150 +++++++++ docs/reference/context-env.md | 171 ++++++++++ docs/reference/cursor.md | 158 +++++++++ docs/reference/directory.conf | 17 + docs/reference/effects.md | 219 ++++++++++++ docs/reference/elab-phases.md | 239 +++++++++++++ docs/reference/filtering-paging-nodes.md | 105 ++++++ docs/reference/generic-derivation.md | 187 +++++++++++ docs/reference/mapping-types.md | 198 +++++++++++ docs/reference/predicates.md | 168 ++++++++++ docs/reference/query-algebra.md | 167 ++++++++++ docs/reference/result-problem.md | 225 +++++++++++++ docs/reference/running-operations.md | 185 ++++++++++ docs/reference/schema-sdl.md | 315 ++++++++++++++++++ docs/reference/sql-mapping.md | 262 +++++++++++++++ docs/tutorial/db-backed-model.md | 17 +- docs/tutorial/directory.conf | 3 +- docs/tutorial/in-memory-model.md | 147 ++++---- docs/tutorial/intro.md | 19 +- docs/tutorial/mutations-subscriptions.md | 216 ++++++++++++ modules/circe/src/test/scala/CirceData.scala | 2 + .../src/test/scala/CirceEffectData.scala | 2 + .../src/test/scala/CircePrioritySuite.scala | 2 + .../core/src/main/scala/composedmapping.scala | 2 + .../src/main/scala/queryinterpreter.scala | 4 + .../core/src/main/scala/valuemapping.scala | 2 + .../test/scala/compiler/CompilerSuite.scala | 6 + .../scala/compiler/EnvironmentSuite.scala | 2 + .../test/scala/compiler/FragmentSuite.scala | 2 + .../compiler/PreserveArgsElaborator.scala | 2 + .../test/scala/compiler/ProblemSuite.scala | 2 + .../test/scala/compiler/ScalarsSuite.scala | 6 + .../scala/compiler/SkipIncludeSuite.scala | 2 + .../test/scala/composed/ComposedData.scala | 6 + .../scala/composed/ComposedListSuite.scala | 2 + .../directives/QueryDirectivesSuite.scala | 6 + .../directives/SchemaDirectivesSuite.scala | 5 + .../scala/mapping/MappingValidatorSuite.scala | 2 + .../core/src/test/scala/sdl/SDLSuite.scala | 2 + .../subscription/SubscriptionSuite.scala | 4 + .../main/scala/grackle/FilterMapping.scala | 91 +++++ .../grackle/MutationSubscriptionMapping.scala | 89 +++++ .../scala/grackle/QuickStartMapping.scala | 86 +++++ .../src/test/scala/DerivationSuite.scala | 8 + .../src/test/scala/RecursionSuite.scala | 4 + .../generic/src/test/scala/ScalarsSuite.scala | 2 + .../test/scala/SqlComposedWorldMapping.scala | 4 + .../test/scala/SqlCompositeKeyMapping.scala | 2 + .../src/test/scala/SqlCursorJsonMapping.scala | 2 + .../src/test/scala/SqlEmbeddingMapping.scala | 2 + .../SqlFilterOrderOffsetLimitMapping.scala | 4 + .../src/test/scala/SqlInterfacesMapping.scala | 4 + .../src/test/scala/SqlJsonbMapping.scala | 2 + .../src/test/scala/SqlJsonbSuite.scala | 2 + .../test/scala/SqlNestedEffectsMapping.scala | 6 + .../src/test/scala/SqlPaging1Mapping.scala | 2 + .../src/test/scala/SqlPaging3Mapping.scala | 2 + .../src/test/scala/SqlTestMapping.scala | 2 + .../src/test/scala/SqlUnionSuite.scala | 2 + .../src/test/scala/SqlUnionsMapping.scala | 2 + .../src/test/scala/SqlWorldMapping.scala | 4 + 93 files changed, 7227 insertions(+), 100 deletions(-) create mode 100644 docs/concepts/architecture.md create mode 100644 docs/concepts/compiler-elaboration.md create mode 100644 docs/concepts/composition.md create mode 100644 docs/concepts/directory.conf create mode 100644 docs/concepts/effects-batching.md create mode 100644 docs/concepts/introspection-fragments-variables.md create mode 100644 docs/concepts/mappings-cursors.md create mode 100644 docs/concepts/nullability-lists.md create mode 100644 docs/concepts/query-interpreter.md create mode 100644 docs/concepts/schema-model.md create mode 100644 docs/getting-started/directory.conf create mode 100644 docs/getting-started/install.md create mode 100644 docs/getting-started/overview.md create mode 100644 docs/getting-started/quick-start.md create mode 100644 docs/how-to/circe-backend.md create mode 100644 docs/how-to/compose-mappings.md create mode 100644 docs/how-to/custom-scalars-enums.md create mode 100644 docs/how-to/directory.conf create mode 100644 docs/how-to/effects-batching.md create mode 100644 docs/how-to/errors.md create mode 100644 docs/how-to/filtering-ordering-paging.md create mode 100644 docs/how-to/generic-derivation.md create mode 100644 docs/how-to/interfaces-unions.md create mode 100644 docs/how-to/jsonb-columns.md create mode 100644 docs/how-to/query-directives.md create mode 100644 docs/how-to/schema-directives.md create mode 100644 docs/how-to/serve-over-http.md create mode 100644 docs/how-to/sql-backends.md create mode 100644 docs/how-to/validate-mappings.md create mode 100644 docs/reference/circe-mapping.md create mode 100644 docs/reference/context-env.md create mode 100644 docs/reference/cursor.md create mode 100644 docs/reference/directory.conf create mode 100644 docs/reference/effects.md create mode 100644 docs/reference/elab-phases.md create mode 100644 docs/reference/filtering-paging-nodes.md create mode 100644 docs/reference/generic-derivation.md create mode 100644 docs/reference/mapping-types.md create mode 100644 docs/reference/predicates.md create mode 100644 docs/reference/query-algebra.md create mode 100644 docs/reference/result-problem.md create mode 100644 docs/reference/running-operations.md create mode 100644 docs/reference/schema-sdl.md create mode 100644 docs/reference/sql-mapping.md create mode 100644 docs/tutorial/mutations-subscriptions.md create mode 100644 modules/docs/src/main/scala/grackle/FilterMapping.scala create mode 100644 modules/docs/src/main/scala/grackle/MutationSubscriptionMapping.scala create mode 100644 modules/docs/src/main/scala/grackle/QuickStartMapping.scala diff --git a/build.sbt b/build.sbt index 110e250f..500a47ca 100644 --- a/build.sbt +++ b/build.sbt @@ -421,6 +421,7 @@ lazy val profile = project lazy val docs = project .in(file("modules/docs")) + .dependsOn(core.jvm, circe.jvm, generic.jvm) .enablePlugins(TypelevelSitePlugin, AutomateHeaderPlugin) .settings(commonSettings) .settings( diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md new file mode 100644 index 00000000..b3d96542 --- /dev/null +++ b/docs/concepts/architecture.md @@ -0,0 +1,106 @@ +# Architecture overview + +Grackle is a GraphQL server built as a *compiler* and an *interpreter*. A query string is parsed and rewritten into a small algebra, that algebra is interpreted against your data, and the result is assembled into JSON. This page is the mental model the rest of the Concepts section hangs off: it walks the three layers end to end, shows how a `Mapping` bundles the pieces together, and points at the deeper page for each part. It assumes you are a Scala developer comfortable with cats-effect; it does not re-teach GraphQL. + +## The three layers + +Every operation moves through three stages. Nothing in between is magic — each stage has a concrete type, and you can inspect the value at each boundary. + +```text + GraphQL text + │ + ▼ +┌──────────────────────┐ parse → validate → elaborate (phases) +│ QueryCompiler │ in the Elab monad: StateT[Result, ElabState, _] +└──────────┬───────────┘ + │ Operation(query: Query, rootTpe: NamedType) + ▼ +┌──────────────────────┐ the Query algebra — a sealed ADT: +│ Query (algebra) │ Select, Filter, Unique, Limit, Offset, +└──────────┬───────────┘ OrderBy, Count, Component, Effect, Environment, … + │ + ▼ +┌──────────────────────┐ walks the algebra against a root Cursor, +│ QueryInterpreter │ driven by the Schema type at each step +│ (+ Mapping/Cursor) │ +└──────────┬───────────┘ + │ ProtoJson (a possibly-partial JSON tree) + ▼ └── completeAll: batch & resolve deferred subtrees, stage by stage + io.circe.Json + ┌─────────────────────────────┐ + every step above threads a ───────────│ Result[+T] │ + Result, so errors/warnings │ Success | Warning | │ + accumulate instead of throwing │ Failure | InternalError │ + └─────────────────────────────┘ +``` + +**Layer 1 — compile.** A [`QueryCompiler`](compiler-elaboration.md) turns the query string into an executable `Operation`. It parses the text into an *untyped* tree, validates variables, fragments and field mergeability, then folds a sequence of `Phase`s over the tree. The result is an `Operation` carrying a `Query` value and the root `NamedType`. + +**Layer 2 — the query algebra.** `Query` is a sealed ADT — a tree of interpretable nodes such as `Select`, `Filter`, `Unique`, `Limit`, `Offset`, `OrderBy`, `Count`, `Component`, `Effect` and `Environment`. It is the contract between the two halves of the system: the compiler's only job is to *produce* a `Query`, and the interpreter's only job is to *consume* one. Because it is an ordinary data structure, it is inspectable and testable in isolation. + +**Layer 3 — interpret.** The [`QueryInterpreter`](query-interpreter.md) walks the `Query` against a root [`Cursor`](mappings-cursors.md), dispatching on each node *and* the GraphQL type it is expected to produce. It builds a `ProtoJson` — a possibly-partial JSON tree whose deferred subtrees (component joins, effectful fields) are resolved later. A final `completeAll` pass batches and resolves those deferred subtrees and substitutes the results back in, yielding `io.circe.Json`. + +Alongside all three layers runs a single error channel: every step returns a [`Result`](../reference/result-problem.md), so failures *accumulate* rather than throw. More on that [below](#result-the-error-channel). + +## A Mapping bundles the whole pipeline + +You do not construct compilers and interpreters yourself. A [`Mapping[F]`](mappings-cursors.md) is the one abstraction you write, and it derives everything else. A mapping holds three things: + +- a `schema` — the GraphQL `Schema` it serves; +- a `typeMappings` catalog — how each GraphQL type and field is backed by data; +- a `selectElaborator` — the query-rewriting phase that turns field *arguments* into algebra. + +From those, the trait builds the `compiler` and `interpreter` and exposes the top-level entry points `compileAndRun` and `compileAndRunSubscription`. Here is a complete in-memory mapping over an ordinary list of Scala values, showing all three pieces in one object. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/docs/src/main/scala/grackle/QuickStartMapping.scala", "#quickstart")) +``` + +Read it as the pipeline in miniature. The `schema""" … """` interpolator validates the SDL at compile time and yields a bare `Schema` (its runtime sibling, `Schema(text)`, returns a `Result[Schema]` instead — use the interpolator when the text is fixed). The `typeMappings` list pairs each GraphQL type with a way to read it: a `ValueObjectMapping` for the `Query` and `Book` object types, whose `ValueField`s project a function out of the focused Scala value (`_.title`, `_.author`). The `selectElaborator` is the rewriting step: it matches the `book(id:)` field and emits `Elab.transformChild(child => Unique(Filter(Eql(BookType / "id", Const(id)), child)))`, replacing the field's argument with a predicate the interpreter can run. + +That last move is the heart of the compiler half. A `selectElaborator` is a `PartialFunction[(TypeRef, String, List[Binding]), Elab[Unit]]`: given the type, field name and parsed arguments, it returns an action in the `Elab` monad that rewrites the field's subtree. Most of the work of writing a mapping is deciding, field by field, how an argument becomes a `Filter`, `Limit`, `OrderBy` or `Env` entry. The [compiler and elaboration](compiler-elaboration.md) page covers the `Elab` monad, the built-in phases (introspection, variable substitution, fragment expansion, field merging) and the order they run in. + +## Cursors navigate the backing data + +Where the compiler produces a `Query`, the interpreter needs something that knows how to *read* your data. That is the [`Cursor`](mappings-cursors.md): a read-only navigator pointing at one position in an arbitrary backing model during execution. A cursor carries a `focus` (the current value, typed `Any`), a `Context` (its path through the schema plus the GraphQL `Type` it is expected to represent), an optional parent, and an `Env`. Its navigation methods — `field`, `narrow`, `asLeaf`, `asList`, `asNullable` — are *type-directed*: each pattern-matches on the GraphQL type **and** the runtime value together. + +The interpreter's `runValue` makes this concrete. It dispatches on the pair `(query, tpe.dealias)`: a scalar or enum type becomes `cursor.asLeaf` (a `Json` leaf); an object, interface or union recurses through its fields; a `ListType` drives `asList`; a `NullableType` unwraps via `asNullable` (which returns a two-layer `Result[Option[Cursor]]` — `None` maps to JSON `null`). Because the navigation is driven by the schema type rather than the value's class, the very same algebra runs unchanged whether the focus is an in-memory case class, a circe `Json`, or a row materialised from SQL. + +That is also why Grackle has many backends behind one interpreter: each backend supplies its own `Cursor` and overrides one extension point. `Mapping.mkCursorForMappedField` is the protected hook that produces a backend-specific cursor for a field — `ValueMapping` applies a function to the parent focus, `CirceMapping` reads a JSON subtree, `SqlMapping` reads a column or follows a join, `ComposedMapping` returns a cursor that delegates to another mapping. Swap the cursor and you swap the backend; the algebra and the interpreter are untouched. + +## Staging: deferred subtrees and `completeAll` + +Not every field can be resolved in one pass. A `Component` node (a field served by a *different* mapping) or an `Effect` field (one that performs an `F` effect) cannot be filled in immediately, so the interpreter leaves a placeholder in the `ProtoJson` tree instead of a finished value. `ProtoJson` is exactly that: an opaque, possibly-partial JSON value that is either complete `io.circe.Json` or a wrapper holding deferred leaves. + +`completeAll` resolves them in stages. It gathers every deferred leaf, **groups them by the mapping (or handler) responsible**, and evaluates each group in one batch — calling that mapping's `combineAndRun`, which is the override point a backend uses to turn N deferred sub-queries into a single round trip (SQL mappings batch a stage into one statement here). It then recurses to complete any newly-revealed deferrals, and finally scatters the results back into the enclosing JSON by object identity. This staged, batch-per-mapping design is what makes cross-mapping [composition](composition.md) and [effectful fields and batching](effects-batching.md) efficient rather than N+1. + +A consequence worth keeping in mind: Grackle has no built-in Relay `Connection` type and no built-in websocket transport. Paging is assembled by hand from `Limit` / `Offset` / `OrderBy` / `Count` nodes (plus cursor predicates), and subscriptions are plain `fs2.Stream`s you wire to a transport yourself. + +## `Result`: the error channel + +Running parallel to all three layers is `Result[+T]`, a four-armed type that lets Grackle accumulate problems instead of throwing: + +- `Success(value)` — a value, no problems. +- `Warning(problems, value)` — a value **and** non-fatal `Problem`s. The interpreter uses this to keep partial data while still reporting field-level errors. +- `Failure(problems)` — no value; GraphQL errors. +- `InternalError(throwable)` — a programming/infrastructure fault. + +The crucial distinction for the response shape: the `Problem`s carried by `Failure` and `Warning` surface in the GraphQL response's `errors` array, but an `InternalError` does **not** — it is raised into the effect `F` instead. Never assume an internal error comes back as JSON. (At the root, a top-level `Failure` is even converted to `Warning(errs, null)` so a hard failure still yields the `data: null` + `errors` shape GraphQL clients expect.) The full type, its combinators and `Problem` are covered in the [Result reference](../reference/result-problem.md). + +## Where to go next + +Each layer has a dedicated concept page that picks up where this overview stops: + +- **[The compiler and elaboration](compiler-elaboration.md)** — the `Elab` monad, `SelectElaborator`, and the built-in phase pipeline. +- **[Mappings and cursors](mappings-cursors.md)** — the `Mapping` catalog, the cursor abstraction, and `mkCursorForMappedField`. +- **[How the query interpreter works](query-interpreter.md)** — `runValue`/`runFields`, `ProtoJson`, and type-directed dispatch. +- **[How cross-mapping delegation executes](composition.md)** — `Component` nodes, staging and `combineAndRun`. +- **[Effects and batching internals](effects-batching.md)** — `Effect` fields, deferred leaves and per-mapping batching. + +## See also + +- [What is Grackle?](../getting-started/overview.md) — the one-screen orientation and backend menu. +- [Quick start: your first query](../getting-started/quick-start.md) — build and run a mapping end to end. +- [The schema model](schema-model.md) — how the GraphQL `Type` ADT is represented internally. +- [Query algebra reference](../reference/query-algebra.md) — every node of the `Query` ADT. +- [Result, Problem & ResultT reference](../reference/result-problem.md) — the error channel in full. diff --git a/docs/concepts/compiler-elaboration.md b/docs/concepts/compiler-elaboration.md new file mode 100644 index 00000000..e4929a3a --- /dev/null +++ b/docs/concepts/compiler-elaboration.md @@ -0,0 +1,146 @@ +# The compiler and elaboration + +This page explains how Grackle turns a GraphQL query string into an executable query — the `QueryCompiler` and the *elaboration* phases it runs. It is aimed at developers who write `SelectElaborator`s or custom `Phase`s and want to understand what the compiler does before and after their code runs, why phase order matters, and how elaboration-time decisions reach the interpreter at runtime. For the exhaustive list of `Query` nodes see the [query algebra reference](../reference/query-algebra.md); for the `Elab` combinator surface see the [Elab monad & phases reference](../reference/elab-phases.md). + +## The three stages: parse, validate, elaborate + +Grackle is a compiler. A query string travels through three stages before anything is executed: + +1. **Parse.** `QueryParser` turns GraphQL text into a list of `UntypedOperation`s plus the document's fragments. The result is *untyped algebra*: a tree of `UntypedSelect` nodes carrying raw arguments (`Binding`s) and directives, not yet checked against the schema. +2. **Validate.** Before any phase runs, the compiler validates variable and fragment usage (undefined, unused, cyclic) and the GraphQL field-mergeability rules. Failures here are accumulated as `Problem`s in the `Result`. +3. **Elaborate.** A sequence of `Phase`s is folded over the tree, rewriting the untyped algebra into the directly-interpretable `Query` algebra and producing a compiled `Operation`. + +`QueryCompiler.compile` is the entry point and takes several parameters that tune these stages: + +```scala +def compile( + text: String, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): Result[Operation] +``` + +- `name` selects one operation when the document defines several named ones. +- `untypedVars` supplies the JSON variables to substitute. +- `introspectionLevel` (`Full` / `TypenameOnly` / `Disabled`) controls which `__schema`/`__type` fields are permitted. +- `reportUnused` (default `true`) decides whether unused variables and fragments are reported as warnings; set it to `false` for persisted or partial queries. +- `env` seeds an external [`Env`](../reference/context-env.md) that every `Cursor.env` lookup can read at runtime — useful for request-scoped context such as auth or feature flags. + +Errors and warnings from every stage surface as `Problem`s in the returned `Result`. Remember the invariant: validation `Problem`s come back in the GraphQL `errors` array, but an `InternalError` is raised into the effect `F` instead — see [Result, Problem & ResultT](../reference/result-problem.md). + +## From `UntypedSelect` to `Select` + +The parser produces a tree of `UntypedSelect` nodes. Consider the simplest possible query: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/CompilerSuite.scala", "#compile_simple")) +``` + +Parsing alone needs no schema. `character(id: "1000") { name }` becomes an `UntypedSelect("character", …)` whose `args` list holds `Binding("id", StringValue("1000"))`, wrapping an inner `UntypedSelect("name", …)`. Leaf fields have `child == Empty`. Nothing here is executable yet — the `id` argument is just data hanging off the node. + +Elaboration eliminates those arguments. Compiling the same shape against a mapping whose `SelectElaborator` knows about `character` produces the interpretable algebra: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/CompilerSuite.scala", "#compile_elaborated")) +``` + +The `UntypedSelect` with an `id` argument has become a `Select("character", …)` whose child is `Unique(Filter(Eql(CharacterType / "id", Const("1000")), …))`. The argument did not vanish — it was *consumed* and turned into algebra: a `Filter` on a `Predicate` that the interpreter can evaluate, wrapped in `Unique` to assert a single result. The nested `name`/`friends` selections are now plain `Select`s combined with the `~` group operator. This before/after pair captures what elaboration does: arguments are pushed down into nodes like `Filter`, `Unique`, `Limit` and `OrderBy` so the interpreter only ever sees a tree it can run. + +The mechanism is `Elab.transformChild`. A `SelectElaborator` case returns `Elab.transformChild(child => Unique(Filter(Eql(CharacterType / "id", Const(id)), child)))`; the function is captured and applied to the *already-elaborated* child of the matching `Select`. Multiple `transformChild` calls on the same node compose with `andThen` rather than replacing one another, so two cases that both target a node both run, in order. Writing one of these is covered in [Filter, sort and page a field](../how-to/filtering-ordering-paging.md). + +## The `Elab` monad + +Every phase runs inside the `Elab` monad: + +```scala +type Elab[T] = StateT[Result, ElabState, T] +``` + +`StateT` over `Result` means `Elab` threads an `ElabState` through the traversal *and* accumulates GraphQL errors and warnings. `ElabState` carries everything an elaborator can see — the `schema`, the current `Context` (your position in the schema, as a `TypeRef`), the resolved query `vars`, the `fragments` map, the node's local elaboration `Env`, the accumulated `attributes`, the pending `childTransform`, and a `parent` pointer used to navigate up and down the tree. + +You rarely touch `ElabState` directly. Instead you compose the combinators the `Elab` object exposes: + +- **Readers:** `Elab.context`, `Elab.schema`, `Elab.vars`, `Elab.fragments`, `Elab.hasField`, `Elab.hasSibling`, `Elab.resultName`. +- **Writers:** `Elab.transformChild` (rewrite the child), `Elab.env` (bind elaboration-time values), `Elab.addAttribute` (inject a synthetic field), `Elab.push`/`Elab.pop` (descend into a child context). +- **Errors:** `Elab.failure`, `Elab.warning`, `Elab.internalError`, `Elab.liftR`. + +Elaboration descends into a child selection with `Elab.push(childContext, child)` — which saves the parent `ElabState` — and returns with `Elab.pop`. `Context.forField(fieldName, alias)` computes the child's type context, and `hasSibling` works by inspecting the saved parent state. This push/pop walk is exactly what lets an elaborator know its position in the schema and inspect neighbouring selections. The full combinator surface is documented in the [Elab monad & phases reference](../reference/elab-phases.md). + +## Auto-prepended phases, and why your elaborator sees clean Selects + +You configure a mapping's phases via `compilerPhases` (by default `selectElaborator :: componentElaborator :: effectElaborator`). But the compiler does not run that list as-is. `compileOperation` builds the real pipeline like this: + +```text +allPhases = + IntrospectionElaborator(level)? // unless Disabled + :: VariablesSkipAndFragmentElaborator // substitute vars, apply @skip/@include, expand fragments + :: MergeFields // apply GraphQL field-merge rules + :: yourPhases // selectElaborator, componentElaborator, effectElaborator, … +``` + +and folds it left over the query, running `phase.transformFragments *> phase.transform(acc)` for each phase. The order is deliberate and matters: + +- `VariablesSkipAndFragmentElaborator` runs **first**, so variable references are already substituted into `Binding` values, `@skip`/`@include`-guarded subtrees are dropped, and fragment spreads are expanded into ordinary selections. +- `MergeFields` then applies the field-merge rules, collapsing duplicate selections. +- Only then does your `SelectElaborator` run. By the time it sees the tree, it is dealing with a clean, variable-substituted, fragment-expanded, field-merged set of typed `Select` nodes — never a raw `VariableRef`, fragment spread, or `@skip` directive. + +This is why a `SelectElaborator` case can pattern-match `(QueryType, "character", List(Binding("id", StringValue(id))))` and trust that the argument list is final and normalised. (Argument elaboration also permutes args into schema-declared order and fills defaults, so your patterns should assume canonical order, not source order; optional arguments arrive as `AbsentValue`, distinct from `NullValue`.) + +Ordering within `yourPhases` matters too: `SelectElaborator.transform` only fires on `UntypedSelect`, whereas `ComponentElaborator` and `EffectElaborator` fire on the already-typed `Select`. So `selectElaborator` must run before the other two — the default order — or they will see `UntypedSelect` nodes and fall through. `ComponentElaborator` (for [composed mappings](composition.md)) and `EffectElaborator` (for [effects and batching](effects-batching.md)) are themselves phases; this page treats them as the built-in stages that turn cross-mapping and effectful boundaries into `Component` and `Effect` nodes. + +## Elaboration-time `Env` becomes runtime `Cursor.env` + +Not every argument becomes a `Filter`. Some fields are *computed* from their arguments by a `CursorField`, and elaboration's job is to carry the argument values from compile time to run time. The bridge is the [`Env`](../reference/context-env.md): an immutable, type-tagged string-to-value bag. + +During elaboration, `Elab.env("x" -> x, "y" -> y)` writes into the current node's *local* env. If that local env is non-empty after the `Select` is elaborated, it is materialised as an `Environment(env, select)` node. At run time the interpreter installs those bindings, and a `CursorField` reads them back with `cursor.env[Int]("x")`. The following self-contained mapping wires both ends together: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/EnvironmentSuite.scala", "#env_mapping")) +``` + +Trace the `sum` field. The `SelectElaborator` case `(NestedType, "sum", List(Binding("x", IntValue(x)), Binding("y", IntValue(y)))) => Elab.env("x" -> x, "y" -> y)` consumes the two arguments and stashes their values in the local env — it does *not* transform the child, because `sum` is a leaf with no selection set. On the other side, `def sum(c: Cursor): Result[Int]` reads `c.env[Int]("x")` and `c.env[Int]("y")` and adds them. The `Nested.sum` field is mapped to `CursorField("sum", sum)`, so running `query { nested { sum(x: 13, y: 23) } }` produces: + +```json +{ + "data" : { + "nested" : { + "sum" : 36 + } + } +} +``` + +The same mapping shows the *external* seeding path: `url` reads `c.env[Boolean]("secure")`, and that boolean is never an argument — it is supplied by passing `env = Env("secure" -> true)` to `compile` (and seeded into `ElabState`'s `localEnv` for the whole operation). This is the canonical way to thread request-scoped context such as auth or feature flags into field computations. + +Two things to keep in mind. `Elab.env` writes only to the current node's local env, so the value is visible to that node's own children and cursor — not to its siblings; sibling inspection goes through the parent state via `hasSibling`, a different mechanism entirely. And `Env.get[T]` is filtered by runtime type tag, so a value stored under the wrong type returns `None`; use `Cursor.envR` / `Elab.envE` when you want a missing or mistyped lookup to become a descriptive `Result` failure instead of a silent `None`. + +## `addAttribute`: synthetic computed fields + +Sometimes a `CursorField` needs data the client never asked for — a count to compute a `hasMore` flag for pagination, say. `Elab.addAttribute(name, query)` records a synthetic field that is merged into the elaborated `Select`, even though it appears nowhere in the original query: + +```scala +// inside a SelectElaborator case: +Elab.addAttribute("numCountries", Count(Select("items", Select("code")))) +``` + +Here a `Count` subquery is injected alongside the requested selections. The interpreter evaluates it and exposes the result for a `CursorField` to read, but it is invisible in the response because the client did not select it. This is how computed paging metadata is assembled by hand — Grackle has no built-in Relay `Connection` type, so `Limit`/`Offset`/`OrderBy`/`Count` and synthetic attributes are the raw material you compose. See [Filter, sort and page a field](../how-to/filtering-ordering-paging.md) for the full pattern. + +## `UntypedOperation` vs `Operation`; root type resolution + +The parser yields `UntypedOperation`s — `UntypedQuery`, `UntypedMutation`, or `UntypedSubscription` — each carrying an untyped `query`, its variable definitions, and directives. Compilation turns one of these into an `Operation(query, rootTpe, directives)`: a fully elaborated, directly-interpretable result. + +Before running any phase, `compileOperation` resolves the root type. It calls `op.rootTpe(schema)`, which maps the operation kind to the schema's `Query`, `Mutation`, or `Subscription` root type, and seeds the elaboration state with `Context(rootTpe)`. That initial `Context` is the starting position from which the push/pop walk descends — every `(TypeRef, fieldName, args)` your `SelectElaborator` matches on is computed by stepping the context down from this root. If the schema declares no matching root type, compilation fails before any phase runs. + +The output `Operation` is what you hand to the interpreter. From here, the [query interpreter](query-interpreter.md) walks the `Query` algebra against a [`Mapping` and its `Cursor`s](mappings-cursors.md), and for SQL backends the algebra is compiled further into SQL. + +## See also + +- [Architecture overview](architecture.md) — where the compiler sits in the end-to-end pipeline. +- [How the query interpreter works](query-interpreter.md) — what happens to the `Operation` after compilation. +- [Mappings and cursors](mappings-cursors.md) — how `CursorField`s read the `Env` you bind during elaboration. +- [Filter, sort and page a field](../how-to/filtering-ordering-paging.md) — write a `SelectElaborator` that emits `Filter`/`OrderBy`/`Limit`/`Count`. +- [Query algebra reference](../reference/query-algebra.md) — every `Query` node and structural helper. +- [Elab monad & compiler phases reference](../reference/elab-phases.md) — the full `Elab` combinator surface and built-in phases. +- [Context & Env reference](../reference/context-env.md) — the `Env` and `Context` types in detail. diff --git a/docs/concepts/composition.md b/docs/concepts/composition.md new file mode 100644 index 00000000..cc4ef537 --- /dev/null +++ b/docs/concepts/composition.md @@ -0,0 +1,154 @@ +# How cross-mapping delegation executes + +Grackle can stitch several independent [`Mapping`](mappings-cursors.md)s together behind a single GraphQL schema, so that one field of a type is served by a SQL backend while another is served by an in-memory or effectful one. This page explains the *mechanism* behind that composition: why it has to run in several stages, how a `Delegate` field mapping becomes a `Component` boundary in the query algebra, how the interpreter defers each delegated subtree, and how those deferred subtrees are grouped, batched, and stitched back into the final JSON. It is aimed at developers who already use `ComposedMapping` and want to understand what happens when they run a nested cross-mapping query. For the step-by-step recipe, see [Compose mappings](../how-to/compose-mappings.md); this page is the "why". + +## Why composition is multi-stage + +A single mapping resolves a query in one pass: the [query interpreter](query-interpreter.md) walks the elaborated [`Query`](../reference/query-algebra.md) against that mapping's `Cursor`s, producing `ProtoJson` and then `Json`. Composition cannot work that way, because **the parent mapping has no local data for a delegated field**. + +Consider a `Country` type whose `currency` field is served by a *different* mapping. When the parent resolves `country(code: "GBR") { name currency { code exchangeRate } }`, it can produce `name` from its own `Country` value, but it has nothing in scope to produce `currency` — that subtree belongs to the currency mapping, which has its own schema, its own `Cursor`s, and possibly its own effect. So the parent does what it *can* do (resolve `name`), and **defers** the `currency` subtree to be run later, by the other mapping's interpreter, in a separate stage. Once that stage produces the currency JSON, it is substituted back into the hole the parent left. Composition is therefore inherently staged: a parent stage that emits placeholders, followed by one stage per target mapping that fills them in, recursing until nothing is left deferred. + +This is reflected in `ComposedMapping[F]`, the base class a parent mapping extends. Its whole job is to supply a placeholder cursor for delegated fields: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/main/scala/composedmapping.scala", "#composed_base")) +``` + +The override of `mkCursorForMappedField` returns a `ComposedCursor` whose `focus` is `null` and whose `parent` is `None` — there genuinely is no parent data for a delegated subtree, so any logic that reads `parent.focus` inside a delegated field would see `null`. The cursor exists only to carry the `Context` and `Env` forward into the delegated stage. Everything else a `ComposedMapping` needs — its own `schema`, `typeMappings`, and `selectElaborator` — is ordinary `Mapping` machinery; composition adds only the delegation behaviour on top. + +## From `Delegate` to a `Component` boundary + +You mark a field for delegation with a `Delegate` field mapping inside the parent's `ObjectMapping`: + +```scala +case class Delegate( + fieldName: String, + mapping: Mapping[F], + join: (Query, Cursor) => Result[Query] = ComponentElaborator.TrivialJoin +) +``` + +`Delegate(fieldName, mapping, join)` declares that `fieldName` on this object type is served by `mapping`. It is always `hidden = false` and `subtree = true`. The optional `join` is a `(Query, Cursor) => Result[Query]` and defaults to `ComponentElaborator.TrivialJoin`, which passes the child query through unchanged (`(q, _) => q.success`). + +`Delegate` is *not* itself a query-algebra node. It is rewritten into one during query compilation by the `componentElaborator` phase. Every mapping lazily derives this phase from its own `Delegate` field mappings: it walks the elaborated query and, for any `Select` whose `(objectType, fieldName)` matches a registered delegation, wraps that `Select` in a `Component` node from the [query algebra](../reference/query-algebra.md): + +```scala +case class Component[F[_]]( + mapping: Mapping[F], + join: (Query, Cursor) => Result[Query], + child: Query) extends Query +``` + +`Component` simply marks a component boundary: the subtree `child` is to be evaluated by `mapping`, after `join` has been applied. You never construct `Component` by hand — it exists only as the output of `componentElaborator`. + +Phase ordering matters here. A mapping's `compilerPhases` are `List(selectElaborator, componentElaborator, effectElaborator)`. Because `componentElaborator` runs **after** `selectElaborator`, the child query inside a `Component` is *already elaborated* — root arguments have been turned into `Filter`/`Unique`/etc. by the parent's own `selectElaborator`. This is also why the composed mapping must re-declare `selectElaborator` cases for the root fields it owns: phases do not compose automatically across mappings, so the composed parent elaborates root arguments against its *own* type refs before delegation ever happens. See [compiler & elaboration](compiler-elaboration.md) for the phase pipeline in general. + +## Interpretation: the join builds a continuation, deferred as a component + +When the interpreter reaches a `Component(mapping, join, child)` node, it does not recurse into `child` itself. Instead it applies `join(child, cursor)` — handing the join both the (elaborated) child query and the parent's `Cursor`, focused on the parent's raw model value. The join's result is a **continuation query**: the query the *other* mapping will actually run. + +This is where a cross-mapping join is expressed. A `Country.currency` join, for example, pattern-matches the parent's focus and rewrites the child: + +```text +join(Select("currency", _, child), cursor where focus = Country("GBR", "United Kingdom", "GBP")) + => Select("fx", Unique(Filter(Eql(CurrencyType / "code", Const("GBP")), child))) +``` + +The join reads the parent's `currencyCode` out of the focused `Country` and produces a query the currency mapping understands — selecting `fx` (the currency mapping's own field) filtered by that code. The join is also where you express the error contract: if the cursor focus is not the type you expect, return `Result.internalError(...)`. An [internal error](../reference/result-problem.md) is raised into the effect `F`; it does not appear in the response `errors` array. + +Having computed the continuation, the interpreter wraps it as a **deferred subtree** rather than running it. `runValue` emits `ProtoJson.component(mapping, continuation, cursor)` — an opaque placeholder in the proto-JSON tree, tagged with the target mapping. The current stage finishes with that placeholder sitting in the spot where the delegated field's value will go. Nothing in the parent's stage knows the currency mapping's data; it only knows *which* mapping owes a result for *which* query. + +One subtlety: the continuation selects the other mapping's field name (`fx`, not `currency`), but the client asked for `currency`. The interpreter realigns the result name via `alignResultName(child, cont)` so that the stitched-in JSON appears under the original field name or alias the client used. You do not have to manage this in the join. + +## `completeAll`: group by mapping, run, recurse + +Deferred subtrees are resolved by `QueryInterpreter.completeAll`, which runs at the end of a stage. It does three things: + +1. **Gather** every deferred subtree (`DeferredJson`) anywhere in the proto-JSON tree. +2. **Group** them by `(mapping, handler)` — all the placeholders owed by the same target mapping are collected together. +3. **Run** each group by calling that mapping's `combineAndRun(queries)`, where `queries` is the list of `(continuation, cursor)` pairs for the group. The resulting `Json` for each placeholder is substituted back into its hole. + +Because each delegated stage may *itself* contain delegated fields, this process recurses: `completeAll` re-runs per target mapping until no deferred subtrees remain. Composition is fully recursive — you can compose a mapping that is itself composed — but each level of nesting is a distinct interpreter stage, so deep delegation multiplies the number of stages. + +The staging looks like this for `country(code: "GBR") { name currency { code exchangeRate } }`: + +```text +Stage 0 parent (ComposedMapping) + resolve country -> { name: "United Kingdom", currency: } + │ + ▼ completeAll groups by mapping +Stage 1 CurrencyMapping.combineAndRun([ fx(code:"GBP") { code exchangeRate } ]) + => { code: "GBP", exchangeRate: 1.25 } + │ + ▼ substitute back +Result { country: { name: "United Kingdom", + currency: { code: "GBP", exchangeRate: 1.25 } } } +``` + +```graphql +query { + country(code: "GBR") { + name + currency { code exchangeRate } + } +} +``` + +```json +{ + "data": { + "country": { + "name": "United Kingdom", + "currency": { "code": "GBP", "exchangeRate": 1.25 } + } + } +} +``` + +## Why batching matters: `combineAndRun` + +`completeAll` passes the *whole group* of a mapping's deferred queries to `combineAndRun` in one call, which is the hook that makes cross-mapping batching possible. The default implementation runs each query independently: + +```scala +def combineAndRun(queries: List[(Query, Cursor)]): F[Result[List[ProtoJson]]] = + queries + .map { case (q, c) => (q, schema.queryType, c) } + .traverse((interpreter.runOneShot _).tupled) + .map(ProtoJson.combineResults) +``` + +That is correct but unbatched: a list-valued delegated field over *N* parent rows becomes *N* independent backend calls. Because all *N* continuations arrive in the *same* `combineAndRun` call, a mapping can override the method to collapse them into a single backend lookup — provided the returned list stays **positionally aligned** with the input list. `completeAll` zips a group's results back onto its placeholders in order, so the *i*-th `ProtoJson` you return must answer the *i*-th `(query, cursor)` you were handed. + +The effectful currency mapping in Grackle's SQL-composed world example does exactly this. It groups the per-country `currencies` continuations and issues one combined lookup instead of one per country: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala", "#composed_combine")) +``` + +The shape to notice: continuations that match the `SimpleCurrencyQuery` pattern are partitioned out, grouped by their child query and context, merged into a single `currencies(countryCodes)` query carrying all the codes, run once via `super.combineAndRun`, and then `unpackResults` redistributes the rows of that one response back to the individual placeholders by their original indices (`indexedQueries`). Anything that does not group falls through to the default per-query path. The corresponding suite asserts the currency mapping is called exactly once for a multi-country query — proof that the *N*-into-1 collapse happened. Batching is strictly opt-in: without an override you get *N* calls, which is correct but slower. This is the same staged-effect machinery described in [effects & batching](effects-batching.md), reused for cross-mapping delegation. + +## Scalars vs lists, and result-name realignment + +A join can produce either a single value or a list, and the *shape* of what it returns selects between them: + +- **Scalar / single object** — return a single `Select` (as the `Country.currency` join does). The interpreter emits one `ProtoJson.component` and realigns its result name to the client's field/alias. +- **List** — return a `Group` of `Select`s, one per element. The interpreter special-cases a `Group` inside a `Component`: it builds a JSON array, one deferred component per element, under the delegated field's name. + +The list case looks like this — a `Collection.items` join that fans the parent's `itemIds` out into one delegated query per id: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/composed/ComposedListSuite.scala", "#composed_list_join")) +``` + +Each `id` produces a `Select("itemById", Unique(Filter(...)))`, and the surrounding `Group` tells the interpreter to assemble an array from the per-element results. Returning a single `Select` where a list is expected — or a `Group` where a scalar is expected — is a shape mismatch the interpreter cannot stitch. In both cases the join targets the *other* mapping's field names (`fx`, `itemById`), and the result-name realignment ensures the client still sees the field or alias it actually requested (`currency`, `items`). + +A final note on `TrivialJoin`: it is sufficient only when the delegated field needs no parent-specific key transform — a genuine root field, or one the target mapping can resolve from inherited `Env`/[context](../reference/query-algebra.md) without rewriting the query. As soon as the continuation depends on a value from the parent's focus (a foreign key, a list of ids), you must supply an explicit join. + +## See also + +- [Compose mappings](../how-to/compose-mappings.md) — the task-oriented recipe, with the full runnable code. +- [Effects & batching](effects-batching.md) — the staged-effect machinery `combineAndRun` builds on. +- [The query interpreter](query-interpreter.md) — how a single mapping resolves a query into JSON. +- [Compiler & elaboration](compiler-elaboration.md) — the phase pipeline that turns query text into the `Query` algebra. +- [Mappings & cursors](mappings-cursors.md) — the `Mapping`/`Cursor` model that composition coordinates. +- [Architecture overview](architecture.md) — where composition sits in Grackle's compiler/interpreter design. diff --git a/docs/concepts/directory.conf b/docs/concepts/directory.conf new file mode 100644 index 00000000..0a0b6b2f --- /dev/null +++ b/docs/concepts/directory.conf @@ -0,0 +1,12 @@ +laika.title = Concepts +laika.navigationOrder = [ + architecture.md + compiler-elaboration.md + mappings-cursors.md + query-interpreter.md + schema-model.md + nullability-lists.md + effects-batching.md + composition.md + introspection-fragments-variables.md +] diff --git a/docs/concepts/effects-batching.md b/docs/concepts/effects-batching.md new file mode 100644 index 00000000..bf0f89dd --- /dev/null +++ b/docs/concepts/effects-batching.md @@ -0,0 +1,112 @@ +# Effects and batching internals + +Most of Grackle's interpreter is a pure walk over the [`Query`](../reference/query-algebra.md) algebra: it threads a [`Cursor`](mappings-cursors.md) through the tree and reads data out of whatever your `Mapping` is backed by. Some fields, though, need to *do* something — run a database query, call an HTTP service, update a `Ref`. This page explains how Grackle keeps those side effects out of the pure tree-walk. It covers where an effect can attach (root vs. nested), how the compiler marks a field as effectful, how the interpreter *defers* each occurrence into a placeholder and resolves it in a later stage, and how it batches every occurrence of a nested effect field into a single call so a query over N rows does not turn into N+1 service calls. + +It is for developers reasoning about the cost and ordering of effects. The recipes for *writing* them live in the [effects how-to](../how-to/effects-batching.md), and the exact signatures in the [effects reference](../reference/effects.md). + +## Separating side-effecting fields from pure tree-walking + +The interpreter's job is to produce JSON from a `Query` and a root `Cursor`. If a field needed to perform IO *inline* while that walk was happening, the walk would have to be effectful everywhere, ordering would be hard to reason about, and — worse — every row in a list would trigger its own call. Grackle avoids all of this by treating an effectful field as a *deferral*: when the interpreter reaches such a field it does not run the effect, it records a placeholder describing the effect to run and the continuation to evaluate afterwards. The pure walk finishes and produces a `ProtoJson` — a possibly-partial JSON tree with holes where the deferrals are. A separate stage then runs the deferred effects and fills the holes. + +This is the same deferral machinery that handles cross-`Mapping` composition (component joins): both produce holes in the `ProtoJson` that are resolved later. The difference is who fills the hole — a delegated interpreter for a component, or an `EffectHandler` for an effect field. Keeping the effects in their own stage is what makes batching possible at all, because by the time the stage runs the interpreter can see *every* occurrence of the field at once. + +## Root effects vs. nested effects: two attachment points + +An effect can attach to a field at one of two very different points in execution. + +A **root effect** attaches to a *top-level* field — a `Query`, `Mutation` or `Subscription` field — and runs *once, up front, before the rest of the query is interpreted*. It is a `FieldMapping` (`RootEffect`, or its streaming sibling `RootStream`) whose job is to perform some IO and hand back a `(Query, Cursor)` to interpret. This is how mutations do their write, and how a query can do per-request setup. In `runOneShot` the interpreter partitions the root selections into effectful and pure: each effectful root runs its `RootEffect` first, then the returned query is interpreted against the returned cursor. Root effects are *not* batched — each independent root field fires its own effect, so a request selecting three effectful root fields runs three effects. + +A **nested effect** attaches to a field *deep inside the result tree* — for example `Country.currencies`, where each country in a list needs its currencies fetched from an external service. It is an `EffectField` naming an `EffectHandler[F]`. Because the field can occur many times (once per country, across many countries), the interpreter defers *every* occurrence and resolves them together. This is the case batching exists for. + +```text + query { ... } + │ + ▼ + ┌─────────────┐ + │ root level │ RootEffect / RootStream + │ │ ── runs ONCE, before interpretation (runOneShot) + │ │ ── one effect per root field, never batched + └──────┬──────┘ + │ interpret the (query, cursor) it returns + ▼ + ┌──────────────────────────────────────────────┐ + │ nested level (inside the result tree) │ + │ country[0].currencies ─┐ │ + │ country[1].currencies ─┤ EffectField │ + │ country[2].currencies ─┘ + EffectHandler │ + │ └──────────────► deferred, then ONE batched runEffects call + └──────────────────────────────────────────────┘ +``` + +`RootEffect`, `RootStream` and `EffectField` share the supertype `EffectMapping`, whose `subtree = true` marks the field as owning its entire selection subtree. `RootStream` is the streaming form used by subscriptions; reaching one during a normal query or mutation is an internal error — `runOneShot` checks for it and raises `RootStream only permitted in subscriptions`. (Mutations served over the subscription transport go the other way, via `RootEffect.toRootStream`, which lifts a one-shot effect into a single-element stream. Subscriptions themselves are covered under [mutations and subscriptions](../tutorial/mutations-subscriptions.md).) + +## `EffectElaborator` wraps a field's `Select` in an `Effect` node + +For a nested effect field, the marking happens during compilation. `EffectElaborator` is a `Phase` that the `Mapping` installs after the usual select/component elaboration. It looks up each selected field in the type mappings, and where it finds an `EffectField` it rewrites the field's `Select` so the selection subtree is wrapped in an `Effect` algebra node: + +```text +Select(fieldName, resultName, child) + │ EffectElaborator: an EffectField exists for this field + ▼ +Select(fieldName, resultName, Effect(handler, Select(fieldName, _, child))) +``` + +`Effect` is an ordinary `Query` node — `case class Effect[F[_]](handler: EffectHandler[F], child: Query)` — so it travels through the algebra like any other. Nothing has run yet; the elaborator has only *marked* the field. When the interpreter later reaches `Select(_, _, Effect(handler, cont))`, it does not interpret `cont` inline. Instead it builds a deferred placeholder describing the handler, the continuation query and the parent cursor, and moves on. (The `Effect` node is one of the algebra cases listed in the [architecture overview](architecture.md); the elaboration machinery that inserts it is the subject of [compiler and elaboration](compiler-elaboration.md).) + +## Staged interpretation: `ProtoJson`, `EffectJson`, scatter/gather + +The placeholder is a `ProtoJson.EffectJson`, built by `ProtoJson.effect(mapping, handler, query, cursor)`. It captures four things: the owning `mapping`, the `handler` (as an `Option` — `None` for component deferrals, `Some(handler)` for effect fields), the continuation `query`, and the parent `cursor` the handler will read from. The interpreter wraps that into the `ProtoJson` tree exactly where the field's value belongs, leaving a typed hole. + +Resolution happens in `completeAll`, which runs as the *next stage*. It works in two passes — a *gather* and a *scatter* — around the batched effect calls in the middle: + +```text + stage N produces a ProtoJson tree with EffectJson holes + │ + ▼ GATHER: walk the whole tree, collect every deferred EffectJson + │ GROUP: by (mapping, handler) → one batched call per group + │ RUN: handler.runEffects(batch) (the next stage) + │ EVAL: interpret each continuation child against its returned cursor + ▼ SCATTER: substitute each resolved Json back into its own hole (by identity) + fully-resolved Json (recursing while new holes appear) +``` + +The gather pass (`gatherDeferred`) walks the partial tree collecting every `EffectJson`. The scatter pass (`scatterResults`) walks it again and substitutes each resolved `Json` back into the exact placeholder it came from, keyed by object identity through an `IdentityHashMap`. In between, the deferred effects are run and their continuations interpreted — and because that interpretation can itself produce fresh `EffectJson` holes, `completeAll` recurses, one extra stage per layer of effect. + +## Batching by `(mapping, handler)` in `completeAll` and why it kills N+1 + +The heart of the mechanism is the grouping in the middle of `completeAll`. Here is the whole method: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/main/scala/queryinterpreter.scala", "#complete_all")) +``` + +Read the middle of it. After `gatherDeferred` has flattened every deferral out of `pjs`, they are grouped with `.groupMap(ej => (ej.mapping, ej.handler))(identity)`. The grouping key is the pair of the owning `mapping` and the `handler`. Every `EffectJson` that shares that key lands in one `batch`, and each group becomes exactly *one* call: + +- for a component deferral (`handler` is `None`) the batch goes to `mapping.combineAndRun(queries)`; +- for an effect field (`handler` is `Some(handler)`) the batch goes to `handler.runEffects(queries)`, where `queries` is the full `List[(Query, Cursor)]` — one pair per occurrence of the field across the entire result. + +That single `runEffects` call is what collapses N+1. Without deferral, a result with 200 countries would call the currency service 200 times. With it, all 200 `Country.currencies` placeholders are gathered, grouped under the one handler, and passed as a 200-element batch to a single `runEffects` — which can collect the distinct country codes and issue one service call for the lot. + +The grouping key is the crux, and it is *identity*-based: two occurrences batch together only if they carry the **same handler instance**. This is why effect handlers are written as a single shared `object` (for example `object CurrencyQueryHandler extends EffectHandler[F]`) referenced from the field mapping. Here is how the `EffectField` is declared in the mapping, alongside the ordinary `SqlField`/`SqlObject` mappings: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala", "#effect_typemappings")) +``` + +`EffectField("currencies", CurrencyQueryHandler, List("code2"))` attaches the shared `CurrencyQueryHandler` to `Country.currencies`. Construct a fresh handler per field, or per row, and the keys would differ, the batch would split, and the N+1 would come straight back. The `List("code2")` is the field's *required* columns: it forces the parent SQL `SELECT` to include `code2` so the handler can read each country's code off the parent cursor when it builds its batched call. + +On the way back, `runEffects` must return exactly one continuation `Cursor` per input pair, in the same order — `completeAll` zips the returned cursors against the continuations positionally (`(conts, cs).parMapN`), so a length or order mismatch silently corrupts the result. The interpreter calls `Query.extractChild` on each input query to recover the continuation child to interpret, and errors with `Continuation query has the wrong shape` if it cannot. The how-to covers the order-preservation patterns (`runGrouped`, captured indices, `sortBy`) in detail. + +## Cost model: one extra stage per distinct effect field + +The deferral is what makes the cost model simple. A nested effect field is resolved in its own `completeAll` stage, so **each distinct effect field costs one extra interpreter stage** — but *all* occurrences of that field share that one stage. Ten thousand rows of `Country.currencies` is still one extra stage and one `runEffects` call, not ten thousand. + +Stages compose by nesting. A *doubly*-nested effect — say `Currency.country` selected underneath `Country.currencies` — runs in a *second* stage on top of the first: `completeAll` interprets the first handler's continuations, those produce fresh `EffectJson` holes for the inner field, and `completeAll` recurses to resolve them. Each layer is still batched within itself; you pay one stage per layer of effect nesting, not one per row at any layer. Root effects sit outside this entirely: they run before interpretation begins and are never batched, so their cost is simply one effect per effectful root field. + +## See also + +- [Run side effects with `RootEffect` and batch nested fields](../how-to/effects-batching.md) — the task-focused recipes, with the full handler implementations. +- [Effects and batching reference](../reference/effects.md) — exact signatures for `EffectField`, `RootEffect`, `RootStream`, `EffectHandler` and the circe constructors. +- [The query interpreter](query-interpreter.md) — how `ProtoJson`, `Cursor` and `completeAll` fit into the wider interpretation pass. +- [The compiler and elaboration](compiler-elaboration.md) — where `EffectElaborator` runs among the other phases. +- [Architecture overview](architecture.md) — the three-layer pipeline these stages live in. diff --git a/docs/concepts/introspection-fragments-variables.md b/docs/concepts/introspection-fragments-variables.md new file mode 100644 index 00000000..502fe7cd --- /dev/null +++ b/docs/concepts/introspection-fragments-variables.md @@ -0,0 +1,238 @@ +# Introspection, fragments and variables + +This page explains what Grackle's compiler does to a GraphQL document *before* your +own mapping logic ever sees it: it answers introspection queries, evaluates `@skip` +and `@include`, expands fragments into type-narrowed selections, substitutes operation +variables, and coerces every input value against the schema. Almost all of this is +automatic and runs in fixed phases ahead of your [elaborators](compiler-elaboration.md). +It is aimed at developers debugging query compilation who want to know *why* a compiled +[`Query`](../reference/query-algebra.md) looks the way it does, and which knobs they +actually control. + +These behaviours all happen in the first stage of the pipeline described in +[Architecture overview](architecture.md): query text → `QueryCompiler` (parse, type-check, +elaborate) → `Query` algebra → interpreter. Concretely, `compile` assembles its phase list +as the (optional) `IntrospectionElaborator`, then `VariablesSkipAndFragmentElaborator`, +then `MergeFields`, and only then your phases: + +```text +parse → IntrospectionElaborator? → VariablesSkipAndFragmentElaborator → MergeFields → → Query + │ │ + │ ├─ variable substitution + │ ├─ @skip / @include + │ ├─ fragment expansion + Narrow(T, …) + │ └─ leaf/subselection validation + └─ rewrite __schema/__type/__typename into Introspect nodes +``` + +Everything below is one of those built-in phases (or the shared input-coercion routine they +call), with the small surface you configure called out at the end. + +## Introspection is a separate mapping over a meta-schema + +GraphQL introspection — the `__schema`, `__type` and `__typename` meta-fields a client uses +to discover a server's types — is not something you implement. Grackle supplies it for any +schema automatically. You never write a mapping for the `__Type`, `__Field` or `__Schema` +meta-types. + +It works in two parts. At compile time the `IntrospectionElaborator` phase rewrites any +selection named `__schema`, `__type` or `__typename` into an `Introspect(schema, child)` +node in the [query algebra](../reference/query-algebra.md). At run time an `Introspect` +node is dispatched to a dedicated interpreter, `Introspection.interpreter(targetSchema)`, +which is itself an ordinary `ValueMapping` over a fixed meta-schema (`Introspection.schema`). +That meta-mapping exposes the *target* schema's types, fields and directives as plain Scala +values, so an introspection query about your schema is answered by running a small, +self-contained Grackle mapping whose "data" is your schema's own `Type`s and `Field`s. + +Because it is a distinct schema, introspection is routed by schema identity: the field +elaborator switches to its introspection logic only when the schema *is* `Introspection.schema`. +`__typename` is special-cased — the interpreter answers `Introspect(_, Select("__typename"))` +directly without spinning up the full meta-mapping — which is why `__typename` keeps working +in places `__schema` would not. + +How much introspection a given query may use is the one thing you choose, via the +`introspectionLevel` argument to `compile`/`compileAndRun`. It is a +`QueryCompiler.IntrospectionLevel` with three cases: + +- **`Full`** (the default) — `__schema`, `__type` and `__typename` are all allowed. +- **`TypenameOnly`** — only `__typename` is permitted; `__schema`/`__type` are rejected with + `"Introspection is disabled"`. +- **`Disabled`** — the `IntrospectionElaborator` phase is *omitted entirely* + (`IntrospectionElaborator(Disabled)` returns `None`). + +That omission has a consequence worth internalising: under `Disabled`, `__schema`/`__type` +are never rewritten into `Introspect` nodes, so they fall through to ordinary field +resolution and fail like any other unknown field — `"No field '__type' for type Query"` — +rather than with a tidy "introspection is disabled" message. `__typename` is blocked with +`"Introspection is disabled"`. So the *same* query produces *different* errors under +`TypenameOnly` versus `Disabled`; do not assume a single uniform message when you turn +introspection off for a production endpoint. + +## `@skip` and `@include` are evaluated and stripped early + +`@skip` and `@include` are built-in directives (`DirectiveDef.Skip`, `DirectiveDef.Include`) +allowed on fields, fragment spreads and inline fragments. They are resolved by the +`VariablesSkipAndFragmentElaborator` phase, which runs before any of your phases. The rule +is: a node is dropped when `(skip && if) || (include && !if)`. The `if` argument may be a +literal `BooleanValue` or a Boolean variable; a missing argument, a non-Boolean value, or a +duplicate `skip`/`include` on the same node fails compilation. + +The following test compiles a query that exercises every combination, with the two Boolean +variables `$yup = true` and `$nope = false`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/SkipIncludeSuite.scala", "#skip_include")) +``` + +Read the `expected` value at the bottom: of the four aliased selections only `b` and `c` +survive. `a: field @skip(if: $yup)` and `d: field @include(if: $nope)` are gone — not +emitted as nulls, but absent from the compiled `Group` altogether. A skipped subtree becomes +`Query.Empty` and disappears from the algebra; the data mapping is never asked about it. + +The important structural point is *when* this happens. `@skip`/`@include` are filtered out of +the directive list inside this phase, before any remaining directives are handed to your +[custom-directive](../how-to/query-directives.md) logic. So `skip`/`include` are invisible to +phases that run later, and a skipped node never reaches them at all. If you write a phase that +inspects directives, prepend it so it runs before this elaborator. + +## Fragments expand into `Group` and `Narrow` + +Named fragment spreads (`...userFragment`) and inline fragments (`... on User { … }`) are +also expanded by `VariablesSkipAndFragmentElaborator`, at compile time. The expansion depends +on the relationship between the context type and the fragment's type condition. If the context +type is already a subtype of the fragment's type condition, the fragment's children are inlined +directly. Otherwise they are wrapped in a `Narrow(T, child)` node that restricts that part of +the selection to instances of type `T`. The "does this fragment apply" test follows the GraphQL +spec's *fragment spread is possible* rule, computed from the schema's subtype relationships. + +The next snippet selects from `profiles`, whose type is an interface, and spreads two fragments +plus `__typename`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/FragmentSuite.scala", "#fragment_typed")) +``` + +The `expected` algebra shows exactly how the document is rewritten: + +- `id` stays a plain `Select` — it is a field of the interface, valid for every member. +- `__typename` becomes an `Introspect(schema, Select("__typename"))` node, per the previous + section. +- `...userFragment` and `...pageFragment` — whose definitions carry the type conditions + `on User` and `on Page` — each become a `Narrow(User, …)` / `Narrow(Page, …)`, because `User` + and `Page` are *subtypes* of the `profiles` interface, not the interface itself. At run time a + `Narrow(T, …)` selects its children only for objects whose runtime type is `T`. + +A fragment whose type condition is the context type (or a supertype of it) would inline +*without* a `Narrow`. The flip side is that a fragment on a supertype may only select fields +that exist on that supertype: selecting a subtype-only field through a supertype fragment fails +at compile time (for example `"No field 'name' for type Profile"`), and you must narrow +explicitly with `... on User { name }` inside it. + +Fragment validation is strict and happens up front, before elaboration produces the algebra +above. Undefined fragments, duplicate fragment names, fragment cycles +(`"Fragment cycle starting from 'x'"`), unused fragments, and type-incompatible spreads are all +rejected. The unused check is paired with the unused-variable check and can be turned off with +`reportUnused = false` on `compile`. + +## Variables: definition, typing and JSON coercion + +Operation variables (`query doSearch($pattern: Pattern) { … }`) are declared in the document +and supplied separately as JSON through the `untypedVars` argument. Compilation treats them in +three steps: + +1. The `$name: T` definitions are turned into `InputValue` definitions resolved against the + target schema — this is where an unknown variable type is caught. +2. The supplied JSON values are coerced against those definitions by `Value.checkVarValue` + (see the next section), producing query-algebra `Value`s. +3. Variable *references* inside arguments, list literals and input objects are only resolved + later, during the `VariablesSkipAndFragmentElaborator` phase, by substituting each + `VariableRef` with its bound value. An unbound reference fails with + `"Variable 'x' is undefined"`. + +Variables get the same dual definedness check as fragments: a reference to an undeclared +variable fails (`"Variable 'x' is undefined"`), and a *declared but never used* variable also +fails (`"Variable 'x' is unused"`) unless you pass `reportUnused = false`. This is why a +`@skip(if: $cond)` whose `$cond` you forgot to declare is a compile error, and a leftover +declared variable is too. + +## Input coercion: defaults, absent vs null, ID widening and `@oneOf` + +Every input value — whether a literal argument written in the query or a JSON variable value — +is coerced against its `InputValue` definition before it reaches your mapping. There are two +parallel routines: `Value.checkValue` for literal query values (`Value` → `Value`) and +`Value.checkVarValue` for incoming JSON (`Json` → `Value`). They apply the same rules: + +- **Defaulting.** An omitted argument or input-object field that has a `defaultValue` is filled + with that default. +- **Absent vs null.** This distinction is load-bearing. An *omitted* nullable argument or field + coerces to `AbsentValue`; an *explicit* `null` coerces to `NullValue`. They are different + cases of the `Value` ADT, and your elaborators and cursors must handle both. For example, a + top-level `field(arg: null)` yields `NullValue` while `field` with no argument yields + `AbsentValue`. +- **ID widening.** A `StringValue` or `IntValue` supplied for an `ID`-typed input is widened to + `IDValue` (the integer via its string form). A literal `id: 123` and a JSON variable `123` + both arrive as `IDValue("123")`, so never rely on receiving an `IntValue` for an `ID` argument. +- **Custom-scalar widening.** For a non-built-in `ScalarType`, any `Int`/`Float`/`String`/`Boolean` + literal is accepted without validation. Grackle does not check the *contents* of a custom + scalar here — that is your mapping's job (see + [Define custom scalars and enums](../how-to/custom-scalars-enums.md)). +- **Enum validation.** Enum values are checked against the schema's declared values. +- **Recursion and unknown fields.** Lists and input objects are coerced element by element and + field by field; an unknown input-object field is rejected. The error wording differs by path + — literal arguments say `"… for input object value of type X in field 'f' of type 'Query'"`, + while variable values say `"… in input object value of type X in variable values"`. + +A worked illustration: given `input Pattern { name: String age: Int id: ID userType: … date: … }`, +the variable JSON `{ "name": "Foo", "age": 23, "id": 123 }` coerces to an `ObjectValue` whose +`name` is a `StringValue`, `age` an `IntValue`, `id` an `IDValue("123")` (Int widened to ID), +and whose unsupplied `userType` and `date` fields are present as `AbsentValue` — not dropped, +not null. + +### `@oneOf` input objects + +An input object declared `input X @oneOf` must have *exactly one* member present and non-null. +Coercion enforces this: zero present members, more than one present, or a present-but-`null` +member each fail with a specific message such as +`"Exactly one key must be specified for oneOf input object X …"`. The same rule applies to both +literal arguments and variable values. The `@oneOf` constraint is also surfaced through +introspection on `__Type.isOneOf`, and the `oneOf` directive appears in `__schema.directives`, +because `oneOf` is one of the built-in directive definitions (alongside `skip`, `include`, +`deprecated` and `specifiedBy`) merged into every schema. + +## What is automatic and what you configure + +Almost all of this subsystem is fixed behaviour you cannot turn off: + +- introspection elaboration and execution, including `__typename` and the deprecation filtering + of introspection results; +- `@skip`/`@include` evaluation; +- fragment expansion, type narrowing and fragment validation; +- variable substitution and the validation of variable definitions; +- input-value coercion, including defaults, `AbsentValue`/`NullValue`, ID and custom-scalar + widening, enum checks and `@oneOf`; +- merging of duplicate fields. + +What you actually configure is a short list: + +- **`introspectionLevel`** per query (`Full` / `TypenameOnly` / `Disabled`). +- **`reportUnused`** per query, to suppress the unused-variable / unused-fragment errors. +- **Custom query directives** — declared in your SDL (so they pass location and argument + validation) and interpreted by a `QueryCompiler.Phase` you prepend to your phases. Declaring + the directive only validates *placement*; behaviour comes from the phase. See + [Write a custom query directive](../how-to/query-directives.md). +- **`SelectElaborator.select`** logic, which receives already-coerced arguments and the + surviving (non-`skip`/`include`) directives for each field — the place your own per-field + argument handling lives. + +## See also + +- [The compiler and elaboration](compiler-elaboration.md) — the phase pipeline these + behaviours plug into. +- [Write a custom query directive](../how-to/query-directives.md) — the how-to recipe for the + one extension point in this subsystem. +- [Query algebra reference](../reference/query-algebra.md) — the `Select`/`Group`/`Narrow`/`Introspect`/`Empty` + nodes this page refers to. +- [Elab monad & compiler phases reference](../reference/elab-phases.md) — the `Phase` and `Elab` + API for writing your own phases. +- [Nullability and lists](nullability-lists.md) — how the `NullableType` wrapper relates to the + `AbsentValue`/`NullValue` distinction. diff --git a/docs/concepts/mappings-cursors.md b/docs/concepts/mappings-cursors.md new file mode 100644 index 00000000..147990d8 --- /dev/null +++ b/docs/concepts/mappings-cursors.md @@ -0,0 +1,96 @@ +# Mappings and cursors + +This page explains how a Grackle `Mapping` ties a GraphQL schema to your data, and how that wiring is realised at run time by `Cursor`s. It is for developers building or debugging mappings who want to understand *why* the pieces are shaped the way they are: the two-layer catalog (a `TypeMapping` selects a GraphQL type, a `FieldMapping` wires one of its fields to data), how field resolution dispatches per backend, and the `focus`/`tpe` duality that every cursor walks. It assumes you are comfortable with cats-effect and have seen a mapping before; for exact signatures see the [mapping types](../reference/mapping-types.md) and [cursor](../reference/cursor.md) references. + +## The two-layer model: TypeMapping and FieldMapping + +A `Mapping[F]` holds three things: the `schema`, a `TypeMappings` catalog, and a `selectElaborator`. The catalog is where the schema-to-data correspondence lives, and it is organised in two layers. + +The outer layer is the **`TypeMapping`**. Each one maps a whole GraphQL type, and there are exactly two concrete kinds: + +- **`ObjectMapping`** maps an object, interface, or union type. It carries a sequence of `FieldMapping`s — one per field you expose. +- **`LeafMapping[T]`** maps a scalar or enum type to a circe `Encoder[T]`. It is the *leaf* construct: there is no `PrimitiveMapping` in Grackle. You only declare `LeafMapping`s for your own scalars and enums; built-in leaf mappings for `String`, `Int`, `Float`, `Boolean`, and `ID` are appended to the catalog automatically, so you never write those yourself. + +The inner layer is the **`FieldMapping`**, nested inside an `ObjectMapping`, that wires a single field to data. The catalog of field mappings includes `ValueField` (apply a function to the parent value), `CursorField` (compute a leaf from the parent `Cursor`), `Delegate` (hand the field off to another `Mapping` for cross-backend composition), and the effectful `EffectField`, `RootEffect`, and `RootStream`. The circe backend adds `CirceField` (a constant `Json`) and `CursorFieldJson` (a computed `Json` subtree) on top of these. + +Here is a real `ObjectMapping` catalog, from the SQL demo's `WorldMapping`. The mapping is SQL-backed, so the field mappings are `SqlField`/`SqlObject`, but the *shape* is the universal one — an `ObjectMapping` for each GraphQL object type, each holding a list of field mappings: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/world/WorldMapping.scala", "#type_mappings")) +``` + +Note `SqlField("countrycode", city.countrycode, hidden = true)`: a *hidden* field mapping has no corresponding declared schema field — it exists only so other field mappings (here, the join that resolves `country`) can depend on it. Every *declared* schema field, by contrast, must have a non-hidden field mapping, or validation reports a `MissingFieldMapping`. + +Leaf mappings sit alongside object mappings in the same catalog. A typical custom-scalar block looks like this: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ScalarsSuite.scala", "#scalars_leafmappings")) +``` + +Each `LeafMapping[T](tpe)` needs an implicit circe `Encoder[T]` in scope; that encoder is what turns a `java.util.UUID` or your `Genre` enum into JSON at a leaf position. + +A `List` of these mappings compiles where a `TypeMappings` is expected because of an implicit conversion (`TypeMappings.fromList`). That conversion always produces a *checked* catalog. A checked catalog is validated against an unfolding of the schema — but lazily, the first time the `compiler` is forced, not at construction. So a mapping with a missing or ambiguous type mapping can look fine until the first query is compiled, at which point validation throws. To skip validation you must opt in explicitly with `TypeMappings.unchecked(...)`. + +## MappingPredicate: matching a type to a context + +A `TypeMapping` does not name its type directly; it carries a `MappingPredicate` that decides *when* the mapping applies and at what priority. When the interpreter needs the mapping for a type at some position, the catalog asks every candidate predicate for an `Option[Int]` priority and the highest wins. A tie between two equally-specific mappings is an `AmbiguousTypeMappings` error, not a silent pick. + +There are three predicates: + +- **`TypeMatch(tpe)`** matches the type *anywhere* it appears, at priority `0`. This is the default you get from the convenience constructors `ObjectMapping(tpe)(...)` and `LeafMapping[T](tpe)`. +- **`PathMatch(path)`** matches the type only when it is reached via a specific field path. Because its priority grows with path length, a path-specific mapping outranks a bare `TypeMatch` for the same type — which is exactly how you give one GraphQL type two different mappings depending on where it occurs in a query. Prefer `PathMatch` for context-sensitive mappings. +- **`PrefixedTypeMatch(prefix, tpe)`** is the legacy `PrefixedMapping` semantics, kept for backwards compatibility; new code should reach for `PathMatch` instead. + +This priority-ordered, context-aware lookup is why a single schema type can be backed by different data in different parts of a query without ambiguity. + +## From field selection to a child cursor + +Once the catalog is built, resolving a selected field is a two-step dispatch. + +`mkCursorForField` (the entry point, and `final`) does the **catalog lookup**: given the parent cursor and a field name, it finds the applicable `ObjectMapping` for the parent's context and pulls out the matching `FieldMapping`. It then hands that field mapping to `mkCursorForMappedField`. + +`mkCursorForMappedField` does the **per-backend dispatch**. It is the protected override point each backend specialises. The base `Mapping` implementation handles the backend-agnostic cases — `EffectMapping` and `CursorField`. Each concrete mapping then extends it for its own field-mapping types: + +- `ValueMappingLike` handles `ValueField` by applying its function `f: T => Any` to the parent focus, producing the child value. +- `CirceMappingLike` handles `CirceField` and `CursorFieldJson`, producing cursors over `Json`. +- `ComposedMapping` returns a `ComposedCursor` for `Delegate` fields, which defers the sub-selection to another mapping. + +So the flow for every field is the same: lookup resolves *which* field mapping applies; dispatch turns that field mapping into a child `Cursor` of the right concrete type. The cursor is the thing the interpreter actually walks. + +## The focus/tpe duality + +A `Cursor` is Grackle's read-only navigator over your backing data during interpretation. Each cursor pairs two things: + +- a **`focus`**: the current runtime value, typed `Any` — it could be a Scala case class, an `io.circe.Json` node, a SQL result row, anything; and +- a **`tpe`**: the GraphQL `Type` the focus is *expected* to represent, derived from the cursor's `Context`. + +Every navigation method matches on `(tpe, focus)` *together*. This duality is the heart of how interpretation stays type-directed while the underlying data stays untyped. The cleanest place to see it is `ValueCursor`, the reference implementation over plain Scala values: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/main/scala/valuemapping.scala", "#value_cursor")) +``` + +Read `isList`, `asList`, and `asNullable`: each one matches on the pair. `isList` is true only when `tpe` is a `ListType` **and** `focus` is a `List[_]` — a `ListType` paired with a non-list focus reports `false`, and `asList` would return a `Result.internalError`. `asNullable` similarly insists `tpe` is a `NullableType` and `focus` an `Option[_]`, returning `Result[Option[Cursor]]`: `Some(child)` when the option is defined, `None` when empty (the interpreter renders `None` as `Json.Null`). `narrowsTo`/`narrow` handle interface and union narrowing — and `ValueCursor` decides membership at run time with the `ValueObjectMapping`'s `ClassTag` (`classTag.runtimeClass.isInstance(focus)`), which is why you must supply the correct concrete type parameter, e.g. `ValueObjectMapping[Human](...)`. Finally, `field` delegates straight back to `mkCursorForField`, re-entering the lookup-and-dispatch cycle one level deeper. + +Two things to notice about the design. First, every navigation method returns `Result` and has a permissive error default (inherited from `AbstractCursor`): a cursor only overrides the cases its model actually supports, and calling the wrong method for a focus yields an error `Result`, never a thrown exception. Second, `preunique` does *not* return a unique element — it re-types the focus as `tpe.nonNull.list` so the `Unique` query operator can subsequently run a list traversal and pull out the single match. The `focus`/`tpe` agreement must always hold: a cursor that reports a `tpe` the query does not expect surfaces as a "Mismatched query and cursor type" internal error rather than silently mis-walking. + +That dispatch — picking `asLeaf` vs `runFields` vs `runList` vs `asNullable` from `(query, tpe.dealias)` — happens in the interpreter's `runValue`, covered in [how the query interpreter works](query-interpreter.md). What matters here is that the cursor exposes exactly the type-driven operations the interpreter needs, and matches each one against `(tpe, focus)`. + +## Concrete cursors per backend + +Because the `Cursor` trait is only this navigation contract, each backend ships its own implementation over its own focus type: + +- **`ValueCursor`** (above) walks plain Scala values; `focus` is `Any` and narrowing uses a `ClassTag`. +- **`CirceCursor`** walks an `io.circe.Json` document; `focus` is `Json`, `asLeaf` validates the JSON shape against the GraphQL scalar/enum type, and `narrowsTo` checks that the required fields are present. +- **Generic-derivation cursors** are produced by `CursorBuilder[T]` instances derived from your Scala types; they are the most direct way to obtain a cursor by hand. +- **SQL cursors** walk the rows of a compiled SQL result. + +They all extend either `AbstractCursor` (which supplies the error defaults so a backend overrides only what it supports) or `ProxyCursor` (which delegates to an underlying cursor and overrides a few methods — `NullCursor`, `ListTransformCursor`, and friends are built this way). The interpreter never cares which concrete cursor it holds; it only calls the navigation methods, and each cursor answers them against its own focus. + +## See also + +- [How the query interpreter works](query-interpreter.md) — how `runValue`/`runFields`/`runList` drive a cursor to build the result JSON. +- [The compiler and elaboration](compiler-elaboration.md) — how `selectElaborator` rewrites a query into the algebra the interpreter walks. +- [How cross-mapping delegation executes](composition.md) — what `Delegate`, `ComposedCursor`, and `combineAndRun` do across backends. +- [Mapping types reference](../reference/mapping-types.md) — exact signatures for every `TypeMapping` and `FieldMapping`. +- [Cursor reference](../reference/cursor.md) and [Context & Env reference](../reference/context-env.md) — the full `Cursor`, `Context`, and `Env` APIs. diff --git a/docs/concepts/nullability-lists.md b/docs/concepts/nullability-lists.md new file mode 100644 index 00000000..3282b07d --- /dev/null +++ b/docs/concepts/nullability-lists.md @@ -0,0 +1,134 @@ +# Nullability and lists + +The one feature of Grackle's type model most likely to trip you up is *inverted nullability*: in the internal model a bare type is already **non-null**, and optionality is an explicit `NullableType` wrapper — the exact opposite of GraphQL SDL, where `String` is nullable and `String!` is non-null. This page explains why the model is built that way, how the `[T]` / `[T!]` / `[T!]!` list forms translate into nested `ListType` and `NullableType` wrappers, how the parser produces those nestings, and which navigation helpers see *through* the modifiers when you write mappings and elaborators. It assumes you have met the [schema model](schema-model.md) and the `Type` ADT; it is about understanding the mechanism, not a reference of every method. + +## Why Grackle inverts SDL nullability + +In GraphQL's surface syntax, types are nullable by default and you opt *in* to non-null with a trailing `!`. Grackle's in-memory model flips the default: every named type (`ScalarType`, `EnumType`, `ObjectType`, an interface, a union, or a `TypeRef` to one) is non-null on its own, and you opt *in* to nullability by wrapping it in `NullableType`. So the SDL field `name: String` parses to `NullableType(StringType)`, while `name: String!` parses to a bare `StringType`. + +The reason is that the model is consumed far more often than it is written. The interpreter, mappings and elaborators constantly ask "what is the underlying named type here, ignoring optionality and lists?" — and a non-null-by-default representation makes a bare `NamedType` the common, unwrapped case. Wrapping is reserved for the genuinely optional positions, which keeps the type you usually care about at the surface rather than buried under a wrapper. It also means optionality composes by structure: a nullable list of non-null elements and a non-null list of nullable elements are simply different arrangements of the same two wrappers, with no special flags. + +This matters because the wrappers are unnamed *modifiers*, not types you look up by name: + +```scala +case class ListType(ofType: Type) extends Type +case class NullableType(ofType: Type) extends Type +``` + +When you write SDL in a schema you always use normal GraphQL — `String`, `String!`, `[Post]`. The inverted representation only surfaces when you inspect the parsed `Type` values, which is exactly what this page is about. (`NullableType` even renders itself with a trailing `?` in `toString`, so `[Post]` prints back as `[Post?]?` — a useful tell that you are looking at the model and not SDL.) + +## How `[T]`, `[T!]`, `[T!]!` map to `ListType`/`NullableType` + +A GraphQL list type has two independent nullability switches: the list itself, and its elements. Both default to nullable in SDL, and each `!` removes one wrapper. Reading from the outside in, here is how the four combinations of `[Post]` translate, given that `Post` is an object type: + +```text + SDL internal Type (outermost first) renders as + ─────────────── ────────────────────────────────────────── ────────── + [Post] NullableType(ListType(NullableType(Post))) [Post?]? + [Post!] NullableType(ListType(Post)) [Post]? + [Post]! ListType(NullableType(Post)) [Post?] + [Post!]! ListType(Post) [Post] +``` + +The pattern is mechanical: a missing `!` on the list adds an outer `NullableType`; a missing `!` on the element adds an inner `NullableType` around the element type; `ListType` always sits between them. The fully-non-null `[Post!]!` is the only form with no `NullableType` at all — it is just `ListType(Post)`. + +Nothing stops these from nesting. `[[Int!]!]` is a non-null list of non-null `Int`s nested inside a nullable outer list, which becomes `NullableType(ListType(ListType(IntType)))` (and renders back as `[[Int]]?`) — the wrappers stack to whatever depth the parser allows. + +## The parser's wrapping logic and `maxListTypeDepth` + +The translation above is produced by `SchemaParser.mkType`. Its inner recursive helper, `loop`, walks the untyped `Ast.Type` and threads a single `nullable` flag. The flag starts `true` (SDL's default), each `Ast.Type.NonNull` clears it for the type it guards, and a small `wrap` helper applies a `NullableType` only when the flag is still set: + +```scala +def loop(tpe: Ast.Type, nullable: Boolean): Result[Type] = { + def wrap(tpe: Type): Type = if (nullable) NullableType(tpe) else tpe + + tpe match { + case Ast.Type.List(tpe) => loop(tpe, true).map(tpe => wrap(ListType(tpe))) + case Ast.Type.NonNull(Left(tpe)) => loop(tpe, false) + case Ast.Type.NonNull(Right(tpe)) => loop(tpe, false) + case Ast.Type.Named(Name(nme)) => + wrap(ScalarType.builtIn(nme).getOrElse(lazyRef(schema, nme))).success + } +} + +loop(tpe, true) // SDL default: nullable +``` + +Two details make the inversion fall out. First, a list re-enters `loop` with `nullable = true`, so the *element* type defaults to nullable again, independently of the list's own nullability. Second, a named type resolves to a built-in `ScalarType` if its name is one of the five built-ins, otherwise to a by-name `TypeRef` (`lazyRef`) so mutually recursive schemas work; the `wrap` is applied to whichever it is. + +List nesting is bounded. The parser only accepts list modifiers up to `maxListTypeDepth` deep, which defaults to `5` in `GraphQLParser.defaultConfig`; exceeding it is a parse error ("exceeded maximum list type depth"). If you genuinely need deeper nesting, build a `GraphQLParser` with a custom `Config` and feed it to `SchemaParser` (see [parsing SDL at runtime](../how-to/validate-mappings.md)). In practice the limit is generous — real schemas rarely nest lists beyond one or two levels. + +## Navigation helpers that see through the modifiers + +Because optionality and lists are wrappers rather than fields, the `Type` ADT carries helpers that test or strip them. The important thing to internalise is that **the predicates inspect only the outermost wrapper**, while the *underlying* helpers strip every wrapper (and every `TypeRef` alias) down to a named type: + +- `isNullable` / `isList` — `true` only if the *outermost* constructor is `NullableType` / `ListType`. They do not look inside. +- `nullable` / `nonNull` — add or remove the outer `NullableType` (`nonNull` strips all leading nullables). +- `item` — for a list, the element type (unwrapping a leading `NullableType` first); otherwise `None`. +- `list` — wrap as a list, preserving a leading nullable. +- `underlying`, `underlyingNamed`, `underlyingObject` — strip all aliases, nullability and list wrappers; `underlyingNamed` always reaches a `NamedType`, while `underlyingObject` yields the object/interface/union or `None` for a leaf. + +The consequence of "outermost only" is the most common surprise: a nullable list answers `isList` with `false`, because its outer wrapper is the `NullableType`. The following block inspects a small schema and shows exactly what each helper returns. It compiles and runs at site build time, so the printed values are the real ones. + +```scala mdoc:silent +import grackle.syntax._ + +val schema = + schema""" + type Query { + posts: [Post] + } + type Post { + id: Int! + title: String + tags: [String!]! + } + """ + +val QueryType = schema.ref("Query") +val PostType = schema.ref("Post") + +// `field` dealiases the TypeRef for you, so no explicit `.dealias` is needed. +val postsTpe = QueryType.field("posts").get // [Post] -> [Post?]? +val tagsTpe = PostType.field("tags").get // [String!]! -> [String] +``` + +Now read the wrappers off those field types. `id: Int!` is non-null, `title: String` is nullable, and the two list fields differ only in their `!`s: + +```scala mdoc +PostType.field("id").get.isNullable // Int! -> false +PostType.field("title").get.isNullable // String -> true +postsTpe.isNullable // [Post] is a NULLABLE list -> true +postsTpe.isList // outermost is NullableType -> false +postsTpe.nonNull.isList // strip the nullable, now a list -> true +postsTpe.item // the element type, Post? -> Some(Post?) +tagsTpe.isList // [String!]! has no outer nullable -> true +tagsTpe.item // element is non-null String -> Some(String) +``` + +There is also a *path* family for navigating a chain of field names at once. `path(fieldNames)` returns the `Type` reached by following the names, transparently passing through list and nullable wrappers along the way; `pathIsList` and `pathIsNullable` answer whether the path *passes through* a list or a nullable field at any hop. That "any hop" semantics is deliberate and differs from the single-wrapper predicates above: + +```scala mdoc +QueryType.path(List("posts", "title")) // sees through [Post?]? -> Some(String?) +QueryType.pathIsList(List("posts", "title")) // passes through the posts list -> true +QueryType.pathIsNullable(List("posts", "id")) // passes through nullable posts -> true (even though id is Int!) +``` + +`pathIsNullable(List("posts", "id"))` is `true` even though `id` is `Int!`, because the route to it goes through the *nullable* `posts` field — if `posts` resolves to `null` there is no `id` to speak of. This is the right question to ask when you are deciding whether a whole projected path can be absent. + +## Implications for writing mappings and elaborators + +The inverted model is something you read constantly and rarely write, so the practical advice is about reading it correctly: + +- **Reach for the `underlying*` helpers, not manual unwrapping.** When you need the object behind a field — to look up its mapping, or to build a `Context` — use `underlyingObject` / `underlyingNamed` rather than pattern-matching on `NullableType`/`ListType` yourself. They handle nullable-of-list-of-nullable and `TypeRef` aliasing in one call, so your code does not break when someone adds or removes a `!`. +- **Don't read `isList` on a nullable field.** A `[T]` field is `isList == false` because its outer wrapper is the nullable. Call `.nonNull.isList`, or use `pathIsList`, when the field might be optional. +- **The interpreter dispatches on the wrappers in order.** During execution the [query interpreter](query-interpreter.md) peels the type one layer at a time: a `NullableType` drives `Cursor.asNullable` (a missing value becomes JSON `null`), a `ListType` drives `asList`, and the underlying named type drives leaf or object handling. The order of your wrappers is therefore exactly the order the result JSON is shaped, which is why the structural representation — rather than a pair of boolean flags — is what the model uses. +- **Match on the *underlying* type in elaborators.** A `SelectElaborator` keys on `(TypeRef, fieldName, args)`; the `TypeRef` it sees is the underlying named type, already stripped of list and nullable wrappers. You build path predicates with `/` (for example `PostType / "id"`), and that operator likewise navigates through modifiers. You almost never need to mention `NullableType` or `ListType` by name in mapping code — that is the payoff of the non-null-by-default design. + +## See also + +- [The schema model](schema-model.md) — the full `Type` ADT, `NamedType` hierarchy, `TypeRef`, and `=:=`/`<:<`. +- [The GraphQL schema and SDL reference](../reference/schema-sdl.md) — every type, modifier and operator in table form, including the navigation helpers above. +- [How the query interpreter works](query-interpreter.md) — how `asNullable` and `asList` peel these wrappers during execution. +- [Cursor reference](../reference/cursor.md) — the `asNullable` / `asList` navigation methods the interpreter calls. +- [Interfaces and unions](../how-to/interfaces-unions.md) — defining the object/abstract types these modifiers wrap. diff --git a/docs/concepts/query-interpreter.md b/docs/concepts/query-interpreter.md new file mode 100644 index 00000000..8074c979 --- /dev/null +++ b/docs/concepts/query-interpreter.md @@ -0,0 +1,168 @@ +# How the query interpreter works + +This page explains how Grackle turns an elaborated `Query` and a root [`Cursor`](mappings-cursors.md) into a JSON response. It is for developers debugging execution — especially the "Mismatched query and cursor type" internal errors — who want a mental model of the interpreter's type-directed dispatch, its partial-result tree (`ProtoJson`), how deferred and effectful subtrees are batched, and how field-level errors are accumulated rather than thrown. It assumes you have already met the [query algebra and elaboration](compiler-elaboration.md) phases that produce the `Query` the interpreter consumes; here we pick up where elaboration leaves off. + +## From query to JSON: the staged pipeline + +`QueryInterpreter[F]` is constructed from a `Mapping[F]` and exposes a single public driver: + +```scala +def run(query: Query, rootTpe: Type, env: Env): Stream[F, Result[Json]] +``` + +`run` builds a synthetic root `Cursor` — `RootCursor(Context(rootTpe), None, env)`, whose focus is `()` — and then branches on whether `rootTpe` is the schema's subscription type: + +- For an ordinary query or mutation it calls `runOneShot`, evaluated once via `Stream.eval`. +- For a subscription it calls `runSubscription`, which yields one element per emitted stream value. + +Either path produces a `ProtoJson` — a *possibly-incomplete* result tree (see [below](#protojson-a-partial-result-tree)). `run` then threads each `ProtoJson` through `QueryInterpreter.complete`, which resolves any deferred subtrees into a fully-concrete `io.circe.Json`. So the overall shape is: + +```text +run + ├─ runSubscription (subscription root) ─┐ + └─ runOneShot (query / mutation root)─┤ + ▼ + runRootValue ─► runValue ─► ProtoJson (one stage) + ▼ + complete / completeAll (resolve deferred subtrees) + ▼ + io.circe.Json +``` + +`runOneShot` does a little orchestration before it touches the model. It `ungroup`s the top-level selections and partitions them into *pure* queries, *effectful* queries (root effects, e.g. mutations), and *introspection* queries, runs each group, and merges the results with the `Result` semigroup. A pure, non-introspection selection is handed to `runRootValue`, which obtains the mapping's default root cursor (`mapping.defaultRootCursor`) and then calls the recursive core, `runValue`. An effectful selection first runs its `RootEffect` to obtain a `(query, cursor)` pair and then calls `runValue` directly. Introspection is interpreted by a separate introspection interpreter against the same root cursor. + +Two guards live here. If a `RootStream` field (only meaningful for subscriptions) appears in a one-shot query, `runOneShot` returns `Result.internalError("RootStream only permitted in subscriptions")`. Symmetrically, `runSubscription` rejects more than one root selection with `"Only one root selection permitted for subscriptions"`. There is no built-in graphql-ws transport; `runSubscription` produces a plain `fs2.Stream` you wire to a transport yourself (see [effects and batching](effects-batching.md)). + +## Type-directed dispatch in `runValue` + +`runValue` is the heart of the interpreter. It interprets one `Query` node against one `Cursor` at an expected GraphQL `Type`, and it decides what to do by pattern-matching on the pair `(query, tpe.dealias)`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/main/scala/queryinterpreter.scala", "#run_value")) +``` + +Read this as a dispatch table keyed first on the structural query node, then on the dealiased type: + +- **`Environment(childEnv, child)`** — push the extra `Env` bindings onto the cursor (`cursor.withEnv`) and recurse. This is how `Query.Environment` nodes inserted during elaboration make values visible to predicates and joins downstream. +- **Scalar / enum types (`ScalarType | EnumType`)** — terminal. Call `cursor.asLeaf`, which renders the focus as circe `Json`, and wrap it with `ProtoJson.fromJson`. +- **Object / interface / union types** — delegate to `runFields`, then assemble the named field results into an object with `ProtoJson.fromDisjointFields`. +- **`NullableType(tpe)`** — call `cursor.asNullable` (a `Result[Option[Cursor]]`). `Some(child)` recurses on the inner type; `None` becomes `ProtoJson.fromJson(Json.Null)`. This is where absent optional values turn into JSON `null`. +- **`ListType(tpe)`** — delegate to `runList` (with `unique = false`). +- **`Unique(child)`** — call `cursor.preunique` to re-type the focus as a list, then `runList(child, tpe.nonNull, c, unique = true, nullable = tpe.isNullable)`. `Unique` is how an elaborated query collapses a list down to its single matching element. +- **`Component(...)`** and **`TransformCursor(f, child)`** — the staged and cursor-rewriting cases, covered under [deferred evaluation](#deferred-and-staged-evaluation) and list transforms below. + +If no arm matches, `runValue` returns `Result.internalError(s"Stuck at type $tpe for ${query.render}")` — a sign the elaborated query and the schema type disagree about shape. + +Note the inversion to keep in mind when reading this code: in Grackle's internal `Type` model a bare type is already non-null, and optionality is the explicit `NullableType` wrapper matched above — the opposite of SDL, where `String` is nullable and `String!` is not. See the [schema model](schema-model.md) for the full story. + +### Object selections in `runFields` + +`runFields` produces the `List[(resultName, ProtoJson)]` pairs that `runValue` folds into an object. It dispatches on the query node: + +- **`Group`** — flatten and concatenate the sub-results; a `Group` that contains type-case branches additionally `mergeFields` so that selections spread across fragments on the same field are merged. +- **`Select` on a nullable type** — unwrap with `asNullable`; an absent value short-circuits the whole field to `Json.Null`. +- **`Select(_, _, Count(...))`** — resolve the counted child, take its `listSize` (or `1` for a non-list), and emit a JSON number. There is no `Connection` machinery here; counting is `Count` over a field, nothing more. +- **`Introspect(__typename)`** — for an object yield its name; for an interface or union, narrow the cursor against each candidate implementation/member and report the first that matches. +- **`Select(fieldName, resultName, child)`** — the common case: descend with `cursor.field(fieldName, resultName)` and recurse into `runValue` at the field's type. The field type defaults to `ScalarType.AttributeType` for synthetic attributes not present in the schema. +- **`Narrow(subtpe, child)`** — narrow the cursor to `subtpe`; if the runtime value is not a member, contribute no fields (the fragment does not apply). +- **`Component`**, **`Effect`**, **`Environment`**, **`TransformCursor`** — produce deferred field values or rewrite the cursor, as in `runValue`. + +### List handling in `runList` + +`runList` iterates the parent cursor as a list (`parent.asList(Iterator)`) and applies, in order, the operations an elaborated `FilterOrderByOffsetLimit` carries: + +1. **filter** — keep elements for which the `Predicate` holds (via `filterA`, so a predicate failure aborts the list); +2. **order** — apply `OrderSelections`; +3. **offset / limit** — `drop`, `take`, or `slice` the sorted iterator. + +These are the primitives behind [filtering, ordering, and paging](../how-to/filtering-ordering-paging.md) — there is no ready-made cursor-paging or Relay `Connection` helper; you assemble paging from `Limit`/`Offset`/`OrderBy`/`Count` by hand. After slicing, each surviving element is interpreted with `runValue`. When `unique = true` (the `Unique` case), `runList` enforces cardinality on the result: exactly one match returns that element; zero matches returns `Json.Null` if the type is nullable, otherwise `Result.internalError("No match")`; more than one returns `Result.internalError("Multiple matches")`. A leading `TransformCursor` is peeled off and applied to the whole list via a `ListTransformCursor`, letting a backend re-project the element set (the SQL backend uses this to substitute query-shaped rows). + +## `cursorCompatible`: why mismatches are internal errors + +Before walking, `runValue`, `runFields`, and each element loop in `runList` call `cursorCompatible(tpe, cursor.tpe)`. It strips nullable and list wrappers off both the query's expected type and the cursor's reported type, then accepts only if both strip down to leaves, or if the stripped types are nominally equal (`nominal_=:=`): + +```scala +def cursorCompatible(tpe: Type, cursorTpe: Type): Boolean = { + def strip(tpe: Type): Type = + tpe.dealias match { + case NullableType(tpe) => strip(tpe) + case ListType(tpe) => strip(tpe) + case _ => tpe + } + + (strip(tpe).isLeaf && strip(cursorTpe).isLeaf) || + (strip(tpe) nominal_=:= strip(cursorTpe)) +} +``` + +When this guard fails you get `"Mismatched query and cursor type in runValue"` (or `runFields` / `runList`). This is deliberately an **internal error**, not a GraphQL error: it almost always means a custom `Cursor` reported a `tpe` that does not line up with the query, usually because its `mkChild`/`context.asType` re-typing is wrong. Per Grackle's error model, internal errors are raised into the effect `F` rather than surfaced in the response `errors` array — so a `cursorCompatible` failure is a bug to fix in your mapping, not something a client ever sees as a field error. If you are implementing your own cursor, keeping the focus value and `Context` type in lockstep is what keeps this guard happy. + +## `ProtoJson`: a partial-result tree + +`runValue` does not build `io.circe.Json` directly. It builds `ProtoJson`, an opaque type (`type ProtoJson <: AnyRef`) representing a *partially-constructed* result. A `ProtoJson` is either a complete `io.circe.Json`, or — when some subtree is deferred — one of the package-private wrappers `ProtoObject`, `ProtoArray`, `ProtoSelect`, or a `DeferredJson` leaf (`EffectJson`). + +The crucial property is **eager collapse**: the constructors `fromJson`, `fromFields`, `fromDisjointFields`, `fromValues`, and `select` collapse straight to a plain `Json` node the moment all of their children are already `Json`. Deferral wrappers therefore appear only when a subtree genuinely needs later resolution. You can build a `ProtoJson` only through these constructors and inspect it only via `ProtoJson.isDeferred` — its internal cases are private to `QueryInterpreter`, so you cannot pattern-match its structure from outside the module. + +For a query with no components or effects, this means the entire result is already concrete `Json` by the time `runValue` returns. You can see the collapse directly: + +```scala mdoc:silent +import grackle.QueryInterpreter +import grackle.QueryInterpreter.ProtoJson +import io.circe.Json +import cats.effect.IO +import cats.effect.unsafe.implicits.global + +val pj: ProtoJson = ProtoJson.fromFields(Seq( + "a" -> ProtoJson.fromJson(Json.fromInt(1)), + "b" -> ProtoJson.fromJson(Json.fromString("x")) +)) +``` + +```scala mdoc +ProtoJson.isDeferred(pj) + +QueryInterpreter.complete[IO](pj).unsafeRunSync() +``` + +Because every field above is already `Json`, `fromFields` collapses to a plain object, `isDeferred` is `false`, and `complete` returns it unchanged. `fromDisjointFields` (used for the object case in `runValue`) is the same idea but assumes field names are already disjoint and does not re-merge them, whereas `fromFields` merges first. + +## Deferred and staged evaluation + +Two query nodes produce a `DeferredJson` leaf instead of an immediate value: + +- **`Component(mapping, join, child)`** — a join into a *composed* sub-mapping (see [composition](composition.md)). `runValue` runs the `join` to derive the continuation query for the related mapping and wraps it as `ProtoJson.component(mapping, cont, cursor)`. For a list-typed component it does this per element. +- **`Effect(handler, cont)`** — a field whose value comes from an effectful, often batched, resolver. `runFields` wraps it as `ProtoJson.effect(mapping, handler, cont, cursor)`. + +Both produce an `EffectJson(mapping, handler, query, cursor)` leaf embedded in the surrounding `ProtoJson`. Nothing has run yet; the work is staged. `complete`/`completeAll` then resolves these leaves stage by stage: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/main/scala/queryinterpreter.scala", "#complete_all")) +``` + +Three sub-steps do the work: + +- **gather** — `gatherDeferred` walks each `ProtoJson` and collects every `DeferredJson` leaf. (A `ProtoJson` that is already plain `Json` has none, so it is returned untouched.) +- **batch** — the gathered leaves are grouped by `(mapping, handler)` and each batch is run *once*: a component batch via `mapping.combineAndRun`, an effect batch via `handler.runEffects`. Grouping is what makes batched resolution possible — N sibling deferred fields targeting the same handler become one call, which is how you avoid N+1 queries (the basis of the [effects and batching](effects-batching.md) story). Each batch's results are then `completeAll`-ed recursively, so deferred subtrees nested inside deferred results are resolved in subsequent stages. +- **scatter** — `scatterResults` rebuilds the concrete `Json`, replacing each `DeferredJson` leaf with its resolved value. The substitution uses a `java.util.IdentityHashMap` keyed on the leaf nodes, so results are matched by **object identity**, not equality. Copying or re-creating an `EffectJson` after it has been gathered would break the lookup. + +## Error accumulation: `Result`, `Warning` vs `Failure` + +The interpreter never throws for query-level problems; it threads everything through `Result[+T]`, which has four arms: + +- **`Success(value)`** — a value, no problems. +- **`Warning(problems, value)`** — a value *and* non-fatal `Problem`s. This is how the interpreter keeps partial data while still collecting field-level errors. +- **`Failure(problems)`** — problems, no value. These `Problem`s become the GraphQL `errors` array. +- **`InternalError(throwable)`** — a programming/internal error, raised into the effect `F`. It does **not** appear in the response `errors` array. + +`runList` shows the `Warning` mechanism concretely: as it interprets each list element, a per-element `Warning` contributes its `Problem`s to an accumulator but still keeps the element's value, so one bad row in a list does not discard the others. An `InternalError` or `Failure`, by contrast, aborts the list immediately. + +At the root, `runOneShot` performs one important conversion. If the merged result of the root selections is a top-level `Result.Failure(errs)`, it rewrites it to `Result.Warning(errs, ProtoJson.fromJson(Json.Null))`. That is what produces the GraphQL `data: null` + `errors` response for a hard root failure, rather than dropping the whole response — the data becomes JSON `null` while the `errors` array carries the problems. The practical consequence when you inspect interpreter output: a root failure shows up as a `Warning` carrying problems, not a bare `Failure`, so check for `Warning` problems too. For the full `Result` / `Problem` model and how problems are formatted into the response, see the [error handling how-to](../how-to/errors.md). + +## See also + +- [Mappings and cursors](mappings-cursors.md) — what a `Cursor` is and how a `Mapping` builds the cursors this interpreter walks. +- [The compiler and elaboration](compiler-elaboration.md) — how the `Query` the interpreter consumes is produced. +- [Effects and batching](effects-batching.md) — the deferred/batched resolution machinery from the mapping author's side. +- [Composition of mappings](composition.md) — how `Component` joins stage work across mappings. +- [Architecture overview](architecture.md) — where the interpreter sits in the overall pipeline. +- [Cursor reference](../reference/cursor.md) and [query algebra reference](../reference/query-algebra.md) — exact signatures for the types named here. diff --git a/docs/concepts/schema-model.md b/docs/concepts/schema-model.md new file mode 100644 index 00000000..6e56438b --- /dev/null +++ b/docs/concepts/schema-model.md @@ -0,0 +1,158 @@ +# The schema model + +A `Schema` is Grackle's in-memory representation of a GraphQL schema: an immutable collection of named type declarations, directive definitions and extensions. It is the ground truth that every other layer consults — mappings attach data to its types, the compiler type-checks queries against it, and the interpreter walks it to assemble results. This page explains how that model is shaped, how its types refer to and compare with one another, and how SDL text becomes a `Schema`. It is for developers working with the `Schema` API directly; for the exhaustive list of methods and constructors see the [Schema & SDL reference](../reference/schema-sdl.md). + +## A schema is a collection of named types + +At its core a `Schema` holds a list of `NamedType` declarations plus the directive definitions and extensions that apply to them. Every GraphQL type kind is a distinct case class: `ScalarType`, `EnumType`, `InterfaceType`, `ObjectType`, `UnionType` and `InputObjectType`. You rarely build these by hand — you write SDL and let Grackle parse it. Here is a real schema (from the World demo) defined with the compile-time-validated `schema"..."` interpolator: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/world/WorldMapping.scala", "#schema")) +``` + +`schema"..."` comes from `import grackle.syntax._`. The text is parsed and fully validated *at compile time*, and the interpolator yields a bare `Schema`. The result is four `ObjectType`s — `Query`, `Country`, `City`, `Language` — each carrying a list of `Field`s, and each field carrying its argument list (`InputValue`s), result `Type` and any directives. The `Query` type is special only by name: it is picked up as the query root (see [Root operation types](#root-operation-types-and-the-schema-type) below). + +You read the model back through the schema's lookup helpers. `schema.types` is the full list of named types (with any extensions merged in), `schema.definition(name)` looks one up by name, and `schema.directives` lists the directive definitions. Two things are worth knowing up front. First, the five built-in directive definitions (`@skip`, `@include`, `@deprecated`, `@specifiedBy`, `@oneOf`) are silently appended to every parsed schema, so `schema.directives` always contains them even when your SDL declares none. Second, the five built-in scalar names — `Int`, `Float`, `String`, `Boolean`, `ID` — are always available without being declared. + +## Type vs NamedType, and TypeRef + +`Type` is the root sealed trait of the type hierarchy and carries all the navigation operators (nullability, lists, field lookup, subtyping). `NamedType` is the subset of types that have a schema-defined `name`: the six declared kinds above, plus one more — `TypeRef`. + +`ListType` and `NullableType` are *not* named types. They are unnamed *modifiers* that wrap another `Type`, and they are how Grackle models lists and optionality. Because Grackle's model is non-null by default, a bare `ScalarType` or `ObjectType` is already non-null, and optionality is the explicit `NullableType` wrapper — the inverse of SDL surface syntax, where `String` is nullable and `String!` is not. That inversion has its own page; see [Nullability and lists](nullability-lists.md). + +A `TypeRef` is a lazy, by-name reference to a type defined in a schema: + +```scala +case class TypeRef private[grackle] (schema: Schema, name: String) extends NamedType { + override lazy val dealias: NamedType = schema.definition(name).getOrElse(this) + override lazy val exists: Boolean = schema.definition(name).isDefined +} +``` + +`TypeRef` solves two problems. The first is **mutual recursion**: in the World schema, `Country.cities` refers to `City` and `City.country` refers back to `Country`. A `TypeRef` lets a field name its result type without the target having to exist yet — the reference is resolved lazily through `dealias` when you actually navigate it. + +The second is **value-equality**. `TypeRef` is the only type representation that compares meaningfully with `==`, because two `TypeRef`s for the same name in the same schema are equal by case-class structure. This is exactly what elaborators and mappings rely on when they pattern-match on a type. You obtain one with `schema.ref(name)`: + +```scala mdoc:silent +import grackle.Schema +import grackle.syntax._ + +val schema: Schema = + schema""" + type Query { + hero(episode: Episode!): Character! + character(id: ID!): Character + } + enum Episode { + NEWHOPE + EMPIRE + JEDI + } + interface Character { + id: String! + name: String + friends: [Character!] + } + type Human implements Character { + id: String! + name: String + friends: [Character!] + homePlanet: String + } + """ + +val QueryType = schema.ref("Query") +val CharacterType = schema.ref("Character") +``` + +`schema.ref(name)` is *checked*: it throws `IllegalArgumentException` if the name is not defined in the schema, so a `TypeRef` you get this way always points somewhere. There is also `schema.uncheckedRef(name)`, which skips the check and can produce a dangling reference whose `.exists` is `false` and whose navigation returns `None`. Note that `TypeRef`'s constructor is `private[grackle]` — you cannot `new TypeRef(...)`; always go through `schema.ref` / `schema.uncheckedRef`. + +## Comparing types: `=:=`, `<:<`, `==` and `dealias` + +Because a type can appear either directly or behind a `TypeRef` alias, plain `==` is usually the wrong comparison: it distinguishes a type from an alias to that same type. Grackle provides alias-aware operators instead. + +`a =:= b` is **type equivalence** — equal after both sides are dealiased: + +```scala +def =:=(other: Type): Boolean = (this eq other) || (dealias == other.dealias) +``` + +`a <:< b` is the **subtype relation**. An object type is a subtype of every interface it implements, a member type is a subtype of any union it belongs to, and the modifiers are covariant: `NullableType` and `ListType` propagate `<:<` to their element types, and a non-null type is a subtype of its nullable form. + +`dealias` resolves a `TypeRef` to its target named type, looking it up by name in the schema (and leaving the reference untouched if the target is undefined); on any other type it is the identity. There is also `nominal_=:=`, which compares two types purely by their underlying name. + +The practical rule: compare with `=:=`, not `==`. The one deliberate exception is `TypeRef`s obtained from the *same* schema via `schema.ref` — those are designed to be `==`-comparable, which is the whole point of using them in elaborator pattern matches. The [compiler and elaboration](compiler-elaboration.md) page shows that comparison in action. + +```scala mdoc +// Same name, alias-aware equivalence: +CharacterType =:= schema.ref("Character") + +// Human is a subtype of the Character interface it implements: +schema.ref("Human") <:< CharacterType +``` + +## Root operation types and the schema type + +GraphQL groups its three root operation types under a `schema { ... }` block. Grackle models that block as a synthetic named type — the *schema type* — exposed as `schema.schemaType`. If your SDL contains no explicit `schema { ... }` block (as the schemas above do not), Grackle synthesises a default one referencing the types named `Query`, `Mutation` and `Subscription`, including only those that actually exist: + +```text +type Schema { + query: Query! + mutation: Mutation # only if a type named Mutation exists + subscription: Subscription # only if a type named Subscription exists +} +``` + +From that, `schema.queryType` returns the query root, while `schema.mutationType` and `schema.subscriptionType` return `Option`s. There is an important asymmetry: `queryType` is non-optional and calls `.get` internally, so a schema with *no* query root throws `NoSuchElementException`. The mutation and subscription accessors are safe `Option`s. `schema.isRootType(tpe)` tells you whether a given type is one of the three roots. + +You can also declare the schema type explicitly when you want non-default names, by writing a `schema { query: ... }` block in your SDL; Grackle then uses that instead of the synthesised default. + +## Type extensions + +GraphQL's `extend` keyword lets you add to a type declared elsewhere — `extend type Query { ... }`, `extend interface ...`, `extend enum ...`, and so on, plus `extend schema`. Grackle parses these into `TypeExtension` and `SchemaExtension` values stored *separately* from the base types, on `schema.typeExtensions` and `schema.schemaExtensions`. + +The merge is lazy and happens at read time. `schema.types` returns `baseTypes` unchanged when there are no extensions, and otherwise maps each base type through its applicable extensions; `schema.schemaType` merges schema extensions onto the base schema type the same way. So extensions never mutate the base declarations — they are folded in when you ask for the resolved view. Extensions are validated too: you cannot apply an object extension to a non-object, or extend a type that does not exist. + +## Compile-time `schema"..."` vs runtime `Schema(text)` + +There are two ways to turn SDL into a `Schema`, and choosing correctly matters. + +The `schema"..."` interpolator validates **at compile time** and expands to roughly `Schema(s, ...).toOption.get`, yielding a *bare* `Schema`. An invalid schema is a compile error (the message is prefixed with `Invalid schema:`), so by the time your program runs the schema is known good. Use it whenever the SDL is a literal in your source. + +`Schema(text)` is the **runtime** factory. It returns a `Result[Schema]` — Grackle's success/failure type — so you use it when the SDL is not known until runtime, or when you want to inspect validation problems yourself rather than fail the build. Validation errors accumulate as `Problem`s inside a `Result.Failure`: + +```scala mdoc +import grackle.Result + +val parsed: Result[Schema] = + Schema( + """ + type Query { + episodeById(id: String!): Episod + } + type Episode { + id: String! + } + """ + ) + +parsed match { + case Result.Failure(ps) => ps.map(_.message).toChain.toList + case Result.Success(s) => s.types.map(_.name) + case other => List(other.toString) +} +``` + +The deliberate typo `Episod` (the type is named `Episode`) is caught by the schema validator and surfaces as `Reference to undefined type 'Episod'`. Note that these are GraphQL `Problem`s, not exceptions: `Result.Failure` carries a `NonEmptyChain[Problem]`, and the same `Result` type threads through the rest of Grackle. (`Result` also has `Warning` and `InternalError` cases; only `Failure` and `Warning` carry problems — see [The compiler and elaboration](compiler-elaboration.md) and the errors reference.) + +Validation is thorough. On construction Grackle checks references to undefined types, duplicate definitions, union members that are not object types, interface conformance (a type must declare every field of every interface it implements, with a subtype-compatible result type and matching arguments) and transitive-interface obligations, interface cycles, extension target mismatches, and directive validity. All of these accumulate into the `Problem` chain rather than stopping at the first error. + +Finally, the model round-trips: `schema.toString` renders a `Schema` back to SDL via `SchemaRenderer`. The built-in directive definitions are deliberately omitted from that rendering, so a round-tripped schema reads exactly like what you wrote, even though `schema.directives` still contains them. + +## See also + +- [Nullability and lists](nullability-lists.md) — how `[T]`, `[T!]` and `[T!]!` map to `ListType`/`NullableType`, and why the model inverts SDL. +- [The compiler and elaboration](compiler-elaboration.md) — how queries are type-checked and rewritten against this model. +- [Mappings and cursors](mappings-cursors.md) — how a `Mapping` attaches data to the schema's types. +- [Schema & SDL reference](../reference/schema-sdl.md) — the full `Schema`/`Type` API, constructors and operators. +- [Define custom scalars and enums](../how-to/custom-scalars-enums.md) and [Define and use schema directives](../how-to/schema-directives.md) — task recipes built on this model. diff --git a/docs/directory.conf b/docs/directory.conf index d5dc14e8..b4ac675b 100644 --- a/docs/directory.conf +++ b/docs/directory.conf @@ -1,4 +1,9 @@ laika.navigationOrder = [ index.md + getting-started + tutorial + how-to + concepts + reference CONTRIBUTING.md ] diff --git a/docs/getting-started/directory.conf b/docs/getting-started/directory.conf new file mode 100644 index 00000000..35070db3 --- /dev/null +++ b/docs/getting-started/directory.conf @@ -0,0 +1,6 @@ +laika.title = Getting Started +laika.navigationOrder = [ + overview.md + install.md + quick-start.md +] diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md new file mode 100644 index 00000000..ff2f4c20 --- /dev/null +++ b/docs/getting-started/install.md @@ -0,0 +1,88 @@ +# Installation + +This page lists the Grackle modules you can depend on and the platforms each supports, so you can set up a `build.sbt` for your project. You need only `grackle-core`; every backend is an optional add-on. To run something end to end, follow the [quick start](quick-start.md) after wiring up the dependency. + +## Core dependency + +Grackle is published for **Scala 2.13 and 3.3+** under the `org.typelevel` organisation. The only required module is `grackle-core`, which gives you the schema model, the query compiler and interpreter, and the in-memory `ValueMapping` backend: + +```scala +// Required: Scala 2.13/3.3+ +libraryDependencies += "org.typelevel" %% "grackle-core" % "@VERSION@" +``` + +The `@VERSION@` placeholder resolves to the latest published version when the site is built; substitute the version you want from the [releases](https://github.com/typelevel/grackle/releases). `grackle-core` pulls in [cats](https://typelevel.org/cats), [cats-effect](https://typelevel.org/cats-effect/), [fs2](https://github.com/typelevel/fs2) and [circe](https://circe.github.io/circe/) transitively, so you do not add those yourself. + +## Optional backends + +Each backend ships as a separate module. Add only the ones whose data source you map. They all depend on `grackle-core`, so you do not need to list it twice. + +```scala +// Optional: in-memory JSON backend backed by circe +libraryDependencies += "org.typelevel" %% "grackle-circe" % "@VERSION@" + +// Optional: in-memory generic Scala backend (derives mappings from your ADTs) +libraryDependencies += "org.typelevel" %% "grackle-generic" % "@VERSION@" + +// Optional: Postgres via Doobie (JVM only) +libraryDependencies += "org.typelevel" %% "grackle-doobie-pg" % "@VERSION@" + +// Optional: Postgres via Skunk +libraryDependencies += "org.typelevel" %% "grackle-skunk" % "@VERSION@" + +// Optional: Oracle via Doobie (JVM only) +libraryDependencies += "org.typelevel" %% "grackle-doobie-oracle" % "@VERSION@" + +// Optional: SQL Server via Doobie (JVM only) +libraryDependencies += "org.typelevel" %% "grackle-doobie-mssql" % "@VERSION@" +``` + +| Module | Backend | Use it when | +| --- | --- | --- | +| `grackle-core` | In-memory `ValueMapping` | You map a schema onto plain Scala values you already hold in memory. | +| `grackle-circe` | In-memory `CirceMapping` | Your data is already circe `Json`. | +| `grackle-generic` | In-memory `GenericMapping` | You want a mapping derived from your case classes / sealed traits. | +| `grackle-doobie-pg` | Postgres via [Doobie](https://typelevel.org/doobie/) | You run SQL through Doobie against Postgres. | +| `grackle-doobie-oracle` | Oracle via Doobie | You run SQL through Doobie against Oracle. | +| `grackle-doobie-mssql` | SQL Server via Doobie | You run SQL through Doobie against SQL Server. | +| `grackle-skunk` | Postgres via [Skunk](https://typelevel.org/skunk/) | You prefer Skunk's pure-Scala Postgres protocol. | + +For what each backend does and how to choose, see [What is Grackle?](overview.md); for wiring a SQL backend up, see [Use a SQL backend](../how-to/sql-backends.md). + +## Platform and cross-build support + +Grackle is cross-published for the JVM, [Scala.js](https://www.scala-js.org/) and [Scala Native](https://scala-native.org/). The split is along backend lines: + +- **`grackle-core`, `grackle-circe`, `grackle-generic` and `grackle-skunk`** are available on **all three platforms** (JVM, JS, Native), for both Scala 2.13 and Scala 3. +- **The Doobie-based SQL backends — `grackle-doobie-pg`, `grackle-doobie-oracle`, `grackle-doobie-mssql` — are JVM only**, because Doobie's JDBC layer is JVM only. If you cross-build for JS or Native, scope these dependencies to the JVM target (for example with `.jvmSettings` on a cross project) rather than the shared settings. + +For a cross-project build, that typically looks like adding `grackle-core` (and any cross-platform backend) to the shared `.settings(...)` and confining the Doobie modules to `.jvmSettings(...)`. + +## Imports you'll usually need + +Almost every Grackle program starts from two imports: the top-level package, and the `syntax` object that provides the compile-time `schema"""..."""` interpolator and the `.success` / `.failure` helpers on `Result`. + +```scala +import grackle._ +import grackle.syntax._ +``` + +As you build a mapping you will commonly add the namespaced sub-packages for the query algebra, predicates and elaboration. The doc example mappings, for instance, open with: + +```scala +import grackle._ +import grackle.syntax._ +import grackle.Query._ // Select, Filter, OrderBy, … +import grackle.Predicate._ // Eql, Contains, … +import grackle.Value._ // query-argument values +import grackle.QueryCompiler._ // SelectElaborator, elaboration phases +``` + +Effectful code additionally imports your effect type — `import cats.effect.IO` is the usual choice. Backends bring their own packages: `import grackle.circe._` for the circe backend, `import grackle.generic._` for generic derivation, and the relevant `grackle.doobie.*` / `grackle.skunk.*` packages for SQL. + +## Next steps + +- [Quick start: your first query](quick-start.md) — install done, now run a query against an in-memory mapping. +- [What is Grackle?](overview.md) — the compiler/interpreter model and how to pick a backend. +- [Introduction (tutorial)](../tutorial/intro.md) — build a working server step by step. +- [Serve a mapping over HTTP](../how-to/serve-over-http.md) — expose a mapping through http4s. diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md new file mode 100644 index 00000000..28030f13 --- /dev/null +++ b/docs/getting-started/overview.md @@ -0,0 +1,101 @@ +# What is Grackle? + +Grackle is a GraphQL server library for Scala, built on the Typelevel stack (cats-effect and fs2). You give it a GraphQL schema and a *mapping* that explains how each type and field is served from your data — an in-memory value, a derived Scala ADT, a circe JSON document, or a SQL database — and it answers GraphQL queries against it. This page orients you to the core model and vocabulary so you can pick the right backend and the right next page; it assumes you are a Scala developer comfortable with cats-effect basics but does not assume deep GraphQL knowledge. + +## Grackle in one paragraph + +Grackle is a compiler and an interpreter. A query arrives as GraphQL text; the **`QueryCompiler`** parses it, validates variables, fragments and field merging, then runs a sequence of *elaboration* phases that rewrite the parsed tree into an executable **`Query`** algebra — a small sealed ADT of nodes such as `Select`, `Filter`, `Unique`, `Limit`, `OrderBy` and `Component`. The **`QueryInterpreter`** then walks that algebra against your data using a **`Cursor`**, a read-only navigator over your backing model, producing a `ProtoJson` (a possibly-partial JSON tree) that is finally resolved into an `io.circe.Json` response. Everything that ties those two halves together — the schema, the catalog of mappings, and the interpreter — lives in a single object: the `Mapping`. + +```text + GraphQL query text + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ QueryCompiler │ + │ parse → validate → elaborate (SelectElaborator, │ + │ fragments, skip/include, components, …) │ + └────────────────────────────────────────────────────┘ + │ + ▼ + Query algebra (Select, Filter, Unique, Limit, OrderBy, Component, …) + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ QueryInterpreter + Mapping / Cursor │ + │ walk the algebra, navigate your data │ + └────────────────────────────────────────────────────┘ + │ + ▼ + ProtoJson ──(resolve deferred / batched subtrees)──▶ JSON response +``` + +The compiler half is covered in depth under [the compiler and elaboration](../concepts/compiler-elaboration.md); the interpreter half under [how the query interpreter works](../concepts/query-interpreter.md). For the whole picture in one place, see the [architecture overview](../concepts/architecture.md). + +## The central idea: a `Mapping` ties a schema to data + +The pivotal abstraction is `Mapping[F[_]]`. A mapping holds three things: + +- a `schema` — the GraphQL type system your server exposes; +- a `typeMappings` catalog — for each GraphQL type, how its fields are served from your data; +- a `selectElaborator` — how field arguments (filters, ids, paging) are translated into the query algebra. + +From those, the mapping derives the `QueryCompiler` and `QueryInterpreter` for you, and exposes the top-level entry points `compileAndRun` (for queries and mutations) and `compileAndRunSubscription` (for subscriptions). Validation of the catalog against the schema happens automatically the first time the compiler is built. + +Here is a complete, minimal mapping — an in-memory list of books served over GraphQL. It is the [quick-start](quick-start.md) example, shown here only to make the shape concrete: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/docs/src/main/scala/grackle/QuickStartMapping.scala", "#quickstart")) +``` + +Read it top to bottom and you see the three parts: the `schema` literal (validated at compile time by the `schema"""..."""` interpolator from `grackle.syntax._`, which yields a bare `Schema`); the `typeMappings` list, where a `ValueObjectMapping` describes each object type and a `ValueField` wires each field to a function over the backing value; and the `selectElaborator`, which turns the argument `book(id: 2)` into a `Filter`/`Unique` over the data. Run a query against it with `QuickStartMapping.compileAndRun(...)`. + +The catalog distinguishes two kinds of entry. A **`TypeMapping`** maps a whole GraphQL type: `ObjectMapping` for object, interface and union types (it holds the field mappings), and `LeafMapping[T]` for scalar and enum types (it holds a circe `Encoder[T]`). Within an object mapping, a **`FieldMapping`** — `ValueField`, `CursorField`, `Delegate`, `EffectField` and others — wires one field to your data. Built-in leaf mappings for `String`, `Int`, `Float`, `Boolean` and `ID` are appended for you, so you only declare `LeafMapping`s for *custom* scalars and enums. (There is no `PrimitiveMapping` type; `LeafMapping` is the leaf construct.) The full catalog is documented in the [mapping types reference](../reference/mapping-types.md), and the mechanism by which a mapping produces a cursor over your data is explained in [mappings and cursors](../concepts/mappings-cursors.md). + +## The backend menu: which `Mapping` to extend + +You do not implement `Mapping` from scratch. You extend one of the backend-specific subclasses, each of which knows how to build a `Cursor` over a particular kind of data. Choose by where your data lives: + +| Backend | Extend | Use it when your data is… | +| --- | --- | --- | +| In-memory values | `ValueMapping[F]` | ordinary Scala values (case classes, lists) you hold in memory or fetch yourself. | +| Generic derivation | `GenericMapping[F]` | Scala ADTs you would rather not wire field-by-field — cursors are derived from the data types. | +| circe JSON | `CirceMapping[F]` | already-assembled `io.circe.Json` documents (e.g. a response from another service). | +| SQL | `SqlMapping[F]` | rows in a relational database; the mapping compiles the query algebra down to SQL and batches joins. | + +The starting points are: + +- **`ValueMapping`** — the simplest backend. You describe each field with `ValueField`/`CursorField` over an in-memory value. Good for prototypes, small fixed datasets, and data you assemble in your own effect before mapping. The [quick start](quick-start.md) builds one from scratch, and [filter, sort and page a field](../how-to/filtering-ordering-paging.md) extends it. +- **`GenericMapping`** — derives cursors directly from your Scala data types, so you write less field-by-field wiring. It is the basis of the [in-memory model tutorial](../tutorial/in-memory-model.md) (the Star Wars model); see also [serve Scala ADTs with generic derivation](../how-to/generic-derivation.md). +- **`CirceMapping`** — serves GraphQL straight from `io.circe.Json`, which is convenient when you already have JSON in hand. See [serve GraphQL from circe JSON](../how-to/circe-backend.md). +- **`SqlMapping`** — the database backend. It is the most powerful: it turns the `Query` algebra into SQL, assembles joins and embeddings, and batches nested fields into single round-trips. This is the [DB-backed model tutorial](../tutorial/db-backed-model.md) and the [SqlMapping reference](../reference/sql-mapping.md). + +These are not mutually exclusive. A `ComposedMapping` can *federate* several mappings — delegating different parts of one schema to different backends and stitching the results together — which is how Grackle joins, say, a SQL table to a value-backed lookup. See [compose multiple mappings](../how-to/compose-mappings.md). + +A note on paging and subscriptions while you weigh backends: Grackle has **no built-in Relay `Connection` type**. You assemble paging by hand from the `Limit`, `Offset`, `OrderBy` and `Count` algebra nodes (plus cursor predicates) — see [filter, sort and page a field](../how-to/filtering-ordering-paging.md). Likewise there is **no built-in websocket transport**; subscriptions are plain `fs2.Stream`s that you wire to a transport of your choosing. + +## Effects and `F[_]` + +Every mapping is parameterised by an effect type `F[_]`. A `Mapping[F[_]]` requires `implicit val M: MonadThrow[F]` — `F` must be a `cats.MonadThrow`, so it can sequence effects and raise errors. In practice `F` is `cats.effect.IO` (or any cats-effect-compatible effect), which is why the examples extend, for instance, `ValueMapping[IO]`. + +Two consequences are worth keeping straight from the start: + +- **Subscriptions are fs2 streams.** `compileAndRunSubscription` returns a `Stream[F, Json]`. `compileAndRun` runs the same machinery but expects exactly one result, so use the subscription entry point for anything streaming. +- **Internal errors are raised into `F`, not returned as GraphQL `errors`.** Grackle accumulates expected, query-level problems in a four-armed `Result` type (`Success`, `Warning`, `Failure`, `InternalError`); `Failure` and `Warning` carry the `Problem`s that surface in the response `errors` array, while `InternalError` is raised into the effect `F` instead and never appears in the JSON. How to construct and report errors is covered in [construct, accumulate and report errors](../how-to/errors.md). + +The effect model — including how nested effectful fields are batched — is explained in [effects and batching internals](../concepts/effects-batching.md). + +## Where to go next + +You now have the vocabulary: a **schema**, a **mapping** that ties it to a backend, a **compiler** that elaborates queries into a **`Query` algebra**, and an **interpreter** that walks that algebra with a **`Cursor`**. From here: + +1. [Installation](install.md) — add Grackle to your build. +2. [Quick start: your first query](quick-start.md) — run the book example above end to end. +3. [In-memory model tutorial](../tutorial/in-memory-model.md) then the [DB-backed model tutorial](../tutorial/db-backed-model.md) — build a real service step by step. + +## See also + +- [Quick start: your first query](quick-start.md) +- [In-memory model tutorial](../tutorial/in-memory-model.md) +- [Architecture overview](../concepts/architecture.md) +- [Mappings and cursors](../concepts/mappings-cursors.md) +- [Mapping types reference](../reference/mapping-types.md) diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 00000000..b68f5a75 --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -0,0 +1,167 @@ +# Quick start: your first query + +This tutorial walks you from an empty file to a running GraphQL query, entirely in memory and with no +database. You will define a tiny two-type schema, back it with an ordinary Scala `List`, wire the two +together with a [`ValueMapping`](../reference/mapping-types.md), teach Grackle how to interpret one field +argument, and run a query to see the JSON response. It is aimed at first-time users who want something +working in a few minutes; every step is real, compiled code that you can copy and run. For the larger, +multi-type Star Wars example, see the [in-memory tutorial](../tutorial/in-memory-model.md). + +## Add the dependency + +Add `grackle-core` to your build. That single module gives you the schema parser, the query +compiler/interpreter, and the in-memory `ValueMapping` used here. + +```scala +libraryDependencies += "org.typelevel" %% "grackle-core" % "@VERSION@" +``` + +See [Installation](install.md) for the other backend modules (circe, generic, doobie, skunk) and for +cross-build details. + +## The imports + +A core mapping pulls names from a handful of packages. The query algebra (`Filter`, `Unique`), +predicates (`Eql`, `Const`), GraphQL argument values (`IntValue`), and the compiler's elaboration +helpers each live in their own object, and `grackle.syntax._` brings in the `schema"..."` interpolator. + +```scala +import cats.effect.IO + +import grackle._ +import grackle.Predicate._ +import grackle.Query._ +import grackle.QueryCompiler._ +import grackle.Value._ +import grackle.syntax._ +``` + +## The whole mapping + +Here is the complete, compiled mapping. Read it top to bottom — the sections below unpack each part — +then we will run a query against it. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/docs/src/main/scala/grackle/QuickStartMapping.scala", "#quickstart")) +``` + +### The data + +The `Book` case class and the `books` list are plain Scala with no Grackle dependency. This is the point +of a [`ValueMapping`](../reference/mapping-types.md): your domain values stay ordinary, and the mapping +is the only place that knows about GraphQL. Anything you can hold in a Scala value — a `List`, a `Map`, +the result of a prior computation — can back a `ValueMapping`. + +### The schema + +The schema is written with the `schema"""..."""` interpolator from `grackle.syntax._`, which **validates +the SDL at compile time** and yields a bare `Schema`. (If you instead need to build a schema from text at +runtime — say, loaded from a file — use the `Schema(text)` factory, which returns a `Result[Schema]` you +can handle for parse errors.) Note the GraphQL nullability markers: `[Book!]!` is a non-null list of +non-null `Book`s, while `book(id: Int!): Book` returns a nullable `Book` (there may be no book with that +id) taking a non-null `Int!` argument. + +`schema.ref("Query")` and `schema.ref("Book")` produce `TypeRef`s — lightweight, named references into +the schema. You use them both when declaring mappings and, later, when matching on a type in the +elaborator. + +### The type mappings + +`typeMappings` is the catalog that tells Grackle how to serve each field. There is one +`ValueObjectMapping` per object type: + +- `ValueObjectMapping[Unit](tpe = QueryType, ...)` maps the root `Query` type. Its focus value is `Unit` + because the root has no parent value, so each `ValueField` ignores its argument (`_ => books`) and just + returns the list. Both `books` and `book` start from the same `books` list — the difference is that + `book` will be narrowed to a single element by the elaborator below. +- `ValueObjectMapping[Book](tpe = BookType, ...)` maps the `Book` type. Here the focus value is a `Book`, + so each `ValueField` projects one field out of it: `ValueField("title", _.title)` reads `book.title`. + +The type parameter on `ValueObjectMapping[Book]` matters: Grackle uses it at runtime to decide whether a +focus value belongs to this mapping, so supply the concrete element type rather than leaving it inferred. +You declare a field mapping for **every** field in the schema; a declared field with no mapping is a +validation error. You do not, however, declare mappings for the built-in scalars (`Int`, `String`, …) — +those leaf mappings are supplied automatically. + +> Note: writing `val typeMappings = List(...)` works because a `List` of mappings is implicitly converted +> to the `TypeMappings` catalog. That catalog is validated lazily the first time a query is compiled, so a +> missing or mismatched mapping surfaces on the first run rather than at construction. + +### The elaborator + +By default a field's arguments are simply ignored by the interpreter. To make `book(id: ...)` actually +select a book, you override `selectElaborator` — the per-mapping phase that rewrites the parsed query +before it is interpreted. See [the compiler and elaboration](../concepts/compiler-elaboration.md) for the +full pipeline; here one case is enough: + +```scala +case (QueryType, "book", List(Binding("id", IntValue(id)))) => + Elab.transformChild(child => Unique(Filter(Eql(BookType / "id", Const(id)), child))) +``` + +This matches the `book` field on `QueryType` whose `id` argument is an `IntValue`, binds the integer as +`id`, and rewrites the child query. `Filter(Eql(BookType / "id", Const(id)), child)` keeps only the books +whose `id` field equals the supplied value (`BookType / "id"` is a typed path to that field), and `Unique` +asserts the result is a single element and unwraps the list into one object — exactly the shape the +nullable `book: Book` field promises. The same `Filter`/`Eql` machinery scales up to sorting and paging, +covered in [Filter, sort and page a field](../how-to/filtering-ordering-paging.md). + +## Run a query + +`ValueMapping` extends `Mapping[IO]`, so the mapping object exposes `compileAndRun`, which parses, +type-checks, and elaborates the query text, then interprets it and renders the JSON response. It returns +an `IO[Json]`; run it with cats-effect's runtime to get the value. + +Ask for one book by id: + +```scala mdoc:silent +import grackle.docs.QuickStartMapping +import cats.effect.unsafe.implicits.global + +val query = """ + query { + book(id: 2) { + title + author + } + } +""" + +val response = QuickStartMapping.compileAndRun(query).unsafeRunSync() +``` + +The elaborator turned `book(id: 2)` into a filter for `id == 2` followed by `Unique`, so the response is +a single object wrapped in the standard `data` envelope (this JSON is the real value `response` holds, +rendered at build time): + +```scala mdoc:passthrough +println("```json\n" + response.spaces2 + "\n```") +``` + +The `books` field needs no argument and no elaboration — it returns the whole list: + +```scala mdoc:silent +val all = QuickStartMapping.compileAndRun("query { books { id title } }").unsafeRunSync() +``` + +```scala mdoc:passthrough +println("```json\n" + all.spaces2 + "\n```") +``` + +That is a complete Grackle server: a schema, a mapping over in-memory data, one elaboration rule, and a +call to `compileAndRun`. If a query is invalid — an unknown field, a wrong argument type — the failure is +reported as a `Problem` in the response's `errors` array rather than thrown; see +[Reporting errors](../how-to/errors.md) for how `Result` and `Problem` work. + +## Next steps + +- [In-memory model](../tutorial/in-memory-model.md) — the full Star Wars tutorial: interfaces, enums, + nested relationships, and a richer elaborator. +- [Filter, sort and page a field](../how-to/filtering-ordering-paging.md) — go beyond a single `Eql` + predicate to filtering, ordering, and paging. +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — what `selectElaborator` does + inside the query pipeline. +- [Mappings and cursors](../concepts/mappings-cursors.md) — how a mapping produces the `Cursor`s the + interpreter walks to build the response. +- [Mapping types reference](../reference/mapping-types.md) — the catalog of mappings (`ValueMapping`, + `CirceMapping`, `GenericMapping`, `SqlMapping`) and which to reach for. diff --git a/docs/how-to/circe-backend.md b/docs/how-to/circe-backend.md new file mode 100644 index 00000000..52b798ea --- /dev/null +++ b/docs/how-to/circe-backend.md @@ -0,0 +1,167 @@ +# Serve GraphQL from circe JSON + +This how-to is for developers whose data is already JSON — test fixtures, statically-known documents, or responses from a REST/HTTP service — and who want to serve it over GraphQL with no database. It shows you how to build a `CirceMapping` over an in-memory circe `Json` value: mapping a constant subtree with `CirceField`, mapping a custom scalar with `LeafMapping`, computing a field from JSON with `CursorField`, fetching JSON from an effect with `computeJson`/`computeEncodable`, and the one rule that trips people up — opaque-subtree priority. The "why" behind cursors and leaf validation lives in [mappings and cursors](../concepts/mappings-cursors.md) and the [CirceMapping reference](../reference/circe-mapping.md); this page is the recipe. + +## When to choose the circe backend + +Reach for the circe backend (`grackle-circe`) when your data source is circe `Json` rather than a relational database: + +- **Fixtures and tests** — back a schema with a literal JSON document so you can exercise queries without spinning up a database. +- **Statically-known data** — configuration, reference data, or any document baked into the app. +- **JSON from REST/HTTP** — you already have a response body as `Json` (or a domain value with a circe `Encoder`) and want to project a GraphQL view over it. + +The backend needs only a `cats.MonadThrow[F]` — there is no connection pool, codec, or transactor to wire. If your data lives in a SQL store, use a [SQL backend](sql-backends.md) instead; you can still splice JSON-valued fields into a SQL mapping (see [the last section](#splice-json-into-a-sql-mapping)). + +Add the dependency: + +```scala +libraryDependencies += "org.typelevel" %% "grackle-circe" % "@VERSION@" +``` + +## Define the schema and a Json value + +A standalone JSON-backed mapping extends `CirceMapping[F]` and provides three things: a `schema`, a `Json` value, and `typeMappings` that tie the two together. Here is the canonical example from the circe test suite. Read it top to bottom — the GraphQL schema, then the `data` value (built with the `json"…"` interpolator from `io.circe.literal._`), then the `typeMappings`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CirceData.scala", "#circe_mapping")) +``` + +The schema is written with the `schema"…"` interpolator (from `grackle.syntax._`), which validates at compile time and yields a bare `Schema`. Note the GraphQL nullability is ordinary SDL here: `bool: Boolean` is nullable, `children: [Child!]!` is a non-null list of non-null `Child`. The `data` value is one `Json` object whose shape mirrors the `Root` type — object fields are looked up by name, `array` is a JSON array, `choice` is the enum value as a JSON string, and `children` is a JSON array of objects that each match one of the interface's implementations. + +## Map a field with CirceField + +The single line that connects schema to data is `CirceField("root", data)` in the `Query` mapping. `CirceField(fieldName, value: Json)` maps a GraphQL field to a fixed `Json` subtree: everything reachable under `Query.root` is served by walking `data`. You do **not** write a field mapping for `bool`, `int`, `object`, `children` and so on — Grackle matches the JSON structurally against the `Root` type as it navigates. + +Querying it: + +```graphql +query { + root { + object { + id + aField + } + } +} +``` + +```json +{ + "data": { + "root": { + "object": { + "id": "obj", + "aField": 27 + } + } + } +} +``` + +Built-in scalars (`Int`, `Float`, `String`, `Boolean`, `ID`) and enums need no mapping: an enum must be a JSON string equal to a declared enum value, an `Int` is re-parsed from the JSON number, and a `Float` is passed through. A JSON key that is not a field in the schema — `hidden` in the data above — is simply unreachable: querying `root { hidden }` returns `"No field 'hidden' for type Root"` in the response `errors`. + +## Map a custom scalar with LeafMapping + +Custom GraphQL scalars are the one leaf type the circe backend can't resolve on its own. The schema declares `scalar BigDecimal`, so the mapping registers `LeafMapping[BigDecimal](BigDecimalType)`. That tells Grackle how the `BigDecimal` GraphQL scalar relates to the Scala type when it reads the JSON number `1.2`. Without the `LeafMapping`, a custom scalar's JSON value falls through a catch-all and is not handled as you'd expect — always add a `LeafMapping[T]` for each non-built-in scalar you serve. + +## Compute a field from JSON with CursorField + +Sometimes a GraphQL field has no direct JSON counterpart and must be derived. The example maps `computed` with `CursorField("computed", computeField, List("hidden"))`. A `CursorField` computes its value from the parent `Cursor`; the third argument lists **required** sibling fields that must be available even though they aren't queried. Here `computeField` reads the JSON-only `hidden` field (value `13`) via `c.fieldAs[Json]("hidden")`, takes its number, and returns `+ 1`: + +```graphql +query { + root { + computed + } +} +``` + +```json +{ + "data": { + "root": { + "computed": 14 + } + } +} +``` + +This is the pattern for reading data that exists in the JSON but is deliberately absent from the schema: keep the value in the document, leave it out of the SDL, and pull it in through `required`. For a JSON *subtree* (rather than a scalar) computed this way, use `CursorFieldJson` instead — covered [below](#splice-json-into-a-sql-mapping). + +## Run queries, including effectful roots + +You run an operation with `mapping.compileAndRun(query)`, which returns the response as `F[Json]` (for `CirceMapping[IO]`, an `IO[Json]`). + +When the JSON isn't already in hand — you have to fetch it from an HTTP service, read a file, or otherwise perform an effect — use a `RootEffect`. `CirceMappingLike` adds three ways to do this, shown side by side in the effect test mapping (the bodies here bump a `SignallingRef` only to prove each effect runs exactly once; in real code that's where your `F`-effect goes): + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CirceEffectData.scala", "#circe_effects")) +``` + +The three styles differ only in what the effect yields: + +- **`RootEffect.computeJson(field)((path, env) => F[Result[Json]])`** — your effect produces a `Json`; the backend wraps it in a `CirceCursor` for you. This is the everyday choice for "fetch JSON, serve it". +- **`RootEffect.computeEncodable[A](field)((path, env) => F[Result[A]])`** — your effect produces a domain value `A`; given an implicit circe `Encoder[A]` (here `EncodeStruct`), the backend encodes it to JSON and wraps it. +- **`RootEffect.computeCursor(field)(...)`** — full control: you call `circeCursor(path, env, json)` yourself. Use this when you need to choose the cursor's `path` explicitly. + +That path choice is the subtle part. `computeJson`/`computeEncodable` (and `circeCursor(p, e, json)` with the handler's `p`, as in `foo`) expect `json` to be the **value of the field** — so `foo`/`bar`/`baz` each yield just `{ "n": …, "s": … }`. The `qux` handler instead roots the cursor with `circeCursor(Path.from(p.rootTpe), e, json)`, so its `json` must be an **object containing the field name**: `{ "qux": { … } }`. Mixing these up produces a shape mismatch. For streaming roots (subscriptions), `RootStream.computeJson` and `RootStream.computeEncodable` are the `fs2.Stream` counterparts — each emitted element becomes one cursor. See [effects and batching](effects-batching.md) for how root effects fit the wider effect model. + +## Opaque-subtree priority: JSON beats an explicit mapping + +`CirceField` (and `CursorFieldJson`) mark their subtree as **opaque** (`subtree = true`). When the cursor looks up a field, it checks the current JSON object *first*, and only falls back to the rest of your `typeMappings` when the JSON object lacks that field. This is the rule that surprises people, so here is a mapping built to expose it: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CircePrioritySuite.scala", "#circe_priority")) +``` + +Both `present` and `fallback` are `Barrel`s whose `monkey` is a `Monkey`, and there is an explicit `CirceField("name", Json.fromString("Steve"))` mapping on `Monkey`. The difference is the JSON: + +- `present`'s JSON is `{ "monkey": { "name": "Bob" } }` — the `name` field is **present in the JSON**, so the literal `"Bob"` wins and the explicit `Monkey.name` mapping is shadowed. +- `fallback`'s JSON is `{ "monkey": {} }` — the JSON object **omits** `name`, so Grackle falls back to the explicit mapping and serves `"Steve"`. + +```graphql +query { + present { monkey { name } } + fallback { monkey { name } } +} +``` + +```json +{ + "data": { + "present": { "monkey": { "name": "Bob" } }, + "fallback": { "monkey": { "name": "Steve" } } + } +} +``` + +The practical takeaway: an explicit field mapping for a type embedded inside a `CirceField` subtree only takes effect for keys the JSON doesn't already supply. If you want the explicit mapping to govern a field, don't put that key in the JSON. + +## Splice JSON into a SQL mapping + +The behavior above isn't limited to the circe module. `CirceMappingLike[F]` is a *trait* that carries the whole field-mapping surface — `CirceField`, `CursorFieldJson`, `circeCursor` — and `SqlMappingLike[F]` extends it. That single inheritance is why you can decode a database column into a JSON value inside a SQL mapping and serve it as a GraphQL subtree, using `CursorFieldJson`: + +```scala mdoc:compile-only +import io.circe.Json +import grackle._ +import grackle.circe.CirceMappingLike + +// Inside a mapping that mixes in CirceMappingLike[F] (e.g. a SqlMapping): +trait Sketch[F[_]] extends CirceMappingLike[F] { + // decode a sibling column (declared in `required`) into a Json subtree + def decode(c: Cursor): Result[Json] = ??? + + def jsonField: FieldMapping = + CursorFieldJson("categories", decode, required = List("categoriesId")) +} +``` + +`CursorFieldJson(fieldName, f, required)` runs `f` against the parent `Cursor` — which can read the SQL-backed sibling columns named in `required` — and serves the resulting `Json` as an opaque subtree, exactly like `CirceField`. For the full SQL-side recipe (decoding a column with a circe `Encoder` and exposing it as structured GraphQL), see [storing and querying JSONB columns](jsonb-columns.md). + +## See also + +- [CirceMapping reference](../reference/circe-mapping.md) — every signature: `CirceField`, `CursorFieldJson`, `CirceCursor`, `circeCursor`, and the `computeJson`/`computeEncodable` syntax. +- [Mappings and cursors](../concepts/mappings-cursors.md) — how a `Cursor` walks a data source against the schema, the concept behind `CirceCursor`. +- [Custom scalars and enums](custom-scalars-enums.md) — more on `LeafMapping[T]` and scalar/enum handling. +- [Storing and querying JSONB columns](jsonb-columns.md) — splicing JSON into a SQL backend with `CursorFieldJson`. +- [Effects and batching](effects-batching.md) — where `RootEffect`/`RootStream` fit the effect model. diff --git a/docs/how-to/compose-mappings.md b/docs/how-to/compose-mappings.md new file mode 100644 index 00000000..ca900710 --- /dev/null +++ b/docs/how-to/compose-mappings.md @@ -0,0 +1,135 @@ +# Compose multiple mappings (federation) + +This recipe shows how to serve one GraphQL schema from several independent `Mapping`s — for example a SQL-backed `world` schema joined to an in-memory `currencies` mapping. You extend [`ComposedMapping`](../reference/mapping-types.md), wire delegated fields with `Delegate`, and express the cross-mapping link with a `join` function. It assumes you can already build a single mapping (see [DB-backed Model](../tutorial/db-backed-model.md)); for the staged interpretation behind delegation, see [How composition executes](../concepts/composition.md). + +## When to reach for composition + +Compose mappings when one field's subtree lives in a different backend or service than its parent: a SQL `Country` whose `currency` comes from an in-memory cache or a remote API, or two value mappings owned by different teams that you want to expose behind a single endpoint. The parent mapping serves its own fields and *delegates* the foreign subtree to another mapping, which runs in its own interpreter stage. + +A few things composition is **not**: there is no automatic schema merging, and `selectElaborator` does not compose across mappings — you re-declare what the composed level owns. Delegation is wired explicitly, field by field. + +## Step 1: build the component mappings independently + +Start with each component as an ordinary, standalone mapping. Here is a tiny in-memory currency mapping with a single `fx(code:)` root field. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/composed/ComposedData.scala", "#composed_currency")) +``` + +Its `selectElaborator` turns the `code` argument into `Unique(Filter(Eql(CurrencyType / "code", Const(code)), child))`, so `fx(code: "GBP")` resolves to a single `Currency`. The country component is the same shape, modelling `Country(code, name, currencyCode)` over an in-memory list. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/composed/ComposedData.scala", "#composed_country")) +``` + +Note what each component schema does **not** have: `CountryMapping`'s `Country` type exposes `code` and `name` but no `currency` field, and the two mappings know nothing about each other. The link between them is added only at the composed level. + +## Step 2: define the composed schema as a superset + +The composed mapping needs its **own** schema. This is usually a superset of the components that adds the join field — here `Country.currency: Currency!`, which appears in neither component schema. Forgetting it leaves nowhere to hang the `Delegate`. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/composed/ComposedData.scala", "#composed_mapping")) +``` + +Walk the three load-bearing pieces of `ComposedMapping`: + +- **Its own schema and refs.** The `Query` type re-exposes `country`, `fx` and `countries`, and `Country` gains the new `currency: Currency!` field. `QueryType`, `CountryType` and `CurrencyType` are this mapping's refs, distinct from the components'. +- **Its own `selectElaborator`.** Elaboration does not compose: the composed mapping re-declares the `fx` and `country` root-argument cases, filtering on its **own** `CurrencyType` / `CountryType` refs. Drop these and the root arguments are never bound. +- **`Delegate` field mappings.** Inside ordinary `ObjectMapping`s, `Delegate("country", CountryMapping)`, `Delegate("countries", CountryMapping)` and `Delegate("fx", CurrencyMapping)` route those root fields straight through to the component that owns them. `Delegate("currency", CurrencyMapping, countryCurrencyJoin)` routes `Country.currency` through an explicit join. + +A `Delegate` is a `FieldMapping` of the form `Delegate(fieldName, mapping, join)`, where `join` defaults to `ComponentElaborator.TrivialJoin` — the child query passes through unchanged. The first three delegates above use that default; only `Country.currency` supplies a custom join. + +## Step 3: write the join + +The `join` is a `(Query, Cursor) => Result[Query]`. It receives the **already-elaborated** child query and the parent's `Cursor` (focused on the raw parent model value), and returns the continuation query that the *other* mapping will run. This is where the cross-mapping JOIN is expressed. + +In `countryCurrencyJoin` (the last method in the snip above), the focus is a `CountryData.Country` and the query is `Select("currency", _, child)`. The join rewrites it to `Select("fx", Unique(Filter(Eql(CurrencyType / "code", Const(c.currencyCode)), child)))` — that is, it reads `currencyCode` off the parent country and turns "give me this country's currency" into "run `fx` filtered to that code" against `CurrencyMapping`. Two details matter: + +- The join emits the **other** mapping's field name (`currency` becomes `fx`); the interpreter realigns the result name afterwards, so the client still sees `currency` (or whatever alias it asked for). +- Always handle the mismatch case. The `case _` returns `Result.internalError(...)`. Per the [error model](errors.md), an internal error is raised into the effect `F` rather than surfacing in the response `errors` array, which is the right behaviour for a "this should never happen" cursor-shape violation. + +With that wired, a nested query resolves end to end across both mappings: + +```graphql +query { + country(code: "GBR") { + name + currency { code exchangeRate } + } +} +``` + +```json +{ + "data": { + "country": { + "name": "United Kingdom", + "currency": { "code": "GBP", "exchangeRate": 1.25 } + } + } +} +``` + +## Step 4: list-valued joins return a `Group` + +When the delegated field is a list, the join must return a `Group` of `Select`s — one delegated query per element — not a single `Select` that yields a list. This `collectionItemJoin` turns a `Collection` with an `itemIds: List[String]` into one `itemById` lookup per id. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/composed/ComposedListSuite.scala", "#composed_list_join")) +``` + +The parent focus is a `Collection`; the join maps each `id` in `itemIds` to `Select("itemById", Unique(Filter(Eql(ItemType / "id", Const(id)), child)))` and wraps the lot in `Group(...)`. The interpreter special-cases a `Group` inside a component and assembles the results into a JSON array. Returning a single `Select` where a list is expected (or vice versa) is a shape mismatch. + +## Step 5: TrivialJoin when the key is already in context + +If the target mapping can resolve the field from the key already present in the parent's context — a true root field, or a field whose key the target picks up from its inherited `Env` — you don't need a custom join at all. Leave the `join` argument off `Delegate` and it defaults to `ComponentElaborator.TrivialJoin`, passing the child query through unchanged. + +The SQL-backed federation does exactly this for `Country.currencies`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala", "#composed_sql")) +``` + +`SqlComposedMapping` takes the two component mappings as constructor arguments (`world` and `currency`), so it composes a SQL `world` schema with an effectful in-memory `currency` mapping. The `Query` root fields `country` / `countries` / `cities` delegate to `world`; `Country.currencies` delegates to `currency` with the default `TrivialJoin`, because the join key (`Country.code`) is already in the parent country's context and the currency mapping resolves it from its own `Env`. As before, the composed level re-declares `selectElaborator` for the root arguments (`country`'s `code`, `cities`' `namePattern`) on its own refs. + +To instantiate it, pass concrete component mappings to the constructor, for example with Skunk: + +```scala +new SqlComposedMapping( + new SkunkTestMapping(pool) with SqlWorldMapping[IO], + currencyMapping +) +``` + +Building the SQL side (transactor or session pool, table defs, joins) is covered in [Choose and configure a SQL backend](sql-backends.md). + +## Step 6: batch delegated queries with `combineAndRun` + +By default each delegated subquery runs independently: a list of countries, each needing its currency, issues N separate currency lookups. To collapse them into one backend call, override `combineAndRun` on the delegated mapping. + +```scala +def combineAndRun(queries: List[(Query, Cursor)]): F[Result[List[ProtoJson]]] +``` + +The default implementation runs each `(query, cursor)` pair independently. The effectful currency mapping overrides it to group sibling `currencies` lookups into a single call: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala", "#composed_combine")) +``` + +The override unpacks each delegated `Select("currencies", _, _)` together with its country `code`, partitions the groupable queries, and folds the codes into one `SimpleCurrencyQuery(codes, child)` that fetches every requested currency at once. It then repacks the batched results back into per-query `ProtoJson`s. The hard invariant: **the returned list must stay positionally aligned with the input list** — the `indexedQueries` / `repackedResults` machinery exists precisely to preserve that order. A two-city query that touches several countries issues exactly one `currencies(...)` lookup with this override in place, versus one per country without it. + +Batching is opt-in per mapping; this is the only built-in way to coalesce cross-mapping calls — there is no automatic dataloader. See [Effects and batching](effects-batching.md) for the related root-effect batching pattern. + +## How it executes + +You never construct the boundary by hand. At compile time the `componentElaborator` phase rewrites each delegated `Select` into a `Component(targetMapping, join, child)` node. At interpretation time the parent runs its part, applies `join` to its `Cursor` and the child query, and emits a deferred `ProtoJson.component` tagged with the target mapping. `QueryInterpreter.completeAll` then gathers those deferred subtrees, groups them by mapping, calls each mapping's `combineAndRun` in a fresh stage, and recurses until nothing is deferred — which is why deep composition multiplies stages and why batching pays off. The full mechanism is in [How composition executes](../concepts/composition.md). + +## See also + +- [How composition executes](../concepts/composition.md) — the staged interpretation, `Component` nodes, and `completeAll` stitching behind delegation. +- [Mapping types](../reference/mapping-types.md) — `ComposedMapping`, `Delegate`, and the field-mapping reference. +- [Choose and configure a SQL backend](sql-backends.md) — building the SQL component of a federated mapping. +- [Effects and batching](effects-batching.md) — the effectful root and batching patterns that pair with `combineAndRun`. +- [Filter, sort and page a field](filtering-ordering-paging.md) — the `Filter`/`Unique`/`Eql` building blocks used inside joins. diff --git a/docs/how-to/custom-scalars-enums.md b/docs/how-to/custom-scalars-enums.md new file mode 100644 index 00000000..a3e3a1f8 --- /dev/null +++ b/docs/how-to/custom-scalars-enums.md @@ -0,0 +1,135 @@ +# Define custom scalars and enums + +GraphQL gives you five built-in scalars (`Int`, `Float`, `String`, `Boolean`, `ID`), but real domains need more: a `UUID`, a `Date`, a duration `Interval`, an enumerated `Genre`. This page shows you how to declare such types in your schema and wire them to Scala types. It is for developers who already have domain scalars (UUIDs, dates, times, durations) and want them to appear in their GraphQL API. You will declare the types in SDL, encode their results with [`LeafMapping`](../reference/mapping-types.md), decode them when they arrive as arguments with `Value` extractors, and (optionally) point at their specification with `@specifiedBy`. + +## Declare the scalar and enum in SDL + +Custom scalars are declared with `scalar Name`; enums with `enum Name { ... }`. The example mapping below defines five custom scalars and one enum, then uses them throughout a `Movie` type and its query arguments. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ScalarsSuite.scala", "#scalars_schema")) +``` + +The SDL here is ordinary GraphQL. Each `scalar UUID` line introduces a name; each enum lists its allowed value names (`DRAMA`, `ACTION`, `COMEDY`). Note that scalar names are case-sensitive and validated at parse time: referencing `Date` in a field without a matching `scalar Date` declaration is a `Reference to undefined type` error. The five built-in scalar names are always available without declaration. + +Because this schema is written with the `schema"""..."""` interpolator (from `grackle.syntax._`), it is parsed and fully validated at **compile time** and yields a bare `Schema`. The runtime factory `Schema(text)` returns a `Result[Schema]` instead — use that when the SDL comes from a file or config. See [the schema model](../concepts/schema-model.md) for the distinction. + +## Why the schema only records the name + +A `scalar UUID` declaration produces a `ScalarType` whose `isBuiltIn` is `false`. That is *all* the schema layer knows about it: the name. The schema does not know that `UUID` should become a `java.util.UUID`, nor how to serialise or parse one. Defining `scalar UUID` on its own does nothing at runtime — values pass through unchanged until you supply the encoding and decoding in the mapping layer. + +This separation is deliberate. The schema describes the *shape* of your API; the [mapping](../concepts/mappings-cursors.md) describes how that shape is backed by data. The next two sections supply the two halves: encoding results, and decoding arguments. + +## Encode results with `LeafMapping` + +A `LeafMapping[T]` ties a scalar or enum type in the schema to a Scala type `T`, using an implicit circe `Encoder[T]` to turn `T` into JSON in the response. There is no separate `PrimitiveMapping`; `LeafMapping` is the single construct for custom scalars and enums. (The built-in `String`/`Int`/`Float`/`Boolean`/`ID` leaf mappings are added for you automatically, so you only declare your own.) + +You declare one `LeafMapping` per custom type, alongside your object mappings, in the `typeMappings` catalog: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ScalarsSuite.scala", "#scalars_leafmappings")) +``` + +Each `LeafMapping[T](tpe)` takes the schema type reference (`UUIDType`, `GenreType`, …, obtained with `schema.ref("UUID")`) and requires an implicit `Encoder[T]` in scope. For `UUID`, `LocalDate`, `LocalTime`, `ZonedDateTime` and `Duration`, circe's standard library encoders apply. For the domain `Genre` enum there is no library encoder, so the mapping supplies one explicitly: + +```scala mdoc:silent +import io.circe.Encoder + +sealed trait Genre +object Genre { + case object Drama extends Genre + case object Action extends Genre + case object Comedy extends Genre +} + +implicit val genreEncoder: Encoder[Genre] = + Encoder[String].contramap { + case Genre.Drama => "DRAMA" + case Genre.Action => "ACTION" + case Genre.Comedy => "COMEDY" + } +``` + +The encoder maps each ADT case to the exact value name declared in the enum. With these `LeafMapping`s in place, a `Movie`'s `genre` and `releaseDate` fields render as the enum value name and an ISO date string in the response JSON. + +## Decode custom-scalar arguments with `Value` extractors + +Encoding handles *output*. For *input* — a custom scalar or enum used as a field argument — you decode the incoming `Value` during elaboration. Grackle delivers argument values as the `Value` ADT (`StringValue`, `IntValue`, `EnumValue`, …); a custom scalar arrives as whatever literal the client wrote (typically a `StringValue`), and an enum arrives as an `EnumValue`. You convert these to your domain type with small extractor objects: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ScalarsSuite.scala", "#scalars_values")) +``` + +Each object defines an `unapply` that pattern-matches the raw `Value` and returns an `Option` of the domain type — `UUIDValue` parses a `StringValue` into a `UUID`, `GenreValue` turns an `EnumValue`'s `name` back into a `Genre`. You then use them as patterns inside your [`SelectElaborator`](../concepts/mappings-cursors.md), binding the parsed value into the query algebra: + +```scala +override val selectElaborator = SelectElaborator { + case (QueryType, "movieById", List(Binding("id", UUIDValue(id)))) => + Elab.transformChild(child => Unique(Filter(Eql(MovieType / "id", Const(id)), child))) + case (QueryType, "moviesByGenre", List(Binding("genre", GenreValue(genre)))) => + Elab.transformChild(child => Filter(Eql(MovieType / "genre", Const(genre)), child)) +} +``` + +A non-built-in scalar passes argument type-checking unchanged, so the raw literal reaches the elaborator as-is. If an extractor then returns `None` (an unparseable UUID, an unknown enum name) the `case` does not match; `SelectElaborator` falls through to `Elab.unit` and leaves the query untransformed, so the argument silently has no effect. To reject a malformed value, add a fallback `case` for that field that fails explicitly — for example with `Elab.failure(...)` — rather than relying on the extractor. For more on turning arguments into predicates, see [filtering, ordering and paging](filtering-ordering-paging.md). + +## Document a scalar with `@specifiedBy` + +The GraphQL built-in directive `@specifiedBy(url: "...")` records, in the schema itself, where a custom scalar's format is specified. Grackle parses it and exposes the URL through `ScalarType.specifiedByURL`. The following block parses a one-scalar schema at runtime with `Schema(text)` (hence the `Result[Schema]`), looks the scalar up by name, and reads the directive back: + +```scala mdoc +import grackle._ + +val rfcSchema = + Schema(""" + type Query { now: DateTime } + scalar DateTime @specifiedBy(url: "https://scalars.graphql.org/andimarek/date-time") + """) + +val dateTimeUrl = + rfcSchema.toOption.flatMap(_.definition("DateTime")).flatMap { + case st: ScalarType => st.specifiedByURL + case _ => None + } +``` + +`specifiedByURL` returns an `Option[String]` — the URL when the directive is present, `None` otherwise. The five built-in scalars carry no `@specifiedBy` directive. The directive is metadata only: it has no effect on encoding or decoding, but tools and clients can surface it through introspection. + +## Generic backend: derive a leaf cursor builder + +When you use the [generic backend](generic-derivation.md) (`GenericMapping`), you map a Scala value tree to the schema with `CursorBuilder` instances instead of an explicit `typeMappings` catalog of leaf mappings. A custom scalar or enum then needs a *leaf* cursor builder, which you obtain with `CursorBuilder.deriveLeafCursorBuilder[T](tpe)`. As with `LeafMapping`, it requires an implicit circe `Encoder[T]`. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/ScalarsSuite.scala", "#generic_scalars")) +``` + +The key line is the `CursorBuilder[Genre]`, bound to the schema's `GenreType` via `deriveLeafCursorBuilder[Genre](GenreType)`. It uses the `genreEncoder` defined just above it, so the enum renders as its value name. The java-time and `UUID` fields of `Movie` (`releaseDate: LocalDate`, `nextShowing: OffsetDateTime`, `duration: Duration`, `id: UUID`) need no per-field wiring: the generic backend's implicit `leafCursorBuilder` derives a leaf builder for any type with a circe `Encoder` in scope. Note that `deriveObjectCursorBuilder[Movie](MovieType)` builds the surrounding object cursor that ties those leaves together. + +## Mapping enum value names to a domain ADT + +Across both backends the enum recipe is the same, and is worth stating on its own. A `enum Genre { DRAMA ACTION COMEDY }` declaration produces an `EnumType` that knows only the value names. To connect those names to a sealed ADT you provide two total, mutually-inverse mappings: + +- **Decode** (name to ADT), used for arguments — the `fromString` helper behind `GenreValue.unapply`: + +```scala +def fromString(s: String): Option[Genre] = + s.trim.toUpperCase match { + case "DRAMA" => Some(Genre.Drama) + case "ACTION" => Some(Genre.Action) + case "COMEDY" => Some(Genre.Comedy) + case _ => None + } +``` + +- **Encode** (ADT to name), used for results — the `Encoder[Genre]` shown earlier. + +Keep the string literals in both directions identical to the value names in the SDL. Decoding returns an `Option` so an unknown name fails cleanly rather than throwing; encoding is total because every ADT case has a declared name. + +## See also + +- [Reference: mapping types](../reference/mapping-types.md) — the full `LeafMapping` and field-mapping catalog. +- [Reference: schema and SDL](../reference/schema-sdl.md) — `ScalarType`, `EnumType`, and how SDL is parsed. +- [Concept: the schema model](../concepts/schema-model.md) — why the schema records names, not codecs. +- [Concept: mappings and cursors](../concepts/mappings-cursors.md) — how `LeafMapping`, elaborators and cursors fit together. +- [How-to: filtering, ordering and paging](filtering-ordering-paging.md) — more on turning arguments into predicates. +- [How-to: generic derivation](generic-derivation.md) — the `CursorBuilder` backend in full. diff --git a/docs/how-to/directory.conf b/docs/how-to/directory.conf new file mode 100644 index 00000000..40fdd5c0 --- /dev/null +++ b/docs/how-to/directory.conf @@ -0,0 +1,17 @@ +laika.title = How-to Guides +laika.navigationOrder = [ + filtering-ordering-paging.md + compose-mappings.md + sql-backends.md + interfaces-unions.md + jsonb-columns.md + custom-scalars-enums.md + schema-directives.md + query-directives.md + effects-batching.md + errors.md + validate-mappings.md + generic-derivation.md + circe-backend.md + serve-over-http.md +] diff --git a/docs/how-to/effects-batching.md b/docs/how-to/effects-batching.md new file mode 100644 index 00000000..745c89f6 --- /dev/null +++ b/docs/how-to/effects-batching.md @@ -0,0 +1,144 @@ +# Run effects and batch nested fields + +This how-to is for developers who need a mapping to run a side effect — call an external service, hit a second database, update a `Ref` — as part of resolving a query, and who want nested fields to do that **once per query rather than once per row** (no N+1). Grackle gives you two attachment points: a `RootEffect` that fires once at a top-level field before the rest of the query runs, and an `EffectField` + `EffectHandler` pair that defers a nested field and batches every occurrence of it into a single call. This page is the recipe for both; the mechanism behind the batching — staged interpretation, `ProtoJson` placeholders, grouping by `(mapping, handler)` — lives in the [effects and batching concept](../concepts/effects-batching.md) and the [effects reference](../reference/effects.md). + +## Run an effect at a root field + +A `RootEffect` is a `FieldMapping` for a top-level `Query`/`Mutation`/`Subscription` field. It runs once, up front, before the interpreter walks the rest of the query, and yields a (possibly rewritten) query plus a root `Cursor`. Its primary constructor is private, so you always go through one of the companion factories, choosing by how much you need to influence the result: + +| Factory | You provide | Use it when | +| --- | --- | --- | +| `computeUnit(name)(Env => F[Result[Unit]])` | a pure side effect | you only write (e.g. a mutation) and the query/cursor stay as-is | +| `computeCursor(name)((Path, Env) => F[Result[Cursor]])` | a custom root `Cursor` | you compute the data and build the cursor yourself | +| `computeChild(name)((Query, Path, Env) => F[Result[Query]])` | a replacement child query | you want the effect to decide *what* is queried (e.g. inject a `Filter`/`Limit`) | +| `apply(name)((Query, Path, Env) => F[Result[(Query, Cursor)]])` | both query and cursor | you need full control | + +`computeUnit` and `computeCursor` leave the client's query untouched, so the only channel for passing effect results downstream is the `Env` attached to the returned `Cursor`. If you need to change what is queried, reach for `computeChild` or the full `apply`. + +The circe backend adds two shortcuts on top of `computeCursor` so you can return data directly instead of building a cursor: `computeJson` (return raw `Json`) and `computeEncodable` (return any value with a circe `Encoder`). The following mapping puts all of these side by side on a `CirceMapping`, with a `SignallingRef[F, Int]` counter that each effect bumps so a test can prove it fired: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CirceEffectData.scala", "#circe_effects")) +``` + +Each field wires one `RootEffect` into the `Query` `ObjectMapping`: + +- `foo` uses `computeCursor` and builds a `circeCursor(path, env, json)` focused on the field — the `Json` carries only the struct's fields (`n`, `s`). +- `bar` uses `computeJson` and returns the same shape of `Json`; the mapping builds the cursor for you. +- `baz` uses `computeEncodable` and returns a `Struct`; the implicit `Encoder[Struct]` turns it into `Json` and the mapping builds the cursor. +- `qux` shows the focus detail that trips people up: it builds the cursor with `Path.from(p.rootTpe)` (root-focused) and nests the field name *inside* the `Json` (`"qux" -> {...}`). A field-focused cursor like `foo` must not include the field name; a root-focused cursor must. Choosing the wrong focus yields a shape mismatch against the schema. + +One thing to note before you rely on this for batching: **independent root fields each run their own effect.** A query asking for `foo`, `bar` and `baz` together runs three separate `RootEffect`s, so the counter ends at `3`, not `1`. Root effects are *not* batched across sibling fields — only nested `EffectField` occurrences are. That batching is the next section. + +To drive a root effect and observe it from a test, compile and run the query with `Mapping.compileAndRun` and read the counter back out. The pattern (schematic): + +```text +for { + ref <- SignallingRef[IO, Int](0) + map = new TestCirceEffectMapping(ref) + res <- map.compileAndRun("""query { foo { s n } }""") + n <- ref.get // == 1 for a single root effect +} yield (res, n) +``` + +`compileAndRun(text)` compiles the query through the [`QueryCompiler`](../concepts/compiler-elaboration.md) and runs it, returning `F[Json]`. The `SignallingRef` is the observable side effect that proves the `RootEffect` fired the expected number of times. + +## Batch a nested field with an `EffectField` + +When the field that needs an effect is *nested* — say `Country.currencies`, backed by an external currency service — running the effect inline would call the service once per country, the classic N+1. Grackle solves this by deferring the field: the `EffectElaborator` compiler phase wraps it in an `Effect` algebra node, the interpreter turns every occurrence into a deferred placeholder, and at the end of the stage it gathers all of them and hands them to your handler in **one** call. You implement that one call. + +### Step 1 — declare the `EffectField` and its required columns + +In the parent object's `ObjectMapping`, replace what would be an `SqlObject` with an `EffectField(fieldName, handler, required)`. The `required` list names sibling columns that must be fetched in the parent query so your handler can read them from each parent `Cursor`. Here is the world mapping's `typeMappings`, where `Country.currencies` is an `EffectField` declaring `List("code2")`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala", "#effect_typemappings")) +``` + +Two lines carry the recipe. `SqlField("code2", country.code2)` is an ordinary mapped column on `Country`, and `EffectField("currencies", CurrencyQueryHandler, List("code2"))` attaches the handler to the `currencies` field while declaring `code2` as required. That `required` list is what makes `SqlMapping` add `code2` to the parent `SELECT`; without it, `parentCursor.fieldAs[String]("code2")` inside the handler would fail. The `Currency` mapping at the bottom shows that effect fields nest: `Currency.country` is itself an `EffectField` backed by a second handler. + +This corresponds to the GraphQL schema: + +```graphql +type Country { + name: String! + code2: String! + currencies: [Currency!]! +} + +type Currency { + code: String! + exchangeRate: Float! + countryCode: String! + country: Country! +} +``` + +### Step 2 — implement `runEffects`: read parents, make ONE call + +`EffectHandler[F]` has a single method, `runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]]`. The interpreter passes you one `(continuation-query, parent-cursor)` pair for **every** occurrence of the field across the whole result, and you must return **exactly one continuation `Cursor` per input pair, in the same order**. Read the keys off the parent cursors, collapse them to a distinct set, make a single service call, then build one cursor per input: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala", "#currency_handler")) +``` + +Walking the handler: + +- It reads `code2` off each parent cursor with `fieldAs[String]("code2")` — the column it required in Step 1 — keeping both the per-input list (`countryCodes`, order-preserving, with `None` for missing) and the de-duplicated `distinctCodes`. +- For each pair it derives the child GraphQL `Context` with `Query.childContext(parentCursor.context, query)`. The `query` it receives is the *continuation child* — the selection set under `currencies` — not the original field select, so you must derive the context rather than reuse the parent's. +- `currencyService.get(distinctCodes)` is the **single** call for the entire batch, regardless of how many countries are in the result. `ResultT` threads the `F[Result[...]]` for you. +- `unpackResults` splits the one service result back out per country (in `countryCodes` order), and the final `zip` builds one `CirceCursor(childContext, json, Some(parentCursor), parentCursor.env)` per input — preserving order. + +Because the interpreter groups deferred effects by `(mapping, handler)` *identity*, the batching only happens if the **same handler object instance** is used for every occurrence. `CurrencyQueryHandler` is a single `object`, so every `Country.currencies` in the result shares it and collapses into one `runEffects` call. Constructing a fresh handler per field or per row would split the batch and reintroduce N+1. + +### Step 3 — verify batching with a call counter + +The `CurrencyService` in this example holds a `Ref[F, Int]` counter that increments on each `get`. Running a query that returns several countries and asserting the counter goes from `0` to `1` is the proof that the effect ran once for the whole query, not once per row. That assertion is exactly what `SqlNestedEffectsSuite` checks. If you build your own service, expose a counter (or a recorded-calls list) and assert on it the same way — it is the cheapest regression guard against an accidental N+1. + +## Re-query the same mapping (doubly-nested effects) + +When the nested field's result is itself produced by the *same* SQL mapping — here `Currency.country`, which needs a `Country` — the handler re-queries Grackle. The challenge is that the interpreter still hands you a flat batch of `(query, cursor)` pairs, so you group identical continuation queries, run each group once, and restore the original input order afterwards. The `runGrouped` helper packages that pattern: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala", "#country_handler")) +``` + +The shape to copy: + +- `runGrouped` zips every input with its position, groups by the continuation `Query` (`.groupMap(_._1._1)(...)`), runs `op` once per group, then `flatten`s and `sortBy(_._2)` to put the cursors back into the order the interpreter expects. This is the order-restoration the contract demands. +- Inside `op`, the handler reads the parent `countryCode`s, maps them to their ISO codes, and builds **one** `combinedQuery` — `Select("country", None, Filter(In(CountryType / "code", codes), child))` — that fetches every needed country in a single SQL round-trip via `sqlCursor(combinedQuery, Env.empty)`. +- `mkListCursor` focuses the resulting cursor on the `country` list and `preunique`s it; `partitionCursor` splits that list back out per input code so each input pair gets its own cursor; `.zip(indices)` reattaches the captured positions. +- The `case _` arm returns `Result.internalError("Continuation query has the wrong shape")` — a reminder that this lands in the effect `F`, not the GraphQL `errors` array (see [error handling](errors.md)). + +Each distinct nested effect field costs one extra interpreter stage, so a doubly-nested effect (`Currency.country` inside `Country.currencies`) resolves over successive stages — correct, and still one call per field-level per stage, but worth knowing when you reason about round-trips. + +## Wire it to a real backend + +`SqlNestedEffectsMapping` is backend-agnostic. To run it you combine it with a concrete SQL backend (see [Choose and configure a SQL backend](sql-backends.md)) and inject the external service. The test suite constructs the `CurrencyService` *first* — so it can inspect the call counter — then builds the mapping over a Doobie/Postgres transactor and binds the abstract `def currencyService` to the constructed instance. Schematic (the real, DB-backed version lives in the test suite): + +```text +for { + service <- CurrencyService[IO] // construct first; holds the counter +} yield { + val mapping = + new DoobiePgTestMapping(transactor) with SqlNestedEffectsMapping[IO] { + lazy val currencyService = service // inject the external service + } + (service, mapping) +} +``` + +The same recipe works for Skunk, or for a service that is an HTTP client rather than a second database — the only requirement is that `currencyService.get` returns an `F[...]` you can run inside `runEffects`. Substituting a stub service with a counter is also how you test batching without a live database. + +## A note on subscriptions + +A `RootStream` is the streaming analogue of `RootEffect`: its effect returns `Stream[F, Result[(Query, Cursor)]]` and emits one result per stream element. It is **only** valid in a `Subscription` — a normal query or mutation that reaches one raises an internal error, `RootStream only permitted in subscriptions`. Mutations served through the subscription transport use `RootEffect.toRootStream` to lift a single-shot effect into a one-element stream. The end-to-end subscription walkthrough is in the [mutations and subscriptions tutorial](../tutorial/mutations-subscriptions.md). + +## See also + +- [Effects and batching](../concepts/effects-batching.md) — why effects are deferred, staged interpretation, and how `(mapping, handler)` grouping eliminates N+1. +- [Effects reference](../reference/effects.md) — exact signatures for `RootEffect`, `RootStream`, `EffectField` and `EffectHandler`. +- [Mutations and subscriptions tutorial](../tutorial/mutations-subscriptions.md) — root effects and `RootStream` in a working server. +- [Filtering, ordering and paging](filtering-ordering-paging.md) — assembling the `Filter`/`Limit`/`In` predicates a `computeChild` or re-query handler injects. +- [Error handling](errors.md) — why `internalError` is raised into `F` and never appears in the response `errors` array. +- [The cursor and query interpreter](../concepts/query-interpreter.md) — what a `Cursor` is and how continuation queries are evaluated. diff --git a/docs/how-to/errors.md b/docs/how-to/errors.md new file mode 100644 index 00000000..d904f4e0 --- /dev/null +++ b/docs/how-to/errors.md @@ -0,0 +1,201 @@ +# Construct, accumulate and report errors + +This recipe shows how to produce errors from your resolvers, elaborators and validation code so they surface correctly in a GraphQL response. Every fallible operation in Grackle yields a [`Result[A]`](../reference/result-problem.md), an `Ior`-like type with four cases: `Success` (value only), `Warning` (value *and* problems), `Failure` (problems only), and `InternalError` (a `Throwable`). You will pick the right case for client errors, partial-data warnings and internal bugs; accumulate several problems at once; lift `Option`/`Either`/effects into `Result`; and attach locations and paths to a `Problem`. It assumes cats-effect basics; for how each case maps to the response JSON, see the [Result, Problem & ResultT reference](../reference/result-problem.md). + +## Choose the right case + +The case you return decides what the client sees: + +| Case | Carries | Response | Use for | +| --- | --- | --- | --- | +| `Success(value)` | value | `{ "data": … }` | the happy path | +| `Warning(problems, value)` | value + problems | `{ "errors": […], "data": … }` | partial data plus a non-fatal complaint | +| `Failure(problems)` | problems | `{ "errors": […] }` (no `data`) | a client-facing error, no usable value | +| `InternalError(throwable)` | a `Throwable` | raised into the effect `F` | a bug or unexpected condition | + +The single most important rule: **`InternalError` never reaches the `errors` array.** At the response boundary `Mapping.mkResponse` calls `M.raiseError` for it, so it propagates as an effect-level failure (an `IO` error, say) rather than as JSON. Use `Result.failure` for anything the client should see, and `Result.internalError` only for genuine bugs. + +## Return a client error + +To reject a request, return a `Result.failure`. The one-argument form takes a message string; the `Problem` overload lets you attach a `path` so the client can see *where* in the result the error sits. + +```scala mdoc:silent +import grackle.{Problem, Result} + +val notFound: Result[Int] = + Result.failure("No field 'foo' for type Character") + +val withPath: Result[Int] = + Result.failure(Problem("Name is required", path = List("user", "name"))) +``` + +A `Failure` has no value, so `mkResponse` emits an errors-only response with no `data` key. This is exactly what query compilation produces for a bad query — selecting an unknown field yields `Result.Failure(NonEmptyChain(Problem("No field 'hidden' for type Root")))`, and the client receives: + +```json +{ "errors": [ { "message": "No field 'hidden' for type Root" } ] } +``` + +## Return partial data with a warning + +When you can still produce a value but want to flag something, return a `Result.warning`. It carries both the problem and the value, so the response contains **both** `errors` and `data`. Inside an elaborator, use the equivalent `Elab.warning`, which threads the problem through the elaboration `Result`. + +The query-directives example wires an `@upperCase` directive whose elaborator uppercases `String` fields. Applied to a non-`String` field it does not abort — it emits an `Elab.warning` and leaves that field untouched: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/directives/QueryDirectivesSuite.scala", "#upper_query")) +``` + +The query tags `name` (a `String`) and `age` (an `Int`) with `@upperCase`. `name` is uppercased to `"MARY"`; `age` is the wrong type, so the elaborator yields a warning instead of transforming it. The expected response keeps the whole `data` block (`age` is still `42`) *and* carries the warning in `errors` — the defining difference between `Warning` and `Failure`, since a `Warning` preserves partial data. The choice is deliberate: the elaborator's own source (the `#upper_phase` snippet on [Write a custom query directive](query-directives.md)) carries the comment "We could make this fail the whole query by yielding `Elab.failure` here", which would have dropped `data` entirely. + +## Signal an internal bug + +Reserve `Result.internalError` for conditions that are not the client's fault: a `None` where your invariants guarantee a value, a corrupt cached row, an impossible match. It wraps a `Throwable` (or a message string) and, as above, surfaces as a raised effect, not as a GraphQL error. + +```scala mdoc:silent +val bug: Result[Int] = + Result.internalError(new RuntimeException("unexpected empty index")) +``` + +To run a side-effecting thunk and capture any non-fatal exception as an `InternalError` automatically, use `Result.catchNonFatal`: + +```scala mdoc:silent +val parsed: Result[Int] = + Result.catchNonFatal("42".toInt) // Success(42) + +val blewUp: Result[Int] = + Result.catchNonFatal("oops".toInt) // InternalError(NumberFormatException) +``` + +`catchNonFatal` returns `Success` if the body completes and `InternalError` if it throws something `NonFatal`. Because the result is an `InternalError`, that exception will be re-raised into `F` at the response boundary rather than swallowed into the `errors` array. + +## Compose and accumulate + +`Result` has a `MonadError` instance, so it composes in a `for`-comprehension. `flatMap` carries a `Warning`'s problem chain forward while keeping the value, and short-circuits on the first `Failure` or `InternalError`: + +```scala mdoc +import grackle.Result +import grackle.syntax._ // for `.success` and `Option#toResult` + +val combined: Result[Int] = + for { + a <- Result.warning("deprecated field used", 1) // Warning(…, 1) + b <- 2.success // Success(2) + c <- Option(3).toResult("missing c") // Success(3) + } yield a + b + c +``` + +```scala mdoc +combined.toProblems.toList.map(_.message) +``` + +The result is a `Warning` holding `6` and the single carried-forward problem. Had any step been a `Failure`, the comprehension would have short-circuited and `combined` would have been that `Failure`. + +### Report every error at once + +`for`/`flatMap` stop at the first failure. When you want to collect **all** problems — the way the compiler reports several schema errors in one go — reach for the `Parallel`/`Applicative` instance via `parMapN`, `mapN` or `traverse`. These accumulate problems from every operand instead of short-circuiting: + +```scala mdoc +import cats.syntax.all._ + +val v1: Result[Int] = Result.failure("name must not be empty") +val v2: Result[Int] = Result.failure("age must be positive") + +val allErrors: Result[(Int, Int)] = (v1, v2).parMapN((_, _)) +``` + +```scala mdoc +allErrors.toProblems.toList.map(_.message) +``` + +Both messages appear, because `parMapN` combined the two `Failure`s into one chain (`ps0 ++ ps`). Swap `parMapN` for a `for`-comprehension and you would see only the first. + +For a list of results where you want to preserve length and order while still gathering every problem, use `Result.combineAllWithDefault`. It substitutes a default for each failed element and accumulates all the problems into a single `Warning` (or returns the first `InternalError` if there is one): + +```scala mdoc +val rows: List[Result[Int]] = + List(Result.success(10), Result.failure("row 2 bad"), Result.success(30)) + +val merged: Result[List[Int]] = + Result.combineAllWithDefault(rows, default = 0) +``` + +```scala mdoc +(merged.toOption, merged.toProblems.toList.map(_.message)) +``` + +The failed middle row becomes `0` and its message is retained, so you get `(Some(List(10, 0, 30)), List("row 2 bad"))`. + +## Lift Option, Either and effects + +Most resolver code starts from an `Option`, an `Either`, or an effectful computation. The `grackle.syntax._` extensions and the `Result` companion lift these without manual pattern matching: + +```scala mdoc:silent +import grackle.syntax._ + +def lookup(id: Int): Option[String] = if (id == 1) Some("Mary") else None + +// Option -> Result: Failure when empty +val byOption: Result[String] = lookup(1).toResult("no user with id 1") + +// Option -> Result, but a None here is a bug, not a client error -> InternalError +val byInvariant: Result[String] = lookup(1).toResultOrError("index out of sync") + +// Either[Problem, A] or Either[String, A] -> Result +val byEither: Result[String] = Result.fromEither(Right("Mary"): Either[String, String]) + +// Any value -> Success +val ok: Result[String] = "Mary".success +``` + +`toResult` produces a `Failure` (a client error) when the `Option` is empty, while `toResultOrError` produces an `InternalError` — pick the one matching whose fault an empty value is. `Result.fromOption`/`Result.fromEither` are the companion equivalents, and both accept either a `Problem` or a plain message string. + +To thread a `Result` through an effect `F` (typically `IO` or an `fs2.Stream`), wrap it in [`ResultT`](../reference/result-problem.md). Its `flatMap` preserves warning, failure and internal-error propagation across the `F` boundary, so you can mix pure `Result`s with effectful steps in one comprehension: + +```scala mdoc:silent +import cats.effect.IO +import grackle.ResultT + +val effectful: ResultT[IO, Int] = + for { + a <- ResultT.fromResult[IO, Int](Result.success(1)) // pure Result + b <- ResultT.liftF(IO.pure(2)) // lift an F[A] + c <- ResultT.warning[IO, Int]("slow path", 3) // warning inside F + } yield a + b + c + +val out: IO[Result[Int]] = effectful.value +``` + +`ResultT.liftF` lifts an `F[A]`, `ResultT.fromResult` lifts a pure `Result[A]`, and the `warning`/`failure`/`internalError` constructors mirror the ones on `Result`. Running `out` yields `Result.Warning` holding `6` and the `"slow path"` problem. + +## Attach locations, path and extensions + +A `Problem` is the GraphQL-spec error object: `Problem(message, locations, path, extensions)`. Only `message` is required; `locations` is a list of `(line, col)` pairs, `path` is the field path, and `extensions` is an optional circe `JsonObject`. Build one directly when you want a richer error than a bare message: + +```scala mdoc:silent +import io.circe.JsonObject +import io.circe.syntax._ + +val rich: Problem = + Problem( + message = "Value out of range", + locations = List(3 -> 12), + path = List("user", "age"), + extensions = Some(JsonObject("code" -> "OUT_OF_RANGE".asJson)) + ) +``` + +Empty fields are dropped from the JSON, so a message-only `Problem` encodes to just `{ "message": … }` while the one above carries all four keys. You can see the exact encoding rules in the `ProblemSuite` cases — empty `locations`/`path`/`extensions` simply vanish: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ProblemSuite.scala", "#problem_encoding")) +``` + +The first two cases show the omission rule (no `locations` key, then `message`-only); the third shows the matching `toString`, `"foo (at bar/baz: 1..2, 5..6)"`. Note also that `Problem` equality **ignores** `extensions` — `eqProblem` compares only `(message, locations, path)` — so if you assert on extensions in tests, compare the encoded JSON (`asJson`), not the `Problem` values. To add problems to an existing `Result` after the fact (turning a `Success` into a `Warning`, or accumulating onto a `Failure`), use `withProblems`. + +## See also + +- [Result, Problem & ResultT reference](../reference/result-problem.md) — every constructor, combinator and the exact response-mapping rules. +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — where `Elab.warning`/`Elab.failure` fit in the pipeline and how problems acquire locations. +- [Write a custom query directive](query-directives.md) — the full `@upperCase` elaborator behind the warning example. +- [Run effects and batch nested fields](effects-batching.md) — using `ResultT` and effectful resolvers in anger. +- [Validate a mapping and read the failures](validate-mappings.md) — `ValidationFailure`/`Severity`, the construction-time diagnostic channel distinct from runtime `Problem`s. diff --git a/docs/how-to/filtering-ordering-paging.md b/docs/how-to/filtering-ordering-paging.md new file mode 100644 index 00000000..db722b42 --- /dev/null +++ b/docs/how-to/filtering-ordering-paging.md @@ -0,0 +1,176 @@ +# Filter, sort and page a field + +This how-to wires GraphQL arguments on a list field to Grackle's filtering, ordering and paging machinery: the `Filter`, `OrderBy`, `Offset`, `Limit` and `Count` query nodes, the predicate ADT (`Eql`, `Contains`, …), and the elaboration hooks that install them. It is for developers exposing list endpoints who want server-side filtering, sorting and paging. Everything here is assembled by hand from small `Query` nodes — Grackle has no built-in Relay `Connection` type, so the paging section shows how to build counted and "has-more" paging yourself. For the "why" behind these nodes, see the [predicates reference](../reference/predicates.md) and [filtering & paging nodes reference](../reference/filtering-paging-nodes.md). + +## How filtering works + +Filtering, ordering and paging are not part of your schema's runtime data — they are query rewrites installed during compilation. You match a field and its argument `Binding`s in a `SelectElaborator` and use `Elab.transformChild` to wrap the field's child query with the nodes you want. The predicate inside a `Filter` is a `Term[Boolean]`: a reified, introspectable function `Cursor => Result[Boolean]`. Because it is reified rather than an opaque Scala function, an in-memory mapping can evaluate it against the `Cursor` while a SQL backend can compile the same predicate into a `WHERE` clause. You build the operands from a schema `Type` with the `Type / "field"` path syntax and compare them against literals with `Const`. + +## Filter a list field from a GraphQL argument + +The following mapping is a plain in-memory `ValueMapping[IO]` over a `List[Item]` — no database, no SQL. It exposes two filtered list fields and a computed `tagCount` field, and wires both filters in its `selectElaborator`. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/docs/src/main/scala/grackle/FilterMapping.scala", "#filter")) +``` + +The schema declares `itemsByTag(tag: ID!): [Item!]!` and `itemsByTagCount(count: Int!): [Item!]!`. Both `ValueField` mappings ignore their argument and return the full `items` list; the filtering happens entirely in the elaborator: + +- The `itemsByTag` case matches a `Binding("tag", IDValue(tag))` and rewrites the child to `Filter(Contains(ItemType / "tags", Const(tag)), child)`. `Contains` tests membership of a **list-valued** field, so `ItemType / "tags"` resolves to a list term and `Const(tag)` is the element to look for. +- The `itemsByTagCount` case filters with `Eql(ItemType / "tagCount", Const(count))`. `tagCount` has no backing data — it is a `CursorField` computed from `tags.size` — yet the filter still works, because predicates run against the `Cursor`, not the raw row. + +`Eql`/`Contains` require an implicit `Eq[T]` for the field's element type (both are available from cats here); the ordering predicates (`Lt`, `GtEql`, …) and `OrderSelection` require `Order[T]` instead. The element type must match the mapped field type, **including** `Option` — use `IsNull[Int]` over an `Option[Int]` field, `OrderSelection[Option[String]]` over a nullable string, and so on. + +Because `FilterMapping` needs no database, you can run a query against it directly: + +```scala mdoc:silent +import cats.effect.unsafe.implicits.global +import grackle.docs.FilterMapping + +val filtered = + FilterMapping.compileAndRun(""" + query { + itemsByTag(tag: "B") { label } + } + """).unsafeRunSync() +``` + +```scala mdoc:passthrough +println("```json") +println(filtered.spaces2) +println("```") +``` + +Only items whose `tags` list contains `"B"` (`AB` and `BC`) come back — the `Contains` predicate ran server-side, filtering the in-memory list before the response was built. + +### The predicate vocabulary + +`Filter` holds any `Predicate` (a `Term[Boolean]`), and the predicate ADT covers the usual leaves and combinators. The common ones, with the typeclass each needs: + +| Predicate | Use | Requires | +| --- | --- | --- | +| `Eql` / `NEql` | equality / inequality | `Eq[T]` | +| `Lt` / `LtEql` / `Gt` / `GtEql` | ordering comparisons | `Order[T]` | +| `In(term, list)` | membership in a static `List[T]` | `Eq[T]` | +| `Contains(listTerm, elem)` | list field contains an element | `Eq[T]` | +| `IsNull(optTerm, isNull)` | null / not-null test on an `Option` field | — | +| `Matches` / `StartsWith` | regex / prefix on a `String` field | — | +| `And` / `Or` / `Not`, `True` / `False` | boolean combinators | — | + +For SQL-style wildcard matching there is also `Like(term, pattern, caseInsensitive)`, but note it lives in the `grackle.sql` module, **not** in core, and is only meaningful against a SQL backend. Its first parameter is a union type `Term[String] | Term[Option[String]]`, so it accepts both nullable and non-nullable string terms. To combine many predicates, fold them with the `Predicate.and` / `Predicate.or` smart constructors (or `And.combineAll`), which short-circuit on `True`/`False` — but guard against an empty list yourself, since an empty `and` collapses to `True`. + +## Single-result lookup + +To return one element rather than a list, filter to the matching row and wrap the whole thing in `Unique`: + +```scala mdoc:compile-only +import grackle.Predicate.{Const, Eql} +import grackle.Query.{Binding, Filter, Unique} +import grackle.QueryCompiler.{Elab, SelectElaborator} +import grackle.Value.IntValue +import grackle.docs.QuickStartMapping.{BookType, QueryType} + +val example = + SelectElaborator { + case (QueryType, "book", List(Binding("id", IntValue(id)))) => + Elab.transformChild(child => + Unique(Filter(Eql(BookType / "id", Const(id)), child))) + } +``` + +`Unique` turns a list-producing child into a single (optionally absent) result, failing if more than one row matches. `Eql(BookType / "id", Const(id))` is the same predicate pattern as before; only the surrounding `Unique` distinguishes a lookup from a filtered list. + +## Compose optional filter, order, offset and limit + +When a field takes several **optional** arguments, the clean pattern is to model each argument as a `Query => Result[Query]` transformer and thread them inner-to-outer inside `Elab.transformChild`. Each transformer leaves the query untouched when its argument is absent, applies the corresponding node when present, and returns a `Result.failure` when the argument is invalid — argument validation belongs here in the elaborator, not in the `Query` ADT, which will happily build an `Offset(-1, …)` or `Limit(0, …)`. + +The next two snips are from a SQL test mapping (`SqlFilterOrderOffsetLimitMapping`), but the structure is backend-independent. First, the per-argument transformers: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlFilterOrderOffsetLimitMapping.scala", "#fool_mk")) +``` + +Each `mk*` function pattern-matches the bound `Value`: + +- `AbsentValue | NullValue` means the argument was omitted — return the query unchanged with `.success`. +- `mkOffset` accepts `0` (no-op) and any positive `Int` (`Offset(num, query)`), and **rejects** a negative offset with `Result.failure`. `mkLimit` similarly requires a strictly positive limit, rejecting `0`. +- `mkFilter` wraps the child in `Filter(Eql(tpe / "id", Const(id)), query)` when a structured `FilterValue` is supplied. +- `mkOrderBy` takes a callback producing `List[OrderSelection[_]]`; an empty list means "no ordering", otherwise it wraps the child in `OrderBy(OrderSelections(oss), query)`. + +Now the elaborator composes them with a `for`-comprehension over `Result`, which short-circuits on the first validation failure: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlFilterOrderOffsetLimitMapping.scala", "#fool_elab")) +``` + +The order of the binds matters: `mkFilter` first, then `mkOrderBy`, then `mkOffset`, then `mkLimit`, so the resulting nodes nest with `Filter` innermost and `Limit` outermost. The `root` case here is the same pattern minus ordering — it binds only `filter`, `offset` and `limit` — while the `listA`/`listB` cases use the full four. Each of those supplies an `OrderSelection` parameterised on that field's actual mapped type (`Option[String]`, `Option[Int]`) — get the type wrong and you will fail to find an `Order[T]` instance at compile time. + +If you have the four pieces as `Option`s already, prefer the `FilterOrderByOffsetLimit` constructor over hand-nesting the nodes. It builds the canonical `Filter → OrderBy → Offset → Limit` nesting for you: + +```scala mdoc:compile-only +import grackle.Query.{FilterOrderByOffsetLimit, OrderSelection, Select} +import grackle.docs.QuickStartMapping.BookType + +val orderTerm = BookType / "title" +val offset: Option[Int] = Some(0) +val limit: Option[Int] = Some(10) +val child: grackle.Query = Select("books") + +val stack = + FilterOrderByOffsetLimit( + pred = None, + oss = Some(List(OrderSelection[String](orderTerm, nullsLast = true))), + offset = offset, + limit = limit, + child = child) +``` + +The matching `FilterOrderByOffsetLimit.unapply` lets a SQL backend destructure an arbitrary nesting back into `(pred, oss, offset, limit, child)`. If you hand-nest the nodes in a different order, both that extractor and SQL compilation may fail to recognise the pattern. For SQL backends, pass `nullsLast = nullsHigh` (a per-dialect `Boolean` from `SqlMapping`) rather than hardcoding `true`, so null ordering matches the database and stays consistent with in-memory execution. + +## Paging + +Grackle ships **no** Relay `Connection` type — there is no `edges`, `node`, `pageInfo`, `endCursor` or `hasNextPage` anywhere in the codebase. You assemble paging by hand from `Offset`, `Limit`, `Count`, `CursorField` and `TransformCursor`, stashing per-request paging state in the elaboration environment with `Elab.env` so that sibling fields (`items`, `total`, `hasMore`) can read it. The two patterns below are the idiomatic recipes. + +### Counted paging (offset / limit / total / items) + +Model a `PagedCountry` object type with `offset`, `limit`, `total` and `items` fields. The elaborator stashes a `PagingInfo(offset, limit)` in the environment when the list field is selected; each sibling field then reads it back: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlPaging1Mapping.scala", "#paging1")) +``` + +Walking the pieces: + +- `setup(offset, limit)` calls `Elab.env(key -> PagingInfo(offset, limit))` to store the request's paging bounds under a per-config key. +- `elabItems` reads that `PagingInfo` back with `Elab.envE` and rewrites the `items` child with `FilterOrderByOffsetLimit(None, Some(order), Some(offset), Some(limit), child)` — the paged slice. +- `elabTotal` replaces the `total` child with `Count(Select("items", Select(countAttr)))`, which a SQL backend compiles to `COUNT(*)`. A real backing count source is essential here: you cannot recover an accurate total from the (already limited) `items` list. +- `genOffset` / `genLimit` are `CursorField` generators that echo the stored bounds back out, reading them from the cursor's environment. + +The `selectElaborator` ties field positions to these hooks: matching `(QueryType, "countries", …)` runs `setup`, while `(PagedCountryType, "items", Nil)` and `(PagedCountryType, "total", Nil)` run `elabItems` / `elabTotal`. Because the state lives in the environment, this nests cleanly — `cities` inside each country pages with its own `CityPaging` config the same way. + +### Has-more paging (overfetch and trim) + +When you want a `hasMore` boolean instead of (or alongside) a total, overfetch by one row and check whether the extra row materialised. This avoids a second `COUNT` query when `items` are already being fetched: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlPaging3Mapping.scala", "#paging3")) +``` + +The mechanics: + +- `elabItems` computes `lim0 = limit.map(_ + 1)` when `hasHasMore` — fetching one extra row — then builds the slice with `FilterOrderByOffsetLimit`. When the extra row is fetched it wraps the result in `TransformCursor(genItems, items)`. +- `genItems` runs after the rows come back: it reads the list size, and if the result is longer than `limit` it returns a `ListTransformCursor` over `elems.init`, dropping the overfetched row so the client only ever sees `limit` items. +- `elabHasMore` only adds a hidden `Count` attribute when `items` are **not** selected (`whenA(!hasItems)`); `genHasMore` then computes `hasMore` from the trimmed list size if items are present, or from that count otherwise. + +The overfetch only happens when both `hasMore` and `items` are requested — the mapping checks `Elab.hasField` to decide. If you compute `hasMore` differently, replicate that conditional and always use `TransformCursor` / `ListTransformCursor` to hide the extra row, or it will leak into the response. + +## A note on Relay connections + +To restate the caveat, because it is the most common surprise: there is **no** built-in Relay connection helper in Grackle. "Cursor-based" paging here means hand-rolling over `Offset` / `Limit` / `Count` / `TransformCursor` plus, if you want opaque cursors, cursor predicates (`Lt`/`Gt` against an encoded sort key) — not the Relay spec's `edges`/`pageInfo`/`endCursor` shape. If you need a Relay-compliant API, you model the `Connection`, `Edge` and `PageInfo` types in your own schema and elaborate them onto these same primitives. + +## See also + +- [Predicates & terms reference](../reference/predicates.md) — the full `Term`/`Predicate` ADT and the `Eq[T]` vs `Order[T]` requirements. +- [Filtering & paging query nodes reference](../reference/filtering-paging-nodes.md) — exact signatures for `Filter`, `OrderBy`, `Offset`, `Limit`, `Count`, `TransformCursor` and `FilterOrderByOffsetLimit`. +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — how `SelectElaborator` and `Elab.transformChild` fit into query compilation. +- [Choose and configure a SQL backend](sql-backends.md) — running these mappings against doobie or skunk, where predicates become `WHERE` clauses. diff --git a/docs/how-to/generic-derivation.md b/docs/how-to/generic-derivation.md new file mode 100644 index 00000000..5feeb1a9 --- /dev/null +++ b/docs/how-to/generic-derivation.md @@ -0,0 +1,159 @@ +# Serve Scala ADTs with generic derivation + +This how-to shows you how to expose ordinary Scala case classes and sealed traits over GraphQL with Grackle's generic backend (`grackle-generic`, package `grackle.generic`), without writing a field-by-field mapping. It is for developers whose in-memory model already lines up with their schema: you derive a `CursorBuilder` for each domain type, wire the data in with `GenericField`, and reach for a handful of combinators only when a field's Scala shape differs from its GraphQL shape. Every recipe here is effect-free except the last, and none of it needs a database. + +## How the generic backend builds a cursor + +The whole backend turns on one typeclass: `CursorBuilder[T]`. A `CursorBuilder[T]` knows the GraphQL `tpe: Type` it produces and how to `build` a [`Cursor`](../concepts/mappings-cursors.md) over a value of `T`. Instances are resolved implicitly, so to serve a type you only need a `CursorBuilder` for it in scope. + +The backend ships implicit builders for the common leaves and collections, and you rarely name them directly: + +- `String`, `Int`, `Boolean` map to `StringType`, `IntType`, `BooleanType`. +- `Long` maps to `IntType`, and both `Float` and `Double` map to `FloatType` — GraphQL has no separate `Long`/`Double`, so they share `Int`/`Float`. +- `Option[T]` becomes a nullable cursor and `List[T]` a list cursor, wrapping the element builder. +- A Scala `Enumeration#Value` serialises to its `toString` and reports `StringType`. +- As a fallback, any type with a Circe `Encoder[T]` in scope becomes a leaf reporting `StringType` (this is what drives `java.time` and `UUID` fields — see [custom scalars](#custom-scalars-and-time-and-uuid-leaves) below). + +Because each field type must have a `CursorBuilder` in scope, that last fallback is sharp-edged: a domain type that happens to have a Circe `Encoder` in scope will be treated as a `String` leaf even where you meant it to be a derived object. Keep `Encoder` instances off types you intend to derive as objects. + +## Derive object and interface builders + +You derive builders semi-automatically with the two entry points on a `GenericMapping`'s `semiauto` object: `deriveObjectCursorBuilder[T](tpe)` for a case class (a GraphQL `object`) and `deriveInterfaceCursorBuilder[T](tpe)` for a sealed trait (a GraphQL `interface` or `union`). Note that the GraphQL `Type` is always passed **explicitly** — nothing is inferred from the Scala type name, so `tpe` is a `schema.ref(...)` you supply. + +Here is the model from the Star Wars demo. `Character` is a sealed trait derived as an interface; `Human` and `Droid` are case classes derived as objects: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/starwars/StarWarsMapping.scala", "#model_types")) +``` + +The key lines: + +- `deriveInterfaceCursorBuilder[Character](CharacterType)` derives the interface builder. At query time it dispatches to the concrete branch's builder (`Human` or `Droid`), then wraps the result so the cursor reports `Character` but can `narrow` to the subtype for an `... on Human` fragment. +- `deriveObjectCursorBuilder[Human](HumanType)` and the `Droid` equivalent derive the object builders. Each case-class field name (`id`, `name`, `appearsIn`, …) is matched **structurally** to a GraphQL field of the same name — there is no `@GraphQLField` or any other annotation in this backend. +- The `.transformField("friends")(resolveFriends)` calls are the one customisation here; the next section covers them. + +There is no separate registration step: because each builder is an `implicit val` in its companion object, deriving the interface automatically finds the subtype builders, and the subtype builders find the leaf builders for `String`, `Option`, `List`, and `Episode.Value`. + +This matches the schema's `interface Character` with `type Human implements Character` and `type Droid implements Character`. Field names and the explicit `tpe` are the only contract; a mismatch between a case-class field and the schema type's fields is not caught until query time. + +## Rename a field whose Scala name differs + +Because matching is structural, a case-class member must be named exactly like its GraphQL field. When the two differ, fix it on the derived builder rather than renaming your domain type: + +```scala +// Inside a `GenericMapping`, with `semiauto._` imported and a `WidgetType` schema.ref: +deriveObjectCursorBuilder[Widget](WidgetType) + .renameField("internalName", "name") // one field + .transformFieldNames { // or rewrite all of them + case "internalName" => "name" + case other => other + } +``` + +`renameField(from, to)` maps a single Scala field name to a GraphQL field name; `transformFieldNames(f: String => String)` rewrites them in bulk (useful for a `snake_case` ↔ `camelCase` convention). + +## Resolve id lists to nested objects with `transformField` + +The escape hatch you will reach for most is `transformField[U](name)(f: T => Result[U])`. It replaces a derived field with a computed value — typically resolving a list of foreign-key ids stored in the model into the nested objects the schema actually declares. + +In the Star Wars model, `friends` is an `Option[List[String]]` of character ids, but the schema declares `friends: [Character!]`. The `resolveFriends` function (visible in the `#model_types` snip above) bridges the gap, and is attached during derivation: + +```scala +deriveObjectCursorBuilder[Human](HumanType).transformField("friends")(resolveFriends) +``` + +`resolveFriends` has type `Character => Result[Option[List[Character]]]`, looking each id up in `characters`. An unknown id is reported with `toResultOrError`, which yields an [`InternalError`](../how-to/errors.md): unlike a `Failure`, an internal error is raised into the effect `F` rather than surfacing in the response `errors` array, since a dangling id is a bug in your data, not a client-facing problem. The replacement value's type — here `Option[List[Character]]` — must itself have a `CursorBuilder` in scope; it is summoned implicitly from the `Option`, `List`, and `Character` interface builders. `transformField` is also how you break recursion, which is the next recipe. + +## Wire the data in with `GenericField` + +A derived `CursorBuilder` becomes reachable through a `GenericMapping[F]` (your mapping extends `GenericMapping[F]`, which needs a `MonadThrow[F]`). On the root `Query` `ObjectMapping`, `GenericField(fieldName, value)` exposes an in-memory value at a schema field, picking up the implicit `CursorBuilder` for the value's type: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/starwars/StarWarsMapping.scala", "#root")) +``` + +Each `GenericField` attaches a list of characters (or just the `Human`/`Droid` subset, via `collect`) to the matching `Query` field. The implicit `CursorBuilder[List[Character]]` (and `List[Human]`, `List[Droid]`) is resolved automatically. A [`SelectElaborator`](../concepts/compiler-elaboration.md) then narrows each list down to the requested element — for example by `id` with `Unique(Filter(Eql(...), child))` — so that `character(id: "1000")` yields a single value rather than the whole list. + +With the schema, the model, and these four `GenericField`s in place, the mapping is complete: this query + +```graphql +{ + hero(episode: EMPIRE) { + name + friends { + name + } + } +} +``` + +resolves `hero` to Luke Skywalker, then walks his `friends` ids through `resolveFriends` into nested `Character` objects, narrowing each to `Human` or `Droid` as needed. + +## Model mutually recursive or graph-shaped data + +Two types that reference each other — or a type that references itself — would deadlock if each builder tried to construct the other eagerly. The generic backend avoids this by taking `CursorBuilder` implicits **by name** (`implicit cb: => CursorBuilder[T]`), so a pair of mutually-recursive `implicit val`s can refer to one another without initialization-order errors. The practical pattern is: store the relationship as an **id** in your model and resolve it with `transformField`. + +Here `Programme` holds a list of production ids and `Production` holds a programme id: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/RecursionSuite.scala", "#recursion_data")) +``` + +`Programme.cursorBuilder` derives the object then `transformField("productions")` resolves the id list into `List[Production]`; `Production.cursorBuilder` does the mirror image with `transformField("programme")` resolving a single `Programme`. The two builders refer to each other's types, but because the implicits are by-name, defining both as `implicit val`s in their companions is safe. Storing ids (rather than the related object directly) is what keeps the cycle finite — the resolver runs only when the field is actually selected. + +The mapping wires both root fields with `GenericField` and elaborates the `id` argument into a `Unique(Filter(...))`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/RecursionSuite.scala", "#recursion_mapping")) +``` + +A query may now recurse to any depth — `programmeById` → `productions` → `programme` → `productions` → … — and each step runs its resolver on demand. + +## Custom scalars and time and UUID leaves + +To serve a custom scalar, give the type a Circe `Encoder` and bind it to the schema's scalar type with `CursorBuilder.deriveLeafCursorBuilder[T](scalarType)`. The generic backend's `ScalarsSuite` does exactly this for a `Genre` ADT and serves `java.time` and `UUID` fields through the implicit `Encoder`-based leaf builder: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/ScalarsSuite.scala", "#generic_scalars")) +``` + +What to notice: + +- `Genre` is a sealed trait, but it is **not** derived as an interface. Its `Encoder[Genre]` maps each case to a `String`, and `deriveLeafCursorBuilder[Genre](GenreType)` makes it a **leaf** reporting the schema's `Genre` scalar. This is the difference between a coproduct served as an `interface` (use `deriveInterfaceCursorBuilder`) and an enum-like ADT served as a `scalar` (use `deriveLeafCursorBuilder`). +- The `Movie` fields `id: UUID`, `releaseDate: LocalDate`, `showTime: LocalTime`, `nextShowing: OffsetDateTime`, and `duration: Duration` need no explicit builder. They each have a Circe `Encoder` (from `io.circe`), so the implicit fallback `leafCursorBuilder` treats them as `String` leaves automatically. +- `deriveObjectCursorBuilder[Movie](MovieType)` then derives the surrounding object as usual, resolving each field's leaf builder from scope. + +For the more general story on declaring scalar and enum types in your schema, see [Define custom scalars and enums](../how-to/custom-scalars-enums.md). + +## Serve a derived value from an effectful root + +`GenericField` is for data already in hand. When the root needs to run an effect in `F` first — read a `Ref`, hit a cache — combine `RootEffect.computeCursor` with the mapping's `genericCursor` helper. `RootEffect.computeCursor(field)((path, env) => F[Result[Cursor]])` runs your effect and yields a cursor; `genericCursor(path, env, value)` builds that cursor for a derived value, deferring construction until the field's context is known: + +```scala +// Sketch of an effectful root inside a `GenericMapping[F]` with `semiauto._`, +// a `StructType` schema.ref, and a derived `CursorBuilder[Struct]`: +case class Struct(n: Int, s: String) +object Struct { + implicit val cb: CursorBuilder[Struct] = + deriveObjectCursorBuilder[Struct](StructType) +} + +def fooField[F[_]: Concurrent](ref: Ref[F, Int]) = + RootEffect.computeCursor("foo") { (path, env) => + ref.update(_ + 1).as(genericCursor(path, env, Struct(42, "hi"))) + } +``` + +The effect (here bumping a counter) runs once per request; its result, a `Result[Cursor]` from `genericCursor`, plugs the derived `Struct` into the response exactly as a `GenericField` would, but only after the effect has completed. For effect mechanics in general — root effects, nested effects, and batching — see [Run effects and batch nested fields](../how-to/effects-batching.md). + +## A note on Scala 2 vs Scala 3 + +The derivation engine differs by Scala version — shapeless on Scala 2, shapeless3 on Scala 3 — but they live in separate source roots and expose the **identical** public surface: `CursorBuilder`, `GenericMapping`, `GenericField`, `semiauto.deriveObjectCursorBuilder`, `deriveInterfaceCursorBuilder`, and the `ObjectCursorBuilder` combinators. Everything in this page works the same on both. The shapeless-specific implicits behind derivation are internals you never reference directly. + +## See also + +- [Generic derivation reference](../reference/generic-derivation.md) — exact signatures for `CursorBuilder`, the provided implicit builders, `GenericField`, and the `semiauto` entry points. +- [In-memory Model](../tutorial/in-memory-model.md) — a guided, end-to-end build of the Star Wars mapping shown here. +- [Mappings and cursors](../concepts/mappings-cursors.md) — what a `Cursor` is and how a `Mapping` turns a query into JSON. +- [Define custom scalars and enums](../how-to/custom-scalars-enums.md) — declaring scalar and enum types in the schema that pair with the leaf builders above. +- [Compose multiple mappings (federation)](../how-to/compose-mappings.md) — combine a generic mapping with SQL or circe backends behind one schema. diff --git a/docs/how-to/interfaces-unions.md b/docs/how-to/interfaces-unions.md new file mode 100644 index 00000000..77b9ed62 --- /dev/null +++ b/docs/how-to/interfaces-unions.md @@ -0,0 +1,123 @@ +# Map interfaces and unions to SQL + +This how-to shows you how to serve polymorphic GraphQL types — interfaces and unions — from a single relational table. You map every subtype's columns onto one shared table, mark the column that says which concrete type a row is with `discriminator = true`, and supply an `SqlDiscriminator` that both narrows fetched rows to a subtype and produces a `WHERE` predicate for a given subtype. It is for developers who already have `SqlMapping`s over their tables (see [Choose and wire a SQL backend](sql-backends.md)) and now need `... on T` inline fragments to resolve correctly. For the validation rules that enforce the single-table requirement, see [Validate your mappings](validate-mappings.md). + +## Map an interface with `SqlInterfaceMapping` + +An interface is mapped with `SqlInterfaceMapping`, which behaves like an `ObjectMapping` but carries a discriminator. All of the interface's fields, and all of its subtypes' extra fields, are columns on **one** shared table. The interface mapping holds the shared columns; each concrete `type … implements …` becomes an ordinary `ObjectMapping` that adds only its own extra columns on that same table. + +The example below maps an `Entity` interface implemented by `Film` and `Series`, all stored in one `entities` table. The GraphQL schema is: + +```graphql +interface Entity { + id: ID! + entityType: EntityType! + title: String + synopses: Synopses + imageUrl: String +} +type Film implements Entity { + id: ID! + entityType: EntityType! + title: String + synopses: Synopses + imageUrl: String + rating: String + label: Int +} +type Series implements Entity { + id: ID! + entityType: EntityType! + title: String + synopses: Synopses + imageUrl: String + numberOfEpisodes: Int + episodes: [Episode!]! + label: String +} +enum EntityType { FILM SERIES } +``` + +The mapping that backs it: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlInterfacesMapping.scala", "#interfaces")) +``` + +The key points: + +- `SqlInterfaceMapping(tpe = EType, discriminator = entityTypeDiscriminator, fieldMappings = …)` maps the `Entity` interface. Its `fieldMappings` are exactly the interface's own fields: `id` (a `key`), `entityType` (the `discriminator = true` column), `title`, and the embedded `synopses` sub-object. +- The discriminating column carries `discriminator = true`. Grackle reads it per row to decide the concrete type; it is otherwise a normal `SqlField`. It must not itself be polymorphic. +- `FilmType` and `SeriesType` are plain `ObjectMapping`s that contribute each subtype's *extra* fields — `rating`/`label`/`imageUrl` for `Film`, `numberOfEpisodes`/`episodes`/`label`/`imageUrl` for `Series` — and every one of those columns comes from the same `entities` table (or a join out of it, as with `Series.episodes`). A subtype inherits the interface mapping's shared fields (`id`, `synopses`), so it need not repeat them; it *may* re-declare one when it wants its own mapping for it (here `Series` re-maps `title` to the same column, and supplies `imageUrl` via a `CursorField` over a hidden column rather than a plain `SqlField`). +- A `PrefixedMapping` is used here for the embedded `Synopses` type because it appears under several parents (`entities`, `films`, `episodes`) backed by different tables; that is orthogonal to the interface mechanism — see [Compose and reuse mappings](compose-mappings.md). `PrefixedMapping` is the legacy form, shown here only because this inlined test mapping predates the newer construct. For new code prefer a path-sensitive `PathMatch` (built by the `ObjectMapping(path)(…)` constructor); see [Mappings and cursors](../concepts/mappings-cursors.md) and the [mapping types reference](../reference/mapping-types.md). + +## Write the `SqlDiscriminator` + +`SqlInterfaceMapping` (and `SqlUnionMapping`) take an `SqlDiscriminator`, the strategy that connects a row to its concrete GraphQL type. It has two methods: + +```scala +trait SqlDiscriminator { + def discriminate(cursor: Cursor): Result[Type] // concrete type of a fetched row + def narrowPredicate(tpe: Type): Result[Predicate] // WHERE predicate selecting one subtype +} +``` + +`discriminate` runs **after** a row is fetched: given the `Cursor`, it reads the discriminator column and returns the matching subtype `Type`. `narrowPredicate` runs **before** the query is executed: given a subtype the query asked for (via `... on Film`), it returns a `Predicate` that restricts the SQL to rows of that subtype, so Grackle does not fetch and then discard non-matching rows. Both return a `Result`, so each cleanly signals failure for a subtype it does not recognise (see the catch-all below). + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlInterfacesMapping.scala", "#discriminator")) +``` + +Reading this: + +- `discriminate` calls `c.fieldAs[EntityType]("entityType")` to pull the discriminator value out of the `Cursor` (decoded through the column's codec into the `EntityType` enum), then maps `Film`/`Series` enum cases to `FilmType`/`SeriesType`. +- `narrowPredicate` builds `Eql(EType / "entityType", Const(EntityType.Film))` for `FilmType` and the analogous predicate for `SeriesType`. `EType / "entityType"` is the path to the discriminator field; `Const(...)` is the value to match. `.success` lifts the predicate into `Result`. +- The catch-all returns `Result.internalError(...)`. An `InternalError` is raised into the effect `F` — it is not added to the GraphQL `errors` array — so reaching it signals a mapping bug, not a client error. + +## Map a union with `SqlUnionMapping` + +A union has no fields of its own, so its mapping is leaner — but the single-table rule and the discriminator still apply, and there are two extra constraints: every field mapping in the union mapping must be **hidden**, and the union mapping may hold only the key and the discriminator. Each member type maps its own column on the shared table through an ordinary `ObjectMapping`. + +The example maps `union Item = ItemA | ItemB`, where both members live in one `collections` table: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlUnionsMapping.scala", "#unions")) +``` + +Note how this differs from the interface case: + +- The `collections` table has one column per member's payload (`itema`, `itemb`) plus the `id` key and the `item_type` discriminator. +- `SqlUnionMapping(tpe = ItemType, …)` carries **only** two fields, and both are `hidden = true`: the `id` key and the `itemType` discriminator (`discriminator = true`). A non-hidden field in the union mapping, or a `SqlObject` sub-object or `SqlJson` field placed there, fails validation — the member payloads belong on the member `ObjectMapping`s, not here. +- Each member — `ItemAType`, `ItemBType` — is a normal `ObjectMapping` that repeats the hidden `id` key (so rows group correctly) and maps its own visible column (`itema`, `itemb`). +- `itemTypeDiscriminator` is an `SqlDiscriminator` exactly as before; here `discriminate` reads the `itemType` column as a `String` and `narrowPredicate` compares `ItemType / "itemType"` against `Const("ItemA")` / `Const("ItemB")`. + +## Query with `... on T` and `__typename` + +Clients select members through GraphQL inline fragments. Each `... on ItemA { … }` block applies only to rows whose discriminator resolves to `ItemA`; `__typename` reports the concrete type Grackle picked. The following query and its response come straight from the union test suite: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlUnionSuite.scala", "#union_query")) +``` + +The query selects `itema` only inside `... on ItemA` and `itemb` only inside `... on ItemB`; the response carries exactly the fields for whichever member each row turned out to be, plus the `__typename`. This works the same for interfaces: select shared interface fields directly on the field and put subtype-specific fields inside `... on Film` / `... on Series`. Internally these inline fragments compile to `Narrow(SubType, …)` nodes and `__typename` to an `Introspect` node, the same algebra produced by named fragments and resolved by the discriminator. For how fragments and `__typename` are elaborated in general, see [Introspection, fragments and variables](../concepts/introspection-fragments-variables.md). + +## Validation errors to expect + +Grackle validates interface and union mappings up front (see [Validate your mappings](validate-mappings.md)). The errors specific to polymorphic mappings are: + +- **`SplitInterfaceTypeMapping` / `SplitUnionTypeMapping`** — a subtype's columns come from more than one table. All implementors/members must share the interface/union's table; to span tables, expose the extra data as a joined `SqlObject` sub-object rather than a bare column. +- **`NoDiscriminatorInObjectTypeMapping`** — the interface or union mapping has no field marked `discriminator = true`. Every polymorphic mapping needs a discriminator column. +- **`IllegalPolymorphicDiscriminatorFieldMapping`** — the discriminator field is itself polymorphic. The discriminator must resolve to a single concrete value per row. +- **`NonHiddenUnionFieldMapping`** — a field mapping inside a `SqlUnionMapping` is not `hidden = true`. The union mapping may expose only hidden key and discriminator fields; visible payload columns belong on the member `ObjectMapping`s. +- **`IllegalSubobjectInUnionTypeMapping` / `IllegalJsonInUnionTypeMapping`** — a `SqlObject` sub-object or a `SqlJson` field was placed directly in the union mapping. Map those on the member objects instead. + +One more rule applies to every object mapping, including subtypes: each must declare at least one `key = true` field (directly or inherited), or it fails with `NoKeyInObjectTypeMapping`. That is why the union members above repeat the hidden `id` key. + +## See also + +- [Choose and wire a SQL backend](sql-backends.md) — the doobie/skunk plumbing the mappings here assume. +- [Compose and reuse mappings](compose-mappings.md) — embedding and context-sensitive mappings; the legacy `PrefixedMapping` used above for the shared `Synopses` type, and the `PathMatch` form preferred in new code. +- [Mapping types reference](../reference/mapping-types.md) — `PathMatch` vs the legacy `PrefixedTypeMatch`/`PrefixedMapping`, and the constructors that build each. +- [Map jsonb columns](jsonb-columns.md) — an alternative way to serve polymorphic data, navigated in-process rather than via SQL joins. +- [Validate your mappings](validate-mappings.md) — the full set of mapping validation errors and how to read them. +- [Mappings and cursors](../concepts/mappings-cursors.md) — what a `Cursor` is and how `discriminate` reads from it. diff --git a/docs/how-to/jsonb-columns.md b/docs/how-to/jsonb-columns.md new file mode 100644 index 00000000..01d4b7c0 --- /dev/null +++ b/docs/how-to/jsonb-columns.md @@ -0,0 +1,112 @@ +# Map a jsonb column with SqlJson + +This how-to shows you how to serve a GraphQL object subtree from a single `jsonb` column. With `SqlJson` you map a GraphQL object or interface field to one JSON column; Grackle fetches the JSON once and navigates every nested selection — nested objects, enums, arrays, fragments, interface members and `__typename` — entirely in-process, with no further SQL. It is for developers who store semi-structured data in a JSON column and want to expose its shape through GraphQL without flattening it into tables. You will see how to declare the column codec, map it, descend into the JSON from a query, handle nullable versus non-null fields, and where the technique stops (you cannot join, filter or order *inside* the JSON). The last section covers `CursorFieldJson`, the cross-backend escape hatch for synthesising JSON from a non-`jsonb` column. This recipe assumes you can already build a SQL mapping with `SqlField`/`SqlObject` — see [Choose and configure a SQL backend](sql-backends.md) for the setup. + +## Declare a jsonb column codec + +A `jsonb` column is mapped like any other column: you declare it inside a `TableDef` with `col`, passing a codec for `io.circe.Json`. The backends differ only in how you spell the codec and its nullability: + +- **doobie** — `col(name, jsonbMeta, nullable = …)`, where `jsonbMeta: Meta[Json]` comes from `doobie-postgres-circe` (`doobie.postgres.circe.jsonb.implicits`). Nullability is an explicit `nullable` flag on `col`. +- **skunk** — `col(name, ccodec.jsonb)`, where `ccodec.jsonb: skunk.Codec[Json]` comes from `skunk-circe`. Nullability is inferred from whether the codec is for `Option[Json]` (use the column's `nullable`/`.opt` wrapper). + +In both cases the column carries `io.circe.Json`; the JSON sub-tree is decoded into a circe value and navigated by a `CirceCursor`. The test mapping below uses a `nullable(jsonb)` codec, so the underlying column may be SQL `NULL`. + +## Map it with SqlJson to a GraphQL object + +`SqlJson(fieldName, columnRef)` maps a GraphQL object- or interface-typed field to a single JSON column. Its signature is minimal: + +```scala +case class SqlJson(fieldName: String, columnRef: ColumnRef) +``` + +`SqlJson` is always visible (`hidden = false`) and always a subtree (`subtree = true`); that flag tells the SQL compiler not to map the field's children to columns, because they are resolved from the decoded JSON instead. The following test mapping declares a `records` table with one `jsonb` column and maps a `Row` GraphQL object over it: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlJsonbMapping.scala", "#jsonb")) +``` + +The `records` table has an `id` column and a `record` column typed `nullable(jsonb)`. The schema's `Row` type has a scalar `id` plus two object-typed fields — `record: Record` (nullable) and `nonNullRecord: Record!` (non-null) — and the `Record` type itself nests scalars, an enum (`Choice`), an array (`arrary: [Int!]`), a sub-object (`object: A`) and an interface (`children: [Child!]!` over `interface Child` with members `A` and `B`). The `Row` `ObjectMapping` maps `id` with an ordinary `SqlField(key = true)` and then maps both `record` and `nonNullRecord` with `SqlJson` — pointing both at the *same* `records.record` column. None of the columns for `Record`, `Choice`, `A`, `B` or `Child` are mapped; Grackle does not need them, because everything below an `SqlJson` field comes out of the JSON document. + +Note that the `Record`, `Choice`, `A`, `B` and `Child` types appear only in the schema — there are no `ObjectMapping`s for them. Their fields are matched against the decoded JSON by name, so the JSON stored in the column must use the same field names and shape as the GraphQL types. + +## Nested objects, enums, arrays and fragments inside the JSON + +Once a field is mapped with `SqlJson`, a client navigates the JSON purely through the GraphQL query. The query selects into `record` exactly as it would into any object field; Grackle resolves each step against the decoded circe value. Here is a query that descends two levels — into the JSON object and then into its nested `object` sub-object — together with the response: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlJsonbSuite.scala", "#jsonb_query")) +``` + +The query asks for `record { record { object { id aField } } }`: the outer `record` is the `Row.record` field (the whole JSON document), and the inner `object` is the `Record.object` field resolved from the JSON. The response pulls `id` and `aField` straight out of the nested JSON object — no join, no second query. The same descent works for the other nested shapes in the schema: a list field decodes the matching JSON array, selecting `choice` yields an enum value (the JSON string is matched against the `Choice` enum), and inline fragments or named fragments on `children` select interface members. The match is by field name, so each GraphQL field reads the JSON key of the same name. Because the navigation is in-process, anything circe can decode into the field's GraphQL type just works. + +## Interface members and __typename inside the JSON + +`children: [Child!]!` is an interface-typed list, and `Child` is resolved from the JSON like everything else under `SqlJson`. To pick out concrete members you use ordinary GraphQL type conditions, and `__typename` reports the concrete type read from the JSON: + +```graphql +query { + record(id: 1) { + record { + children { + __typename + id + ... on A { aField } + ... on B { bField } + } + } + } +} +``` + +There is no `SqlDiscriminator` and no discriminator column here. Unlike a SQL-backed interface — where you map all subtypes onto a shared table and use an `SqlDiscriminator` to choose the concrete type per row (see [Map interfaces and unions to SQL](interfaces-unions.md)) — an interface *inside* JSON is discriminated by the circe cursor from the JSON itself. Each element of the array carries enough structure for Grackle to match it to `A` or `B`, and `__typename` plus the `... on A` / `... on B` fragments resolve against that. This is the same behaviour as the [circe JSON backend](circe-backend.md), because below an `SqlJson` field you are running a circe value mapping. + +## Nullable vs non-null SqlJson, and the "expected jsonb value" error + +The example maps the *same* `records.record` column to two GraphQL fields with different nullability: `record: Record` (nullable) and `nonNullRecord: Record!` (non-null). The same column backing both is legal — the GraphQL type of the field decides how the decoded value is wrapped. When resolving an `SqlJson` field Grackle inspects the fetched value: + +- A `Json` value is wrapped in a `CirceCursor` and navigated as described above. This holds for both the nullable and the non-null field. +- A SQL `NULL` (no value fetched) decodes to a `Json.Null` cursor, which surfaces as GraphQL `null`. +- Anything else — a value that is neither JSON nor absent — is a contract violation. Grackle raises an internal error whose message is `expected jsonb value found …`. + +That error is an `InternalError`, so — per Grackle's [error model](errors.md) — it is raised into the effect `F` rather than appearing in the response `errors` array. In practice this only happens when the column does not actually hold JSON (a codec or column-type mismatch). Map a column whose value is always valid `jsonb` to an `SqlJson` field, and use a nullable GraphQL field for a column that can be SQL `NULL`. + +## Limitation: no SQL joins, filters or ordering inside the sub-tree + +Everything below an `SqlJson` field is navigated by an in-memory `CirceCursor`, not compiled to SQL. The consequence is a hard boundary: + +- You **cannot** `Join` out of a field inside the JSON to another table — there is no column for the SQL compiler to join on. +- You **cannot** `Filter`, `OrderBy` or page on a field inside the JSON. Those nodes compile to SQL `WHERE`/`ORDER BY`/`LIMIT` clauses (see [Filter, sort and page a field](filtering-ordering-paging.md)), and the JSON sub-tree is opaque to SQL. +- You **can** filter, order and join on the *row* that owns the column (`Row.id` here), because that is an ordinary `SqlField`. + +If you need to filter or order by something stored inside the document, that something belongs in its own column or table, not in `jsonb`. `SqlJson` is for serving a self-contained JSON blob whole; use real `SqlObject` + `Join` mappings for anything you need the database to query across. + +## Cross-backend: CursorFieldJson to synthesise JSON from a non-jsonb column + +Sometimes the source column is not `jsonb` at all — it holds an encoded value (an integer, a packed string) that you want to *project* into JSON at read time. `CursorFieldJson` is the tool for that. It is a circe field mapping (`grackle.circe.CursorFieldJson`), reusable inside a SQL mapping, with this shape: + +```scala +case class CursorFieldJson( + fieldName: String, + f: Cursor => Result[Json], + required: List[String], + hidden: Boolean = false) +``` + +You give it a function `Cursor => Result[Json]` that builds the JSON from already-fetched fields, plus the list of field names that function `required`s (so Grackle knows to fetch them). The following mapping stores a category code in an ordinary `int4` column and decodes it into a JSON array of `Category` objects: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlCursorJsonMapping.scala", "#cursor_json")) +``` + +The `brands` table has an `int4` `categories` column. The mapping maps it twice on the `Brand` type: once as a hidden `SqlField("encodedCategories", brands.category, hidden = true)` (so the raw integer is fetched but not exposed), and once as `CursorFieldJson("categories", decodeCategories, List("encodedCategories"))`. The `decodeCategories` function reads the hidden field with `c.fieldAs[Int]("encodedCategories")`, turns the integer into a `List[Category]` via `decodeCategoryInt`, and serialises it with circe's `.asJson` (using the `categoryEncoder` instance defined alongside). The `required = List("encodedCategories")` argument tells Grackle the function depends on that hidden field, so it is included in the fetch. The GraphQL `categories: [Category!]` field then resolves from the synthesised JSON, exactly as if it had come from a real `jsonb` column. + +Like `SqlJson`, the resulting JSON is navigated in-process, so the same limitation applies: you cannot join, filter or order inside the JSON that `CursorFieldJson` produces. + +## See also + +- [Choose and configure a SQL backend](sql-backends.md) — the doobie/skunk setup, table defs and codecs that `SqlJson` builds on. +- [Map interfaces and unions to SQL](interfaces-unions.md) — the `SqlInterfaceMapping`/`SqlDiscriminator` approach for interfaces backed by real columns, contrasted with interfaces inside JSON. +- [Serve GraphQL from circe JSON](circe-backend.md) — the circe value mapping you are effectively running below every `SqlJson` field, including `CursorFieldJson`. +- [Filter, sort and page a field](filtering-ordering-paging.md) — the `Filter`/`OrderBy`/`Limit` nodes that work on real columns but not inside a JSON sub-tree. +- [Construct, accumulate and report errors](errors.md) — why the "expected jsonb value" internal error surfaces in `F`, not the GraphQL `errors` array. +- [SqlMapping reference](../reference/sql-mapping.md) — exact signatures for `SqlJson`, `SqlField`, `SqlObject` and the rest of the SQL field-mapping ADT. diff --git a/docs/how-to/query-directives.md b/docs/how-to/query-directives.md new file mode 100644 index 00000000..c6f06035 --- /dev/null +++ b/docs/how-to/query-directives.md @@ -0,0 +1,88 @@ +# Write a custom query directive + +This how-to shows you how to add a custom GraphQL query directive — a marker like `@upperCase` that a client attaches to a field to change how that field is resolved — and make Grackle act on it. A query directive in Grackle is two things: a declaration in your SDL (so the directive is recognised and placement-checked) and a [compiler `Phase`](../concepts/compiler-elaboration.md) that rewrites the query algebra wherever the directive appears. The worked example is `@upperCase`, which upper-cases the result of any `String` field it is applied to. This page is for advanced users customising query behaviour; for the built-in `@skip`/`@include` directives, which need none of this, see the note at the end. + +## Step 1: declare the directive in your SDL + +A directive must be declared in your schema before a query may use it. The declaration registers the directive in `schema.directives` and tells Grackle where it is allowed to appear; without it, a query carrying `@upperCase` fails compilation with `Undefined directive 'upperCase'`, and using it in the wrong place fails with `Directive 'upperCase' is not allowed on `. These checks run in `Directive.validateDirectivesForQuery` before any phase executes. + +Declare the directive with a directive definition and the locations it may appear at: + +```graphql +type Query { + user: User! +} +type User { + name: String! + handle: String! + age: Int! +} + +directive @upperCase on FIELD +``` + +`on FIELD` means `@upperCase` may be attached only to a field selection. The location names come from the GraphQL spec (`FIELD`, `FRAGMENT_SPREAD`, `INLINE_FRAGMENT`, and so on); list several with `|` if a directive is valid in more than one place. Declaring the directive only validates *placement* — it gives the directive no behaviour. The behaviour comes from the phase you write next. + +## Step 2: write a `Phase` that matches the directive + +Behaviour lives in a [`QueryCompiler.Phase`](../reference/elab-phases.md). A phase's `transform` walks every node of the query algebra; you override it, match the nodes that carry your directive, and rewrite them. Custom directives are still attached to `UntypedSelect` nodes at this stage — the pre-elaboration form of a field selection that carries its raw arguments and directives — so you pattern-match on `UntypedSelect` and inspect its `directives` list. + +Here is the `@upperCase` phase: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/directives/QueryDirectivesSuite.scala", "#upper_phase")) +``` + +Walking through it: + +- The match guard `directives.exists(_.name == "upperCase")` selects only the fields the client tagged. Every other node falls through to the final `case _ => super.transform(query)`, which applies the default recursive traversal unchanged. +- `Elab.context` reads the current [`Context`](../reference/context-env.md) — the elaborator's position in the schema — from the [`Elab` monad](../reference/elab-phases.md) that threads state through the traversal. `c.forField(nme, alias)` then computes the `Context` for the field being selected, lifted into `Elab` with `Elab.liftR`, so you can inspect that field's type. +- `fc.tpe =:= ScalarType.StringType` checks the field is actually a `String`. When it is, `super.transform(query)` elaborates the child as normal and the result is wrapped in `TransformCursor(toUpperCase, _)`. `TransformCursor` is a query-algebra node that hands the interpreter a function `Cursor => Result[Cursor]`, applied to the cursor before the child is interpreted — this is the general hook for post-processing a field's resolved value. +- `toUpperCase` builds that function with `FieldTransformCursor[String](c, _.toUpperCase.success)`. `FieldTransformCursor[T]` wraps a cursor so that, when its leaf value of type `T` is read, the supplied `T => Result[T]` is applied — here, upper-casing the `String`. The result type carries the leaf type, so the transform is type-checked against the field. + +The directive name (`"upperCase"`) is matched as a plain string; nothing ties the phase to the SDL declaration except that they agree on the name. Step 1 guarantees the directive is well-placed; Step 2 decides what it does. + +## Step 3: prepend the phase in `compilerPhases` + +A `Mapping`'s `compilerPhases` lists the phases run during compilation, in order. The default is `selectElaborator`, `componentElaborator`, `effectElaborator`. Your directive phase must run **before** them: `selectElaborator` rewrites `UntypedSelect` into the typed `Select` node, after which the `UntypedSelect` you match on no longer exists. Override `compilerPhases` to put your phase first. + +Here is the full mapping, with the phase from Step 2 and the `compilerPhases` override at the bottom: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/directives/QueryDirectivesSuite.scala", "#upper_mapping")) +``` + +The final line — `List(upperCaseElaborator, selectElaborator, componentElaborator, effectElaborator)` — places `upperCaseElaborator` ahead of the standard three. (The `// #upper_phase` comments are documentation markers in the source, not part of the code.) + +There is one more ordering fact to keep in mind. Grackle always auto-prepends its own phases ahead of everything in `compilerPhases`: introspection elaboration, then `VariablesSkipAndFragmentElaborator`, then `MergeFields`. So by the time your phase runs, variables have been substituted into directive arguments and fragments have been expanded. `VariablesSkipAndFragmentElaborator` removes `@skip`/`@include` from each node's directive list (after acting on them) but leaves your custom directives in place, still attached to the `UntypedSelect`. They stay there until `selectElaborator` rewrites the `UntypedSelect` into a typed `Select` — so prepending your phase to `compilerPhases` (rather than appending) is what puts it in the window where the `UntypedSelect`, and your directive on it, still exist. + +## Result: applying the directive + +With the mapping wired up, the directive takes effect at query time. This query upper-cases `name`, leaves `handle` untouched, and — because `age` is an `Int`, not a `String` — triggers the warning branch: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/directives/QueryDirectivesSuite.scala", "#upper_query")) +``` + +The response carries **both** data and an error. `name` is `"MARY"`, `handle` stays `"mary"`, and `age` is the unmodified `42`, while the `errors` array reports `'upperCase' directive may only be applied to fields of type String`. This is the shape of a `Warning` result: the query still produces data, and the problem is surfaced alongside it. + +## Failing versus warning + +The phase chose to *warn* on misuse, not abort. That choice is the difference between two `Elab` combinators in the `else` branch: + +- `Elab.warning(msg) *> super.transform(query)` records a `Problem` and continues, so the field still resolves (here, unchanged because the transform was skipped). The compiled query succeeds with a `Warning`, and the `Problem` appears in the response `errors` array next to the `data`, as above. +- `Elab.failure(msg)` would instead make compilation produce a `Failure`. The query would not run; the response would carry `errors` and no `data` for that operation. + +Pick `warning` when a misapplied directive should degrade gracefully, and `failure` when it should reject the whole query. Both surface through the GraphQL `errors` array. (Note that this is distinct from an *internal* error raised into the effect `F` — see [error handling](errors.md) for that distinction.) + +## Note: `@skip` and `@include` are handled for you + +You do not write a phase for the two built-in directives. `@skip(if:)` and `@include(if:)` are evaluated automatically by `VariablesSkipAndFragmentElaborator`: a guarded subtree that is skipped becomes `Query.Empty` and disappears from the result, and `@skip`/`@include` themselves are removed from each node's directive list once they have been acted on. So a custom phase that runs after `VariablesSkipAndFragmentElaborator` (the normal case, where your phase still precedes `selectElaborator`) never sees `@skip`/`@include` — and never needs to, since they have already taken effect. Reserve custom phases for directives you have declared yourself. + +## See also + +- [Compiler and elaboration](../concepts/compiler-elaboration.md) — how a query string becomes the `Query` algebra, and where phases fit. +- [Introspection, fragments and variables](../concepts/introspection-fragments-variables.md) — the built-in phases (`@skip`/`@include`, fragments, variables) that run before yours. +- [Elaboration phases reference](../reference/elab-phases.md) — the `Phase` trait and the `Elab` monad combinators (`context`, `warning`, `failure`, `transformChild`). +- [Query algebra reference](../reference/query-algebra.md) — the `Query` nodes, including `UntypedSelect` and `TransformCursor`. +- [Schema directives](schema-directives.md) — declaring directives that annotate the *schema* rather than a query. diff --git a/docs/how-to/schema-directives.md b/docs/how-to/schema-directives.md new file mode 100644 index 00000000..1a32c9bb --- /dev/null +++ b/docs/how-to/schema-directives.md @@ -0,0 +1,201 @@ +# Define and use schema directives + +A *schema directive* is a piece of typed metadata you attach to part of your schema — a type, a field, an +enum value — in the SDL, and then read back off the schema model to drive your own behaviour (authorization, +caching hints, persisted-query tags, and so on). This how-to shows you how to declare directives, apply them, +read the applied [`Directive`](../reference/schema-sdl.md) values off the model, and use the five built-ins +(`@skip`, `@include`, `@deprecated`, `@specifiedBy`, `@oneOf`) through their first-class accessors. It assumes +you are comfortable defining a [`Schema`](../reference/schema-sdl.md) with the `schema"..."` interpolator. + +## Declare a directive + +Declare a directive in your SDL with the standard GraphQL syntax: + +```graphql +directive @name(arg1: Type1, arg2: Type2 = default) [repeatable] on LOC1 | LOC2 | ... +``` + +The arguments are an ordinary input-value list (each may have a default), `repeatable` is optional, and the +`on` clause lists one or more *directive locations* the directive may be applied to. A declaration parses into +a `DirectiveDef`: + +```scala +case class DirectiveDef( + name: String, + description: Option[String], + args: List[InputValue], + isRepeatable: Boolean, + locations: List[DirectiveLocation] +) +``` + +Every parsed schema silently gains the five built-in definitions (`DirectiveDef.builtIns`: `skip`, `include`, +`deprecated`, `specifiedBy`, `oneOf`) appended to whatever you declare, so you never declare those yourself. +Note that `SchemaRenderer` (and therefore `Schema.toString`) deliberately omits the built-ins, so a +round-tripped schema will not print them even though `schema.directives` still contains them. + +## The directive location set + +Each location in the `on` clause is one of these `DirectiveLocation` values. They split into two groups — +*executable* locations (where a directive appears in a query document) and *type-system* locations (where it +appears in the schema): + +| Group | Locations | +| --- | --- | +| Executable | `QUERY`, `MUTATION`, `SUBSCRIPTION`, `FIELD`, `FRAGMENT_DEFINITION`, `FRAGMENT_SPREAD`, `INLINE_FRAGMENT`, `VARIABLE_DEFINITION` | +| Type system | `SCHEMA`, `SCALAR`, `OBJECT`, `FIELD_DEFINITION`, `ARGUMENT_DEFINITION`, `INTERFACE`, `UNION`, `ENUM`, `ENUM_VALUE`, `INPUT_OBJECT`, `INPUT_FIELD_DEFINITION` | + +Schema directives (the subject of this page) use type-system locations. Directives at executable locations +belong on queries — see [Query directives](query-directives.md) for those. + +## Apply directives to types, fields, and enum values + +Apply a declared directive with `@name(args)` at any location it permits. The following schema declares two +custom directives, `@authenticated` and `@hasRole`, and applies them to an object type, several fields, and +uses an enum as an argument value. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/directives/SchemaDirectivesSuite.scala", "#schema_directives")) +``` + +Both `directive` declarations list `FIELD_DEFINITION | OBJECT`, so each may sit on a whole object type or on an +individual field. `@authenticated` appears both ways here — on the `user` field and on the whole `Mutation` +type — while `@hasRole(requires: ADMIN)` sits on the `email`, `phone`, and `backup` fields. The +`requires: ADMIN` argument is an `EnumValue`; because the declaration gives `requires` a default of `ADMIN`, +you could also write a bare `@hasRole`. Because this uses the `schema"..."` interpolator, the whole thing — +including every applied directive — is validated at compile time. + +## Read applied directive values off the model + +An applied directive is a `Directive(name, args)` where `args` is a `List[Binding]`: + +```scala +case class Directive(name: String, args: List[Binding]) +``` + +Every part of the model exposes the directives applied to it through a `directives: List[Directive]` member: +`NamedType.directives` (so any type, reached via `schema.ref(name)` or `.dealias`), `Field.directives`, +`EnumValueDefinition.directives`, `InputValue.directives`, and `ScalarType.directives`. Type and field +directives are exactly what you read in an elaborator to drive behaviour. Here you reach the `email` field of +the `User` type and read the `@hasRole` directive applied to it: + +```scala mdoc:silent +import grackle.Schema +import grackle.syntax._ + +val schema: Schema = + schema""" + type Query { + user: User! + } + type User { + name: String! + email: String! @hasRole(requires: ADMIN) + } + directive @hasRole(requires: Role = ADMIN) on FIELD_DEFINITION | OBJECT + enum Role { ADMIN USER } + """ + +val emailField = schema.ref("User").dealias.fieldInfo("email").get +``` + +```scala mdoc +emailField.directives +``` + +`fieldInfo(name)` returns the `Field`, and its `directives` is the list of applied directives — here a single +`Directive("hasRole", List(Binding("requires", EnumValue("ADMIN"))))`. Use `.dealias` to resolve the +`TypeRef` that `schema.ref` returns into the underlying `ObjectType` before calling `fieldInfo`. To read a +type-level directive instead, call `.directives` directly on the type (for example +`schema.ref("Mutation").directives`). + +### Driving behaviour from directives in an elaborator + +Reading directives only matters when something acts on them. A [`Phase`](../reference/elab-phases.md) in the +[query compiler](../concepts/compiler-elaboration.md) can inspect the directives on the current field and its +enclosing type and decide what to do. This phase reads the `@authenticated` and `@hasRole` directives from the +schema and, for an unauthorized field, either fails the query or nulls the field with a warning: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/directives/SchemaDirectivesSuite.scala", "#permissions_phase")) +``` + +The key line gathers the directives in scope from both the enclosing type and the selected field: +`c.tpe.directives ++ c.tpe.fieldInfo(name).map(_.directives).getOrElse(Nil)`. It then tests `requiresAuth` +with `dirs.exists(_.name == "authenticated")` and extracts the required role by filtering for `hasRole`, +pulling the `requires` binding, and matching its `EnumValue`. When access is denied it either calls +`Elab.failure` (which fails the whole query) or, when `nullAndWarn` is set, emits an `Elab.warning` and rewrites +the selection with a `TransformCursor` that nulls the field, so it comes back as `null` alongside a +[`Problem`](../reference/result-problem.md) in the response `errors`. The warning surfaces in the GraphQL +`errors` array because it is carried on a `Result.Warning`; an `Elab.failure` likewise surfaces as an error. +Neither is an `InternalError` — that case is raised into the effect `F` rather than returned in the JSON +`errors`. + +## Built-in directives and their accessors + +The five built-in directives have first-class accessors on the model, so you read them through a named method +rather than by scanning `directives` yourself: + +| Directive | Applies to (locations) | Accessor | Returns | +| --- | --- | --- | --- | +| `@deprecated(reason: String = "No longer supported")` | `FIELD_DEFINITION`, `ARGUMENT_DEFINITION`, `INPUT_FIELD_DEFINITION`, `ENUM_VALUE` | `isDeprecated` / `deprecationReason` | `Boolean` / `Option[String]` | +| `@specifiedBy(url: String!)` | `SCALAR` | `specifiedByURL` | `Option[String]` | +| `@oneOf` | `INPUT_OBJECT` | `isOneOf` | `Boolean` | +| `@skip(if: Boolean!)` | `FIELD`, `FRAGMENT_SPREAD`, `INLINE_FRAGMENT` | handled by the compiler | — | +| `@include(if: Boolean!)` | `FIELD`, `FRAGMENT_SPREAD`, `INLINE_FRAGMENT` | handled by the compiler | — | + +`isDeprecated`/`deprecationReason` come from the `Deprecatable` mixin and are available on every element that +can be deprecated: `Field`, `InputValue`, and `EnumValueDefinition`. `specifiedByURL` is on `ScalarType` and +returns the `url` argument of an applied `@specifiedBy`. `isOneOf` is on `InputObjectType`. `@skip` and +`@include` are *executable* directives consumed automatically by the compiler's +[`VariablesSkipAndFragmentElaborator`](../concepts/introspection-fragments-variables.md) — you do not read +them off the model. + +Here you read the deprecation accessors off a deprecated field: + +```scala mdoc:silent +val deprSchema: Schema = + schema""" + type Query { + legacyId: ID @deprecated(reason: "Use id instead") + id: ID! + } + """ + +val legacy = deprSchema.ref("Query").dealias.fieldInfo("legacyId").get +``` + +```scala mdoc +legacy.isDeprecated +legacy.deprecationReason +``` + +## How directives are validated + +When a schema is constructed, the parser runs `SchemaValidator.validateSchema`, which calls +`Directive.validateDirectivesForSchema` over every applied directive. For each occurrence it checks the +following, accumulating a [`Problem`](../reference/result-problem.md) for each violation: + +- **Definition exists.** An applied directive whose name has no `DirectiveDef` yields `Undefined directive + ''`. +- **Location is allowed.** Applying a directive at a location not listed in its `on` clause yields `Directive + '' is not allowed on `. +- **Repeatability.** A non-`repeatable` directive applied more than once at the same location yields `Directive + '' may not occur more than once`. +- **Argument types.** Each argument is coerced and type-checked against the definition's `InputValue` list. + An unrecognised argument is rejected with `Unknown argument(s) '' in directive `, and a value of + the wrong type (or a missing required one) fails. For example, omitting the required `url` on `@specifiedBy` + reports `Value of type String required for 'url' in directive specifiedBy`. + +Where these `Problem`s end up depends on how you built the schema. The compile-time `schema"..."` interpolator +turns any validation failure into a **compile error** (prefixed `Invalid schema:`), so an invalid directive +application never reaches runtime. The runtime factory `Schema(text): Result[Schema]` instead returns a +`Result.Failure(NonEmptyChain[Problem])` you can inspect — use it when the SDL is not known statically and you +need to handle validation problems yourself. + +## See also + +- [Define custom scalars and enums](custom-scalars-enums.md) — where `@specifiedBy` and `@oneOf` come into play. +- [Use query directives](query-directives.md) — `@skip`/`@include` and your own executable directives. +- [The schema model and SDL](../reference/schema-sdl.md) — the full reference for `Schema`, `Directive`, and `DirectiveDef`. +- [Compiler and elaboration](../concepts/compiler-elaboration.md) — how a `Phase` reads directives during query compilation. diff --git a/docs/how-to/serve-over-http.md b/docs/how-to/serve-over-http.md new file mode 100644 index 00000000..9c343d71 --- /dev/null +++ b/docs/how-to/serve-over-http.md @@ -0,0 +1,129 @@ +# Serve a mapping over HTTP + +This page shows you how to put a `Mapping[F]` behind an HTTP endpoint so that clients can send it GraphQL queries, mutations and introspection requests. Grackle ships **no** HTTP layer of its own — a mapping is a transport-agnostic runner — so the wiring here is example code you copy into your own service. It is written for developers deploying a Grackle endpoint who are comfortable with [http4s](https://http4s.org/) and cats-effect. The snippets are the canonical ones from Grackle's `demo` module. + +## Goal: expose a `Mapping[F]` as a GraphQL-over-HTTP endpoint + +A `Mapping[F]` already bundles everything needed to execute a request: a `Schema`, the type mappings, and the lazily-built compiler and interpreter. The single method you call per request is: + +```scala +def compileAndRun( + text: String, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty)( + implicit sc: Compiler[F, F] +): F[Json] +``` + +It takes the GraphQL request triad — the query `text`, an optional operation `name`, and optional `variables` as a JSON object — and returns an `F[Json]` already wrapped in the spec response envelope (`{"data": ...}` or `{"errors": [...]}`). Your only job at the HTTP layer is to pull those three values out of the request and hand them to `compileAndRun`. (Subscriptions use `compileAndRunSubscription` instead — see [below](#subscriptions-have-no-built-in-transport).) + +## Build the GET and POST routes + +To serve a mapping, build an `HttpRoutes[F]` with one case for `GET` (the request triad arrives in the URI query string) and one for `POST` (it arrives in a JSON body). The demo's `GraphQLService.mkRoutes` does exactly this: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/GraphQLService.scala", "#service")) +``` + +A few things to note: + +- `mkRoutes[F[_]: Concurrent](prefix: String)(mapping: Mapping[F])` is parameterised by a path `prefix`, so the same factory serves any number of mappings under different paths. +- The `GET` case matches `query`, `operationName` and `variables` from the URI and forwards them to `mapping.compileAndRun(query, op, vars)`. +- The `POST` case decodes the body `as[Json]`, then reads the top-level `query`, `operationName` and `variables` fields. A missing `query` field is rejected with `InvalidMessageBodyFailure("Missing query field")`. This handler reads only those three fields — it implements neither batched operations nor persisted queries. + +### Decoding `variables` on GET, and bad-request handling + +For `POST` the variables are already a JSON value inside the body. For `GET` they arrive as a URL-encoded JSON *string*, so the route installs a custom `QueryParamDecoder[Json]` that parses the string and turns a parse failure into a `ParseFailure("Invalid variables", …)`. The matcher is an `OptionalValidatingQueryParamDecoderMatcher[Json]`, so the route receives a validated result; when it is invalid the route short-circuits to `BadRequest` with the sanitized error message. This is a transport-level 400, separate from GraphQL validation: a malformed `variables` string never reaches the compiler. (Inside the compiler, `variables` must be a JSON *object*; a non-object value fails compilation with `Variables must be represented as a Json object`.) + +## Build the Ember server + +With routes in hand, wrap them in an Ember server. The demo's `DemoServer.mkServer` mounts the GraphQL routes alongside the static GraphQL Playground assets, adds logging and total error handling, and binds the listener: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/DemoServer.scala", "#server")) +``` + +The structure is: + +- `resourceServiceBuilder[IO]("/assets").toRoutes <+> graphQLRoutes` — the static Playground client is combined with the GraphQL routes using the cats `<+>` (`SemigroupK`) operator, then `.orNotFound`. +- `Logger.httpApp(true, false)` wraps the app with request/response logging. +- `ErrorHandling.Recover.total(...)` installs a **total** error handler so that any uncaught `Throwable` is logged via `errorHandler` rather than crashing the server. This is what catches the effect failures raised by internal errors (see [below](#internal-errors-vs-validation-errors)). +- `EmberServerBuilder.default[IO].withHost(ip"0.0.0.0").withPort(port"8080")` binds the server, returning a `Resource[IO, Unit]`. + +## Wire multiple mappings and run + +Each mapping is built as a `Resource`, lifted to routes under its own prefix, and the routes are combined with `<+>` before being handed to the server. The demo's `Main` is an `IOApp` doing this for two mappings: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/Main.scala", "#main")) +``` + +`StarWarsMapping[IO]` and `WorldMapping[IO]` are each a `Resource[IO, Mapping[IO]]`; `.map(mkRoutes("starwars"))` / `.map(mkRoutes("world"))` turn them into routes mounted at `/starwars` and `/world`; `starWarsRoutes <+> worldRoutes` merges them; and `useForever` runs the server until the process is killed. To add another endpoint, construct another mapping, map it through `mkRoutes("yourprefix")`, and fold it into the `<+>` chain. + +## Required dependencies + +The wiring above lives entirely in the `demo` module and depends on http4s — Grackle itself does not. Add the http4s pieces you use: + +```scala +val http4sVersion = "0.23.34" + +libraryDependencies ++= Seq( + "org.http4s" %% "http4s-ember-server" % http4sVersion, + "org.http4s" %% "http4s-circe" % http4sVersion, + "org.http4s" %% "http4s-dsl" % http4sVersion +) +``` + +`http4s-circe` supplies the `Json` entity codecs (`req.as[Json]`, `Ok(json)`), `http4s-dsl` supplies the route DSL and query-param matchers, and `http4s-ember-server` supplies `EmberServerBuilder`. Pick the http4s version that matches the rest of your stack. + +## Set `introspectionLevel` for production + +`compileAndRun` (and the underlying `compile`) take `introspectionLevel: IntrospectionLevel = Full`. The levels are: + +- `Full` — the request may run the standard `IntrospectionQuery` over `__schema`/`__type`. GraphQL client tools such as Playground and GraphiQL need this to discover your schema. +- `TypenameOnly` — only `__typename` is allowed; `__schema`/`__type` are rejected. +- `Disabled` — all introspection is rejected. + +The default is `Full`, which is why the demo's Playground works out of the box. To lock down a public endpoint, pass `introspectionLevel = Disabled` (or `TypenameOnly`) into `compileAndRun`: normal queries still run, but schema discovery is refused. Note that this also breaks Playground/GraphiQL against that endpoint. + +## Internal errors vs validation errors + +How a request fails determines what the client sees. Grackle's `Result` distinguishes ordinary GraphQL failures from internal errors, and the two take different paths out of `compileAndRun`: + +- **GraphQL validation/execution failures** (a `Result.Failure`/`Warning`) come back as a normal **HTTP 200** response carrying an `errors` array — the standard `{"data": ..., "errors": [...]}` envelope. The route's `Ok(result)` returns them like any other success. +- **Internal errors** (`Result.InternalError`) are *not* turned into JSON. Internally, `mkResponse` calls `M.raiseError` for an internal error, so it propagates as a failure in the effect `F`. At the HTTP layer that surfaces as an **HTTP 500**, caught and logged by the total `ErrorHandling.Recover` in `mkServer`. + +So never expect an internal error to appear in the response `errors` array — only `Problem`s from validation/execution failures do. See [Construct, accumulate and report errors](errors.md) for how to produce `Failure`/`Warning` results that *do* surface as GraphQL errors. + +## Subscriptions have no built-in transport + +Grackle exposes subscriptions only through the streaming API: + +```scala +def compileAndRunSubscription( + text: String, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): Stream[F, Json] +``` + +This returns an `fs2.Stream[F, Json]` that emits one response per upstream change. There is **no** built-in websocket or `graphql-ws` / `graphql-transport-ws` transport anywhere in Grackle, and the demo server above serves only `GET`/`POST` — it does not expose subscriptions over HTTP at all. Bridging the stream to a websocket (or Server-Sent Events) is left entirely to you: take the `Stream[F, Json]` from `compileAndRunSubscription` and feed it into your transport of choice, for example an http4s `WebSocketBuilder`. + +Two cautions: + +- Do **not** call `compileAndRun` on a subscription. `compileAndRun` is defined in terms of `compileAndRunSubscription` and asserts the result stream has exactly one element, so a subscription fails at runtime with `Result stream contained N results; expected exactly one.` Use `compileAndRunSubscription` for subscriptions. +- Variables, `operationName` and `env` thread through `compileAndRunSubscription` exactly as they do for queries. + +See [Mutations & Subscriptions](../tutorial/mutations-subscriptions.md) for how a mapping produces a subscription stream in the first place. + +## See also + +- [Mutations & Subscriptions](../tutorial/mutations-subscriptions.md) — build a mapping that exposes mutations and subscription streams. +- [Run effects and batch nested fields](effects-batching.md) — drive root effects and streams from within a mapping. +- [Construct, accumulate and report errors](errors.md) — produce `Result` failures that surface in the GraphQL `errors` array. +- [Running operations reference](../reference/running-operations.md) — full signatures for `compileAndRun`, `compileAndRunSubscription`, `IntrospectionLevel` and the response envelope. diff --git a/docs/how-to/sql-backends.md b/docs/how-to/sql-backends.md new file mode 100644 index 00000000..97734f89 --- /dev/null +++ b/docs/how-to/sql-backends.md @@ -0,0 +1,158 @@ +# Choose and configure a SQL backend + +This how-to is for developers building database-backed mappings. It shows you how to pick a SQL backend (Doobie or Skunk) for Grackle, how the two differ when you declare columns, how to wire a mapping to a connection pool, and how to model the common relational shapes — one-to-many and many-to-one joins, embedded value objects, shared embedded types, and composite-key and associative-table joins. The "why" behind keys, hidden columns and row assembly lives in the [SqlMapping reference](../reference/sql-mapping.md) and the [mapping types reference](../reference/mapping-types.md); this page is the recipe. + +## Pick a backend + +Grackle's SQL machinery lives in a database-agnostic core trait, `SqlMappingLike[F]`, which defines every field-mapping type (`SqlField`, `SqlObject`, `SqlJson`, `Join`, `ColumnRef`, `TableDef`) and the query-to-SQL compiler. A backend module fills in the concrete `Codec`, `Encoder` and `Fragment` types and a `col` helper. You choose one backend per mapping: + +| Backend | Module | Effect / connection | Databases | Notes | +| --- | --- | --- | --- | --- | +| Doobie | `grackle-doobie-pg`, `grackle-doobie-oracle`, `grackle-doobie-mssql` | JDBC `Transactor[F]` | Postgres, Oracle, SQL Server | One artifact per database dialect. JVM only (JDBC). | +| Skunk | `grackle-skunk` | `Resource[F, Session[F]]` | Postgres | Non-blocking, Postgres native protocol. Cross-built for JVM, JS and Native. | + +Both backends produce the same `Mapping[F]`, run the same queries, and accept the same `typeMappings`. The only differences you write by hand are the `col` declarations and the abstract base class you build on (a doobie dialect class such as `DoobiePgMapping[F]` over a `Transactor`, or `SkunkMapping[F]` over a `Resource[F, Session[F]]`). Pick Doobie if you need Oracle or SQL Server, or already run JDBC; pick Skunk for a non-blocking, Postgres-only stack. + +## Declare columns: Doobie vs Skunk `col` + +Every mapped column is a `ColumnRef`. You never build one directly — you call `col(...)` inside a `TableDef`, which captures the table name (via an implicit `TableName`), the column name and the codec. The signature of `col` is where the two backends part ways: + +- **Doobie:** `col(name, Meta[T], nullable: Boolean = false)`. Nullability is an explicit flag; the codec is a doobie `Meta[T]`. +- **Skunk:** `col(name, Codec[T])`. Nullability is *inferred* from whether the codec is for `Option[T]` (use `.opt`); there is no `nullable` argument. + +Here is a real Doobie set of `TableDef`s from the demo's Postgres world mapping. Each `object` extends `TableDef("...")` to bind its `col` calls to that table: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/world/WorldMapping.scala", "#db_tables")) +``` + +Note the nullable columns — `indepyear`, `lifeexpectancy`, `gnp`, `gnpold`, `headofstate`, `capital` — carry `nullable = true`; everything else is non-null. Under Skunk the same three tables would read `col("indepyear", int4.opt)` instead of `col("indepyear", Meta[Int], nullable = true)`, and `col("code", bpchar(3))` instead of `col("code", Meta[String])`; the `nullable = true` columns become `.opt` codecs and the rest stay as their plain codec. Under the hood both backends store a `(codec, Boolean)` tuple where the boolean is the nullability flag — Doobie sets it from your argument, Skunk derives it from `Option[T]`. + +One subtlety: `ColumnRef` equality and `hashCode` use only `(table, column)` — the codec is ignored. The same physical column referenced twice is treated as one, which is what lets a parent and a child object both name the join column, but it also means referencing one column with two different codecs will not be flagged as a mismatch. + +## Wire the mapping to a connection + +`DoobiePgMapping[F]` (and `DoobieOracleMapping[F]`, `DoobieMSSqlMapping[F]`) and `SkunkMapping[F]` are abstract base classes, not traits: each takes its connection in its constructor — a `Transactor[F]` for the Doobie dialects, a `Resource[F, Session[F]]` (typically a pooled session resource) for Skunk. You write your own mapping as a trait that mixes in the corresponding `*Like` trait — `DoobiePgMappingLike[F]` or `SkunkMappingLike[F]`, the database-agnostic core each abstract class extends — then instantiate it against a concrete backend, `new DoobiePgMapping[F](transactor, monitor) with MyDoobieMapping[F]`. The body — `schema`, `TableDef`s and `typeMappings` — is identical between the two backends; only the base class you instantiate and the constructor argument change. + +The doobie and skunk modules are not on this site's build classpath, so the sketch below is illustrative rather than compiled: + +```scala +// Doobie (Postgres): mix in DoobiePgMappingLike, constructed over a Transactor +trait MyDoobieMapping[F[_]] extends DoobiePgMappingLike[F] { + val schema = schema"""...""" + val typeMappings = TypeMappings(/* ObjectMappings + TableDefs */) +} + +object MyDoobieMapping extends DoobieMappingCompanion { + def mkMapping[F[_]: Sync](transactor: Transactor[F], monitor: DoobieMonitor[F]): Mapping[F] = + new DoobiePgMapping[F](transactor, monitor) with MyDoobieMapping[F] +} + +// Skunk: mix in SkunkMappingLike, constructed over a pooled Session resource +trait MySkunkMapping[F[_]] extends SkunkMappingLike[F] { + val schema = schema"""...""" + val typeMappings = TypeMappings(/* ObjectMappings + TableDefs */) +} + +object MySkunkMapping extends SkunkMappingCompanion { + def mkMapping[F[_]: Sync](pool: Resource[F, Session[F]], monitor: SkunkMonitor[F]): Mapping[F] = + new SkunkMapping[F](pool, monitor) with MySkunkMapping[F] +} +``` + +Each backend ships a `*MappingCompanion` whose `mkMapping(transactor)` / `mkMapping(pool)` defaults the monitor to a no-op, so the common case is a one-liner. You build the `Transactor` (or session `Resource`) the way you normally would in cats-effect, hand it to `mkMapping`, and the resulting `Mapping[F]` is a complete GraphQL endpoint — see [Serve a mapping over HTTP](serve-over-http.md) for routing it. The end-to-end Postgres walkthrough, including running the schema against a live database, is the [DB-backed model tutorial](../tutorial/db-backed-model.md). + +## Map a one-to-many and a many-to-one join + +A sub-object field is an `SqlObject`. With one or more `Join`s, Grackle emits a SQL join from parent columns to child columns; `Join(parentCol, childCol)` is the single-condition form. The canonical relational mapping is the world schema — `Country` has many cities and many languages, each `City` has one country, and each `Language` joins back to its countries: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlWorldMapping.scala", "#world_typemappings")) +``` + +Reading the joins: + +- **One-to-many** — `Country.cities` is `SqlObject("cities", Join(country.code, city.countrycode))`: from the parent's `code` column to the child's `countrycode`. The list field comes for free; Grackle assembles all matching `city` rows under each country. +- **Many-to-one** — `City.country` is `SqlObject("country", Join(city.countrycode, country.code))`: the *same* column pair in the opposite direction. You declare the join once per direction you want to traverse. + +The leaf fields are `SqlField`s pointing at columns on the type's own table. Every object mapping above declares at least one `key = true` field — `Country.code`, `City.id`, `Language.language` — because Grackle needs an identity column to group and assemble rows. Where a column exists only to drive a join or as a key, it carries `hidden = true` so it is fetched and used but never appears in the GraphQL output: `country.code`, `city.id` and `city.countrycode` are all hidden. + +`Language.language` is marked `associative = true` as well as `key = true`. An associative key is an identity column that is *not* a database primary key and may repeat across rows (a language code is shared by many countries); marking it associative tells Grackle to group rows correctly anyway. `associative = true` is only valid on a key field — putting it on a non-key triggers `AssocFieldNotKey`. + +The GraphQL these joins serve looks like this: + +```graphql +query { + country(code: "GBR") { + name + cities { + name + } + languages { + language + isOfficial + } + } +} +``` + +## Embed a value object on the parent row + +Sometimes a GraphQL sub-object has no table of its own — it is only a grouping of columns that live on the parent's row. That is *embedding*: an `SqlObject` with **no** `Join`. The embedded type's `SqlField`s point back at the parent's table, and the type repeats the parent's key (hidden) so rows still group. The film/series schema embeds a `Synopses` object built from `synopsis_short` and `synopsis_long` columns: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlEmbeddingMapping.scala", "#embedding")) +``` + +In `FilmType`, `SqlObject("synopses")` has no `Join`, so the `Synopses` object is built from columns on the same `films` row — there is no second table and no extra SQL join. Be careful here: `SqlObject(name)` with no `Join` always means embedding. If you intended a separate table you *must* pass a `Join`, or you will silently get same-row semantics. + +## Reuse a shared embedded type across parents + +In that same mapping the `Synopses` type is embedded under three different parents — `Film`, `Series` and `Episode` — and each parent stores it in a different table (`films`, `series`, `episodes`). A plain `ObjectMapping` for `Synopses` would be ambiguous: which table's columns should it use? You disambiguate by the GraphQL result-path under which the type is reached, as shown at the bottom of the snippet above: + +- `List("films", "synopses")` binds to the `films` columns, +- `List("series", "synopses")` to the `series` columns, +- `List("episodes", "synopses")` to the `episodes` columns. + +Grackle picks the right `ObjectMapping` per occurrence by matching the path under which the `Synopses` type is reached. Reach for a path-keyed mapping whenever one embedded GraphQL type appears under multiple parents stored in different tables. + +The inlined snippet uses `PrefixedMapping`, which is the legacy form of this: it is kept for backwards compatibility and builds a `PrefixedTypeMatch` predicate under the hood. In new code prefer the path-sensitive `ObjectMapping(path)(...)` constructor, which builds a `PathMatch` — the construct the reference recommends for context-sensitive mappings (see [Mappings and cursors](../concepts/mappings-cursors.md) and the [mapping types reference](../reference/mapping-types.md)). The example above predates `PathMatch`, which is why it still reads `PrefixedMapping`. + +## Join on a composite key, and chain through an associative table + +A `Join` holds a *list* of `(parentCol, childCol)` condition pairs. The single-pair form is sugar; the list form expresses a composite key, where every pair is `AND`ed into the `ON` clause. The composite-key mapping joins a parent identified by two columns (`key_1`, `key_2`) to its children: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlCompositeKeyMapping.scala", "#composite_key")) +``` + +`Parent.children` is `SqlObject("children", Join(List((compositeKeyParent.key1, compositeKeyChild.parent1), (compositeKeyParent.key2, compositeKeyChild.parent2))))`. Both parent columns are marked `key = true`, and both equalities are required to match a child row. + +A different need — a **many-to-many relationship through an associative table** — is expressed by passing several `Join`s to one `SqlObject`: `SqlObject(name, j1, j2, ...)` chains the joins, parent → associative table → far side. (In the world mapping above, `Country.languages` reaches `countrylanguage` with a single join because the language code lives directly on the link row; when the far side is a third table you add a second `Join` whose parent columns are the associative table's and whose child columns are the target table's.) The two failure modes to watch for are an empty condition list (`NoJoinConditions`) and chained joins whose intermediate tables do not line up (`MisalignedJoins` / `InconsistentJoinConditions`). + +## The flags you will reach for, and why join columns are hidden + +`SqlField` carries four flags; you will use the first three constantly: + +- **`key = true`** — an identity column used to group and assemble rows. Every object type mapping must have at least one (direct or inherited) key, or validation fails. +- **`hidden = true`** — the column is still fetched and used (for a join, a key, or a discriminator) but is omitted from the GraphQL response. Join and key columns are usually hidden so they do not leak into your schema's output; forgetting `hidden = true` on a join column either exposes it or trips the schema-vs-mapping consistency check. +- **`associative = true`** — marks a key that is not a DB primary key and may repeat (see `Language.language` above). Only valid together with `key = true`. +- **`discriminator = true`** — marks the column an interface/union discriminator reads. That is a separate recipe; see [Map interfaces and unions to SQL](interfaces-unions.md). + +## Validation rules you will hit + +The SQL mapping validator enforces a few structural rules. The two you are most likely to meet while modelling relationships: + +- **At least one key per object mapping.** Every (non-interface, non-union) object type mapping must declare at least one `key = true` field, directly or inherited, or you get `NoKeyInObjectTypeMapping` ("Object type mappings must include at least one direct or inherited key field mapping"). +- **One table per object mapping.** All `SqlField`/`SqlObject` columns of a single object type mapping must come from the *same* table, or validation fails with `SplitObjectTypeMapping` / `SplitEmbeddedObjectTypeMapping`. To span tables you do not add columns from another table — you add an `SqlObject` + `Join` so the second table is a genuine sub-object. + +Validation is deferred to first use: the compiler is built lazily, so a mis-specified mapping will not blow up until the first query is compiled. For the full list of failures and how to force validation early, see [Validate a mapping and read the failures](validate-mappings.md). + +## See also + +- [Map interfaces and unions to SQL](interfaces-unions.md) — single-table polymorphism with an `SqlDiscriminator`. +- [Map a jsonb column with SqlJson](jsonb-columns.md) — serve a JSON sub-tree without further SQL. +- [Filter, sort and page a field](filtering-ordering-paging.md) — add arguments to your list joins. +- [Serve a mapping over HTTP](serve-over-http.md) — route the resulting `Mapping[F]`. +- [DB-backed model tutorial](../tutorial/db-backed-model.md) — the end-to-end Postgres walkthrough, with a live database. +- [SqlMapping reference](../reference/sql-mapping.md) — exhaustive `SqlField`/`SqlObject`/`Join`/`col` signatures. diff --git a/docs/how-to/validate-mappings.md b/docs/how-to/validate-mappings.md new file mode 100644 index 00000000..d753767e --- /dev/null +++ b/docs/how-to/validate-mappings.md @@ -0,0 +1,111 @@ +# Validate a mapping and read the failures + +This page is a recipe for checking that a [`Mapping`](../concepts/mappings-cursors.md) actually covers the schema it claims to serve, and for reading the failures it reports when it does not. It is aimed at developers debugging mapping setup: you have written a `schema` and a `typeMappings` catalog, and you want to know — before you run a single query — which types and fields are unmapped, ambiguous, or mapped with the wrong kind of `TypeMapping`. Validation never inspects query text; it unfolds the schema and checks the catalog against it. + +## Run validation: `validate`, `unsafeValidate`, `validateInto` + +Every `Mapping[F]` exposes three validation entry points. They all take a minimum `Severity` and differ only in how they report failures. + +```scala mdoc:compile-only +import grackle._ + +val mapping: Mapping[cats.effect.IO] = grackle.docs.QuickStartMapping + +// 1. Collect failures as data (no throwing). Default severity is Warning. +val failures: List[ValidationFailure] = mapping.validate() + +// 2. Raise a ValidationException in F if any failure meets the threshold. +val checked: cats.effect.IO[Unit] = mapping.validateInto[cats.effect.IO]() + +// 3. Throw a ValidationException synchronously (handy in a test or a REPL). +mapping.unsafeValidate() +``` + +- `validate(severity)` returns a `List[ValidationFailure]`, filtered to those whose severity is greater than or equal to `severity`. This is the form to reach for when you want to inspect, count, or render failures yourself. +- `validateInto[G](severity)` (for any `ApplicativeError[G, Throwable]`) raises a `ValidationException` wrapping the failures, or yields `().pure[G]` when there are none. Use it to fail an effectful startup. +- `unsafeValidate(severity)` is the same check run through `Either[Throwable, *]` and then `throw`n — convenient in tests and the REPL, not for production paths. + +A failure is *not* a GraphQL error. Validation failures are a separate `ValidationFailure` channel, surfaced as a list or as a thrown `ValidationException`; they never appear in the `errors` array of a query [`Result`](../reference/result-problem.md). Render a failure for humans with `toErrorMessage`, which prints a colourised, multi-line explanation pointing at the schema and mapping source positions. + +### Severity and thresholds + +`ValidationFailure.Severity` has three cases, ordered `Error > Warning > Info`: + +```scala mdoc:silent +import grackle.ValidationFailure.Severity + +val error: Severity = Severity.Error // a mapping that is definitely wrong +val warning: Severity = Severity.Warning // suspicious but not fatal (e.g. an unused type mapping) +val info: Severity = Severity.Info // informational +``` + +Because `validate` keeps failures at or above the threshold, the default `Severity.Warning` reports both errors and warnings but drops `Info`. Pass `Severity.Error` to ignore warnings (for example, to tolerate an unused type mapping while still rejecting a genuinely broken one): + +```scala mdoc:compile-only +import grackle.ValidationFailure.Severity + +val errorsOnly = grackle.docs.QuickStartMapping.validate(Severity.Error) +``` + +## Checked vs unchecked catalogs, and when validation runs + +A [`TypeMappings`](../reference/mapping-types.md) catalog is either *checked* or *unchecked*. The constructor you use decides which: + +- `TypeMappings(...)` and the implicit conversion from a plain `List[TypeMapping]` (so writing `val typeMappings = List(...)` in a mapping) build a **checked** catalog. +- `TypeMappings.unchecked(...)` builds an **unchecked** catalog. (The old `TypeMappings.unsafe(...)` is deprecated in favour of `unchecked`.) + +Validation runs **lazily**, the first time the mapping's `compiler` is forced. At that point a checked catalog calls `unsafeValidate()` internally, so a broken checked mapping throws a `ValidationException` the first time you compile a query through it. An unchecked catalog skips that automatic step entirely — you can still call `validate()` on it explicitly, but nothing runs on your behalf. + +This is why the test mappings below all use `TypeMappings.unchecked(...)`: they deliberately hold broken catalogs so that constructing them and calling `validate()` does not throw, letting the test inspect the returned failures. + +## The `ValidationFailure` catalog + +`validate` unfolds the schema starting from the root operation types (`Query`, and `Mutation`/`Subscription` if present), walking every reachable type and field and matching it against the catalog. Each problem it finds is one of the following `ValidationFailure` subtypes. All are `Severity.Error` except `UnusedTypeMapping`, which is a `Severity.Warning`. + +| Failure | Severity | Triggered when | +| --- | --- | --- | +| `MissingTypeMapping` | Error | A schema type reachable from a root has no `TypeMapping` in the catalog. | +| `AmbiguousTypeMappings` | Error | Two or more equally specific mappings match the same type at the same path (a `MappingPredicate` priority tie). | +| `MissingFieldMapping` | Error | An object mapping exists for a type but declares no `FieldMapping` for one of the type's schema fields. | +| `DeclaredFieldMappingIsHidden` | Error | A field that the schema declares is mapped but marked `hidden = true`. | +| `ObjectTypeExpected` | Error | An `ObjectMapping` is used for a scalar or enum type. | +| `LeafTypeExpected` | Error | A `LeafMapping` is used for an object/interface/union type. | +| `ReferencedTypeDoesNotExist` | Error | A `TypeMapping` references a type name absent from the schema. | +| `UnusedTypeMapping` | Warning | A `TypeMapping` is never reached by unfolding the schema. | +| `UnusedFieldMapping` | Error | An object mapping declares a `FieldMapping` for a field the schema's type does not have. | + +Two points worth internalising. First, "unused" cuts both ways but at different severities: an unreachable *type* mapping is only a warning, whereas a field mapping for a non-existent field is an error (a likely typo in a field name). Second, hidden fields are fine as long as the schema does not declare them — `CursorField`s used purely as internal attributes are expected to be hidden; it is only hiding a *declared* field that fails. + +## Worked example: a missing field mapping + +The mapping below maps `Query` and the object type `Foo`, but the `ObjectMapping` for `Foo` is empty (`Nil`), so the declared field `Foo.bar` has no `FieldMapping`. The catalog is built with `TypeMappings.unchecked` so that `validate()` can be called without the lazy automatic check throwing first. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/mapping/MappingValidatorSuite.scala", "#validator")) +``` + +`M.validate()` returns a one-element list, `List(M.MissingFieldMapping(_, f))` with `f.name == "bar"`. Note that `MissingFieldMapping` is path-dependent on the mapping instance: it is `M.MissingFieldMapping`, matched against the value `M`, because the whole failure catalog is nested inside the `Mapping` trait. Calling `f.toErrorMessage` would print that `Foo.bar: String` is defined by the schema but the `ObjectMapping` for `Foo` does not define a mapping for it, with source positions for both. + +The other failures are triggered the same way — by an unchecked catalog that is wrong in exactly one place: + +- **`MissingTypeMapping`** — drop the `ObjectMapping` for `Foo` entirely. Unfolding `Query.foo: Foo` then reaches `Foo` with nothing in the catalog. +- **`AmbiguousTypeMappings`** — provide two equally specific mappings for `Foo`, e.g. an `ObjectMapping(schema.ref("Foo"))(...)` (a `TypeMatch`) alongside an `ObjectMapping(MappingPredicate.PathMatch(Path.from(schema.ref("Foo"))))(...)` that ties on priority. +- **`ObjectTypeExpected`** — declare `scalar Foo` in the schema but map it with an `ObjectMapping`. +- **`LeafTypeExpected`** — declare `type Foo { bar: String }` but map it with a `LeafMapping[String]`. (Enums and lists of leaves are valid `LeafMapping` targets, so those do *not* fail.) +- **`ReferencedTypeDoesNotExist`** — map `schema.uncheckedRef("Foo")` when the schema has no `Foo`. `uncheckedRef` lets you build the reference precisely so validation can flag it. +- **`UnusedFieldMapping`** — add a `CursorField("quz", ...)` to `Foo` when the schema's `Foo` only has `bar`. +- **`DeclaredFieldMappingIsHidden`** — map `Foo.bar` with `CursorField[String]("bar", _ => ???, Nil, hidden = true)`. The same `hidden = true` on a *non-declared* attribute field is fine. + +The matching `unsafeValidate` path is just the throwing form of the above: given a catalog with a `ReferencedTypeDoesNotExist`-class problem, `M.unsafeValidate()` raises a `ValidationException` rather than returning a list. + +## SQL-specific consistency rules + +`SqlMapping` overrides `validateTypeMapping`/`validateFieldMapping` to add a layer of relational checks on top of the core catalog: object mappings need a key column, associative fields must be keys, interfaces and unions need a discriminator, an object's columns must live in a single table, and union field mappings must be hidden. These run through the same `validate`/`unsafeValidate` machinery and report their own `ValidationFailure`s. See the [SqlMapping reference](../reference/sql-mapping.md) for the full list of SQL consistency rules and the failures each one raises. + +## See also + +- [Mappings and cursors](../concepts/mappings-cursors.md) — what a `Mapping` and its `TypeMappings` catalog are. +- [Mapping types reference](../reference/mapping-types.md) — the `TypeMapping`/`FieldMapping` constructors named above. +- [Construct, accumulate and report errors](errors.md) — the separate `Result`/`Problem` channel that surfaces in a query's `errors` array. +- [Running operations reference](../reference/running-operations.md) — where `compiler` (and the lazy validation it triggers) fits into `compileAndRun`. +- [SqlMapping reference](../reference/sql-mapping.md) — SQL-specific consistency rules. diff --git a/docs/index.md b/docs/index.md index 9b7cd8e8..26a21c8b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ backing data, and cursors into that data. It supports in-memory, DB-backed, and Grackle is structured as a compiler/interpreter. Queries are type-checked against a GraphQL schema and compiled into an internal query algebra. The query algebra may be further compiled in a backend-specific way to materialize data. In particular it can be compiled to efficient SQL and in that regard currently supports Postgres via -[Doobie](https://tpolecat.github.io/doobie/) or [Skunk](https://typelevel.org/skunk/) and Oracle and SQL Server via +[Doobie](https://typelevel.org/doobie/) or [Skunk](https://typelevel.org/skunk/) and Oracle and SQL Server via Doobie. Grackle is an [Apache 2.0 licensed](https://www.apache.org/licenses/LICENSE-2.0) Typelevel project and is available @@ -29,9 +29,29 @@ and [imbus AG](https://www.imbus.de/) over the last five years. ## Getting Started -- See the [tutorial](https://typelevel.org/grackle) and accompanying [demo](https://github.com/typelevel/grackle/tree/main/demo/src/main). +- New to Grackle? Start with [What is Grackle?](https://typelevel.org/grackle/getting-started/overview.html), then + run [your first query](https://typelevel.org/grackle/getting-started/quick-start.html) in five minutes. +- Work through the [tutorials](https://typelevel.org/grackle/tutorial/in-memory-model.html) and the accompanying + [demo](https://github.com/typelevel/grackle/tree/main/demo/src/main). - Online Scaladoc is available [here](https://javadoc.io/doc/org.typelevel/grackle-core_2.13). -- Ask us anything the in **#grackle** channel on the Typelevel [discord server][grackle-dev]. +- Ask us anything in the **#grackle** channel on the Typelevel [discord server][grackle-dev]. + +## Documentation + +The documentation is organised in four parts: + +- **[Tutorials](https://typelevel.org/grackle/tutorial/in-memory-model.html)** — learning-oriented, end-to-end builds: + an [in-memory model](https://typelevel.org/grackle/tutorial/in-memory-model.html), a + [database-backed model](https://typelevel.org/grackle/tutorial/db-backed-model.html), and + [mutations & subscriptions](https://typelevel.org/grackle/tutorial/mutations-subscriptions.html). +- **[How-to guides](https://typelevel.org/grackle/how-to/filtering-ordering-paging.html)** — task-oriented recipes for + filtering & paging, composing mappings, choosing a SQL backend, effects, errors and more. +- **[Concepts](https://typelevel.org/grackle/concepts/architecture.html)** — explanations of the + [architecture](https://typelevel.org/grackle/concepts/architecture.html), the compiler and + [elaboration](https://typelevel.org/grackle/concepts/compiler-elaboration.html), and + [mappings & cursors](https://typelevel.org/grackle/concepts/mappings-cursors.html). +- **[Reference](https://typelevel.org/grackle/reference/schema-sdl.html)** — exhaustive lookup of schemas, mapping + types, the query algebra, predicates, `Result`/`Problem`, and the SQL/circe/generic backends. To add Grackle to your project you should add the following to your `build.sbt`, diff --git a/docs/reference/circe-mapping.md b/docs/reference/circe-mapping.md new file mode 100644 index 00000000..db21a579 --- /dev/null +++ b/docs/reference/circe-mapping.md @@ -0,0 +1,150 @@ +# CirceMapping reference + +The circe backend builds a Grackle [`Mapping`](mapping-types.md) whose data source is in-memory circe `Json` rather than a database. The whole subsystem lives in one file, `modules/circe/src/main/scala/circemapping.scala`, and requires no database — `F` only needs a `cats.MonadThrow`. This page lists its entry points (`CirceMapping`/`CirceMappingLike`), the field mappings (`CirceField`, `CursorFieldJson`), the [`Cursor`](cursor.md) that walks JSON against the schema (`CirceCursor`), the `circeCursor` helper, the `RootEffect`/`RootStream` syntax extensions, and the leaf-validation rules. It is for developers building or reading a circe-backed mapping; for a task-oriented walkthrough see [Serving a GraphQL API from circe JSON](../how-to/circe-backend.md). + +## Entry points + +| Type | Signature | Purpose | +| --- | --- | --- | +| `CirceMapping[F]` | `abstract class CirceMapping[F[_]](implicit val M: MonadThrow[F]) extends Mapping[F] with CirceMappingLike[F]` | Convenience base class for a standalone JSON-backed mapping. Extend it and provide `schema` and `typeMappings`. | +| `CirceMappingLike[F]` | `trait CirceMappingLike[F[_]] extends Mapping[F]` | The mixin carrying all circe behaviour. Mix into another mapping to add JSON-valued fields. | + +`CirceMappingLike` is the trait carrying all behaviour; `CirceMapping` adds the `MonadThrow[F]` instance and the `Mapping[F]` base. Use the trait directly when you want to splice JSON fields into a non-circe backend rather than start from scratch — `SqlMappingLike[F] extends CirceMappingLike[F]` (`modules/sql-core/src/main/scala/SqlMapping.scala`), which is the only reason `CirceField`, `CursorFieldJson`, and `circeCursor` are usable inside [SQL mappings](sql-mapping.md). + +The base class imposes only `MonadThrow[F]`. The effectful-root examples below use `Sync[F]`/`IO`, but that is a requirement of their effect bodies, not of `CirceMapping`. + +## A standalone mapping + +A `CirceMapping` serves an entire subtree from one constant `Json`. The canonical example (`modules/circe/src/test/scala/CirceData.scala`) defines a schema, a `json"""…"""` fixture, and maps the `root` field to it with `CirceField`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CirceData.scala", "#circe_mapping")) +``` + +The `CirceField("root", data)` mapping serves the whole `Root` subtree from `data`. Note two further points the snippet illustrates: + +- **Custom scalars need a `LeafMapping`.** The schema declares `scalar BigDecimal`; serving it from JSON requires `LeafMapping[BigDecimal](BigDecimalType)`. Built-in scalars (`Int`/`Float`/`String`/`Boolean`/`ID`) and enums need no mapping. +- **`CursorField` can read JSON not in the schema.** `data` has a top-level `"hidden": 13` that the schema does not expose. `CursorField("computed", computeField, List("hidden"))` declares `"hidden"` as `required`, and `computeField` reads it with `c.fieldAs[Json]("hidden")`. Querying `hidden` directly fails with `No field 'hidden' for type Root`; it is reachable only internally. + +## Field mappings + +Both circe field mappings extend the sealed parent `CirceFieldMapping`, whose `subtree` flag is `true`: + +| Type | Signature | Serves | +| --- | --- | --- | +| `CirceFieldMapping` | `sealed trait CirceFieldMapping extends FieldMapping { def subtree: Boolean = true }` | Sealed parent; marks both mappings as opaque subtrees. | +| `CirceField` | `case class CirceField(fieldName: String, value: Json, hidden: Boolean = false)(implicit val pos: SourcePos)` | A constant `Json` subtree for `fieldName`. | +| `CursorFieldJson` | `case class CursorFieldJson(fieldName: String, f: Cursor => Result[Json], required: List[String], hidden: Boolean = false)(implicit val pos: SourcePos)` | A `Json` subtree computed from the parent `Cursor` by `f`. `required` lists sibling fields `f` may read. | + +`CursorFieldJson` is the bridge for splicing JSON into a non-JSON backend: a SQL mapping uses it to decode a column into a `Json` value (`modules/sql-core/src/test/scala/SqlCursorJsonMapping.scala`). + +### Opaque-subtree priority + +Because `subtree == true`, the `Json` a field mapping supplies is self-contained: `CirceCursor.field` looks for the field **inside the current JSON object first**, and only falls back to the mapping's other `typeMappings` when the JSON object lacks that field. So an explicit field mapping on a nested type is shadowed whenever the JSON already provides the field. `CircePrioritySuite` (`modules/circe/src/test/scala/CircePrioritySuite.scala`) demonstrates this: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CircePrioritySuite.scala", "#circe_priority")) +``` + +`present`'s JSON contains `monkey.name`, so the literal `"Bob"` wins. `fallback`'s JSON omits `name`, so Grackle falls back to the explicit `CirceField("name", "Steve")` on `Monkey`. Querying `present.monkey.name` returns `"Bob"`; `fallback.monkey.name` returns `"Steve"`. + +## `CirceCursor` + +`CirceCursor` is the [`Cursor`](cursor.md) implementation that traverses a `Json` `focus` against the GraphQL type in its `Context`. It is constructed by `mkCursorForMappedField` (for `CirceField`/`CursorFieldJson`) and by `circeCursor` (for effectful roots). + +```scala +case class CirceCursor(context: Context, focus: Json, parent: Option[Cursor], env: Env) extends Cursor +``` + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `field` | `def field(fieldName: String, resultName: Option[String]): Result[Cursor]` | Looks up `fieldName` in the JSON object; if present, descends. If absent, falls back to `mkCursorForField` (the mapping's other `typeMappings`). This fallback is the opaque-subtree priority rule above. | +| `isLeaf` / `asLeaf` | `def isLeaf: Boolean` · `def asLeaf: Result[Json]` | Whether the focus is a scalar/enum of the expected shape, and its rendering as `Json`. See [leaf validation](#leaf-validation) below. | +| `isList` / `asList` | `def isList: Boolean` · `def asList[C](factory: Factory[Cursor, C]): Result[C]` · `def listSize: Result[Int]` | True when `tpe.isList` and the focus is a JSON array; `asList` yields one child cursor per element. A non-array focus is an internal error. | +| `isNullable` / `asNullable` | `def isNullable: Boolean` · `def asNullable: Result[Option[Cursor]]` · `def isDefined: Result[Boolean]` | A JSON `null` focus yields `None`; any other value yields `Some(child)`. | +| `narrowsTo` / `narrow` | `def narrowsTo(subtpe: TypeRef): Result[Boolean]` · `def narrow(subtpe: TypeRef): Result[Cursor]` | Interface/union narrowing by JSON key inspection. See [narrowing](#narrowing) below. | +| `preunique` | `def preunique: Result[Cursor]` | Re-types an array focus as `tpe.nonNull.list` for a `Unique` operation; a non-array focus is an internal error. | +| `withEnv` | `def withEnv(env0: Env): Cursor` | Returns a copy with `env0` added to the existing `env`. | +| `mkChild` | `def mkChild(context: Context = context, focus: Json = focus): CirceCursor` | Builds a child cursor. It resets the child's `env` to `Env.empty` — environment bindings do **not** propagate downward through navigation automatically. | + +### Leaf validation + +`asLeaf` dispatches on `tpe.dealias` and validates the JSON shape against the expected scalar/enum type. The rules (from `CirceCursor.asLeaf`) are exact: + +| Expected type | JSON requirement | Result | +| --- | --- | --- | +| `BooleanType` | `focus.isBoolean` | passes the JSON through unchanged | +| `StringType`, `IDType` | `focus.isString` | passes the JSON through unchanged | +| `IntType` | `focus.isNumber` | re-parsed via `_.toLong` and re-emitted as `Json.fromLong`; a non-integral number or one overflowing `Long` fails with `Expected Int found …` | +| `FloatType` | `focus.isNumber` | passes the number through unchanged | +| `EnumType` | `focus.isString` and the string is a declared value | passes through; a string that is not a declared enum value is an internal error (`Expected Enum …`) | +| any other `ScalarType` | `!focus.isObject` | catch-all for custom scalars: any non-object JSON passes through | +| anything else | — | internal error (`Expected Scalar type, found …`) | + +The custom-scalar catch-all is why a custom scalar still wants its own `LeafMapping[T]`: without it the raw JSON passes through, but the value is not registered/encoded as your scalar type. `isLeaf` mirrors these shape checks (`Boolean→isBoolean`, `String`/`ID→isString`, `Int`/`Float→isNumber`, `Enum→isString`) but does not validate enum membership. + +### Narrowing + +`narrowsTo` decides interface/union fragment membership and `__typename` resolution by inspecting the JSON object's keys. A subtype `subtpe` matches only when **both** hold: + +- every non-nullable field of `subtpe` is present as a key in the JSON object, and +- every key of the JSON object corresponds to a field of `subtpe`. + +A missing required key, or an extra unknown key, causes the narrow to fail. `narrow` re-types the cursor to `subtpe` when `narrowsTo` succeeds, and is an internal error otherwise. + +## `circeCursor` + +`circeCursor` builds the right cursor for a position. It backs the effect syntax and is callable directly inside `computeCursor` handlers. + +```scala +def circeCursor(path: Path, env: Env, value: Json): Cursor +``` + +| `path` | Result | +| --- | --- | +| `path.isRoot` | a `CirceCursor` at `Context(path.rootTpe)`, focused directly on `value` | +| otherwise | a `DeferredCursor` that builds the `CirceCursor` once the parent `Context` is known | + +The path choice determines what `value` must contain. With `circeCursor(p, e, json)` (and therefore `RootEffect.computeJson`, which uses `p`), `json` is the **value of the field**. With `circeCursor(Path.from(p.rootTpe), e, json)`, `json` is an **object containing the field name**. Mixing these up produces a shape mismatch. + +`mkCursorForMappedField` is the override that turns circe field mappings into cursors: + +| Field mapping | Cursor produced | +| --- | --- | +| `CirceField(_, json, _)` | `CirceCursor(fieldContext, json, Some(parent), parent.env)` | +| `CursorFieldJson(_, f, _, _)` | runs `f(parent)`, then `CirceCursor(fieldContext, result, Some(parent), parent.env)` | +| anything else | delegates to `super.mkCursorForMappedField` | + +## Effectful and streaming roots + +`CirceMappingLike` enriches the `RootEffect` and `RootStream` companions with circe-specific constructors. See [the effects reference](effects.md) for `RootEffect`/`RootStream` in general. + +| Syntax | Signature | +| --- | --- | +| `RootEffect.computeJson` | `def computeJson(fieldName: String)(effect: (Path, Env) => F[Result[Json]])(implicit pos: SourcePos): RootEffect` | +| `RootEffect.computeEncodable` | `def computeEncodable[A](fieldName: String)(effect: (Path, Env) => F[Result[A]])(implicit pos: SourcePos, enc: Encoder[A]): RootEffect` | +| `RootStream.computeJson` | `def computeJson(fieldName: String)(effect: (Path, Env) => Stream[F, Result[Json]])(implicit pos: SourcePos): RootStream` | +| `RootStream.computeEncodable` | `def computeEncodable[A](fieldName: String)(effect: (Path, Env) => Stream[F, Result[A]])(implicit pos: SourcePos, enc: Encoder[A]): RootStream` | + +`computeJson` delegates to the core `computeCursor`, mapping the yielded `Json` through `circeCursor(p, e, _)`. `computeEncodable[A]` is `computeJson` after applying the implicit circe `Encoder[A]`. The `RootStream` variants are the subscription/stream forms: each emitted `Json` (or encoded value) becomes a `CirceCursor` result. Grackle ships no websocket transport — wire the resulting `fs2.Stream` to one yourself. + +The three effect styles side by side (`modules/circe/src/test/scala/CirceEffectData.scala`): + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CirceEffectData.scala", "#circe_effects")) +``` + +- `foo` uses `RootEffect.computeCursor` with an explicit `circeCursor(p, e, json)` — full control, `json` is the field value. +- `bar` uses `computeJson`: yield the `Json`, the syntax wraps it. +- `baz` uses `computeEncodable`: yield a `Struct`, the implicit `Encoder[Struct]` and the syntax do the rest. +- `qux` uses `computeCursor` rooted via `Path.from(p.rootTpe)`, so its `json` is an object `{ "qux": { … } }` that includes the field name. + +The effect bodies here perform a real effect (a `SignallingRef` update) to prove each root runs exactly once, which is why the mapping is `CirceMapping[F : Sync]`. + +## See also + +- [Serving a GraphQL API from circe JSON](../how-to/circe-backend.md) — task-oriented recipe using these types. +- [Mapping types reference](mapping-types.md) — the full mapping/field-mapping catalogue this backend plugs into. +- [Cursor reference](cursor.md) — the `Cursor` contract `CirceCursor` implements. +- [Effects reference](effects.md) — `RootEffect`/`RootStream` and how roots run. +- [SQL mapping reference](sql-mapping.md) — `SqlMappingLike` inherits `CirceMappingLike`, enabling `CursorFieldJson` over a database. diff --git a/docs/reference/context-env.md b/docs/reference/context-env.md new file mode 100644 index 00000000..0e8e94c7 --- /dev/null +++ b/docs/reference/context-env.md @@ -0,0 +1,171 @@ +# Context & Env reference + +Every [`Cursor`](cursor.md) carries two immutable companions threaded through both elaboration and interpretation: a `Context`, which records *where* in the output tree the cursor sits, and an `Env`, a small type-checked key/value store for values computed during query processing. This page enumerates both types — the `Context` fields and their derivations, its `equals`/`hashCode` caveats, and the `Env` algebra with its type-checked lookups. Both are defined in `modules/core/src/main/scala/cursor.scala`, in package `grackle`; bring them into scope with `import grackle._`. + +## `Context` + +`Context` represents a position in the output tree in terms of three parallel paths plus the schema root. All four fields are public and immutable. + +| Field | Type | Meaning | +| --- | --- | --- | +| `rootTpe` | `Type` | The GraphQL `Type` at the root of the operation (for example the `Query` type). | +| `path` | `List[String]` | The schema field names from the root to this position. | +| `resultPath` | `List[String]` | `path` with query aliases applied — the names that appear in the response JSON. | +| `typePath` | `List[Type]` | The GraphQL `Type` at each level of `path`. | + +```scala +case class Context( + rootTpe: Type, + path: List[String], + resultPath: List[String], + typePath: List[Type] +) +``` + +### Reversed path ordering + +`path`, `resultPath`, and `typePath` are stored **innermost-first**: the head is the deepest (current) element and the last entry is the field directly under the root. Each derivation prepends to the head, so descending into a field is a cons, not an append. The current type is therefore the head of `typePath`: + +| Member | Signature | Yields | +| --- | --- | --- | +| `tpe` | `lazy val tpe: Type` | `typePath.headOption.getOrElse(rootTpe)` — the type at this position, falling back to `rootTpe` at the root. | +| `isRoot` | `def isRoot: Boolean` | `path.isEmpty` — whether this context is still at the operation root. | + +Because the lists run innermost-first, the textual schema path reads in reverse: a context two fields deep has `path == List("inner", "outer")`. + +### Derivations + +Each derivation returns a child (or parent) context with the three paths extended or trimmed consistently. The `forField` variants are partial — they consult `tpe.underlyingField` and fail with a `Result` error if no such field exists on the current type. + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `forField` | `def forField(fieldName: String, resultName: String): Result[Context]` | Descend into `fieldName`, recording `resultName` in `resultPath`. Errors with `"No field '…' for type …"` if `fieldName` is not a field of `tpe`. | +| `forField` | `def forField(fieldName: String, resultName: Option[String]): Result[Context]` | As above; `None` reuses `fieldName` as the result name. | +| `forFieldOrAttribute` | `def forFieldOrAttribute(fieldName: String, resultName: Option[String]): Context` | Total variant: if `fieldName` is not a schema field it falls back to `ScalarType.AttributeType`, so it never errors. Used for synthetic attribute paths (for example join keys) that have no GraphQL field. | +| `forPath` | `def forPath(path1: List[String]): Result[Context]` | Fold `forField(hd, hd)` over a list of field names to reach a deeper position. | +| `asType` | `def asType(tpe: Type): Context` | Replace the type at the current position (`typePath.head`, or `rootTpe` at the root) without changing the paths. This is how a cursor re-types itself on narrowing or when entering a child of a known type. | +| `forUnderlyingNamed` | `def forUnderlyingNamed: Context` | Replace the current type with its `underlyingNamed.dealias` — strip list/nullable wrappers and aliases down to the named type. | +| `parent` | `def parent: Option[Context]` | Drop the head of all three paths, yielding the enclosing context, or `None` at the root. | + +`forField` and `forFieldOrAttribute` are the workhorses: the interpreter calls them as it descends from a parent cursor into a selected field, and a [`SelectElaborator`](elab-phases.md) walks them to validate paths inside predicates. + +### Companion constructors + +`object Context` provides three ways to build a context. The single-argument form is the usual entry point for rooting a context at an operation's root type. + +| Constructor | Signature | Result | +| --- | --- | --- | +| `Context(rootTpe)` | `def apply(rootTpe: Type): Context` | A root context: `Context(rootTpe, Nil, Nil, Nil)`. | +| `Context(rootTpe, fieldName, resultName)` | `def apply(rootTpe: Type, fieldName: String, resultName: Option[String]): Option[Context]` | A one-field context, or `None` if `fieldName` is not a field of `rootTpe`. | +| `Context(path)` | `def apply(path: Path): Result[Context]` | Fold `forField` over a [`Path`](predicates.md), returning a `Result` because any step can fail. | + +### `equals` / `hashCode` caveats + +`Context` overrides both `equals` and `hashCode`, and the implementations are deliberately narrower than the generated case-class versions. Read them before using a `Context` as a map key or comparing two by `==`: + +```scala +override def equals(other: Any): Boolean = + other match { + case Context(oRootTpe, oPath, oResultPath, _) => + rootTpe =:= oRootTpe && resultPath == oResultPath && path == oPath + case _ => false + } + +override def hashCode(): Int = resultPath.hashCode +``` + +Two consequences follow: + +- **`typePath` is ignored by `equals`.** Only `rootTpe`, `path`, and `resultPath` are compared. Two contexts that disagree only in the *types* along the path (for example one narrowed to a subtype) are considered equal. This is intentional — the interpreter keys deferred work on output position, not on the type stack. +- **`rootTpe` is compared with `=:=`, not `==`.** Roots compare by GraphQL type *equivalence* (dealiased structural equality), so a `TypeRef` and its resolved definition match. `hashCode`, however, is *only* `resultPath.hashCode`, so it ignores `rootTpe` and `path` entirely. The pair is internally consistent (equal contexts share a `resultPath` and so share a hash), but the hash is intentionally coarse — expect collisions between contexts that differ only in `rootTpe` or `path`. + +## `Env` + +`Env` is a sealed key/value environment threaded through cursors and query elaboration. It has exactly two cases — `EmptyEnv` and `NonEmptyEnv` — and stores values as `Any`, recovering their type at lookup via a `ClassTag`. + +```scala +sealed trait Env { + def add[T](items: (String, T)*): Env + def add(env: Env): Env + def contains(name: String): Boolean + def get[T: ClassTag](name: String): Option[T] + def isEmpty: Boolean + + def getR[A: ClassTag: TypeName](name: String): Result[A] + def addFromQuery(query: Query): Env +} +``` + +| Member | Signature | Behaviour | +| --- | --- | --- | +| `add` | `def add[T](items: (String, T)*): Env` | Add one or more bindings, returning a `NonEmptyEnv`. Later keys overwrite earlier ones. | +| `add` | `def add(env: Env): Env` | Merge another `Env` in; its bindings win on key clashes. | +| `contains` | `def contains(name: String): Boolean` | Whether `name` is bound (regardless of value type). | +| `get` | `def get[T: ClassTag](name: String): Option[T]` | Type-checked lookup: `Some(v)` only if `name` is bound **and** its value is a `T`; otherwise `None`. | +| `getR` | `def getR[A: ClassTag: TypeName](name: String): Result[A]` | Like `get`, but a missing or wrongly-typed key is a `Result` failure carrying `"Key '…' of type … was not found in …"` rather than `None`. | +| `isEmpty` | `def isEmpty: Boolean` | Whether the environment holds no bindings. | +| `addFromQuery` | `def addFromQuery(query: Query): Env` | Walk leading `Query.Environment` nodes off the front of `query`, folding each node's `Env` into this one (and stopping at the first non-`Environment` node). | + +### Type-checked lookups + +`get` (and therefore `getR`) is checked at run time by the value's `ClassTag`: `NonEmptyEnv.get` does `elems.get(name).flatMap(classTag[T].unapply)`, so a key that *is* present but stored as a different type returns `None` — not the raw value cast blindly. Use `getR` when you want a descriptive error instead of a silent `None`; its message includes the requested type name (via `TypeName`) and the current environment contents. + +### Constructors + +`object Env` exposes the empty value and a varargs factory. + +| Constructor | Signature | Result | +| --- | --- | --- | +| `Env.empty` | `def empty: Env` | The `EmptyEnv` singleton. | +| `Env(items*)` | `def apply[T](items: (String, T)*): Env` | A `NonEmptyEnv(Map(items: _*))`, or an empty map's `NonEmptyEnv` if no items are given. | + +The two concrete cases are `case object EmptyEnv` (every lookup is `None`/`false`; `add` promotes it to a `NonEmptyEnv`) and `case class NonEmptyEnv(elems: Map[String, Any])`. + +### Where `Env` comes from + +During elaboration a `SelectElaborator` stages values into the environment with `Elab.env(...)`, which the compiler emits as `Query.Environment(env, child)` nodes in the [query algebra](query-algebra.md). At interpretation time `addFromQuery` lifts those nodes' bindings into the live environment. A [`Cursor`](cursor.md) reads them back through `env`/`envR`/`fullEnv`, walking from the local environment up through its parents. A field resolver therefore sees a value a `SelectElaborator` case staged earlier, without that value appearing anywhere in the cursor's backing data. + +## Example: rooting a context and a round-trip through `Env` + +The following compiles against the `QuickStartMapping` doc example, whose schema has a `Query` type with a `book` field of type `Book`. Rooting a `Context` at the `Query` type and deriving the child `book` context returns a `Result` because `forField` is partial: + +```scala mdoc:silent +import grackle._ +import grackle.docs.QuickStartMapping + +val rootCtx: Context = Context(QuickStartMapping.QueryType) +``` + +```scala mdoc +rootCtx.isRoot +rootCtx.tpe + +// Descend into the `book` field; `forField` returns a Result because the +// field might not exist on the current type. +val bookCtx: Result[Context] = rootCtx.forField("book", "book") +bookCtx.toOption.map(_.path) // innermost-first: List("book") +bookCtx.toOption.map(_.resultPath) // alias-applied path + +// An unknown field is a Result failure, not an exception. +rootCtx.forField("nope", "nope") +``` + +`Env` lookups are type-checked at run time, so asking for the wrong type yields `None` (from `get`) or a descriptive failure (from `getR`): + +```scala mdoc +val env: Env = Env("answer" -> 42) + +env.get[Int]("answer") // Some(42) +env.get[String]("answer") // None — present, but not a String +env.getR[Int]("answer") // Result.Success(42) +env.getR[String]("answer") // Result.Failure with a descriptive message +env.contains("answer") +``` + +## See also + +- [Cursor reference](cursor.md) — the navigator that carries a `Context` and an `Env`. +- [Query algebra reference](query-algebra.md) — the `Query.Environment` node that injects an `Env`. +- [Elaboration phases reference](elab-phases.md) — where a `SelectElaborator` stages values with `Elab.env`. +- [Result, Problem & ResultT reference](result-problem.md) — the `Result` returned by `forField` and `getR`. +- [How the query interpreter works](../concepts/query-interpreter.md) — how contexts and environments thread through a full query walk. diff --git a/docs/reference/cursor.md b/docs/reference/cursor.md new file mode 100644 index 00000000..22f139d5 --- /dev/null +++ b/docs/reference/cursor.md @@ -0,0 +1,158 @@ +# Cursor reference + +A `Cursor` is Grackle's read-only navigator over an abstract backing data model during query interpretation. It pairs an untyped runtime `focus` value with the GraphQL `Type` it is expected to represent, and exposes type-directed navigation — descend into a field, render a leaf as JSON, iterate a list, narrow an interface — with every method returning a [`Result`](result-problem.md) so failures accumulate rather than throw. This page lists the `Cursor` trait and its methods, the provided base and proxy cursors, and what you must implement to write a custom one. For the `Context`/`Env` types a cursor carries, see the [Context & Env reference](context-env.md); for how the interpreter walks a `Cursor`, see [How the query interpreter works](../concepts/query-interpreter.md). + +## The `Cursor` trait + +A `Cursor` (`modules/core/src/main/scala/cursor.scala`) holds four pieces of state and derives a position from its `Context`: + +| Member | Signature | Meaning | +| --- | --- | --- | +| `parent` | `def parent: Option[Cursor]` | The enclosing cursor, or `None` at the root. | +| `focus` | `def focus: Any` | The backing value at this position, untyped. | +| `context` | `def context: Context` | Schema path, alias-applied result path, and GraphQL type stack. | +| `path` | `def path: List[String] = context.path` | Schema field names from the root (innermost-first). | +| `resultPath` | `def resultPath: List[String] = context.resultPath` | `path` with query aliases applied. | +| `tpe` | `def tpe: Type = context.tpe` | The GraphQL `Type` the `focus` is expected to have. | +| `env` | `private[grackle] def env: Env` | The local environment bindings at this cursor. | +| `withEnv` | `def withEnv(env: Env): Cursor` | A copy with additional environment values. | + +`focus` is typed `Any`, and the value and `tpe` must agree: navigation methods pattern-match on the two together. A `ListType` `tpe` carrying a non-list `focus`, for example, reports `isList == false` and errors from `asList`. Keep the backing value in sync with the `Context` type. + +## Type predicates and accessors + +Each "is" predicate tests the shape of the `focus`; each "as" accessor produces the corresponding value, returning an error `Result` if the focus is not of that shape. Match the accessor to the cursor's actual type — the predicate tells you which one is valid. + +| Method | Signature | Yields | +| --- | --- | --- | +| `isLeaf` / `asLeaf` | `def isLeaf: Boolean` · `def asLeaf: Result[Json]` | Whether the focus is scalar/enum, and its rendering as circe `Json`. `asLeaf` is the terminal step for scalar fields. | +| `isList` / `asList` | `def isList: Boolean` · `final def asList: Result[List[Cursor]] = asList(List)` · `def asList[C](factory: Factory[Cursor, C]): Result[C]` · `def listSize: Result[Int]` | Whether the focus is a list, one child `Cursor` per element (collected into any `Factory`), and its length. | +| `isNullable` / `asNullable` | `def isNullable: Boolean` · `def asNullable: Result[Option[Cursor]]` · `def isDefined: Result[Boolean]` · `def isNull: Boolean` | Whether the focus is nullable; `asNullable` yields `Some(child)` when present, `None` when absent. | + +`asNullable` has two layers: a present value is `Result.Success(Some(c))`, an absent value is `Result.Success(None)`, and a type mismatch is an internal error. The interpreter maps `None` to `Json.Null`. `isNull` is derived as `isNullable && asNullable == Success(None)`. + +## Field navigation + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `field` | `def field(fieldName: String, resultName: Option[String]): Result[Cursor]` | Descend into a named field, optionally under a query alias (`resultName`). Concrete cursors delegate to `mkCursorForField`. | +| `fieldAs` | `def fieldAs[T: ClassTag: TypeName](fieldName: String): Result[T]` | `field(fieldName, None)` followed by `as[T]`. | +| `nullableField` | `def nullableField(fieldName: String): Result[Cursor]` | Transparently unwraps a nullable focus before descending into `fieldName`. | +| `path` | `def path(fns: List[String]): Result[Cursor]` | Follow a multi-field path, unwrapping nullables along the way, to a single cursor. | +| `listPath` | `def listPath(fns: List[String]): Result[List[Cursor]]` | Follow a path, fanning out across any lists or nullables encountered. | +| `flatListPath` | `def flatListPath(fns: List[String]): Result[List[Cursor]]` | Like `listPath`, but also flattens a list at the end of the path. | + +`path`, `listPath`, and `flatListPath` are how predicates and joins reach nested attribute values; see the [Predicates & terms reference](predicates.md). + +## Narrowing + +Interface and union members are reached by narrowing to a concrete subtype before its subtype-only fields become accessible. + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `narrowsTo` | `def narrowsTo(subtpe: TypeRef): Result[Boolean]` | Tests run-time membership of `subtpe`. | +| `narrow` | `def narrow(subtpe: TypeRef): Result[Cursor]` | Re-types the cursor to `subtpe`, erroring if the focus is not narrowable. | + +`narrow` backs `Narrow` query nodes and `__typename` resolution. + +## Unique support + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `preunique` | `def preunique: Result[Cursor]` | Re-types the focus as a list (`tpe.nonNull.list`) so the caller can run it as a single-element list. | + +`preunique` does **not** return the unique element. It is the antecedent of a `Unique` operation: it wraps the focus so the interpreter can then run the list with `unique = true`, which errors on "No match" (size 0, non-nullable) or "Multiple matches" (size > 1) and otherwise yields the one element. See [Filtering & paging query nodes](filtering-paging-nodes.md) for `Unique`. + +## Casting and environment + +| Method | Signature | Behaviour | +| --- | --- | --- | +| `as` | `def as[T: ClassTag: TypeName]: Result[T]` | Cast the focus to a concrete Scala type, with a descriptive error if the runtime class does not match. | +| `env` | `def env[T: ClassTag](nme: String): Option[T]` | Look up an environment key locally, then walk parents. | +| `envR` | `def envR[T: ClassTag: TypeName](nme: String): Result[T]` | Like `env`, but a missing key is a `Result` error rather than `None`. | +| `fullEnv` | `def fullEnv: Env` | The cumulative environment from the root down to this cursor. | +| `withEnv` | `def withEnv(env: Env): Cursor` | A copy with extra environment bindings. | + +`env` lookups are type-checked at run time via `ClassTag`, so a key present but stored as the wrong type returns `None`; use `envR` for a descriptive error. The `Env` type itself is documented in the [Context & Env reference](context-env.md). + +### Companion helpers + +```scala +object Cursor { + def flatten(c: Cursor): Result[List[Cursor]] + def flatten(cs: List[Cursor]): Result[List[Cursor]] +} +``` + +`Cursor.flatten` recursively expands lists and nullables into a flat `List[Cursor]` of leaf positions. + +## Worked navigation + +The following examples build a real `Cursor` by hand with `CursorBuilder[T].build(context, value)` — the most direct way to obtain one outside the interpreter — and exercise the accessors above against Grackle's generic Star Wars model. They are taken verbatim from `DerivationSuite`. For `CursorBuilder` itself, see [generic derivation](../how-to/generic-derivation.md). + +A leaf cursor is rooted at a scalar `Context`; `isLeaf` is true and `asLeaf` renders the focus as `Json`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_primitive")) +``` + +`field` descends by GraphQL field name (`None` means no alias). Because `name` is nullable in the schema, the child cursor is nullable, so `asNullable` is unwrapped first: its `Result[Option[Cursor]]` is flattened with `toResultOrError("missing")`, which turns the absent (`None`) case into an error, before `asLeaf` renders the present value: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_product")) +``` + +For a list field, `asNullable` unwraps the nullable wrapper and `asList(List)` yields one child `Cursor` per element, each traversed with `asLeaf`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_list")) +``` + +`appearsIn` is declared `[Episode!]` on the `Character` interface — a nullable list — so the snip unwraps with `asNullable` before `asList`, and each element is itself a leaf enum cursor. `narrow` re-types an interface cursor to a concrete subtype; `homePlanet` exists only on `Human`, so the cursor must be narrowed from `Character` first: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_narrow")) +``` + +## Provided base and proxy cursors + +`Cursor` is a wide trait, so two base classes in `object Cursor` supply defaults, and several concrete cursors cover common positions. + +| Type | Signature | Purpose | +| --- | --- | --- | +| `AbstractCursor` | `abstract class AbstractCursor extends Cursor` | Gives every navigation method an error default (`isLeaf = false`, `asLeaf`/`asList`/`asNullable`/`preunique`/`narrow`/`field` all `Result.internalError`, `narrowsTo = false`). A concrete cursor overrides only the cases its model supports. | +| `ProxyCursor` | `class ProxyCursor(underlying: Cursor) extends Cursor` | Delegates every method to `underlying`; the base for cursors that wrap another and override a few methods. | +| `EmptyCursor` | `case class EmptyCursor(context: Context, parent: Option[Cursor], env: Env) extends AbstractCursor` | A contentless position carrying only `context`/`env`; accessing `focus` errors. | +| `ListTransformCursor` | `case class ListTransformCursor(underlying: Cursor, newSize: Int, newElems: Seq[Cursor]) extends ProxyCursor(underlying)` | A list proxy that substitutes an alternative element set/size — the result of a `TransformCursor` over a list (filter/order/limit re-projection). | +| `NullCursor` | `case class NullCursor(underlying: Cursor) extends ProxyCursor(underlying)` | Always reports `isDefined = false` and `asNullable = None`, forcing a null. | +| `NullFieldCursor` | `case class NullFieldCursor(underlying: Cursor) extends ProxyCursor(underlying)` | Wraps every child field result in a `NullCursor`. | +| `DeferredCursor` | `case class DeferredCursor(context: Context, parent: Option[Cursor], env: Env, deferredPath: List[String], mkCursor: (Context, Cursor) => Result[Cursor]) extends AbstractCursor` | Defers construction of the real cursor until a fixed `deferredPath` is traversed, invoking `mkCursor` at the final step. | + +`DeferredCursor` also has a `Path`-based constructor, `DeferredCursor(path: Path, mkCursor: (Context, Cursor) => Result[Cursor]): Cursor`. Its `field` errors unless `fieldName` equals the next element of `deferredPath` — it can only walk the pre-declared path, not arbitrary fields. + +## Implementing a custom Cursor + +To navigate your own backing model, extend `AbstractCursor` and override the navigation methods your model supports — the unsupported ones keep their error defaults. The canonical reference is `ValueCursor` (`modules/core/src/main/scala/valuemapping.scala`), a complete `Cursor` over plain Scala values: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/main/scala/valuemapping.scala", "#value_cursor")) +``` + +Note the recurring shapes: + +- **`mkChild` + `context.asType`.** Every child cursor re-types the `Context` to the child's GraphQL type (`context.asType(tpe)`) while passing the child `focus`. Keeping this re-typing correct is what makes the interpreter's `cursorCompatible` guard pass; a `tpe` that doesn't match the query type surfaces as a "Mismatched query and cursor type" internal error rather than a useful message. +- **Match on `(tpe, focus)` together.** `isList`, `asList`, `asNullable`, `isDefined`, and `preunique` all require both the GraphQL type *and* the runtime value to agree (`(_: ListType, _: List[_])`, `(NullableType(tpe), o: Option[_])`, and so on). +- **`narrowsTo`/`narrow` consult the mapping.** `ValueCursor.narrowsTo` checks both that `subtpe <:< tpe` and that the mapped runtime class matches the focus; `narrow` then re-types via `mkChild(context.asType(subtpe))`. +- **`field` bridges into the mapping.** `ValueCursor.field` simply calls `mkCursorForField(this, fieldName, resultName)`, handing field resolution back to the [`Mapping`](mapping-types.md). + +For a cursor over a structured document rather than native values, `CirceCursor` (`modules/circe/src/main/scala/circemapping.scala`) is the other reference implementation: its `asLeaf` validates each scalar/enum against the JSON shape and its `narrowsTo` checks that required fields are present. See [serving GraphQL from circe JSON](../how-to/circe-backend.md). + +The interpreter obtains its starting cursor from the mapping's `RootCursor` (focus is `Unit`), whose `field` delegates to `mkCursorForField` to enter the real model; see [How the query interpreter works](../concepts/query-interpreter.md) for the full root-to-JSON walk. + +## See also + +- [Context & Env reference](context-env.md) — the `Context` and `Env` types a `Cursor` carries. +- [Mapping types reference](mapping-types.md) — `mkCursorForField` and the mappings that build cursors. +- [How the query interpreter works](../concepts/query-interpreter.md) — how `runValue`/`runFields`/`runList` walk a `Cursor`. +- [Mappings and cursors](../concepts/mappings-cursors.md) — the concept behind tying a schema to data. +- [Result, Problem & ResultT reference](result-problem.md) — the four-armed `Result` every cursor method returns. diff --git a/docs/reference/directory.conf b/docs/reference/directory.conf new file mode 100644 index 00000000..240886be --- /dev/null +++ b/docs/reference/directory.conf @@ -0,0 +1,17 @@ +laika.title = Reference +laika.navigationOrder = [ + schema-sdl.md + mapping-types.md + query-algebra.md + predicates.md + filtering-paging-nodes.md + cursor.md + context-env.md + result-problem.md + elab-phases.md + sql-mapping.md + circe-mapping.md + generic-derivation.md + effects.md + running-operations.md +] diff --git a/docs/reference/effects.md b/docs/reference/effects.md new file mode 100644 index 00000000..484b7553 --- /dev/null +++ b/docs/reference/effects.md @@ -0,0 +1,219 @@ +# Effects reference (RootEffect / RootStream / EffectHandler) + +This page is the authoritative signature reference for Grackle's effects subsystem: the `Effect` +algebra node, the `EffectMapping` field mappings (`RootEffect`, `RootStream`, `EffectField`), every +root-effect factory (including the circe extensions), and the `EffectHandler` contract with its +ordering guarantee. It is aimed at developers wiring effects into a `Mapping`; for the narrative of +*why* effects are deferred and batched see [How effects and batching work](../concepts/effects-batching.md), +and for task-oriented recipes see [the effects how-to](../how-to/effects-batching.md). All signatures +are taken from `modules/core/src/main/scala` (and `modules/circe/src/main/scala` for the circe +extensions). + +## `Effect` node and `EffectMapping` + +Effects attach to a field at one of two points: a **root** field (`RootEffect`/`RootStream`), which runs +once before the rest of the query is interpreted, or a **nested** field (`EffectField`), whose effect is +deferred into an `Effect` algebra node and resolved (batched) at the end of a stage. + +| Type | File | Signature | +| --- | --- | --- | +| `Effect` | `modules/core/src/main/scala/query.scala` | `case class Effect[F[_]](handler: EffectHandler[F], child: Query) extends Query` | +| `EffectMapping` | `modules/core/src/main/scala/mapping.scala` | `trait EffectMapping extends FieldMapping { def subtree: Boolean = true }` | + +`Effect` is the query-algebra node that embeds a possibly-batched deferred effect; the +[`EffectElaborator`](query-algebra.md) compiler phase inserts it around any field backed by an +`EffectField`. `EffectMapping` is the common supertype of `EffectField`, `RootEffect`, and `RootStream`; +`subtree = true` marks the field as owning its entire selection subtree. + +## `RootEffect` + +A `FieldMapping` for a top-level (`Query`/`Mutation`/`Subscription`) field that performs an initial effect +and yields an effect-specific `Query` plus a root `Cursor`. The primary constructor is **private** — always +construct one through the companion-object factories below (the bare `apply` additionally takes a +`DummyImplicit` to disambiguate its overloads). + +| Member | Signature | +| --- | --- | +| type | `case class RootEffect private (fieldName: String, effect: (Query, Path, Env) => F[Result[(Query, Cursor)]]) extends EffectMapping` | +| `hidden` | `def hidden = false` | +| `toRootStream` | `def toRootStream: RootStream` | +| `apply` | `def apply(fieldName: String)(effect: (Query, Path, Env) => F[Result[(Query, Cursor)]])(implicit pos: SourcePos, di: DummyImplicit): RootEffect` | +| `computeUnit` | `def computeUnit(fieldName: String)(effect: Env => F[Result[Unit]])(implicit pos: SourcePos): RootEffect` | +| `computeCursor` | `def computeCursor(fieldName: String)(effect: (Path, Env) => F[Result[Cursor]])(implicit pos: SourcePos): RootEffect` | +| `computeChild` | `def computeChild(fieldName: String)(effect: (Query, Path, Env) => F[Result[Query]])(implicit pos: SourcePos): RootEffect` | + +Factory semantics: + +- **`apply`** — full control: perform the effect and return both an (effect-specific) query and its + corresponding root cursor. +- **`computeUnit`** — run a side effect only (e.g. a mutation write); the elaborated client query and the + mapping's default root cursor are left unchanged. The only channel for passing results downstream is the + `Env` attached to the returned cursor (the factory calls `qc.map(_.withEnv(env))`). +- **`computeCursor`** — run the effect and yield a custom root `Cursor`; the elaborated query is used + unchanged. This is the form used by `ValueMapping`/`CirceMapping` (via `valueCursor`/`circeCursor`). +- **`computeChild`** — run the effect to compute a replacement *child* query, which is then executed + against the mapping's default root cursor (e.g. to inject a `Filter`/`Limit` derived from the effect). + +`toRootStream` lifts a `RootEffect` into a single-element `RootStream` (`Stream.eval(effect(...))`) so a +mutation can be served through the subscription path. + +## `RootStream` + +The streaming analogue of `RootEffect`, used for **subscriptions**. The effect returns +`Stream[F, Result[(Query, Cursor)]]`, emitting one result per stream element. It is **only** valid in a +subscription: for a normal query/mutation `runOneShot` detects a reachable `RootStream` and returns +`Result.internalError("RootStream only permitted in subscriptions")`. + +| Member | Signature | +| --- | --- | +| type | `case class RootStream private (fieldName: String, effect: (Query, Path, Env) => Stream[F, Result[(Query, Cursor)]]) extends EffectMapping` | +| `hidden` | `def hidden = false` | +| `apply` | `def apply(fieldName: String)(effect: (Query, Path, Env) => Stream[F, Result[(Query, Cursor)]])(implicit pos: SourcePos, di: DummyImplicit): RootStream` | +| `computeCursor` | `def computeCursor(fieldName: String)(effect: (Path, Env) => Stream[F, Result[Cursor]])(implicit pos: SourcePos): RootStream` | +| `computeChild` | `def computeChild(fieldName: String)(effect: (Query, Path, Env) => Stream[F, Result[Query]])(implicit pos: SourcePos): RootStream` | + +`RootStream` has no `computeUnit` (a unit effect would emit no values); use `computeCursor` or +`computeChild`. There is no built-in websocket/`graphql-ws` transport — a subscription is just an +`fs2.Stream` you wire to a transport of your choosing. + +### Worked example: `get` / `put` / `watch` on one `ValueMapping` + +The subscription test mapping uses all three root constructors side by side — `RootEffect.computeCursor` +for the `get` query field, another for the `put` mutation field, and `RootStream.computeCursor` for the +`watch` subscription field, all backed by a single `SignallingRef[IO, Int]`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/subscription/SubscriptionSuite.scala", "#subscription_mapping")) +``` + +Each factory builds its root cursor with `valueCursor(path, env, n)`. The `put` field reads its argument +from the `Env` (populated by the `SelectElaborator`), runs `ref.set(n)`, and yields a cursor over the new +value; `watch` turns `ref.discrete` into a stream that emits a cursor per change. + +## circe extensions: `computeJson` / `computeEncodable` + +`grackle-circe` adds two convenience constructors to each of `RootEffect` and `RootStream`, via the +implicit classes `CirceMappingRootEffectSyntax` / `CirceMappingRootStreamSyntax` (in +`modules/circe/src/main/scala/circemapping.scala`). They are in scope inside any `CirceMapping`/`SqlMapping` +body. Each is layered on `computeCursor` and builds the cursor for you with `circeCursor`. + +| Constructor | Signature | +| --- | --- | +| `RootEffect.computeJson` | `def computeJson(fieldName: String)(effect: (Path, Env) => F[Result[Json]])(implicit pos: SourcePos): RootEffect` | +| `RootEffect.computeEncodable` | `def computeEncodable[A](fieldName: String)(effect: (Path, Env) => F[Result[A]])(implicit pos: SourcePos, enc: Encoder[A]): RootEffect` | +| `RootStream.computeJson` | `def computeJson(fieldName: String)(effect: (Path, Env) => Stream[F, Result[Json]])(implicit pos: SourcePos): RootStream` | +| `RootStream.computeEncodable` | `def computeEncodable[A](fieldName: String)(effect: (Path, Env) => Stream[F, Result[A]])(implicit pos: SourcePos, enc: Encoder[A]): RootStream` | + +- **`computeJson`** — return raw `io.circe.Json`; the mapping wraps it in a `circeCursor`. +- **`computeEncodable`** — return any `A` with an `Encoder[A]` in scope; it is encoded to `Json` and then + handled as `computeJson`. + +The circe effects test mapping shows `computeCursor` (field-focussed and root-focussed), `computeJson`, +and `computeEncodable` together: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/circe/src/test/scala/CirceEffectData.scala", "#circe_effects")) +``` + +Independent root fields are **not** batched: a query selecting `foo`, `bar`, and `baz` runs three separate +root effects (the counter ends at 3). Note also that `qux` focuses its cursor on the root with +`Path.from(p.rootTpe)` and nests the field name inside the `Json`, whereas `foo` builds a field-focussed +cursor over the bare struct — choosing the wrong focus produces a shape mismatch against the schema. + +## `EffectField` and `EffectHandler` + +`EffectField` is a `FieldMapping` for a **nested** field. It names an `EffectHandler[F]` and an optional +list of `required` sibling columns that must be present in the parent query so the handler can read them +off the parent `Cursor`. + +| Type | File | Signature | +| --- | --- | --- | +| `EffectField` | `modules/core/src/main/scala/mapping.scala` | `case class EffectField(fieldName: String, handler: EffectHandler[F], required: List[String] = Nil, hidden: Boolean = false)(implicit val pos: SourcePos) extends EffectMapping` | +| `EffectHandler` | `modules/core/src/main/scala/query.scala` | `trait EffectHandler[F[_]] { def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] }` | + +`EffectField` parameters: + +- **`fieldName`** — the nested field this effect backs. +- **`handler`** — the `EffectHandler` invoked once per stage with the full batch for this field. +- **`required`** — sibling fields/columns to fetch in the parent so the handler can read them via + `parentCursor.fieldAs[T](name)`. In `SqlMapping` this drives `columnsForLeaf`, adding the columns to the + parent `SELECT`. Omitting a needed column makes the corresponding `fieldAs` fail at runtime. +- **`hidden`** — whether the field is suppressed from introspection. + +### `runEffects` contract and the one-cursor-per-input rule + +`runEffects` receives one `(continuation-query, parent-cursor)` pair per **occurrence** of the field across +the whole result, and must return **exactly one continuation `Cursor` per input pair, in the same order**. +The interpreter pairs the returned cursors back against the inputs by position (`(conts, cs).parMapN`) and +scatters the resulting JSON into the original tree; a mismatched length or order silently corrupts the +result. Handlers that group/batch internally must restore the original order (the SQL examples capture +indices and `sortBy(_._2)`). + +The `Query` handed to the handler is the **continuation child** (the selection set under the effect field), +not the original field `Select`. The interpreter runs `Query.extractChild` on each input and raises +`"Continuation query has the wrong shape"` if the returned cursor's query is not a single `Select`-shaped +child. + +A minimal handler that batches a service call over a nested SQL field, reading the `required` `code2` +column off each parent cursor and making a single `currencyService.get(distinctCodes)` call for the whole +batch: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala", "#currency_handler")) +``` + +The matching `ObjectMapping` declares the field with `EffectField("currencies", CurrencyQueryHandler, List("code2"))`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala", "#effect_typemappings")) +``` + +> **Batch key is `(mapping, handler)` identity.** To collapse all occurrences into one `runEffects` call you +> must reuse the *same* handler object instance across them (e.g. `object CurrencyQueryHandler extends EffectHandler[F]`). +> Constructing a fresh handler per field or per row splits the batch and reintroduces the N+1. + +## Supporting helpers + +| Helper | File | Signature | Role | +| --- | --- | --- | --- | +| `Query.childContext` | `modules/core/src/main/scala/query.scala` | `def childContext(c: Context, query: Query): Result[Context]` | Derives the GraphQL [`Context`](context-env.md) for the continuation query a handler must produce (e.g. to build a `CirceCursor` child). | +| `Query.extractChild` | `modules/core/src/main/scala/query.scala` | `def extractChild(query: Query): Option[Query]` | Pulls the inner query out of a `Select`/`Environment`; the interpreter uses it to get the continuation each returned cursor is run against. | +| `Mapping.combineAndRun` | `modules/core/src/main/scala/mapping.scala` | `def combineAndRun(queries: List[(Query, Cursor)]): F[Result[List[ProtoJson]]]` | Default batch runner for *component*-delegated deferrals; overridable (e.g. SQL mappings combine queries). `EffectHandler`-based deferrals bypass this and call `handler.runEffects` directly. | + +## Where effects run + +- **Compilation.** `EffectElaborator` (added by `Mapping.compilerPhases` after the select and component + elaborators) rewrites `Select(fieldName, resultName, child)` into + `Select(fieldName, resultName, Effect(handler, Select(fieldName, resultName, child)))` for every field that + has an `EffectField` mapping — the `Effect` node wraps the field's own (elaborated) `Select`, not just its + bare child, which is why the handler later recovers the continuation with `Query.extractChild`. The + interpreter then defers that field instead of evaluating it inline. + +- **Roots.** `QueryInterpreter.runOneShot` partitions the ungrouped root queries into effectful (those with + a `RootEffect`) and pure. Each effectful root runs its `RootEffect` first, then `runValue` is applied to + the returned `(query, cursor)`. If any reachable root is a `RootStream` outside a subscription, it + returns `Result.internalError("RootStream only permitted in subscriptions")`. + +- **Nested fields.** Interpreting `Effect(handler, cont)` produces a deferred `ProtoJson.effect(mapping, handler, query, cursor)` + placeholder (an `EffectJson`) rather than evaluating inline. At the end of the stage, `completeAll` + gathers every deferred placeholder across the `ProtoJson` tree and groups them with + `.groupMap(ej => (ej.mapping, ej.handler))(identity)`. Each group becomes exactly one + `handler.runEffects` call carrying the whole batch — this is where N+1 collapses into a single call. The + continuation child of each input is then run against its returned cursor, and the JSON is scattered back + into the placeholders by identity. + +Each distinct nested effect field therefore costs **one extra interpreter stage**, but every occurrence of +that field shares the stage. Doubly-nested effects (an effect field inside another effect field's subtree) +run in successive stages, each handler's continuation being interpreted as a fresh sub-run. Note that +`runEffects` returning `Result.failure`/`Warning` surfaces as `Problem`s in the response `errors`, whereas +`Result.internalError` is raised into `F` and never appears in the JSON `errors` array — see +[`Result` and `Problem`](result-problem.md). + +## See also + +- [How effects and batching work](../concepts/effects-batching.md) — the mechanism and rationale behind deferred, batched effects. +- [Effects and batching how-to](../how-to/effects-batching.md) — recipes for root effects and nested `EffectHandler` batching. +- [Mutations and subscriptions tutorial](../tutorial/mutations-subscriptions.md) — `RootEffect`/`RootStream` end to end. +- [Cursor reference](cursor.md) and [Context & Env reference](context-env.md) — what a handler reads from and builds. +- [Query algebra reference](query-algebra.md) — the `Effect` node among the other algebra nodes and elaboration phases. +- [SQL mapping reference](sql-mapping.md) and [circe mapping reference](circe-mapping.md) — the backends that supply `computeJson`/`computeEncodable` and `combineAndRun` overrides. diff --git a/docs/reference/elab-phases.md b/docs/reference/elab-phases.md new file mode 100644 index 00000000..de11c17d --- /dev/null +++ b/docs/reference/elab-phases.md @@ -0,0 +1,239 @@ +# Elab monad & compiler phases reference + +This page is the API reference for the elaboration layer of the [query compiler](../concepts/compiler-elaboration.md): the `Elab` monad and its combinators, the `Phase` trait, the `SelectElaborator` you override on a `Mapping`, and the built-in phases the compiler runs. It is for developers writing custom elaborators or phases. For the `Query` ADT these phases rewrite, see the [query algebra reference](query-algebra.md); for the prose walkthrough of how a query becomes an `Operation`, see [compiler & elaboration](../concepts/compiler-elaboration.md). All types here live in `object QueryCompiler` (`modules/core/src/main/scala/compiler.scala`); import them with `import grackle.QueryCompiler._`. + +## The `Elab` monad + +```scala +type Elab[T] = StateT[Result, ElabState, T] +``` + +`Elab` is `cats.data.StateT` over [`Result`](result-problem.md), threading an `ElabState` through the traversal of a query and accumulating GraphQL warnings/errors via `Result`. A phase's `transform` walks the [`Query`](query-algebra.md) tree inside `Elab`; elaborators almost never touch `ElabState` directly — they compose the combinators on the `Elab` object below. + +### `ElabState` + +`ElabState` is the state threaded by `Elab`. It is copied (not mutated) as the traversal descends, with `push`/`pop` saving and restoring it across child selections. + +| Field | Type | Holds | +| --- | --- | --- | +| `parent` | `Option[ElabState]` | The saved state of the enclosing node (set by `push`, restored by `pop`). | +| `schema` | `Schema` | The schema the query is compiled against. | +| `context` | `Context` | The [`Context`](context-env.md) (schema position / `TypeRef`) of the node being elaborated. | +| `vars` | `Vars` | Resolved query variables, as `Map[String, (Type, Value)]`. | +| `fragments` | `Map[String, UntypedFragment]` | The query's fragment definitions, by name. | +| `query` | `Query` | The query at the current node (used by `hasField`/`hasSibling`/`resultName`). | +| `localEnv` | `Env` | The [`Env`](context-env.md) bound *at this node* via `Elab.env`; materialised as an `Environment` node. | +| `attributes` | `List[(String, Query)]` | Synthetic attribute selects added via `Elab.addAttribute`. | +| `childTransform` | `Query => Elab[Query]` | The pending transform applied to the *elaborated* child (set by `Elab.transformChild`). | + +`push` resets `localEnv`, `attributes`, and `childTransform` for the new node and chains the old state onto `parent`; `pop` fails with `"Cannot pop root state"` if there is no parent. + +### Reader combinators + +These inspect `ElabState` without changing it. + +| Combinator | Type | Returns | +| --- | --- | --- | +| `Elab.schema` | `Elab[Schema]` | The schema being elaborated. | +| `Elab.context` | `Elab[Context]` | The current node's `Context`. | +| `Elab.vars` | `Elab[Vars]` | The resolved variables map. | +| `Elab.fragments` | `Elab[Map[String, UntypedFragment]]` | All fragment definitions. | +| `Elab.fragment(nme: String)` | `Elab[UntypedFragment]` | The named fragment, **failing** with `"Fragment '$nme' is not defined"` if absent. | +| `Elab.hasField(name: String)` | `Elab[Boolean]` | `true` if the current node has a child selection named `name`. | +| `Elab.hasSibling(name: String)` | `Elab[Boolean]` | `true` if the *parent* has a child named `name` (i.e. a sibling of this node). | +| `Elab.fieldAlias(name: String)` | `Elab[Option[String]]` | The alias, if any, of the child named `name`. | +| `Elab.resultName` | `Elab[Option[String]]` | The result name (alias, else field name) of the current node. | +| `Elab.localEnv` | `Elab[Env]` | The env bound directly at this node (not inherited). | +| `Elab.env[T: ClassTag](nme: String)` | `Elab[Option[T]]` | The env value bound to `nme`, searching this node then ancestors; `None` if absent or the stored value is not a `T`. | +| `Elab.envE[T: ClassTag: TypeName](nme: String)` | `Elab[T]` | As `env`, but **fails** with a descriptive message if the key is missing or mistyped. | +| `Elab.attributes` | `Elab[List[(String, Query)]]` | The synthetic attributes added at this node. | +| `Elab.transform` | `Elab[Query => Elab[Query]]` | The accumulated `childTransform` to apply to the child. | + +`hasSibling` reads the parent state's query — a different mechanism from `localEnv`, which is local to the node and not visible to siblings. + +### Writer combinators + +These return `Elab[Unit]` (except where noted) and modify `ElabState`. + +| Combinator | Effect | +| --- | --- | +| `Elab.transformChild(f: Query => Elab[Query])` | Captures `f` in `childTransform`; applied to the *already-elaborated* child in `SelectElaborator.transform`. This is how field arguments become algebra. | +| `Elab.transformChild(f: Query => Query)` | Pure overload (resolved via a `DummyImplicit`). | +| `Elab.transformChild(f: Query => Result[Query])` | `Result`-returning overload. | +| `Elab.env(nme: String, value: Any)` | Bind one name/value in the node's local env. | +| `Elab.env(kv, kvs*)` / `Elab.env(kvs: Seq[(String, Any)])` | Bind several name/value pairs. | +| `Elab.env(other: Env)` | Merge an entire `Env` into the local env. | +| `Elab.addAttribute(name: String, query: Query = Empty)` | Record a synthetic field merged into the elaborated `Select`; the client never asked for it but a `CursorField` can read it. | +| `Elab.push` / `Elab.push(context, query)` / `Elab.push(schema, context, query)` | Save the current state and descend into a child (resetting local env/attributes/childTransform). | +| `Elab.pop` | Restore the parent state. | + +`transformChild` calls **compose** rather than replace: `ElabState.addChildTransform` chains each via `childTransform.andThen(_.flatMap(f))`, so two cases that both call `transformChild` on the same node both run, in order. `Elab.env` writes only to the *current* node's local env — it is materialised as an `Environment(env, select)` wrapper and is visible to that node's children/cursor, **not** to siblings. + +### Lifting & error reporting + +| Combinator | Type | Effect | +| --- | --- | --- | +| `Elab.unit` | `Elab[Unit]` | Pure `()` — the no-op an elaborator returns to leave a field's arguments un-eliminated. | +| `Elab.pure[T](t: T)` | `Elab[T]` | Lift a pure value. | +| `Elab.liftR[T](rt: Result[T])` | `Elab[T]` | Lift a `Result` into `Elab`, threading state unchanged. | +| `Elab.warning(msg: String)` / `Elab.warning(err: Problem)` | `Elab[Unit]` | Record a GraphQL warning; elaboration continues. Surfaces in the response `errors` array. | +| `Elab.failure[T](msg: String)` / `Elab.failure[T](err: Problem)` | `Elab[T]` | Record a GraphQL error (a [`Problem`](result-problem.md)); the field fails to compile. Surfaces in `errors`. | +| `Elab.internalError[T](msg: String)` / `Elab.internalError[T](err: Throwable)` | `Elab[T]` | Raise an internal error. This is **not** a GraphQL `Problem`; it propagates into the effect `F` and does **not** appear in the response `errors` array. | + +Reach for `failure`/`warning` for malformed queries the client should see, and `internalError` only for impossible-state bugs. + +## The `Phase` trait + +A `Phase` rewrites a `Query`. The compiler folds a list of phases over the parsed query (see [the pipeline](#the-phase-pipeline) below). + +```scala +trait Phase { + def transformFragments: Elab[Unit] = Elab.unit + def transform(query: Query): Elab[Query] + def transformSelect(fieldName: String, alias: Option[String], child: Query): Elab[Query] + def validateSubselection(fieldName: String, child: Query): Elab[Unit] +} +``` + +| Member | Default behaviour | +| --- | --- | +| `transformFragments` | No-op. Override to rewrite fragment definitions before the body (e.g. `IntrospectionElaborator`). | +| `transform(query)` | The recursive walk. It pattern-matches every `Query` node, `push`es the child context, recurses, and `pop`s — for `Unique`/`Filter`/`Count`/`Narrow`/fragments/`Group` etc. it descends with the correctly-narrowed `Context`. Override the cases you care about and call `super.transform(query)` for the rest. | +| `transformSelect(fieldName, alias, child)` | Validates the subselection, computes the child `Context` via `c.forField(fieldName, alias)`, then recurses into `child`. Used by the default `transform` for `Select`/`UntypedSelect`. | +| `validateSubselection(fieldName, child)` | Enforces leaf rules (see below). | + +`validateSubselection` rejects mismatched selection sets: a leaf field with a non-empty `{ ... }` fails with `"Leaf field 'x' of T must have an empty subselection set"`, and a non-leaf field with an empty selection fails with `"Non-leaf field 'x' of T must have a non-empty subselection set"`. + +When you subclass `Phase` directly, override only the node cases you handle. The standard idiom matches `UntypedSelect` (or `Select`), does your work, and delegates everything else to `super.transform`, so the base traversal keeps the `Context` correct as it descends. + +## `SelectElaborator` + +`SelectElaborator` is the phase you supply on a `Mapping` to give field arguments meaning. It extends `Phase`, overriding `transform` to fire on `UntypedSelect` nodes; everything else falls through to `super.transform`. + +```scala +trait SelectElaborator extends Phase { + def select(ref: TypeRef, name: String, args: List[Binding], directives: List[Directive]): Elab[Unit] + def elaborateFieldArgs(tpe: NamedType, field: Field, args: List[Binding]): Result[List[Binding]] +} + +object SelectElaborator { + def apply(sel: PartialFunction[(TypeRef, String, List[Binding]), Elab[Unit]]): SelectElaborator + def identity: SelectElaborator +} +``` + +For each `UntypedSelect`, `SelectElaborator.transform`: + +1. Resolves the field on the schema and runs `elaborateFieldArgs`, which **types and normalises** the arguments: validates/types enums, coerces `String`/`Int` to `ID` where the schema says so, fills defaults for missing arguments, and **permutes arguments into schema-declared order**. Unknown argument names fail with `"Unknown argument(s) 'quux' in field character of type Query"`. +2. Calls `select(ref, name, eArgs, dirs)` (or, for introspection fields, `elaborateIntrospection`) to consume the normalised arguments. +3. Recurses into the child, then applies the accumulated `childTransform` to it, wraps the result in `Select(name, alias, child')`, merges any `addAttribute` selects, and — if `localEnv` is non-empty — wraps the whole thing in an `Environment` node. + +Because args are normalised first, `select` cases match the **canonical** order, not source order: `List(Binding("offset", IntValue(o)), Binding("limit", IntValue(l)))` assumes schema order. A case that does not match leaves the field's arguments un-eliminated. Optional arguments arrive as `Value.AbsentValue` (distinct from `Value.NullValue`); match `Binding("namePattern", AbsentValue)` separately or the elaborator silently no-ops. + +| Member | Meaning | +| --- | --- | +| `SelectElaborator(sel)` | Factory: builds a `SelectElaborator` whose `select` runs `sel` when `sel.isDefinedAt((ref, name, args))`, else `Elab.unit`. The common path. | +| `SelectElaborator.identity` | A `SelectElaborator` that discards all arguments (`select` always returns `Elab.unit`). | +| `select(ref, name, args, directives)` | The low-level hook. Override directly (instead of the factory) when you need the field's `directives`, or to wrap `transform`. | +| `elaborateFieldArgs(tpe, field, args)` | The argument typing/normalisation step. Returns `Result[List[Binding]]`; rarely overridden. | + +### The factory form + +The canonical elaborator is a `PartialFunction[(TypeRef, String, List[Binding]), Elab[Unit]]` matched on the field's type ref, name, and normalised bindings, returning `Elab.transformChild(...)` to rewrite the child into interpretable algebra. The following matches `character(id: ...)` and rewrites its selection set into a `Unique` over a `Filter`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/CompilerSuite.scala", "#atomic_elaborator")) +``` + +`SelectElaborator { case (QueryType, "character", List(Binding("id", StringValue(id)))) => ... }` matches that one field. `Elab.transformChild(child => Unique(Filter(Eql(CharacterType / "id", Const(id)), child)))` takes the elaborated selection set (`child`) and wraps it: `Filter` keeps only `Character`s whose `id` equals the argument, and `Unique` asserts the result is a single element. After compilation the `UntypedSelect` with an `id` argument has become a directly-interpretable `Select(Unique(Filter(...)))` tree. The `Eql`/`Const`/`/` building blocks are [predicates and terms](predicates.md); for the full repertoire of `Filter`/`Limit`/`Offset`/`OrderBy`/`Count` recipes see [filtering, ordering & paging](../how-to/filtering-ordering-paging.md). + +### The low-level form + +To inspect a field's directives, or to run extra work around the standard `transform`, extend `SelectElaborator` directly: implement `select(ref, name, args, directives)` and override `transform`. The test helper below stashes each field's raw `args`/`directives` in the env via `select`, then in `transform` re-reads them with `Elab.envE` and substitutes them back, preserving the original arguments on the elaborated tree: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/PreserveArgsElaborator.scala", "#preserve_args")) +``` + +Note `select` returns `Elab.env("preserved", Preserved(args, directives))` — storing the arguments rather than eliminating them — and `transform` calls `super.transform(query)` to run the normal elaboration before `Elab.envE[Preserved]("preserved")` retrieves them (failing if absent). This is the pattern for a phase that needs both the field arguments *and* the elaborated subtree. For directive-driven phases written as a plain `Phase` (not a `SelectElaborator`), see [query directives](../how-to/query-directives.md). + +### `IntrospectionLevel` + +`SelectElaborator` also elaborates introspection fields, gated by the `introspectionLevel` passed to `QueryCompiler.compile`. + +```scala +sealed trait IntrospectionLevel +object IntrospectionLevel { + case object Full extends IntrospectionLevel // __schema, __type, __typename allowed + case object TypenameOnly extends IntrospectionLevel // only __typename allowed + case object Disabled extends IntrospectionLevel // no introspection +} +``` + +`IntrospectionElaborator(level: IntrospectionLevel)` returns `Option[IntrospectionElaborator]` — `None` for `Disabled`. With `TypenameOnly` or `Disabled`, a `__schema`/`__type` query fails with `"Introspection is disabled"`. + +## Built-in phases + +The compiler always runs three built-in phases ahead of the mapping's phases; a `Mapping` adds three more by default. + +### The phase pipeline + +`compileOperation` assembles the phase list as: + +```text +IntrospectionElaborator(level)? :: VariablesSkipAndFragmentElaborator :: MergeFields :: +``` + +where `IntrospectionElaborator` is omitted when introspection is `Disabled`, and `mapping.compilerPhases` defaults to: + +```scala +List(selectElaborator, componentElaborator, effectElaborator) +``` + +Each phase runs `phase.transformFragments *> phase.transform(acc)`, folded left over the query. Order matters: variables are substituted, `@skip`/`@include` resolved, and fragments expanded *before* your `SelectElaborator` sees a clean, typed `Select` tree — it never meets a raw fragment spread or variable reference. Equally, `SelectElaborator` (which fires on `UntypedSelect`) must run before `ComponentElaborator`/`EffectElaborator` (which fire on already-typed `Select`), which is why it leads the default `compilerPhases`. + +### Phase reference + +| Phase | Constructor | Fires on | What it does | +| --- | --- | --- | --- | +| `IntrospectionElaborator` | `IntrospectionElaborator(level): Option[IntrospectionElaborator]` | `UntypedSelect("__typename" \| "__schema" \| "__type")` | Wraps introspection fields in an `Introspect(schema, ...)` node, or fails per `IntrospectionLevel`. Also elaborates fragments via `transformFragments`. | +| `VariablesSkipAndFragmentElaborator` | `object` (no args) | every node | Substitutes variable values into `Binding`s, drops `@skip`/`@include`-guarded subtrees, expands fragment spreads and inline fragments (introducing `Narrow` for type conditions), and re-runs `validateSubselection`. | +| `MergeFields` | `object` (no args) | the whole query | Merges duplicate sibling selections via `mergeUntypedQueries`, keying on `(name, alias)` and recursively merging children. | +| `SelectElaborator` | `SelectElaborator(pf)` / `.identity` | `UntypedSelect` | Types/normalises arguments and rewrites `UntypedSelect` into interpretable `Select`/`Filter`/`Unique`/… (above). | +| `ComponentElaborator` | `ComponentElaborator(mappings: Seq[ComponentMapping[F]])` | `Select` | At each `(type, field)` boundary registered in `mappings`, replaces the `Select` with a `Component(mapping, join, …)` node delegating that subtree to another mapping. See [composition](../concepts/composition.md). | +| `EffectElaborator` | `EffectElaborator(effects: (Context, String) => Option[EffectHandler[F]])` | `Select` | Wraps a `Select` whose `(context, field)` has an effect handler in an `Effect(handler, …)` node. See [effects & batching](../concepts/effects-batching.md). | +| `QuerySizeValidator` | `new QuerySizeValidator(maxDepth: Int, maxWidth: Int)` | the whole query | Fails compilation if the query exceeds `maxDepth` levels or `maxWidth` leaves. **Not** on by default — add it to `compilerPhases` yourself (e.g. `super.compilerPhases :+ querySizeValidator`). | + +### `ComponentElaborator` companion + +```scala +object ComponentElaborator { + val TrivialJoin = (q: Query, _: Cursor) => q.success + case class ComponentMapping[F[_]]( + tpe: TypeRef, fieldName: String, mapping: Mapping[F], + join: (Query, Cursor) => Result[Query] = TrivialJoin) + def apply[F[_]](mappings: Seq[ComponentMapping[F]]): ComponentElaborator[F] +} +``` + +`join` computes the continuation query for the delegated mapping from the current `Cursor`; `TrivialJoin` passes the subquery through unchanged. + +### `EffectElaborator` companion + +```scala +object EffectElaborator { + case class EffectMapping[F[_]](tpe: TypeRef, fieldName: String, handler: EffectHandler[F]) + def apply[F[_]](effects: (Context, String) => Option[EffectHandler[F]]): EffectElaborator[F] +} +``` + +The `EffectHandler[F]` an `Effect` node carries has the shape `def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]]`, run by the [interpreter](../concepts/query-interpreter.md) in a later phase; see [effects & batching](effects.md). + +## See also + +- [The query algebra reference](query-algebra.md) — the `Query` ADT (`Select`, `Filter`, `Unique`, `Limit`, `Count`, …) these phases produce, plus structural helpers like `FilterOrderByOffsetLimit`. +- [Compiler & elaboration (concept)](../concepts/compiler-elaboration.md) — the narrative of how a query string becomes an `Operation`. +- [Filter, sort and page a field (how-to)](../how-to/filtering-ordering-paging.md) — task-oriented recipes for writing a `SelectElaborator` that turns arguments into `Filter`/`OrderBy`/`Limit`/`Offset`/`Count`. +- [Predicates, terms & filtering](predicates.md) — the `Predicate`/`Term` vocabulary used inside `Filter`/`OrderBy`. +- [Context & Env reference](context-env.md) — the `Context` and `Env` types threaded through `ElabState`. +- [Result & Problem reference](result-problem.md) — the `Result` effect and `Problem` values `Elab.failure`/`warning` produce. diff --git a/docs/reference/filtering-paging-nodes.md b/docs/reference/filtering-paging-nodes.md new file mode 100644 index 00000000..c7f7bf6a --- /dev/null +++ b/docs/reference/filtering-paging-nodes.md @@ -0,0 +1,105 @@ +# Filtering & paging query nodes reference + +This page is the reference for the `Query` nodes that filter, sort, page, and count a list-producing child: `Filter`, `OrderBy` (with `OrderSelections` / `OrderSelection`), `Offset`, `Limit`, `Count`, and `TransformCursor`, plus the `FilterOrderByOffsetLimit` assembler and its fixed nesting order. All of these live in `object grackle.Query` (module `grackle-core`, `modules/core/src/main/scala/query.scala`); bring them into scope with `import grackle.Query._`. It is aimed at developers assembling paging stacks inside a `SelectElaborator`. For the predicate leaves a `Filter` holds (`Eql`, `Lt`, `In`, `Contains`, …) and the `Term` / `Path` you sort by, see the [predicates reference](predicates.md); for the rest of the `Query` algebra see the [query algebra reference](query-algebra.md); for a task-oriented walkthrough see [how to filter, order and page](../how-to/filtering-ordering-paging.md). + +These nodes wrap a `child` query and compose by nesting. Each is interpretable: the in-memory interpreter evaluates them against a [`Cursor`](cursor.md), while the SQL backend compiles them to `WHERE` / `ORDER BY` / `LIMIT` / `OFFSET` / `COUNT` clauses. None of them is a ready-made Relay connection — there is no built-in `edges` / `pageInfo` / `endCursor` type in Grackle, so counted and cursor-style paging are assembled by hand from the primitives below (see [`FilterOrderByOffsetLimit`](#filterorderbyoffsetlimit) and [`TransformCursor`](#transformcursor)). + +## `Filter` + +`Filter` retains only the elements of `child` that satisfy a [`Predicate`](predicates.md), then continues with `child`. + +| Node | Signature | Semantics | +| --- | --- | --- | +| `Filter` | `case class Filter(pred: Predicate, child: Query) extends Query` | Keeps every element of the list-producing `child` for which `pred` evaluates to `true`. | + +`pred` is a `Predicate` (a `Term[Boolean]`), so it can be any leaf or combination from the [predicates reference](predicates.md) — for example `Filter(Eql(CountryType / "code", Const(code)), child)` for an exact match, or `Filter(In(CityType / "language", languages), child)` for set membership. Wrapping a `Filter` in `Unique` turns a list filter into a single-result lookup. The filter is applied during [elaboration](../concepts/compiler-elaboration.md) inside a `SelectElaborator`, typically via `Elab.transformChild`. + +## `OrderBy`, `OrderSelections`, `OrderSelection` + +Ordering is a single `Query` node, `OrderBy`, carrying an `OrderSelections` list of sort keys. Each key is an `OrderSelection` over a `Term[T]`. + +| Type | Signature | Purpose | +| --- | --- | --- | +| `OrderBy` | `case class OrderBy(selections: OrderSelections, child: Query) extends Query` | Orders the list-producing `child` by `selections`. | +| `OrderSelections` | `case class OrderSelections(selections: List[OrderSelection[_]])` | An ordered list of sort keys, applied in priority order. Its `def order(lc: Seq[Cursor]): Seq[Cursor]` sorts an in-memory `Seq[Cursor]`, comparing by each key until one breaks the tie. | +| `OrderSelection` | `case class OrderSelection[T: Order](term: Term[T], ascending: Boolean = true, nullsLast: Boolean = true)` | One sort key over a `Term[T]`. Requires an implicit `Order[T]`. | + +An `OrderSelection` declares two members worth noting: + +| Member | Signature | Meaning | +| --- | --- | --- | +| `apply` | `def apply(x: Cursor, y: Cursor): Int` | Compares two cursors by this key, honouring `ascending` and `nullsLast`. | +| `subst` | `def subst(term: Term[T]): OrderSelection[T]` | Returns a copy with a different `term` (same direction and null placement). | + +The two flags control direction and null placement: + +- `ascending` (default `true`) — `true` sorts low-to-high, `false` reverses it. +- `nullsLast` (default `true`) — `true` places nulls after non-null values; `false` places them first. SQL backends do **not** rely on this default: they pass `nullsLast = nullsHigh`, a per-dialect `Boolean` from `SqlMapping`, so null ordering matches the database. Hardcoding `true` can make in-memory and SQL results differ. + +`T` must match the field's mapped type, including optionality: order a nullable string field with `OrderSelection[Option[String]](ElemAType / "elemA")`, a non-null integer with `OrderSelection[Int](CountryType / "population")`. Forgetting the `Order[T]` instance, or parameterizing on the wrong static type, is a compile error. + +## `Offset` and `Limit` + +Offset and limit are two separate nodes; combine them for a page window. + +| Node | Signature | Semantics | +| --- | --- | --- | +| `Offset` | `case class Offset(num: Int, child: Query) extends Query` | Drops the first `num` elements of the list-producing `child`. | +| `Limit` | `case class Limit(num: Int, child: Query) extends Query` | Takes the first `num` elements of the list-producing `child`. | + +The `Query` ADT does **not** validate `num`. An `Offset(-1, …)` or `Limit(0, …)` is a well-formed node; rejecting out-of-range arguments is the elaborator's job. The example mappings reject `offset < 0` and `limit <= 0` with [`Result.failure`](result-problem.md) before constructing the node — see the [how-to](../how-to/filtering-ordering-paging.md) for that pattern. + +## `Count` + +`Count` replaces a list with the number of its top-level elements — the building block for a paging `total`. + +| Node | Signature | Semantics | +| --- | --- | --- | +| `Count` | `case class Count(child: Query) extends Query` | Computes the number of top-level elements of `child`. Compiles to SQL `COUNT`. | + +`Count` *replaces* the node it is built from rather than wrapping a selection, so you typically construct it directly in the elaborator, e.g. `Count(Select("items", Select(countAttr)))`. An accurate total needs a real backing count source — you cannot recover it from an already-`Limit`ed items list, which is why counted paging issues a separate `Count` (or maps `total` to a dedicated column) alongside the limited `items`. + +## `TransformCursor` + +`TransformCursor` post-processes the result cursor with an arbitrary function. It is the hook for cursor-style "has more" paging, where you overfetch one extra row and then trim it before the client sees it. + +| Node | Signature | Semantics | +| --- | --- | --- | +| `TransformCursor` | `case class TransformCursor(f: Cursor => Result[Cursor], child: Query) extends Query` | Computes a continuation [`Cursor`](cursor.md) from the current one via `f`, after `child` has produced it. | + +A typical use fetches `limit + 1` rows, then wraps the items query in a `TransformCursor` whose `f` drops the surplus row (using `ListTransformCursor` to rebuild the list cursor with one fewer element). The "extra row exists" fact is what `hasMore` is computed from; the extra row itself is hidden by the transform. If you select only `hasMore` and not `items`, the example mappings skip the overfetch and use a `Count` instead. This is hand-rolled cursor paging built on these primitives — not a Relay connection type, which Grackle does not provide. + +## `FilterOrderByOffsetLimit` + +`FilterOrderByOffsetLimit` is the idiomatic way to assemble the whole four-node stack from `Option`s, and to destructure it again. It is an `object` with a matching `apply`/`unapply`, not a `Query` node of its own. + +| Member | Signature | +| --- | --- | +| `apply` | `def apply(pred: Option[Predicate], oss: Option[List[OrderSelection[_]]], offset: Option[Int], limit: Option[Int], child: Query): Query` | +| `unapply` | `def unapply(q: Query): Option[(Option[Predicate], Option[List[OrderSelection[_]]], Option[Int], Option[Int], Query)]` | + +`apply` nests the nodes in a **fixed order** — `Filter` innermost, then `OrderBy`, then `Offset`, then `Limit` outermost — skipping any whose `Option` is `None`: + +```text +Limit( Offset( OrderBy( Filter( child ) ) ) ) + └─ limit └─ offset └─ oss └─ pred + (outermost) (innermost) +``` + +`unapply` peels that same stack back off in reverse, matching an outer `Limit`, then `Offset`, then `OrderBy`, then `Filter`, and returning `None` only if none of the four is present. Interpreters (notably the SQL backend) rely on this canonical shape to recognize a paging stack. **If you hand-nest the four nodes in a different order, `unapply` will not match and SQL compilation may not recognize the pattern** — prefer `FilterOrderByOffsetLimit` over building `Filter` / `OrderBy` / `Offset` / `Limit` by hand. + +The following snippet (from a counted-paging SQL mapping) shows `FilterOrderByOffsetLimit` used to build the `items` sub-query and `Count` used to build the `total`, with paging state threaded through the elaboration [environment](context-env.md): + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlPaging1Mapping.scala", "#paging1")) +``` + +The inner `PagingInfo.elabItems` calls `FilterOrderByOffsetLimit(None, Some(List(OrderSelection(orderTerm, nullsLast = nullsHigh))), Some(offset), Some(limit), child)` — no filter, one order key with the dialect's `nullsHigh`, and the page window — while `elabTotal` replaces the child with `Count(Select("items", Select(countAttr)))`. `setup` stashes a `PagingInfo` under `key` in the `Elab` environment with `Elab.env`, and the sibling `items` and `total` fields read it back with `Elab.envE` (the `elabItems` / `elabTotal` on `PagingConfig` delegate to the stashed `PagingInfo`). The `genOffset` / `genLimit` methods return the stashed `offset` / `limit` as `Result[Int]`; the mapping wires them into `CursorField`s on the paged object so those values are echoed to the client. This mapping targets a SQL backend, so the snippet is shown as source rather than compiled here. + +## See also + +- [Predicates & terms reference](predicates.md) — the `Predicate` / `Term` / `Path` leaves a `Filter` holds and the terms an `OrderSelection` sorts by. +- [Query algebra reference](query-algebra.md) — the full `Query` ADT these nodes belong to. +- [How to filter, order and page](../how-to/filtering-ordering-paging.md) — the task-oriented recipe that wires these nodes from GraphQL arguments. +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — how `Elab.transformChild` installs these nodes during compilation. +- [Cursor reference](cursor.md) — the focus that `TransformCursor` and `OrderSelection` operate on. diff --git a/docs/reference/generic-derivation.md b/docs/reference/generic-derivation.md new file mode 100644 index 00000000..103bb92b --- /dev/null +++ b/docs/reference/generic-derivation.md @@ -0,0 +1,187 @@ +# Generic derivation reference + +The generic backend (module `grackle-generic`, package `grackle.generic`) serves a GraphQL schema directly from ordinary Scala case classes and sealed traits. This page is the reference for its public surface: the `CursorBuilder[T]` typeclass and the implicit builders, the `derive*` entry points, the `ObjectCursorBuilder` combinators, and the `GenericMapping` wiring (`GenericField`, `genericCursor`). It is for developers already using generic derivation; for a task-oriented walkthrough see the [how-to](../how-to/generic-derivation.md). + +```text +libraryDependencies += "org.typelevel" %% "grackle-generic" % "@VERSION@" +``` + +Derivation is purely structural: there is no `@GraphQLField` or any other annotation. A case-class member name must match its GraphQL field name; to diverge, use `renameField` / `transformFieldNames` / `transformField`. The GraphQL `Type` is always passed explicitly to every `derive*` call — nothing is inferred from the Scala type name. + +## `CursorBuilder[T]` + +The core typeclass (`modules/generic/src/main/scala/CursorBuilder.scala`). It carries the GraphQL `tpe` it produces and a `build` method that constructs a [`Cursor`](cursor.md) over a value of `T`. + +| Member | Signature | +| --- | --- | +| `tpe` | `def tpe: Type` | +| `build` | `def build(context: Context, focus: T, parent: Option[Cursor] = None, env: Env = Env.empty): Result[Cursor]` | +| `contramap` | `final def contramap[A](f: A => T): CursorBuilder[A]` | + +Summon an in-scope instance with the companion's `apply`: + +| Member | Signature | +| --- | --- | +| `CursorBuilder.apply` | `def apply[T](implicit cb: CursorBuilder[T]): CursorBuilder[T]` | + +`contramap` adapts an existing builder to a wrapper type while keeping the same `tpe` — e.g. `CursorBuilder[String].contramap[Planet](_.toString)` for a single-field value class. `build` returns a `Result[Cursor]`; failures are carried as `Problem`s (see [`Result` and `Problem`](result-problem.md)). + +## Provided implicit builders + +These instances are resolved automatically when deriving products and coproducts, so each field type only needs a `CursorBuilder` in scope. Note the GraphQL `Type` each reports — several Scala types collapse onto a shared GraphQL type. + +| Scala type | Implicit | GraphQL `tpe` | +| --- | --- | --- | +| `String` | `stringCursorBuilder` | `StringType` | +| `Int` | `intCursorBuilder` | `IntType` | +| `Long` | `longCursorBuilder` | `IntType` | +| `Float` | `floatCursorBuilder` | `FloatType` | +| `Double` | `doubleCursorBuilder` | `FloatType` | +| `Boolean` | `booleanCursorBuilder` | `BooleanType` | +| `T <: Enumeration#Value` | `enumerationCursorBuilder` | `StringType` | +| `Option[T]` | `optionCursorBuiler` | `NullableType(elem.tpe)` | +| `List[T]` | `listCursorBuiler` | `ListType(elem.tpe)` | +| `T : Encoder` (fallback) | `leafCursorBuilder` | `StringType` | + +```scala +implicit def enumerationCursorBuilder[T <: Enumeration#Value]: CursorBuilder[T] +implicit def optionCursorBuiler[T](implicit elemBuilder: CursorBuilder[T]): CursorBuilder[Option[T]] +implicit def listCursorBuiler[T](implicit elemBuilder: CursorBuilder[T]): CursorBuilder[List[T]] +implicit def leafCursorBuilder[T](implicit encoder: Encoder[T]): CursorBuilder[T] +``` + +Reference notes: + +- `Long` reports `IntType` and both `Float`/`Double` report `FloatType`. GraphQL has no separate `Long`/`Double`, so they share `Int`/`Float`. +- An `Enumeration#Value` serialises via `.toString`. +- `optionCursorBuiler` and `listCursorBuiler` are spelled with the typo (missing `d`). If you ever name them directly rather than relying on implicit resolution, use the typo'd spelling. +- `leafCursorBuilder` is an implicit fallback for **any** `T` with a Circe `Encoder[T]`, and it reports `StringType`. This means a domain type with an `Encoder` in scope is silently treated as a `String` leaf even where you intended an object, and can shadow object derivation. Keep `Encoder` instances off types you derive as objects. + +## Leaf and enumeration constructors + +For custom scalars you build a leaf `CursorBuilder` explicitly and bind it to a schema scalar `Type`. + +| Constructor | Signature | Maps to | +| --- | --- | --- | +| `deriveLeafCursorBuilder` | `def deriveLeafCursorBuilder[T](tpe0: Type)(implicit encoder: Encoder[T]): CursorBuilder[T]` | leaf, `tpe0`, JSON via `encoder` | +| `deriveEnumerationCursorBuilder` | `def deriveEnumerationCursorBuilder[T <: Enumeration#Value](tpe0: Type): CursorBuilder[T]` | leaf, `tpe0`, `.toString` | + +The implicit `leafCursorBuilder` and `enumerationCursorBuilder` (above) are these same constructors fixed to `StringType`. Use the `derive*` forms when the leaf must report a named custom scalar instead of `StringType`, e.g. `CursorBuilder.deriveLeafCursorBuilder[Genre](GenreType)`. The leaf renders its JSON through the supplied `Encoder`. + +## `GenericMapping[F]` and `GenericMappingLike[F]` + +Your mapping object extends `GenericMapping[F]`; the derivation entry points and `GenericField` live in `GenericMappingLike[F]` (`modules/generic/src/main/scala/genericmapping.scala`). + +| Member | Signature | +| --- | --- | +| `GenericMapping[F]` | `abstract class GenericMapping[F[_]](implicit val M: MonadThrow[F]) extends Mapping[F] with GenericMappingLike[F]` | +| `genericCursor` | `def genericCursor[T](path: Path, env: Env, t: T)(implicit cb: => CursorBuilder[T]): Result[Cursor]` | + +`GenericMapping` requires an implicit `MonadThrow[F]` (the in-memory examples use `IO`). It is a full [`Mapping`](mapping-types.md), so you also supply a `schema` and `typeMappings`. `genericCursor` builds a `Cursor` for a value `t` at an arbitrary `path`: at the root (`path.isRoot`) it builds eagerly; otherwise it returns a `DeferredCursor` that defers construction until the surrounding context and parent are known. It is the value you return from a `RootEffect.computeCursor` to plug a generic value into an effectful root. + +## `semiauto` derivation + +The two entry points for product (object) and coproduct (interface/union) derivation, in the `semiauto` object inside `GenericMappingLike`. + +| Member | Signature | +| --- | --- | +| `deriveObjectCursorBuilder` | `final def deriveObjectCursorBuilder[T](tpe: Type)(implicit mkBuilder: => MkObjectCursorBuilder[T]): ObjectCursorBuilder[T]` | +| `deriveInterfaceCursorBuilder` | `final def deriveInterfaceCursorBuilder[T](tpe: Type)(implicit mkBuilder: => MkInterfaceCursorBuilder[T]): CursorBuilder[T]` | + +- `deriveObjectCursorBuilder[T](tpe)` derives an `ObjectCursorBuilder[T]` for a case class, mapping each member name to the `CursorBuilder` resolved for that member's type. `tpe` is the GraphQL object `Type` (a `schema.ref`). +- `deriveInterfaceCursorBuilder[T](tpe)` derives a `CursorBuilder[T]` for a sealed trait whose subtypes each already have a `CursorBuilder`. At runtime it selects the concrete branch and wraps it so the cursor reports the interface `tpe` but can `narrow` to the subtype. It backs GraphQL `interface` and union types. +- The `mkBuilder` evidence (`MkObjectCursorBuilder` / `MkInterfaceCursorBuilder`) is supplied by the scala-version-specific backend; you do not write it by hand. + +A mismatch between case-class fields and the schema type's fields is **not** caught at derivation — it surfaces at query time (e.g. `No field ...`). + +## `ObjectCursorBuilder[T]` combinators + +`deriveObjectCursorBuilder` returns an `ObjectCursorBuilder[T]`, a `CursorBuilder[T]` with three field-customisation combinators. Each returns a new `ObjectCursorBuilder[T]`, so they chain. + +| Combinator | Signature | Effect | +| --- | --- | --- | +| `renameField` | `def renameField(from: String, to: String): ObjectCursorBuilder[T]` | derive field `from` under GraphQL name `to` | +| `transformFieldNames` | `def transformFieldNames(f: String => String): ObjectCursorBuilder[T]` | rewrite every derived field name with `f` (e.g. snake_case) | +| `transformField` | `def transformField[U](fieldName: String)(f: T => Result[U])(implicit cb: => CursorBuilder[U]): ObjectCursorBuilder[T]` | replace the derived field with a computed `Result[U]` | + +`transformField` is the escape hatch when a field's Scala shape differs from its GraphQL shape (for example resolving an `Option[List[String]]` of ids to an `Option[List[Character]]` of nested objects). The replacement type `U` must itself have a `CursorBuilder[U]` in scope — that `implicit cb: => CursorBuilder[U]` parameter is summoned to build the replacement cursor. Because it lets a field be computed lazily, `transformField` is also how recursion between object types is broken (model the link as an id and resolve it here). + +## `GenericField` + +A `FieldMapping` that exposes a Scala value, plus its by-name `CursorBuilder`, at a schema field — typically on the root `Query` `ObjectMapping`. + +| Member | Signature | +| --- | --- | +| factory | `def GenericField[T](fieldName: String, t: T, hidden: Boolean = false)(implicit cb: => CursorBuilder[T], pos: SourcePos): GenericField[T]` | +| case class | `case class GenericField[T](fieldName: String, t: T, cb: () => CursorBuilder[T], hidden: Boolean)(implicit val pos: SourcePos) extends FieldMapping` | +| `subtree` | `def subtree: Boolean = true` | + +The factory is the normal entry point; the `CursorBuilder` is captured as a `() => CursorBuilder[T]` thunk so mutually-recursive builders can be defined as implicit `val`s without an initialization-order deadlock. `subtree` is `true`, and the field can be `hidden`. `GenericMapping` overrides `mkCursorForMappedField` to dispatch a `GenericField` to `cb().build(...)`. + +## Canonical example + +The Star Wars demo derives a sealed-trait interface and its two subtype objects, customises a field, and is the end-to-end reference for the API above (`demo/src/main/scala/demo/starwars/StarWarsMapping.scala`). + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/starwars/StarWarsMapping.scala", "#model_types")) +``` + +Reading it against the reference: + +- `Character` is a `sealed trait`; its `implicit val cursorBuilder` is a `deriveInterfaceCursorBuilder[Character](CharacterType)` — a coproduct mapped to the GraphQL interface type. +- `Human` and `Droid` are case classes; each `cursorBuilder` is a `deriveObjectCursorBuilder[…](…Type)` chained with `.transformField("friends")(resolveFriends)`. +- `resolveFriends` returns `Result[Option[List[Character]]]`: the model stores `friends` as `Option[List[String]]` (ids), but the schema declares `friends: [Character!]`. `transformField` bridges the two, and `CursorBuilder[Option[List[Character]]]` is summoned (via the `Option`/`List`/interface builders) to build the replacement. +- Every member name (`id`, `name`, `appearsIn`, `homePlanet`, `primaryFunction`) matches its GraphQL field name exactly — no annotations. + +The data is wired at the root with `GenericField`, e.g. `GenericField("hero", characters)`; the implicit `CursorBuilder[List[Character]]` is picked up automatically. + +## Traversing a built cursor + +The `Cursor` produced by a derived builder is navigated with the usual [`Cursor`](cursor.md) operations. These unit-level examples (`modules/generic/src/test/scala/DerivationSuite.scala`) show `build`, `field`, `asNullable`, `asList`, `asLeaf` and `narrow` on derived builders. + +Leaf builders produce leaf cursors whose `asLeaf` yields the encoded JSON: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_primitive")) +``` + +An object builder's cursor descends into fields; `Option`-typed fields are unwrapped with `asNullable`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_product")) +``` + +A `List`-typed field is unwrapped with `asList` after `asNullable`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_list")) +``` + +An interface cursor reports the interface type but `narrow`s to a concrete subtype, after which subtype-only fields (`homePlanet`) become reachable: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/generic/src/test/scala/DerivationSuite.scala", "#cursor_narrow")) +``` + +`narrow(subTpe)` only succeeds when `subTpe <:< interfaceTpe` and the concrete branch type `<:< subTpe`; narrowing to an unrelated type, or selecting a subtype-only field without a matching fragment, raises an `InternalError` (`cannot be narrowed`) into the effect `F` rather than appearing in the GraphQL `errors` array. + +## Scala 2 vs Scala 3 backends + +The two compiler versions use different derivation engines in separate source roots, but expose the **identical** public surface — `MkObjectCursorBuilder` / `MkInterfaceCursorBuilder` / `ObjectCursorBuilder` and every signature above are the same on both. + +| | Scala 2 | Scala 3 | +| --- | --- | --- | +| Source root | `modules/generic/src/main/scala-2/genericmapping2.scala` | `modules/generic/src/main/scala-3/genericmapping3.scala` | +| Engine | shapeless | shapeless3 | +| Object derivation | `Generic` / `LabelledGeneric` / `Keys` | `K0.ProductInstances` / `Labelling` | +| Interface derivation | `Coproduct` / `CLiftAll` | `K0.CoproductInstances` | + +These engine-specific implicits (`Generic.Aux`, `K0.ProductInstances`, …) are derivation internals — do not depend on them as public API. Write your code against `derive*`, `ObjectCursorBuilder`, `GenericField` and `genericCursor`, and it compiles unchanged on both versions. + +## See also + +- [Serve Scala ADTs with generic derivation](../how-to/generic-derivation.md) — task-oriented recipes for the API on this page. +- [Mappings and cursors](../concepts/mappings-cursors.md) — how a `CursorBuilder`'s `Cursor` fits the wider model. +- [`Cursor`](cursor.md) — the navigation operations (`field`, `asNullable`, `asList`, `narrow`) used above. +- [`Result` and `Problem`](result-problem.md) — the `Result[Cursor]` that `build` returns, and where errors surface. +- [Mapping types](mapping-types.md) — `GenericField` alongside the other `FieldMapping`s and `RootEffect`. diff --git a/docs/reference/mapping-types.md b/docs/reference/mapping-types.md new file mode 100644 index 00000000..62a4d8b0 --- /dev/null +++ b/docs/reference/mapping-types.md @@ -0,0 +1,198 @@ +# Mapping types reference + +This page is the full catalog of Grackle's mapping constructs: the `Mapping[F]` base, its query entry points, the two kinds of `TypeMapping` (`ObjectMapping` and `LeafMapping[T]`), every `FieldMapping` (`CursorField`, `ValueField`, `Delegate`, `EffectField`, `RootEffect`, `RootStream`, plus circe's `CirceField`/`CursorFieldJson`), the `MappingPredicate`s that select a type mapping, and the `TypeMappings` builders. It is a lookup reference for developers writing mapping constructors; for the *why* behind the design read [Mappings and cursors](../concepts/mappings-cursors.md), and for an end-to-end walkthrough see the [in-memory model tutorial](../tutorial/in-memory-model.md). All members below live in `modules/core/src/main/scala/mapping.scala` unless noted otherwise. + +## `Mapping[F]` + +`Mapping[F[_]]` is the abstract base that ties a GraphQL `Schema` to an underlying data source. A concrete mapping supplies three members; from them Grackle derives the compiler and interpreter. + +| Member | Type | Provided by you? | +| --- | --- | --- | +| `M` | `implicit val M: MonadThrow[F]` | yes (usually via the backend base class) | +| `schema` | `val schema: Schema` | yes | +| `typeMappings` | `val typeMappings: TypeMappings` | yes | +| `selectElaborator` | `val selectElaborator: SelectElaborator` | optional override (defaults to `SelectElaborator.identity`) | +| `compiler` | `lazy val` `QueryCompiler` | derived | +| `interpreter` | `QueryInterpreter[F]` | derived | +| `compilerPhases` | `def compilerPhases: List[QueryCompiler.Phase]` | derived (`List(selectElaborator, componentElaborator, effectElaborator)`) | + +Everything in this catalog — `ObjectMapping`, `LeafMapping`, `CursorField`, `RootEffect`, the `ValidationFailure` case classes — is a **path-dependent type nested inside `Mapping[F]`**. You reference these members through a concrete mapping instance or subclass; for example a validation match reads `case List(M.MissingTypeMapping(_))` where `M` is your mapping. The concrete backends — `ValueMapping` (in-memory Scala values), `ComposedMapping` (federation), `CirceMapping`, `GenericMapping`, `SqlMapping` — each extend `Mapping[F]` and override `mkCursorForMappedField` to build backend-specific `Cursor`s. + +A minimal `ValueMapping` shows the required members in place — schema literal, type refs, a `List` of object mappings (implicitly a `TypeMappings`, see [below](#typemappings-builders)), and a `selectElaborator`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/composed/ComposedData.scala", "#composed_currency")) +``` + +The `CurrencyMapping` above declares `schema`, derives the type refs `QueryType`/`CurrencyType` with `schema.ref(...)`, lists two `ValueObjectMapping`s, and overrides `selectElaborator` to turn the `code` argument into a `Unique`/`Filter`/`Eql` query. There is no `compiler` or `interpreter` to write — Grackle derives them. + +## Entry points + +These methods on `Mapping[F]` run operations or are called by the interpreter. The implicit `Compiler[F, F]` parameter is supplied by `import grackle.syntax._`. + +| Method | Signature | Purpose | +| --- | --- | --- | +| `compileAndRun` | `def compileAndRun(text: String, name: Option[String] = None, untypedVars: Option[Json] = None, introspectionLevel: IntrospectionLevel = Full, reportUnused: Boolean = true, env: Env = Env.empty)(implicit sc: Compiler[F, F]): F[Json]` | Compile and run one query/mutation, yielding the JSON response. | +| `compileAndRunSubscription` | `def compileAndRunSubscription(text: String, ...): Stream[F, Json]` | Compile and run a subscription, yielding a stream of JSON responses. | +| `combineAndRun` | `def combineAndRun(queries: List[(Query, Cursor)]): F[Result[List[ProtoJson]]]` | Evaluate a batch of deferred `(query, cursor)` pairs in one interpreter stage. | +| `defaultRootCursor` | `def defaultRootCursor(query: Query, tpe: Type, parentCursor: Option[Cursor]): F[Result[(Query, Cursor)]]` | Yield the default root cursor focused on the top-level operation type. | +| `mkCursorForMappedField` | `protected def mkCursorForMappedField(parent: Cursor, fieldContext: Context, fm: FieldMapping): Result[Cursor]` | The override point each backend extends to build a child `Cursor` for a resolved field mapping. | + +`compileAndRun` is implemented on top of `compileAndRunSubscription` and expects **exactly one** result element; running a query whose root produces zero or more than one element (e.g. a `Subscription` root) raises `IllegalStateException`. Use `compileAndRunSubscription` for subscriptions. `combineAndRun`'s default runs each deferred query one-shot and combines the `ProtoJson` results; backends that can batch (such as SQL) override it to issue a single combined query — this is the mechanism behind `Delegate`/`ComposedMapping` cross-backend joins. See [Running operations](running-operations.md) for the full driving API. + +## `TypeMapping` + +A `TypeMapping` maps a whole GraphQL type and carries a `MappingPredicate` that decides when it applies; its GraphQL type comes from that predicate (`tpe = predicate.tpe`). It is `sealed` with exactly two concrete kinds. + +```scala +sealed trait TypeMapping extends TypeMappingCompat with Product with Serializable { + def predicate: MappingPredicate + def pos: SourcePos + def tpe: NamedType = predicate.tpe +} +``` + +### `ObjectMapping` + +Maps an object, interface or union type to its field mappings. + +```scala +abstract class ObjectMapping extends TypeMapping { + def fieldMappings: Seq[FieldMapping] + def fieldMapping(fieldName: String): Option[FieldMapping] +} +``` + +Constructors (companion `object ObjectMapping`): + +| Constructor | Use | +| --- | --- | +| `ObjectMapping(predicate)(fieldMappings: FieldMapping*)` | Explicit `MappingPredicate`. | +| `ObjectMapping(tpe: NamedType)(fieldMappings: FieldMapping*)` | Curried form keyed on a type ref — the most common. | +| `ObjectMapping(path: Path)(fieldMappings: FieldMapping*)` | Path-sensitive (builds a `PathMatch`). | +| `ObjectMapping(tpe: NamedType, fieldMappings: List[FieldMapping])` | Named-argument form; common in test/demo code. | + +The `ValueMapping` backend adds `ValueObjectMapping`, whose narrowing for interfaces/unions is driven by a runtime `ClassTag`: `ValueObjectMapping[T](tpe = ...)(...)` or the `ValueObjectMapping(tpe).on[T](...)` builder. You must supply the correct concrete `[T]` because narrowing tests `classTag.runtimeClass.isInstance(focus)`; an erased or wrong type parameter breaks `narrowsTo`/`narrow`. + +### `LeafMapping[T]` + +Maps a scalar or enum GraphQL type to a circe `Encoder`. **There is no `PrimitiveMapping` in Grackle — `LeafMapping[T]` is the leaf/primitive construct.** + +```scala +trait LeafMapping[T] extends TypeMapping { + def encoder: Encoder[T] + def scalaTypeName: String + def pos: SourcePos +} +``` + +Constructors require an implicit circe `Encoder[T]` and a `TypeName[T]`: + +| Constructor | Notes | +| --- | --- | +| `LeafMapping[T](predicate)` | Explicit predicate. | +| `LeafMapping[T](tpe: NamedType)` | Keyed on a type ref — the common form. | +| `LeafMapping[T](path: Path)` | Path-sensitive. | + +You only declare `LeafMapping`s for **custom** scalars and enums; the built-in leaf mappings for `String`, `Int`, `Float`, `Boolean` and `ID` are appended automatically by `TypeMappings` (see [below](#typemappings-builders)). A list of `LeafMapping`s for custom scalar/enum types looks like this: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ScalarsSuite.scala", "#scalars_leafmappings")) +``` + +Each `LeafMapping[T](tpe)` above needs an implicit circe `Encoder[T]` in scope (for example a `Genre` enum encoder) and resolves the schema's `UUIDType`, `GenreType`, `DateType` and so on to those Scala types. There is no separate primitive construct — these leaf mappings *are* how the custom scalars are encoded. + +## `FieldMapping` + +A `FieldMapping` maps a single field **inside an `ObjectMapping`**. The base trait exposes the field name plus flags that govern lookup and traversal. + +```scala +trait FieldMapping extends Product with Serializable { + def fieldName: String + def hidden: Boolean + def subtree: Boolean + def pos: SourcePos +} +``` + +`hidden = true` marks a synthetic/attribute field used only as a `required` dependency of another field — a *declared* schema field whose mapping is hidden triggers a `DeclaredFieldMappingIsHidden` validation failure. `subtree = true` marks fields whose value is a sub-tree the interpreter descends into (effects and delegates), versus leaf-producing fields. The catalog: + +| Field mapping | Signature (constructor) | Semantics | +| --- | --- | --- | +| `CursorField[T]` | `CursorField[T](fieldName, f: Cursor => Result[T], required: List[String] = Nil, hidden = false)(implicit Encoder[T])` | Computes a leaf value from the parent `Cursor`. `required` lists sibling fields/attributes `f` reads, so the interpreter materializes them. `encoder` is implicit. | +| `ValueField[T]` | `ValueField[T](fieldName, f: T => Any, hidden = false)` | Maps a field to a function from the parent value `T` to a child value (`ValueMapping` only). Operates on the parent focus, not sibling cursors. `ValueField.fromValue(name, t)` yields a constant. | +| `Delegate` | `Delegate(fieldName, mapping: Mapping[F], join: (Query, Cursor) => Result[Query] = ComponentElaborator.TrivialJoin)` | Delegates the field to another `Mapping` (cross-backend composition). `join` rewrites the sub-query against the parent cursor. `subtree = true`. | +| `EffectField` | `EffectField(fieldName, handler: EffectHandler[F], required: List[String] = Nil, hidden = false)` | A field whose value is produced by a deferred, batched `EffectHandler[F]`. Extends `EffectMapping` (`subtree = true`). | +| `RootEffect` | constructed only via the companion (see below) | Runs an initial `F`-effect for a root query/mutation field, producing a `(Query, Cursor)`. | +| `RootStream` | constructed only via the companion (see below) | Streaming analogue of `RootEffect` for subscription roots. | + +### `RootEffect` / `RootStream` + +`RootEffect` and `RootStream` are **private case classes** — never use `new`. Construct them through these companion factories, choosing the shape that matches what your effect needs to compute: + +| Factory | Signature | When | +| --- | --- | --- | +| `RootEffect.apply` | `RootEffect(fieldName)(effect: (Query, Path, Env) => F[Result[(Query, Cursor)]])` | Full control over both the rewritten query and the cursor. | +| `RootEffect.computeCursor` | `RootEffect.computeCursor(fieldName)(effect: (Path, Env) => F[Result[Cursor]])` | Query unchanged; build the root cursor (e.g. with `valueCursor(path, env, value)`). | +| `RootEffect.computeChild` | `RootEffect.computeChild(fieldName)(effect: (Query, Path, Env) => F[Result[Query]])` | Cursor unchanged; rewrite the child query. Raises an internal error if the root query has no extractable child. | +| `RootEffect.computeUnit` | `RootEffect.computeUnit(fieldName)(effect: Env => F[Result[Unit]])` | Run a side effect only; query and cursor unchanged (typical for a mutation that returns the recorded value). | +| `RootStream.apply` | `RootStream(fieldName)(effect: (Query, Path, Env) => Stream[F, Result[(Query, Cursor)]])` | Full streaming control. | +| `RootStream.computeCursor` | `RootStream.computeCursor(fieldName)(effect: (Path, Env) => Stream[F, Result[Cursor]])` | Subscription root emitting a cursor per element. | +| `RootStream.computeChild` | `RootStream.computeChild(fieldName)(effect: (Query, Path, Env) => Stream[F, Result[Query]])` | Subscription root rewriting the child query per element. | + +`mapping.rootEffect` / `mapping.rootStream` look these up by field. The `EffectField`/`RootEffect`/`RootStream` mappings drive the `effectElaborator` compiler phase. See [Effects reference](effects.md) for the effect machinery and [Running operations](running-operations.md) for driving subscriptions. + +### Circe field mappings + +`CirceField` and `CursorFieldJson` are **not in core** — they live in `modules/circe/src/main/scala/circemapping.scala` and are usable only from a `CirceMapping`/`CirceMappingLike` subtype. + +| Field mapping | Signature | Semantics | +| --- | --- | --- | +| `CirceField` | `CirceField(fieldName, value: Json, hidden = false)` | Maps a field to a constant `Json` value. | +| `CursorFieldJson` | `CursorFieldJson(fieldName, f: Cursor => Result[Json], required: List[String], hidden = false)` | Computes a `Json` subtree from the parent `Cursor` (e.g. decoding a JSON blob stored in a column). | + +The circe module also adds `RootEffect.computeJson` / `computeEncodable` syntax. See [CirceMapping reference](circe-mapping.md). + +## `MappingPredicate` + +A `MappingPredicate` decides when a `TypeMapping` applies, returning an `Option[Int]` **priority** (highest wins; equal highest priorities are an `AmbiguousTypeMappings` error). + +```scala +trait MappingPredicate { + def tpe: NamedType + def apply(ctx: Context): Option[Int] + def continuationContext(ctx: Context): Option[Context] +} +``` + +| Predicate | Signature | Priority | Use | +| --- | --- | --- | --- | +| `TypeMatch` | `case class TypeMatch(tpe: NamedType)` | `0` | Matches the type in any context. | +| `PathMatch` | `case class PathMatch(path: Path)` | `path.length + 1` for a non-empty path (`0` for an empty path) | Matches a type reached via a specific field path; preferred for context-sensitive mappings. | +| `PrefixedTypeMatch` | `case class PrefixedTypeMatch(prefix: List[String], tpe: NamedType)` | `prefix.length` | Legacy `PrefixedMapping` semantics. | + +Because a non-empty `PathMatch`/`PrefixedTypeMatch` carries higher priority than a bare `TypeMatch` (which is always `0`), a path-specific mapping wins over a plain type mapping for the same type in that path. The `ObjectMapping(tpe)(...)`, `ObjectMapping(path)(...)`, `LeafMapping[T](tpe)` convenience constructors build the appropriate predicate for you, so you rarely instantiate these directly. Prefer `PathMatch` over `PrefixedTypeMatch` in new code; `PrefixedMapping(tpe, mappings)` remains as a backwards-compat shim that throws `IllegalArgumentException` unless all entries share the same `tpe`. + +## `TypeMappings` builders + +`TypeMappings` is the indexed catalog the mapping holds: a `Seq[TypeMapping]` plus lookup indices, with `typeMapping(context)`, `objectMapping(context)`, `fieldMapping(context, fieldName)` and `encoderForLeaf(context)` lookups, and a `validate(severity)` method. Construct it via the companion. + +| Builder | Signature | Behaviour | +| --- | --- | --- | +| `TypeMappings.apply` | `apply(mappings: Seq[TypeMapping])` / `apply(mappings: TypeMapping*)` | **Checked** — the catalog is validated on first use. | +| `TypeMappings.unchecked` | `unchecked(mappings: Seq[TypeMapping])` / `unchecked(mappings: TypeMapping*)` | Skips validation. | +| `TypeMappings.fromList` | `implicit def fromList(mappings: List[TypeMappingCompat]): TypeMappings` | Lets a plain `List` be used where `TypeMappings` is expected. | +| `TypeMappings.empty` | `val empty: TypeMappings` | The empty catalog. | + +Writing `val typeMappings = List(...)` compiles only because of the implicit `fromList` — and `fromList` always builds a **checked** catalog. To skip validation you must call `TypeMappings.unchecked(...)` explicitly. Built-in leaf mappings for `String`, `Int`, `Float`, `Boolean` and `ID` are always appended automatically, so you never declare them yourself. + +Validation does **not** run at construction time. A checked catalog is validated lazily the first time the `compiler` val is forced (`unsafeValidateIfChecked`), which raises a `ValidationException` — so a bad mapping can look fine until the first query is compiled. `validate(severity)` unfolds the schema from the root operation types and reports `ValidationFailure`s including `MissingTypeMapping`, `AmbiguousTypeMappings`, `MissingFieldMapping`, `DeclaredFieldMappingIsHidden`, `ObjectTypeExpected`, `LeafTypeExpected`, `ReferencedTypeDoesNotExist`, `UnusedTypeMapping` and `UnusedFieldMapping`. See [Validate a mapping](../how-to/validate-mappings.md) for triggering and matching each failure. + +## See also + +- [Mappings and cursors](../concepts/mappings-cursors.md) — the two-layer model and how `mkCursorForMappedField` wires fields to data. +- [Build an in-memory model](../tutorial/in-memory-model.md) — a `GenericMapping` over Star Wars ADTs; for a `ValueMapping` from scratch see the [quick start](../getting-started/quick-start.md). +- [Validate a mapping and read the failures](../how-to/validate-mappings.md) — every `ValidationFailure` with worked examples. +- [Effects reference](effects.md) — `RootEffect`, `RootStream` and `EffectHandler` in depth. +- [Running operations reference](running-operations.md) — `compileAndRun` and the driving API. +- [CirceMapping reference](circe-mapping.md) — `CirceField`, `CursorFieldJson` and circe roots. diff --git a/docs/reference/predicates.md b/docs/reference/predicates.md new file mode 100644 index 00000000..ba0f058c --- /dev/null +++ b/docs/reference/predicates.md @@ -0,0 +1,168 @@ +# Predicates & terms reference + +This page is the reference for the `Term[T]` / `Path` / `Predicate` algebra in `grackle.Predicate` (module `grackle-core`), plus the SQL-module `Like`. These are the leaves you assemble into a [`Filter`](filtering-paging-nodes.md) — and the terms you sort by in an [`OrderBy`](filtering-paging-nodes.md) — when you wire user arguments into a query during [elaboration](../concepts/compiler-elaboration.md). It is written for developers building filters; for the surrounding `Query` nodes (`Filter`, `OrderBy`, `Offset`, `Limit`, `Count`) see [Filtering & paging nodes](filtering-paging-nodes.md), and for the task-oriented walkthrough see [How to filter, order and page](../how-to/filtering-ordering-paging.md). + +## `Term[T]` + +`Term[T]` is a *reified* function `Cursor => Result[T]`. It is deliberately **not** an arbitrary `Cursor => Boolean`: interpreters introspect terms (the SQL module turns them into `WHERE` / `ORDER BY` clauses rather than running them in memory), so a `Term` must expose its structure. Every predicate, path and literal in this page is a `Term`. + +`Term[T] extends Product with Serializable` and declares: + +| Member | Signature | Purpose | +| --- | --- | --- | +| `apply` | `def apply(c: Cursor): Result[T]` | Evaluate the term against a [`Cursor`](cursor.md), yielding a [`Result[T]`](result-problem.md). | +| `children` | `def children: List[Term[_]]` | The immediate sub-terms (e.g. `Eql(x, y).children == List(x, y)`). | +| `fold` | `def fold[Acc](acc: Acc)(f: (Acc, Term[_]) => Acc): Acc` | Left-fold over this term and all descendants. | +| `exists` | `def exists(f: Term[_] => Boolean): Boolean` | True if `f` holds for this term or any descendant. | +| `forall` | `def forall(f: Term[_] => Boolean): Boolean` | True if `f` holds for this term and every descendant. | +| `forallR` | `def forallR(f: Term[_] => Result[Boolean]): Result[Boolean]` | Short-circuiting `forall` whose test returns a `Result[Boolean]`. | + +`Term` is invariant by design — the source carries the note that *making it covariant crashes Scala 3*. Do not attempt to make `Term` or `Predicate` covariant. + +## `Path` and the `Type / "field"` syntax + +A `Path` is a typed cursor path rooted at a schema [`Type`](schema-sdl.md). You build one with the `/` operator on a `Type` (`CountryType / "population"`) and an implicit converts it to a `Term`. `Path` declares: + +| Member | Signature | Purpose | +| --- | --- | --- | +| `rootTpe` | `def rootTpe: Type` | The type the path starts from. | +| `path` | `def path: List[String]` | The field names walked so far. | +| `tpe` | `def tpe: Type` | The type at the current end of the path. | +| `asTerm` | `def asTerm[A]: Term[A]` | Convert to a `Term` — see `UniquePath` vs `ListPath` below. | +| `/` | `def /(elem: String): Path` | Extend the path by one field. | +| `%` | `def %(ntpe: NamedType): Path` | Narrow the current type to a subtype (throws if `ntpe` is not a subtype). | +| `isRoot` | `def isRoot: Boolean` | `path.isEmpty`. | + +Construct a path with `Path.from(tpe)` (returns an empty path rooted at `tpe`) or, idiomatically, with `tpe / "field"`, which delegates to `Path.from`. The `Type./` operator lives in `grackle.schema`. + +### `UniquePath` vs `ListPath` + +`asTerm` chooses the concrete term based on whether the path is list-valued, computed from `rootTpe.pathIsList(path)`: + +| `Term` | Signature | Chosen when | Yields | +| --- | --- | --- | --- | +| `PathTerm.UniquePath` | `case class UniquePath[A](path: List[String]) extends Term[A]` | the path is scalar | `A` — errors at runtime with *"Expected exactly one element for path …"* unless the focus is a single `ScalarFocus`. | +| `PathTerm.ListPath` | `case class ListPath[A](path: List[String]) extends Term[List[A]]` | `rootTpe.pathIsList(path)` is true | `List[A]`. | + +Two implicits drive the conversion: `Term.path2Term[A]` (scalar, higher priority) and `TermLow.path2ListTerm[A]` (list). This is why `Contains` takes a `Term[List[T]]` for a list field while `Eql` takes a `Term[T]` for a scalar — the path's shape selects the term. A scalar path used where a `List` term is expected (or vice versa) still *compiles* via these implicits but fails at runtime. + +## `Const` literals + +`Const[T]` is the literal term — the right-hand side of most comparisons: + +| `Term` | Signature | Purpose | +| --- | --- | --- | +| `Predicate.Const` | `case class Const[T](v: T) extends Term[T]` | A constant; `apply` always succeeds with `v`. E.g. `Eql(CountryType / "code", Const(code))`. | + +## Predicate ADT + +`Predicate extends Term[Boolean]` — it is exactly a `Term` that yields a boolean, and it is the thing a [`Filter`](filtering-paging-nodes.md) holds. All of the following live in `object grackle.Predicate`. + +### Constants and boolean combinators + +| Predicate | Signature | Semantics | +| --- | --- | --- | +| `True` | `case object True extends Predicate` | Always matches; identity for `and`, absorbing for `or`. | +| `False` | `case object False extends Predicate` | Never matches; absorbing for `and`, identity for `or`. | +| `And` | `case class And(x: Predicate, y: Predicate) extends Predicate` | `x && y`. | +| `Or` | `case class Or(x: Predicate, y: Predicate) extends Predicate` | `x \|\| y`. | +| `Not` | `case class Not(x: Predicate) extends Predicate` | `!x`. | + +Prefer the smart constructors over hand-nesting when you have a list of predicates: + +| Constructor | Signature | Behaviour | +| --- | --- | --- | +| `Predicate.and` | `def and(props: List[Predicate]): Predicate` | Folds into an `And` chain, short-circuiting on `False`; an empty list yields `True`. | +| `Predicate.or` | `def or(props: List[Predicate]): Predicate` | Folds into an `Or` chain, short-circuiting on `True`; an empty list yields `False`. | +| `And.combineAll` | `def combineAll(preds: List[Predicate]): Predicate` | Right-folds the list with `And`; an empty list yields `True`, a singleton yields itself. | + +### Comparisons and membership + +| Predicate | Signature | Requires | Semantics | +| --- | --- | --- | --- | +| `Eql` | `case class Eql[T: Eq](x: Term[T], y: Term[T])` | `Eq[T]` | `x === y`. Exposes `eqInstance` and `subst`. | +| `NEql` | `case class NEql[T: Eq](x: Term[T], y: Term[T])` | `Eq[T]` | `x =!= y`. | +| `Contains` | `case class Contains[T: Eq](x: Term[List[T]], y: Term[T])` | `Eq[T]` | the list term `x` contains element `y`. | +| `Lt` | `case class Lt[T: Order](x: Term[T], y: Term[T])` | `Order[T]` | `x < y`. | +| `LtEql` | `case class LtEql[T: Order](x: Term[T], y: Term[T])` | `Order[T]` | `x <= y`. | +| `Gt` | `case class Gt[T: Order](x: Term[T], y: Term[T])` | `Order[T]` | `x > y`. | +| `GtEql` | `case class GtEql[T: Order](x: Term[T], y: Term[T])` | `Order[T]` | `x >= y`. | +| `In` | `case class In[T: Eq](x: Term[T], y: List[T])` | `Eq[T]` | `x` is one of the static list `y`. | +| `IsNull` | `case class IsNull[T](x: Term[Option[T]], isNull: Boolean)` | — | `x.isEmpty == isNull`; `x` must be an `Option`-valued term. | + +`In` has a companion helper `In.fromEqls[T](eqls: List[Eql[T]]): Option[In[T]]` which collapses a homogeneous list of `Eql`-against-`Const` over the *same* term into a single `In`, and returns `None` otherwise. + +### String predicates (core) + +| Predicate | Signature | Semantics | +| --- | --- | --- | +| `Matches` | `case class Matches(x: Term[String], r: Regex) extends Predicate` | `r.matches(x)` — full regex match. | +| `StartsWith` | `case class StartsWith(x: Term[String], prefix: String) extends Predicate` | `x.startsWith(prefix)`. | + +## Term transformers (non-boolean) + +These are `Term`s that produce non-boolean values; use them to transform a sub-term before comparing it. They are *not* predicates. + +| Transformer | Signature | Result | +| --- | --- | --- | +| `AndB` | `case class AndB(x: Term[Int], y: Term[Int]) extends Term[Int]` | `x & y` (bitwise AND). | +| `OrB` | `case class OrB(x: Term[Int], y: Term[Int]) extends Term[Int]` | `x \| y` (bitwise OR). | +| `XorB` | `case class XorB(x: Term[Int], y: Term[Int]) extends Term[Int]` | `x ^ y` (bitwise XOR). | +| `NotB` | `case class NotB(x: Term[Int]) extends Term[Int]` | `~x` (bitwise complement). | +| `ToUpperCase` | `case class ToUpperCase(x: Term[String]) extends Term[String]` | `x.toUpperCase`. | +| `ToLowerCase` | `case class ToLowerCase(x: Term[String]) extends Term[String]` | `x.toLowerCase`. | + +## `Like` (sql-core module) + +`Like` is **not** in the core `Predicate` ADT — it lives in `grackle.sql` (module `grackle-sql-core`, with separate scala-2 and scala-3 sources) and is only meaningful against a SQL backend, where it compiles to `LIKE` / `ILIKE`. + +| Predicate | Signature | +| --- | --- | +| `grackle.sql.Like` | `case class Like(x: Term[String] \| Term[Option[String]], pattern: String, caseInsensitive: Boolean) extends Predicate` | + +Notes: + +- The first parameter is the union type `Term[String] | Term[Option[String]]`, so `Like` accepts both a non-nullable and a nullable string term. A `None` focus never matches. +- `pattern` uses SQL wildcards: `%` matches any run of characters, `_` matches a single character. In-memory it is translated to a regex (`%` → `.*`, `_` → `.`); against SQL it compiles to `LIKE`/`ILIKE`. +- `caseInsensitive = true` wraps the regex in `(?i:…)` (and selects `ILIKE` on SQL backends). + +## Required typeclasses + +The element type of a comparison must match the field's *mapped* type, including `Option`, and must carry the right instance: + +| Predicate(s) | Instance required | +| --- | --- | +| `Eql`, `NEql`, `In`, `Contains` | `Eq[T]` | +| `Lt`, `LtEql`, `Gt`, `GtEql` (and `OrderSelection`) | `Order[T]` | +| `IsNull` | none — but `x` must be a `Term[Option[T]]`, so the path must be a nullable field | +| `Matches`, `StartsWith` | none — `x` is fixed to `Term[String]` | +| `Like` | none — `x` is `Term[String] \| Term[Option[String]]`, so it accepts a nullable or non-nullable string field | + +Forgetting the instance, or comparing the wrong static type, is a compile error. The element type must follow the field exactly: use `OrderSelection[Option[String]]` for a nullable string field, and `IsNull[Int]` over an `Option[Int]` field. + +## The vocabulary in a real elaborator + +Predicates are installed during compilation inside a `SelectElaborator`, which matches `(ParentType, fieldName, List(Binding(...)))` and returns an `Elab[Unit]` — most often `Elab.transformChild(child => …)` to wrap the child query with a `Filter`. The world-database test mapping exercises most of the algebra at once: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlWorldMapping.scala", "#world_elaborator")) +``` + +Reading the cases against the tables above: + +- `country` / `city` / `language` use `Eql(path, Const(value))` wrapped in `Unique(Filter(...))` for single-result lookups. +- `countries` builds `GtEql(... population, Const(min))` for filtering and `OrderSelection[Int]` / `OrderSelection[String]` keys for ordering, gated on the GraphQL arguments. +- `cities` uses `Like(CityType / "name", namePattern, true)` — the case-insensitive SQL wildcard match. +- `languages` uses `In(CityType / "language", languages)` for membership against a static list. +- `search` combines `And(Not(Lt(...)), Not(Lt(...)))` — note `Const(Option(year))` because `indepyear` is a nullable field. +- `search2` uses `IsNull[Int](CountryType / "indepyear", isNull = !indep)` over the `Option`-valued `indepyear` term. + +Each of these terms is inert data: the SQL interpreter introspects it via `children` and compiles it to a `WHERE` clause, while an in-memory mapping runs `apply` against each candidate `Cursor`. The `Query` nodes that wrap these predicates (`Filter`, `OrderBy`, `Offset`, `Limit`, `Count`) are documented separately. + +## See also + +- [Filtering & paging nodes](filtering-paging-nodes.md) — the `Filter` / `OrderBy` / `Offset` / `Limit` / `Count` `Query` nodes and the `FilterOrderByOffsetLimit` assembler that hold these predicates. +- [How to filter, order and page](../how-to/filtering-ordering-paging.md) — the task-oriented recipe that wires arguments to predicates and paging nodes. +- [Cursor](cursor.md) — what a `Term` is evaluated against. +- [Query algebra](query-algebra.md) — the `Query` ADT these predicates filter over. +- [Compiler & elaboration](../concepts/compiler-elaboration.md) — how `SelectElaborator` installs predicates during compilation. diff --git a/docs/reference/query-algebra.md b/docs/reference/query-algebra.md new file mode 100644 index 00000000..dacbdfb8 --- /dev/null +++ b/docs/reference/query-algebra.md @@ -0,0 +1,167 @@ +# Query algebra reference + +This page enumerates the `Query` algebra: the sealed ADT in `query.scala` that Grackle uses to represent a GraphQL operation as a tree. It covers every node — both the directly *interpretable* nodes the [query interpreter](../concepts/query-interpreter.md) evaluates and the *pre-elaboration* precursors the [compiler](../concepts/compiler-elaboration.md) rewrites away — plus the structural helper functions and type aliases that operate on them. It is for developers who read, build, or rewrite query-algebra terms (for example, inside a `SelectElaborator`). Unless noted otherwise, every member below lives in `object Query` in package `grackle`; bring them into scope with `import grackle.Query._`. + +## The `Query` trait + +`Query` is a sealed trait. Two members are defined on every node: + +| Member | Signature | Meaning | +| --- | --- | --- | +| `~` | `def ~(query: Query): Query` | Group this query with another. Adjacent `Group`s are flattened, so `a ~ b ~ c` yields a single `Group(List(a, b, c))` rather than nested groups. | +| `render` | `def render: String` | A human-readable debug rendering of the node (used in error messages and tests), not GraphQL or JSON. | + +Because the trait is sealed, the node lists below are exhaustive. + +## Interpretable nodes + +These are the nodes that survive compilation and are evaluated by the interpreter against a [`Cursor`](cursor.md). After a successful `QueryCompiler.compile`, an `Operation.query` is built only from these. + +| Node | Signature | Semantics | +| --- | --- | --- | +| `Select` | `case class Select(name: String, alias: Option[String], child: Query)` | A field selection. `resultName` returns `alias.getOrElse(name)`. Leaf fields have `child == Empty`. | +| `Group` | `case class Group(queries: List[Query])` | A list of sibling selections at the same level. | +| `Unique` | `case class Unique(child: Query)` | Asserts `child` produces a single-element list and yields that element (e.g. the body of `character(id: …)`). | +| `Filter` | `case class Filter(pred: Predicate, child: Query)` | Retains only the elements of `child` that satisfy `pred`. See [predicates](predicates.md). | +| `Limit` | `case class Limit(num: Int, child: Query)` | Takes the first `num` elements of a list-producing `child`. | +| `Offset` | `case class Offset(num: Int, child: Query)` | Drops the first `num` elements of a list-producing `child`. | +| `OrderBy` | `case class OrderBy(selections: OrderSelections, child: Query)` | Orders a list-producing `child` by `selections`. | +| `Count` | `case class Count(child: Query)` | Computes the number of top-level elements of `child`. It *replaces* the node it is built from rather than wrapping a selection. | +| `Narrow` | `case class Narrow(subtpe: TypeRef, child: Query)` | Yields `child` when the focus is of type `subtpe`, `Empty` otherwise. Produced when expanding fragments with a type condition. | +| `Component` | `case class Component[F[_]](mapping: Mapping[F], join: (Query, Cursor) => Result[Query], child: Query)` | Marks a boundary where another mapping takes over; `join` computes the continuation query from the current cursor. Inserted by `ComponentElaborator`. | +| `Effect` | `case class Effect[F[_]](handler: EffectHandler[F], child: Query)` | Embeds (possibly batched) effects to be run by the interpreter in a later phase. See [effects and batching](../how-to/effects-batching.md). | +| `Environment` | `case class Environment(env: Env, child: Query)` | Adds `env` bindings to the runtime environment for `child`. Materialised from elaboration-time `Elab.env`. See [context and env](context-env.md). | +| `TransformCursor` | `case class TransformCursor(f: Cursor => Result[Cursor], child: Query)` | Computes a continuation cursor from the current cursor before evaluating `child`. | +| `Introspect` | `case class Introspect(schema: Schema, child: Query)` | Evaluates an introspection subquery relative to `schema`. Inserted by `IntrospectionElaborator` around `__schema` / `__type` / `__typename`. | +| `Empty` | `case object Empty` | The terminal query / empty selection set. Leaf-field `Select`s have `child == Empty`. | + +### `Select` companion overloads + +The `Select` companion provides three convenience constructors that default `alias` to `None` and `child` to `Empty`: + +| Constructor | Expands to | +| --- | --- | +| `Select(name)` | `Select(name, None, Empty)` | +| `Select(name, alias)` (with `alias: Option[String]`) | `Select(name, alias, Empty)` | +| `Select(name, child)` (with `child: Query`) | `Select(name, None, child)` | + +### `Effect` handler + +`Effect` carries an `EffectHandler`, the interface the interpreter calls to run the embedded effect: + +```scala +trait EffectHandler[F[_]] { + def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] +} +``` + +The `List` argument lets a handler receive several `(Query, Cursor)` pairs at once, which is how batching is expressed. + +### Ordering: `OrderSelections` and `OrderSelection` + +`OrderBy` holds an `OrderSelections`, a wrapper over a list of per-term `OrderSelection`s applied in order: + +| Type | Signature | Notes | +| --- | --- | --- | +| `OrderSelections` | `case class OrderSelections(selections: List[OrderSelection[_]])` | Compares cursors by each `OrderSelection` left-to-right; the first non-equal comparison wins. | +| `OrderSelection` | `case class OrderSelection[T: Order](term: Term[T], ascending: Boolean = true, nullsLast: Boolean = true)` | Orders by a [`Term`](predicates.md) `T`; needs a cats `Order[T]`. `ascending` and `nullsLast` both default to `true`. | + +## Pre-elaboration nodes + +These nodes exist only between parsing and elaboration. After parsing, the tree is built from them (plus the `Group`/`Empty` nodes above); the compiler's phases progressively rewrite them into the interpretable nodes. They are not directly interpretable. + +| Node | Signature | Role | +| --- | --- | --- | +| `UntypedSelect` | `case class UntypedSelect(name: String, alias: Option[String], args: List[Binding], directives: List[Directive], child: Query)` | A field selection carrying raw argument `Binding`s and directives. Rewritten to `Select` by `SelectElaborator`. | +| `UntypedFragmentSpread` | `case class UntypedFragmentSpread(name: String, directives: List[Directive])` | A named fragment spread. Replaced by the fragment's body, guarded by a `Narrow` for its type condition. | +| `UntypedInlineFragment` | `case class UntypedInlineFragment(tpnme: Option[String], directives: List[Directive], child: Query)` | An inline fragment. Replaced by its `child`, guarded by a `Narrow` if a type condition (`tpnme`) is present. | +| `UntypedFragment` | `case class UntypedFragment(name: String, tpnme: String, directives: List[Directive], child: Query)` | A parsed fragment *definition* (the `fragment X on T { … }` declaration), kept in a fragment map and spliced in where spread. | +| `UntypedVarDef` | `case class UntypedVarDef(name: String, tpe: Ast.Type, default: Option[Value], directives: List[Directive])` | A parsed variable *definition*, before its type is resolved against the schema. | +| `Binding` | `case class Binding(name: String, value: Value)` | A single field or directive argument: a name plus a `Value`. The unit a `SelectElaborator` pattern-matches on. `render` yields `"name: value"`. | + +### The `Value` ADT + +`Binding.value` is a `Value` (defined in `object Value`, package `grackle`; `import grackle.Value._`). These are the argument values a `SelectElaborator` matches on — note that GraphQL ID and String literals are distinct cases, and that an omitted optional argument arrives as `AbsentValue`, not `NullValue`. + +| Case | Signature | +| --- | --- | +| `IntValue` | `case class IntValue(value: Int)` | +| `FloatValue` | `case class FloatValue(value: Double)` | +| `StringValue` | `case class StringValue(value: String)` | +| `BooleanValue` | `case class BooleanValue(value: Boolean)` | +| `IDValue` | `case class IDValue(value: String)` | +| `EnumValue` | `case class EnumValue(name: String)` | +| `ListValue` | `case class ListValue(elems: List[Value])` | +| `ObjectValue` | `case class ObjectValue(fields: List[(String, Value)])` | +| `VariableRef` | `case class VariableRef(name: String)` | +| `NullValue` | `case object NullValue` | +| `AbsentValue` | `case object AbsentValue` | + +`VariableRef` nodes are resolved to concrete `Value`s during compilation, so a `SelectElaborator` never sees one. `AbsentValue` lets an elaborator distinguish a missing optional argument from an explicit `null` (`NullValue`) — match it as a separate case, e.g. `Binding("namePattern", AbsentValue)`. + +## Example: parsing into the algebra + +Parsing alone — with no schema — turns GraphQL text into the *untyped* algebra. This test from `CompilerSuite` parses a one-field query and asserts the exact tree it produces: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/CompilerSuite.scala", "#compile_simple")) +``` + +The result is an `UntypedOperation.UntypedQuery` whose `.query` is an `UntypedSelect("character", …)` carrying the raw `List(Binding("id", StringValue("1000")))` argument, with the nested `UntypedSelect("name", …)` as its `child`. The leaf field `name` has `child == Empty`. No types or predicates appear yet — turning the `Binding` into an `Eql`/`Unique`/`Filter` tree is the [`SelectElaborator`'s job](../concepts/compiler-elaboration.md), which runs only once a schema and elaborator are present. + +## Structural helpers + +These functions (also in `object Query`) inspect and rewrite query trees. They are used throughout elaboration and are the supported way for a custom elaborator to reach into a subtree rather than pattern-matching the ADT by hand. Most of them "see through" the wrapper nodes `Environment` and `TransformCursor` to reach the underlying `Select`/`UntypedSelect`. + +| Function | Signature | Result | +| --- | --- | --- | +| `ungroup` | `def ungroup(query: Query): List[Query]` | Flattens nested `Group`s into a flat list of top-level queries; a non-`Group` returns a singleton list. | +| `children` | `def children(q: Query): List[Query]` | The (ungrouped) child selections of a `Select`/`UntypedSelect`; `Nil` for other nodes. | +| `extractChild` | `def extractChild(query: Query): Option[Query]` | The single child selection of a top-level field, or `None`. | +| `substChild` | `def substChild(query: Query, newChild: Query): Option[Query]` | The query with its top-level field's child replaced by `newChild`, or `None`. | +| `hasField` | `def hasField(query: Query, fieldName: String): Boolean` | Whether `fieldName` is a top-level selection of `query`. | +| `fieldAlias` | `def fieldAlias(query: Query, fieldName: String): Option[String]` | The alias, if any, of the top-level field `fieldName`. | +| `mapFields` | `def mapFields(query: Query)(f: Query => Query): Query` | Applies `f` to each top-level `Select` (descending through `Group`/`Environment`/`TransformCursor`). | +| `mapFieldsR` | `def mapFieldsR(query: Query)(f: Query => Result[Query]): Result[Query]` | As `mapFields`, but `f` returns a [`Result`](result-problem.md); failures short-circuit. | +| `mergeQueries` | `def mergeQueries(qs: List[Query]): Query` | Merges *typed* (`Select`) queries, combining selections keyed by `(name, alias)` and recursively merging children. | +| `mergeUntypedQueries` | `def mergeUntypedQueries(qs: List[Query]): Query` | The same merge for *untyped* (`UntypedSelect`) queries; used by the `MergeFields` phase. | + +`mergeQueries` and `mergeUntypedQueries` are not interchangeable: each only merges nodes of its own stage. Use the typed merger when assembling `Select`s in an elaborator (for example, when `Elab.addAttribute` folds a synthetic field into an already-elaborated selection set) and the untyped merger only on pre-elaboration trees. + +### `FilterOrderByOffsetLimit` + +A constructor/extractor that nests `Filter`, `OrderBy`, `Offset`, and `Limit` in canonical order around a child. It is the convenient way to build (or destructure) a paging selection. + +```scala +object FilterOrderByOffsetLimit { + def apply( + pred: Option[Predicate], + oss: Option[List[OrderSelection[_]]], + offset: Option[Int], + limit: Option[Int], + child: Query): Query + + def unapply(q: Query): Option[ + (Option[Predicate], Option[List[OrderSelection[_]]], Option[Int], Option[Int], Query)] +} +``` + +`apply` wraps `child` from the inside out as `Limit(Offset(OrderBy(Filter(child))))`, omitting any layer whose argument is `None`. `unapply` peels those same four layers back off (in `Limit`, `Offset`, `OrderBy`, `Filter` order) and returns `None` only when *none* of the four are present. See [filtering, ordering and paging](../how-to/filtering-ordering-paging.md) for how an elaborator uses it. + +## Type aliases + +Three aliases describe variable definitions across compilation stages: + +| Alias | Definition | Stage | +| --- | --- | --- | +| `UntypedVarDefs` | `type UntypedVarDefs = List[UntypedVarDef]` | Parsed variable definitions, before their types are resolved. | +| `VarDefs` | `type VarDefs = List[InputValue]` | Variable definitions after their declared types are resolved against the schema. | +| `Vars` | `type Vars = Map[String, (Type, Value)]` | The fully resolved `(type, value)` map of an operation's variables, available to an elaborator via `Elab.vars`. | + +## See also + +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — how a query string becomes a tree of these nodes. +- [Compiler phases and the Elab monad](elab-phases.md) — the phase pipeline and the `Elab` combinators (`transformChild`, `env`, `addAttribute`) that rewrite this algebra. +- [Predicates and terms](predicates.md) — the `Predicate`/`Term` types carried by `Filter` and `OrderSelection`. +- [Cursor](cursor.md) and the [query interpreter](../concepts/query-interpreter.md) — how the interpretable nodes are evaluated. +- [Filtering, ordering and paging](../how-to/filtering-ordering-paging.md) — building `Filter`/`OrderBy`/`Limit`/`Offset` from field arguments. diff --git a/docs/reference/result-problem.md b/docs/reference/result-problem.md new file mode 100644 index 00000000..9f96ab0d --- /dev/null +++ b/docs/reference/result-problem.md @@ -0,0 +1,225 @@ +# Result, Problem & ResultT reference + +Every fallible operation in Grackle — parsing, compilation, elaboration, query interpretation, mapping validation, effects — returns a `Result[A]`. This page is the information-oriented reference for the `Result` ADT and its combinators, constructors and typeclass instances; the `ResultT[F, A]` monad transformer; the GraphQL-spec-shaped `Problem` type and its JSON encoding; the `Mapping.mkResponse` boundary that turns a `Result[Json]` into a response envelope; and the separate `ValidationFailure`/`Severity` diagnostic channel. It is for any developer constructing or handling errors. For a task-oriented walkthrough see [Construct, accumulate and report errors](../how-to/errors.md); all signatures below come from `result.scala`, `problem.scala`, `mapping.scala`, `validationfailure.scala` and `syntax.scala` under `modules/core/src/main/scala/`. + +## The `Result` ADT + +`Result[+T]` is a sealed trait with four cases — an `Ior`-like type that carries a value, a non-empty chain of [`Problem`](#problem)s, or both, plus a fourth case for internal `Throwable`s. + +| Case | Signature | Carries | Surfaces as | +| --- | --- | --- | --- | +| `Success` | `final case class Success[+T](value: T)` | value only | `{"data": …}` | +| `Warning` | `final case class Warning[+T](problems: NonEmptyChain[Problem], value: T)` | value **and** problems | `{"errors": […], "data": …}` | +| `Failure` | `final case class Failure(problems: NonEmptyChain[Problem])` | problems only | `{"errors": […]}` (no `data`) | +| `InternalError` | `final case class InternalError(error: Throwable)` | a `Throwable` | raised into the effect `F` | + +`Warning` generalises `Ior.Both`: it keeps partial data while still reporting problems. `InternalError` is **not** a GraphQL error — it is a bug or unexpected condition and is raised into `F` rather than encoded into the `errors` array (see [`mkResponse`](#mapping-results-to-the-response) and the [hard invariant on internal errors](../concepts/architecture.md)). + +### Predicates + +| Member | Signature | True for | Notes | +| --- | --- | --- | --- | +| `hasValue` | `def hasValue: Boolean` | `Success`, `Warning` | the cases for which `toOption` is non-empty | +| `hasProblems` | `def hasProblems: Boolean` | `Warning`, `Failure` | the cases that contribute to the `errors` array | +| `isFailure` | `def isFailure: Boolean` | `Failure` | problems but no value | +| `isInternalError` | `def isInternalError: Boolean` | `InternalError` | isolates the bug case | +| `toProblems` | `def toProblems: Chain[Problem]` | — | accumulated problems as a `Chain`; empty for `Success`/`InternalError` | + +## Combinators + +Instance methods on `Result[T]`. They short-circuit on `Failure`/`InternalError` and carry `Warning` problem chains forward. + +| Member | Signature | Behaviour | +| --- | --- | --- | +| `map` | `def map[U](f: T => U): Result[U]` | maps the value of `Success`/`Warning`; passes `Failure`/`InternalError` through | +| `flatMap` | `def flatMap[U](f: T => Result[U]): Result[U]` | monadic bind; merges a `Warning`'s chain into the continuation (`problems ++ fps`), short-circuits on `Failure`/`InternalError` | +| `traverse` | `def traverse[F[_], U](f: T => F[U])(implicit F: Applicative[F]): F[Result[U]]` | effectful traversal of the value | +| `fold` | `def fold[U](failure: NonEmptyChain[Problem] => U, success: T => U, warning: (NonEmptyChain[Problem], T) => U, error: Throwable => U): U` | exhaustive eliminator over all four cases | +| `foldLeft` / `foldRight` | `def foldLeft[U](u: U)(f: (U, T) => U): U` / `def foldRight[U](lu: Eval[U])(f: (T, Eval[U]) => Eval[U]): Eval[U]` | fold over the value (no-op for `Failure`/`InternalError`) | +| `combine` | `def combine[U >: T](that: Result[U])(implicit S: Semigroup[U]): Result[U]` | `Ior`-like accumulation; merges values via `S` and concatenates problems. `Failure` combined with a value-bearing result is **downgraded to `Warning`**; `InternalError` dominates | +| `withProblems` | `def withProblems(problems: NonEmptyChain[Problem]): Result[T]` | attach problems: `Success`→`Warning`, `Warning`/`Failure` accumulate, `InternalError` unchanged | +| `getOrElse` | `def getOrElse[U >: T](ifNone: => U): U` | the value, or a default for `Failure`/`InternalError` | +| `exists` / `forall` | `def exists(p: T => Boolean): Boolean` / `def forall(p: T => Boolean): Boolean` | predicate over the value (via `toOption`) | +| `toOption` | `def toOption: Option[T]` | `Some` for `Success`/`Warning`, `None` otherwise | +| `toEither` | `def toEither: Either[Either[Throwable, NonEmptyChain[Problem]], T]` | nested `Either`; the left distinguishes `InternalError` (inner `Left`) from `Failure` (inner `Right`) | +| `===` | `def ===[TT >: T](that: Result[TT])(implicit TT: Eq[TT]): Boolean` | structural equality used by the `Eq` instance | + +## Constructors + +On the `Result` companion object. `apply`, `pure` and `success` are synonyms producing a `Success`. + +| Member | Signature | Yields | +| --- | --- | --- | +| `apply` / `pure` / `success` | `def apply[A](a: A): Result[A]` (and `pure`, `success`) | `Success(a)` | +| `unit` | `val unit: Result[Unit]` | `Success(())` | +| `warning` | `def warning[A](warning: Problem, value: A): Result[A]` / `def warning[A](warning: String, value: A): Result[A]` | `Warning(NonEmptyChain(problem), value)`; the `String` form wraps the message in a `Problem` with no path/locations | +| `failure` | `def failure[A](s: String): Result[A]` / `def failure[A](p: Problem): Result[A]` | `Failure(NonEmptyChain(problem))` | +| `internalError` | `def internalError[A](err: Throwable): Result[A]` / `def internalError[A](err: String): Result[A]` | `InternalError`; the `String` form wraps the message in a new `Throwable` | +| `fromOption` | `def fromOption[A](oa: Option[A], ifNone: => Problem): Result[A]` (plus a `String` overload) | `Success` or `Failure(ifNone)` when empty | +| `fromEither` | `def fromEither[A](ea: Either[Problem, A]): Result[A]` (plus a `String`-left overload) | `Left`→`Failure`, `Right`→`Success` | +| `fromProblems` | `def fromProblems(problems: Seq[Problem]): Result[Unit]` / `def fromProblems(problems: Chain[Problem]): Result[Unit]` | `Result.unit` when empty, else `Failure` | +| `catchNonFatal` | `def catchNonFatal[T](body: => T): Result[T]` | `Success`, or `InternalError` on a `NonFatal` throwable | +| `combineAllWithDefault` | `def combineAllWithDefault[T](ress: List[Result[T]], default: => T): Result[List[T]]` | combines a list preserving order/length by substituting `default` for failed elements, accumulating all problems; returns the first `InternalError` if any | + +The `String` overloads of `fromOption`/`fromEither` (and `Option#toResult`) are disambiguated from the `Problem` variants by an implicit `DummyImplicit`, since they would otherwise erase to the same signature. + +### Constructing the four cases + +The four cases and the predicates that distinguish them, in a single compiled block: + +```scala mdoc:silent +import grackle.{Problem, Result} + +val ok: Result[Int] = Result.Success(42) +val ok2: Result[Int] = Result(42) // == Result.success(42) == Result.pure(42) +val warn: Result[Int] = Result.warning("deprecated field used", 42) +val fail: Result[Int] = Result.failure("No field 'foo' for type Character") +val failP: Result[Int] = Result.failure(Problem("boom", path = List("user", "name"))) +val boom: Result[Int] = Result.internalError(new RuntimeException("bug")) + +assert(ok.toOption == Some(42)) +assert(warn.hasValue && warn.hasProblems) // Warning carries both +assert(fail.isFailure && fail.toOption.isEmpty) +assert(boom.isInternalError) +``` + +`warn` is a `Warning` — it has both a value and a problem; `fail` is a `Failure` — a problem and no value; `boom` is an `InternalError` and so contributes nothing to `toProblems`. + +### Composition and accumulation + +`flatMap` (and the `for`-comprehension sugar) carries a `Warning`'s problems forward while keeping the value, and short-circuits the first `Failure`/`InternalError`: + +```scala mdoc:silent +import grackle.syntax._ // for `.success`, `Option#toResult` + +val composed: Result[Int] = + for { + a <- Result.warning("w1", 1) // Warning("w1", 1) + b <- 2.success // Success(2) + c <- Option(3).toResult("missing c") // Success(3) + } yield a + b + c + +assert(composed.toOption == Some(6)) +assert(composed.toProblems.toList.map(_.message) == List("w1")) +``` + +The result is a `Warning(6)` carrying `w1` — the warning's value survived the chain. A `Failure` anywhere in the comprehension would have short-circuited to `Failure`. To collect problems from *independent* operations instead of stopping at the first, use the [`Parallel`/`Applicative`](#typeclass-instances) instances (`parMapN`, `mapN`, `traverse`). + +## Typeclass instances + +Defined in `ResultInstances` / `ResultInstances0` and summoned implicitly. The pivotal distinction is **accumulation vs short-circuit**: the `Monad`/`flatMap` path stops at the first `Failure`/`InternalError`, while the `Applicative`/`Parallel`/`Semigroup` path accumulates problems from every operand. + +| Instance | Signature | Notes | +| --- | --- | --- | +| `MonadError` | `implicit val grackleMonadErrorForResult: MonadError[Result, Either[Throwable, NonEmptyChain[Problem]]]` | the error type `Either[Throwable, NEC[Problem]]` unifies `InternalError` (left) and `Failure` (right) for `raiseError`/`handleErrorWith`. `flatMap` short-circuits | +| `Parallel` | `implicit def grackleParallelForResult[E]: Parallel.Aux[Result, Result]` | its `Applicative.ap` **accumulates** problems from both operands — enables `parMapN` error accumulation; `InternalError` still dominates | +| `Semigroup` | `implicit def grackleSemigroupForResult[A: Semigroup]: Semigroup[Result[A]]` | defined as `_ combine _`; accumulates, downgrading `Failure` to `Warning` when the other side has a value | +| `Traverse` | `implicit val grackleTraverseFunctorForResult: Traverse[Result]` | `size`/`get` treat `hasValue` as one element | +| `Eq` | `implicit def grackleEqForResult[A: Eq]: Eq[Result[A]]` | defined as `_ === _` | + +Use `parMapN` / `mapN` / `traverse` when you want **all** errors reported at once — this is how the compiler surfaces multiple validation errors in a single response rather than failing on the first. + +## `ResultT[F, A]` + +`ResultT` is the monad transformer that lifts `Result` into an effect `F` (typically a cats-effect `IO` or an `fs2.Stream`). It threads `Result` through `F` in `for`-comprehensions while preserving `Warning`/`Failure`/`InternalError` propagation and problem accumulation across the `F` boundary; it is used pervasively in `queryinterpreter.scala` and `mapping.scala`. + +```scala +final case class ResultT[F[_], A](value: F[Result[A]]) +``` + +| Member / constructor | Signature | +| --- | --- | +| `map` | `def map[B](f: A => B)(implicit F: Functor[F]): ResultT[F, B]` | +| `flatMap` | `def flatMap[B](f: A => ResultT[F, B])(implicit F: Monad[F]): ResultT[F, B]` | +| `liftF` | `def liftF[F[_]: Functor, A](fa: F[A]): ResultT[F, A]` | +| `pure` / `unit` | `def pure[F[_], A](a: A)(implicit F: Applicative[F]): ResultT[F, A]` / `def unit[F[_]](implicit F: Applicative[F]): ResultT[F, Unit]` | +| `fromResult` | `def fromResult[F[_], A](a: Result[A])(implicit F: Applicative[F]): ResultT[F, A]` | +| `success` | `def success[F[_]: Applicative, A](a: A): ResultT[F, A]` | +| `warning` | `def warning[F[_]: Applicative, A](warning: Problem, value: A): ResultT[F, A]` — overloaded for `String` and `NonEmptyChain[Problem]` | +| `failure` | `def failure[F[_]: Applicative, A](p: Problem): ResultT[F, A]` — overloaded for `String` and `NonEmptyChain[Problem]` | +| `internalError` | `def internalError[F[_]: Applicative, A](err: Throwable): ResultT[F, A]` — overloaded for `String` | + +To recover the underlying effect of nested `Result`s, read `.value: F[Result[A]]`. Its `flatMap` mirrors `Result#flatMap`: a `Warning` in the outer step has its problems concatenated onto the inner step's outcome, and `Failure`/`InternalError` short-circuit. + +## `Problem` + +`Problem` is the GraphQL-spec error object that is serialised into the `errors` array of a response. + +```scala +final case class Problem( + message: String, + locations: List[(Int, Int)] = Nil, + path: List[String] = Nil, + extensions: Option[JsonObject] = None +) +``` + +| Field | Type | Meaning | +| --- | --- | --- | +| `message` | `String` | human-readable error text; always emitted | +| `locations` | `List[(Int, Int)]` | source `(line, col)` pairs; encoded as `{"line", "col"}` objects | +| `path` | `List[String]` | response path to the offending field | +| `extensions` | `Option[JsonObject]` | arbitrary structured detail | + +### JSON encoding + +`Problem.ProblemEncoder` (an `implicit val ProblemEncoder: Encoder[Problem]`) emits `message` always, but **omits any empty `locations`/`path`/`extensions`**; `locations` become `{"line", "col"}` objects. The `toString` renders a compact human-readable form. The following are verbatim worked examples from `ProblemSuite`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/compiler/ProblemSuite.scala", "#problem_encoding")) +``` + +The first two assertions show the encoding: with empty `locations` only `message` and `path` appear, and `Problem("foo")` encodes to just `{"message": "foo"}`. The last assertion shows `toString`: `Problem("foo", List(1 -> 2, 5 -> 6), List("bar", "baz"))` renders as `"foo (at bar/baz: 1..2, 5..6)"`. Both locations there are ranges; the snippet does not exercise a point location, but the same `toString` collapses one — a `(a, b)` pair with `a == b`, e.g. `Problem("foo", List(3 -> 3))` — to the bare number `"foo (at 3)"` rather than `"3..3"`. + +### Equality + +`eqProblem` is `Eq.by(p => (p.message, p.locations, p.path))` — it **ignores `extensions`**. Two `Problem`s that differ only in their `extensions` compare equal; a test that needs to assert on `extensions` must compare the encoded JSON (`asJson`), not the `Problem` values. + +## Mapping results to the response + +`Mapping.mkResponse` is the single boundary that turns a `Result[Json]` into a GraphQL response envelope. There are two overloads: + +```scala +def mkResponse(result: Result[Json]): F[Json] +def mkResponse(data: Option[Json], errors: Chain[Problem]): Json +``` + +The `Result` overload raises `InternalError` into the effect with `M.raiseError`, and otherwise delegates to the data/errors overload using `result.toOption` and `result.toProblems`: + +| `Result` case | Response | +| --- | --- | +| `Success(json)` | `{"data": json}` | +| `Warning(problems, json)` | `{"errors": [...], "data": json}` — errors **and** data | +| `Failure(problems)` | `{"errors": [...]}` — no `data` key | +| `InternalError(thr)` | `M.raiseError(thr)` — raised into `F`, never JSON | + +The data/errors overload assembles the JSON directly. As a catch-all, when `data` is `None` **and** the error chain is empty it emits a synthetic `{"errors": [{"message": "Invalid query"}]}`; an empty `Result` should not reach this path in normal flow, but a custom interpreter returning neither data nor problems will get this message. + +## `ValidationFailure` and `Severity` + +`ValidationFailure` is a **separate diagnostic axis** from `Problem`, used only when building or validating a `Schema` or `Mapping` — not for per-request GraphQL errors. Do not confuse `Result.Warning` (a runtime `Problem`) with `Severity.Warning` (a construction-time diagnostic). + +| Type | Signature | Purpose | +| --- | --- | --- | +| `ValidationFailure` | `abstract class ValidationFailure(val severity: Severity) extends AnsiColor` | a coloured, padded `formattedMessage` / `toErrorMessage` diagnostic | +| `Severity` | `sealed trait Severity` with cases `Error`, `Warning`, `Info` | severity with an `Order` instance (`Error`=3 > `Warning`=2 > `Info`=1), so validators can threshold by severity | +| `ValidationException` | `final case class ValidationException(failures: NonEmptyList[ValidationFailure]) extends RuntimeException with NoStackTrace` | aggregates failures; `getMessage` concatenates each failure's `toErrorMessage` | + +See [Validate a mapping and read the failures](../how-to/validate-mappings.md) for how these surface when constructing a mapping. + +## `grackle.syntax` extension methods + +Imported via `import grackle.syntax._`, these lift plain values and `Option`s into `Result`: + +| Method | Signature | Yields | +| --- | --- | --- | +| `success` | `def success: Result[A]` (on any `A`) | `Result.success(a)` | +| `toResult` | `def toResult(ifNone: => Problem): Result[T]` / `def toResult(ifNone: => String): Result[T]` (on `Option[T]`) | `Success` or `Failure(ifNone)` when empty | +| `toResultOrError` | `def toResultOrError(ifNone: => Throwable): Result[T]` / `def toResultOrError(ifNone: => String): Result[T]` (on `Option[T]`) | `Success` or `InternalError(ifNone)` when empty | + +## See also + +- [Construct, accumulate and report errors](../how-to/errors.md) — task-oriented recipes for returning, accumulating and reading these errors. +- [Architecture overview](../concepts/architecture.md) — where `Result` sits in the compile → interpret → respond pipeline. +- [Running operations reference](running-operations.md) — `compileAndRun` and the response envelope `mkResponse` produces. +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — where `Problem`s acquire their locations and paths during elaboration. diff --git a/docs/reference/running-operations.md b/docs/reference/running-operations.md new file mode 100644 index 00000000..f3e72158 --- /dev/null +++ b/docs/reference/running-operations.md @@ -0,0 +1,185 @@ +# Running operations reference + +A `Mapping[F]` is a self-contained, transport-agnostic GraphQL endpoint: everything needed to execute an operation hangs off the mapping instance. This page is the information-oriented reference for the two public entry points — `compileAndRun` (one query/mutation → one JSON response) and `compileAndRunSubscription` (a subscription → an `fs2.Stream` of responses) — the `compile → interpret → mkResponse` pipeline they sit on, the `Operation`/`UntypedOperation` types that flow through it, the per-request parameters (`name`, `untypedVars`, `introspectionLevel`, `reportUnused`, `env`), and the response envelope `mkResponse` produces. It is for any developer driving a mapping. Grackle ships no HTTP or websocket transport of its own — for wiring these methods to http4s see [Serve a Mapping over HTTP](../how-to/serve-over-http.md); all signatures below come from `modules/core/src/main/scala/mapping.scala`, `compiler.scala`, `operation.scala` and `queryinterpreter.scala`. + +## The two entry points + +Both methods live on `Mapping[F]`. `compileAndRunSubscription` is the primitive; `compileAndRun` is defined in terms of it. + +| Method | Signature | Returns | Use for | +| --- | --- | --- | --- | +| `compileAndRun` | `def compileAndRun(text: String, name: Option[String] = None, untypedVars: Option[Json] = None, introspectionLevel: IntrospectionLevel = Full, reportUnused: Boolean = true, env: Env = Env.empty)(implicit sc: Compiler[F, F]): F[Json]` | `F[Json]` — one response | queries and mutations | +| `compileAndRunSubscription` | `def compileAndRunSubscription(text: String, name: Option[String] = None, untypedVars: Option[Json] = None, introspectionLevel: IntrospectionLevel = Full, reportUnused: Boolean = true, env: Env = Env.empty): Stream[F, Json]` | `Stream[F, Json]` — one element per emission | subscriptions (also works for queries/mutations) | + +### The exactly-one-element contract + +`compileAndRun` runs `compileAndRunSubscription`, collects the stream with `.compile.toList`, and asserts the result has **exactly one** element: + +| Stream length | Outcome | +| --- | --- | +| `1` | the single `Json` response | +| `0` | `M.raiseError(new IllegalStateException("Result stream was empty."))` | +| `n > 1` | `M.raiseError(new IllegalStateException(s"Result stream contained $n results; expected exactly one."))` | + +A query or mutation always interprets to a single element, so `compileAndRun` is correct for them. A subscription emits one element per upstream change, so calling `compileAndRun` on a subscription **fails at runtime** (not compile time) with the `IllegalStateException` above. Use `compileAndRunSubscription` for subscriptions. + +### Worked example + +The following runs a subscription stream concurrently while three mutations push new values through a `SignallingRef`, so each mutation drives one element out of the subscription. It exercises both entry points against the in-memory mapping defined earlier in `SubscriptionSuite`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/subscription/SubscriptionSuite.scala", "#run_ops")) +``` + +`compileAndRunSubscription("subscription { watch }")` yields a `Stream[IO, Json]`; `.take(4).compile.toList.start` runs it in a fiber so it can observe later emissions. Each `compileAndRun("mutation { put(n: …) }")` returns an `IO[Json]` (one response) and, as a side effect, updates the `SignallingRef` the subscription is watching — producing the next stream element. The `IO.sleep`s only sequence the demonstration; they are not part of the API. + +## The compile → interpret → mkResponse pipeline + +`compileAndRunSubscription` is a thin composition of three stages. Reading its body left to right: + +```text +text ──▶ compiler.compile(text, name, untypedVars, ──▶ Result[Operation] + introspectionLevel, + reportUnused, env) + +Operation ──▶ interpreter.run(op.query, op.rootTpe, env) ──▶ Stream[F, Result[Json]] + +Result[Json] ──▶ mkResponse ──▶ F[Json] (per element) +``` + +The two members it dispatches to are also public on the mapping: + +| Member | Signature | Role | +| --- | --- | --- | +| `compiler` | `lazy val compiler: QueryCompiler` | parses, validates, binds variables and elaborates `text` into a `Result[Operation]`. Built from the schema + compiler phases; accessing it for the first time runs `typeMappings.unsafeValidateIfChecked()` | +| `interpreter` | `val interpreter: QueryInterpreter[F] = new QueryInterpreter(this)` | executes a compiled `Operation` against the mapping's data | + +### `QueryCompiler.compile` + +```scala +def compile( + text: String, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): Result[Operation] +``` + +`compile` runs, in order: parsing (`QueryParser.parseText`), operation selection by `name`, variable and fragment validation, field-merge validation, variable binding, directive validation, and the elaborator phases (including the `IntrospectionElaborator` gated by `introspectionLevel`). Its output is a `Result[Operation]`. See [The compiler and elaboration](../concepts/compiler-elaboration.md) for the phase model and [Elaboration phases reference](elab-phases.md) for the phase list. + +### `QueryInterpreter.run` + +```scala +def run(query: Query, rootTpe: Type, env: Env): Stream[F, Result[Json]] +``` + +`run` dispatches on `rootTpe`: when it is the schema's subscription type (`schema.subscriptionType.exists(_ =:= rootTpe)`) it drives `runSubscription` (many elements); otherwise it runs a single one-shot interpretation (one element). This is why a subscription's stream can emit repeatedly while a query/mutation's emits once — the [exactly-one-element contract](#the-exactly-one-element-contract) above follows directly from this dispatch. + +## `Operation` and `UntypedOperation` + +These are the two forms an operation takes either side of compilation. `parseText` produces `UntypedOperation`s; `compile` selects one and elaborates it into an `Operation`. + +| Type | Signature | Stage | +| --- | --- | --- | +| `UntypedOperation` | `sealed trait UntypedOperation { val name: Option[String]; val query: Query; val variables: UntypedVarDefs; val directives: List[Directive]; def rootTpe(schema: Schema): Result[NamedType] }` | parser output (pre-compile) | +| `Operation` | `case class Operation(query: Query, rootTpe: NamedType, directives: List[Directive])` | compiler output (executable) | + +`UntypedOperation` has three cases — `UntypedQuery`, `UntypedMutation`, `UntypedSubscription` — each carrying the same fields. Its `rootTpe(schema)` resolves the matching root operation type, and fails when the schema does not define one: + +| Case | `rootTpe(schema)` resolves to | Failure when absent | +| --- | --- | --- | +| `UntypedQuery` | `schema.queryType` | — (every schema has a query type) | +| `UntypedMutation` | `schema.mutationType` | `"No mutation type defined in this schema."` | +| `UntypedSubscription` | `schema.subscriptionType` | `"No subscription type defined in this schema."` | + +An `Operation` is what the interpreter consumes: `op.query` is the elaborated [query algebra](query-algebra.md) term, `op.rootTpe` is the resolved root operation type (used by `run` to choose query vs subscription execution), and `op.directives` are any operation-level directives. + +`UntypedVarDefs` (on `UntypedOperation`) are the variable *declarations* parsed from the operation's `($x: T)` header; the request's variable *values* arrive separately as `untypedVars` and are bound to those declarations during compilation. + +### Parsing + +`QueryParser.parseText` is the parse-only stage feeding `compile`: + +```scala +def parseText(text: String): Result[(List[UntypedOperation], List[UntypedFragment])] +``` + +It turns document text into the untyped operations and fragments (the pre-compile AST) and fails with `"At least one operation required"` on an empty document. "Parse" is purely syntactic; "compile" additionally validates, binds and elaborates. + +## Per-request parameters + +The parameters shared by `compileAndRun`, `compileAndRunSubscription` and `QueryCompiler.compile`: + +| Parameter | Type / default | Meaning | Notable failures | +| --- | --- | --- | --- | +| `text` | `String` | the GraphQL document | `"At least one operation required"` if empty | +| `name` | `Option[String] = None` | `operationName` — selects one operation when the document defines several | with multiple operations and `None`: `"Operation name required to select unique operation"`; also `"No operation named …"` / `"Multiple operations named …"`; `"Query shorthand cannot be combined with multiple operations"` | +| `untypedVars` | `Option[Json] = None` | request variables, as a JSON **object** | `"Variables must be represented as a Json object"` if the `Json` is not an object | +| `introspectionLevel` | `IntrospectionLevel = Full` | how much introspection to permit (see below) | introspection fields rejected per level | +| `reportUnused` | `Boolean = true` | report unused variables/fragments as accumulated problems; set `false` to silence | — | +| `env` | `Env = Env.empty` | request-scoped context (see below) | — | + +With a **single** operation, `name` may be `None`. With **multiple** operations, `name` is required. + +### `IntrospectionLevel` + +Defined in `compiler.scala`; controls how much GraphQL introspection (`__schema` / `__type` / `__typename`) a request may use. + +| Value | Permits | +| --- | --- | +| `Full` (default) | all introspection — the standard `IntrospectionQuery` issued by GraphQL Playground / GraphiQL | +| `TypenameOnly` | only `__typename` | +| `Disabled` | no introspection; `__schema` / `__type` are rejected | + +```scala +sealed trait IntrospectionLevel +object IntrospectionLevel { + case object Full extends IntrospectionLevel + case object TypenameOnly extends IntrospectionLevel + case object Disabled extends IntrospectionLevel +} +``` + +Setting `Disabled` (or `TypenameOnly`) breaks client tools' schema discovery while still allowing normal queries. + +### `env` + +`env: Env` threads request-scoped values — auth, tenancy, feature flags — into elaborators and effectful root fields. It is reachable from a [`CursorField`](mapping-types.md) or effect handler via `cursor.env[T](key)`. For example, `compileAndRun(query, env = Env("secure" -> true))` makes `"secure"` available to a field resolver that reads `c.env[Boolean]("secure")`. See [Context & Env reference](context-env.md) for the full `Env` API. + +## The response envelope + +Each `Result[Json]` from the interpreter passes through `mkResponse`, the single boundary that produces the spec-shaped response. There are two overloads: + +```scala +def mkResponse(result: Result[Json]): F[Json] +def mkResponse(data: Option[Json], errors: Chain[Problem]): Json +``` + +The `Result` overload maps each [`Result`](result-problem.md) case to a response, and — crucially — **does not encode `InternalError` as JSON**: + +| `Result` case | Response | +| --- | --- | +| `Success(json)` | `{"data": json}` | +| `Warning(problems, json)` | `{"errors": […], "data": json}` — errors **and** data | +| `Failure(problems)` | `{"errors": […]}` — no `data` key | +| `InternalError(thr)` | `M.raiseError(thr)` — raised into the effect `F`, never JSON | + +So GraphQL validation and execution failures (`Failure` / `Warning`) come back as a normal response with an `errors` array, while an `InternalError` propagates as an effect failure — over HTTP this becomes a 500, handled by your transport layer rather than appearing in the `errors` array. The pure `(data, errors)` overload assembles the JSON directly and, as a catch-all, emits a synthetic `{"errors": [{"message": "Invalid query"}]}` when there is neither data nor any error. + +## `combineAndRun` + +```scala +def combineAndRun(queries: List[(Query, Cursor)]): F[Result[List[ProtoJson]]] +``` + +`combineAndRun` is a lower-level staging hook, **not** part of the normal request path. It combines and executes multiple already-compiled queries, each interpreted in the context of its paired [`Cursor`](cursor.md), returning a result list aligned with the input. It is invoked internally at stage boundaries to evaluate deferred subqueries, and a composed mapping (one extending `ComposedMapping`) may override it to batch those subqueries across backends. You do not call it to serve a request — use `compileAndRun` / `compileAndRunSubscription`. + +## See also + +- [Serve a Mapping over HTTP](../how-to/serve-over-http.md) — wire these methods to http4s GET/POST routes and an Ember server. +- [Mutations & subscriptions tutorial](../tutorial/mutations-subscriptions.md) — build a mapping that exercises `compileAndRun` and `compileAndRunSubscription` end to end. +- [Result, Problem & ResultT reference](result-problem.md) — the `Result` ADT behind the response envelope and `mkResponse`. +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — what `QueryCompiler.compile` does to produce an `Operation`. +- [The query interpreter](../concepts/query-interpreter.md) — how `QueryInterpreter.run` executes an `Operation` against a mapping. +- [Context & Env reference](context-env.md) — the `Env` you pass as the request-scoped `env` parameter. diff --git a/docs/reference/schema-sdl.md b/docs/reference/schema-sdl.md new file mode 100644 index 00000000..7f2b2b8f --- /dev/null +++ b/docs/reference/schema-sdl.md @@ -0,0 +1,315 @@ +# Schema & SDL reference + +This page is an information-oriented reference for Grackle's schema model and its SDL front end: the `Schema` trait and its companion factory, the `Type`/`NamedType` hierarchy, the type operators, directive definitions, the parser entry points, and the renderer that turns a `Schema` back into SDL. It is for developers looking up exact signatures and semantics; for narrative on the model see [The schema model](../concepts/schema-model.md) and [Nullability and lists](../concepts/nullability-lists.md). All members below live in `modules/core/src/main/scala/schema.scala` unless noted, and you bring them into scope with `import grackle._` (and `import grackle.syntax._` for the interpolators). + +## Entry points: `schema"..."` vs `Schema(text)` + +There are two ways to build a `Schema` from SDL text. They differ in *when* validation runs and in *what* they return. + +| Entry point | Import | Validated | Returns | Use when | +| --- | --- | --- | --- | --- | +| `schema"""..."""` | `grackle.syntax._` | compile time | bare `Schema` | the SDL is a literal known at compile time | +| `Schema(text)` | `grackle._` | runtime | `Result[Schema]` | the SDL is dynamic, or you want to inspect validation `Problem`s | + +The `schema"..."` string interpolator is the idiomatic choice. It parses and fully validates the SDL **at compile time**, so an invalid schema is a compile error (its message is prefixed `Invalid schema:` followed by 🐞 bullets). It expands to `Schema(s, CompiletimeParsers.schemaParser).toOption.get`, which is why it hands back a bare `Schema` rather than a `Result[Schema]`. Here is the canonical definition from the StarWars demo — a `Query` root, an `enum`, an `interface`, and two object types that implement it: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("demo/src/main/scala/demo/starwars/StarWarsMapping.scala", "#schema")) +``` + +Note the SDL above uses ordinary GraphQL nullability: `Character!` is non-null, `Character` is nullable, `[Character!]` is a nullable list of non-null elements. How those map onto the internal model is covered under [Type modifiers and the non-null-by-default convention](#type-modifiers-and-the-non-null-by-default-convention). + +The runtime factory `Schema(text): Result[Schema]` is the companion `apply`. It accumulates validation failures into a `Result.Failure(NonEmptyChain[Problem])` instead of failing the build, so it is what you reach for when the SDL is not statically known or when you want to read the errors: + +```scala mdoc:silent +import grackle.{Result, Schema} + +val parsed: Result[Schema] = + Schema(""" + type Query { + episodeById(id: String!): Episod + } + type Episode { + id: String! + } + """) + +val names: Result[List[String]] = + parsed.map(_.types.map(_.name)) // Failure here: "Reference to undefined type 'Episod'" +``` + +| Companion member | Signature | +| --- | --- | +| `Schema.apply` | `def apply(schemaText: String)(implicit pos: SourcePos): Result[Schema]` | +| `Schema.apply` (custom parser) | `def apply(schemaText: String, parser: SchemaParser)(implicit pos: SourcePos): Result[Schema]` | + +> Both forms require an implicit `SourcePos`; the interpolators supply it for you. + +## The `Schema` trait + +A `Schema` is an immutable collection of `NamedType` declarations plus directive definitions and extensions, with the lookup helpers that mappings and elaborators use to wire themselves to types by name. + +| Member | Signature | Notes | +| --- | --- | --- | +| `pos` | `def pos: SourcePos` | source position of the definition | +| `baseTypes` | `def baseTypes: List[NamedType]` | declared types *before* extensions are merged | +| `types` | `lazy val types: List[NamedType]` | `baseTypes` with any `extend` merged in | +| `directives` | `def directives: List[DirectiveDef]` | declared directives plus the five built-ins | +| `schemaExtensions` | `def schemaExtensions: List[SchemaExtension]` | `extend schema ...` blocks | +| `typeExtensions` | `def typeExtensions: List[TypeExtension]` | `extend type/interface/...` blocks | +| `definition` | `def definition(name: String): Option[NamedType]` | look up a type by name | +| `ref` | `def ref(tpnme: String): TypeRef` | checked by-name ref; throws `IllegalArgumentException` if undefined | +| `uncheckedRef` | `def uncheckedRef(tpnme: String): TypeRef` | unchecked by-name ref (may dangle) | +| `uncheckedRef` | `def uncheckedRef(tpe: NamedType): TypeRef` | unchecked ref from a known `NamedType` | +| `baseSchemaType` | `def baseSchemaType: NamedType` | the `schema { ... }` root type, before extensions | +| `schemaType` | `lazy val schemaType: NamedType` | root type with extensions merged | +| `queryType` | `def queryType: NamedType` | the `query` root; **throws** `NoSuchElementException` if absent | +| `mutationType` | `def mutationType: Option[NamedType]` | the `mutation` root, if any | +| `subscriptionType` | `def subscriptionType: Option[NamedType]` | the `subscription` root, if any | +| `isRootType` | `def isRootType(tpe: Type): Boolean` | is this one of the operation roots | +| `implementations` | `def implementations(it: InterfaceType): List[ObjectType]` | object types implementing an interface | +| `subtypes` | `def subtypes(tpe: NamedType): Set[NamedType]` | all types `<:<` the given type | + +> `queryType` is non-optional and ends in `.get`; a schema with no query root throws `NoSuchElementException`. `mutationType`/`subscriptionType` are safe `Option`s. If you omit an explicit `schema { query mutation subscription }` block, Grackle synthesises a default root referencing the types named `Query`, `Mutation`, and `Subscription` — but only those that actually exist. + +Obtaining a `TypeRef` with `schema.ref(name)` is the normal way to refer to a type from a mapping or elaborator. The result is a lazy, by-name reference (see [`TypeRef`](#namedtype-the-named-hierarchy) below) and is the one type form designed to be safe to compare with `==`. + +## `Type`: the root of the hierarchy + +`Type` is the sealed root of every GraphQL type. It carries all the navigation, nullability, list, and subtyping operators; concrete cases are the named types, plus the two unnamed modifiers `ListType` and `NullableType`. + +```text +Type +├─ NamedType (has a schema-defined `name`) +│ ├─ ScalarType +│ ├─ EnumType +│ ├─ InterfaceType ─┐ TypeWithFields +│ ├─ ObjectType ─┘ +│ ├─ UnionType +│ ├─ InputObjectType +│ └─ TypeRef (by-name reference; only `==`-safe form) +├─ ListType(ofType) (unnamed modifier — a list) +└─ NullableType(ofType) (unnamed modifier — optionality) +``` + +### `NamedType`: the named hierarchy + +`NamedType` is the subset of `Type` that has a schema-defined `name`, a `description`, and `directives`. `dealias` on a `NamedType` returns itself, except for `TypeRef`, which resolves to its target. + +```scala +sealed trait NamedType extends Type { + def name: String + def description: Option[String] + def directives: List[Directive] +} +``` + +| Case | Signature (selected fields) | Notes | +| --- | --- | --- | +| `ScalarType` | `case class ScalarType(name, description, directives)` | `isBuiltIn: Boolean`, `specifiedByURL: Option[String]`. Companion exposes `IntType`, `FloatType`, `StringType`, `BooleanType`, `IDType`, and `builtIn(name)` | +| `EnumType` | `case class EnumType(name, description, enumValues: List[EnumValueDefinition], directives)` | `hasValue(name)`, `value(name): Option[EnumValue]`, `valueDefinition(name)` | +| `InterfaceType` | `case class InterfaceType(name, description, fields: List[Field], interfaces: List[NamedType], directives)` | a `TypeWithFields`; may itself implement interfaces | +| `ObjectType` | `case class ObjectType(name, description, fields: List[Field], interfaces: List[NamedType], directives)` | a `TypeWithFields` | +| `UnionType` | `case class UnionType(name, description, members: List[NamedType], directives)` | members must be object types (validated) | +| `InputObjectType` | `case class InputObjectType(name, description, inputFields: List[InputValue], directives)` | `inputFieldInfo(name)`, `isOneOf: Boolean` | +| `TypeRef` | `case class TypeRef private[grackle] (schema: Schema, name: String)` | `dealias` resolves to the target; `exists` reports whether it is defined | + +> `TypeRef`'s constructor is `private[grackle]` — you cannot `new TypeRef(...)`. Always obtain one via `schema.ref` (checked) or `schema.uncheckedRef` (unchecked). An unchecked ref to an undefined name produces a dangling `TypeRef` whose `exists` is `false` and whose navigation methods return `None`/`false`. + +The field-bearing types (`ObjectType`, `InterfaceType`) share these member structures: + +| Type | Signature | Mixin | +| --- | --- | --- | +| `Field` | `case class Field(name, description, args: List[InputValue], tpe: Type, directives: List[Directive])` | `Deprecatable` | +| `InputValue` | `case class InputValue(name, description, tpe: Type, defaultValue: Option[Value], directives)` | `Deprecatable` | +| `EnumValueDefinition` | `case class EnumValueDefinition(name, description, directives)` | `Deprecatable` | + +`Deprecatable` supplies `isDeprecated: Boolean` and `deprecationReason: Option[String]`, sourced from an applied `@deprecated` directive. + +### Type modifiers and the non-null-by-default convention + +`ListType` and `NullableType` are the two unnamed `Type` cases. They wrap another `Type`: + +```scala +case class ListType(ofType: Type) extends Type +case class NullableType(ofType: Type) extends Type +``` + +In Grackle's model **types are non-null by default**. A bare `ScalarType` or `TypeRef` is already non-null; optionality is represented by an explicit `NullableType` wrapper. This is the *opposite* of GraphQL SDL, where `String` is nullable and `String!` is non-null. The parser therefore wraps every non-`!` type in `NullableType`: + +| SDL | Internal model | +| --- | --- | +| `String!` | `StringType` | +| `String` | `NullableType(StringType)` | +| `[String!]!` | `ListType(StringType)` | +| `[String!]` | `NullableType(ListType(StringType))` | +| `[String]` | `NullableType(ListType(NullableType(StringType)))` | + +Write SDL with ordinary GraphQL nullability — the inversion is an internal detail you only meet when you pattern-match on the `Type` ADT. The parser bounds list-modifier nesting at `maxListTypeDepth` (default 5); see [Parser configuration](#parser-configuration). For the full rationale see [Nullability and lists](../concepts/nullability-lists.md). + +## Type operators + +Every `Type` carries these operators. Prefer `=:=` over `==`: `==` distinguishes a type from a `TypeRef` alias to it, which is almost never what you want. (The one designed exception is `TypeRef`s obtained from the *same* schema via `schema.ref` — those *are* `==`-comparable, and that is exactly why elaborator pattern matches use them.) + +| Operator | Signature | Meaning | +| --- | --- | --- | +| `=:=` | `def =:=(other: Type): Boolean` | alias-aware equivalence (sees through `TypeRef`/aliases) | +| `<:<` | `def <:<(other: Type): Boolean` | subtype relation (object `<:<` interface, member `<:<` union, list/nullable covariance) | +| `nominal_=:=` | `def nominal_=:=(other: Type): Boolean` | compares by underlying name | +| `dealias` | `def dealias: Type` | strips `TypeRef` aliasing | +| `isNullable` | `def isNullable: Boolean` | is the outermost wrapper a `NullableType` | +| `nullable` | `def nullable: Type` | wrap in `NullableType` (idempotent) | +| `nonNull` | `def nonNull: Type` | strip an outer `NullableType` | +| `isList` | `def isList: Boolean` | is the (non-null) type a `ListType` | +| `item` | `def item: Option[Type]` | element type of a list, if any | +| `list` | `def list: Type` | wrap in `ListType` | +| `underlying` | `def underlying: Type` | strip *all* list/nullable modifiers | +| `underlyingObject` | `def underlyingObject: Option[NamedType]` | underlying object/interface/union, if any | +| `underlyingNamed` | `def underlyingNamed: NamedType` | the underlying `NamedType` | +| `underlyingField` | `def underlyingField(fieldName: String): Option[Type]` | a field's type after stripping modifiers | +| `isLeaf` | `def isLeaf: Boolean` | is the type a scalar or enum | +| `asNamed` | `def asNamed: Option[NamedType]` | this as a `NamedType`, if it is one | +| `field` | `def field(fieldName: String): Option[Type]` | the result type of a named field | +| `fieldInfo` | `def fieldInfo(fieldName: String): Option[Field]` | the full `Field` (args, directives, …) | +| `hasField` | `def hasField(fieldName: String): Boolean` | does the field exist | +| `path` | `def path(fns: List[String]): Option[Type]` | follow a chain of field names | +| `pathIsList` | `def pathIsList(fns: List[String]): Boolean` | does a path traverse a list | +| `pathIsNullable` | `def pathIsNullable(fns: List[String]): Boolean` | does a path traverse an optional | +| `/` | `def /(pathElement: String): Path` | build a `Path` for predicates | +| `directives` | `def directives: List[Directive]` | applied directives on the type | + +`field`/`fieldInfo` resolve through `dealias`, so they work on a `TypeRef` for a defined type. `field` returns the field's *result type* (with modifiers); `fieldInfo` returns the whole `Field`, from which you read arguments and applied directives. + +## Directives + +A directive declaration in SDL — `directive @name(args) [repeatable] on LOC1 | LOC2` — parses into a `DirectiveDef`. An applied occurrence (`@name(...)` on a type, field, enum value, …) becomes a `Directive`. + +| Type | Signature | +| --- | --- | +| `DirectiveDef` | `case class DirectiveDef(name, description, args: List[InputValue], isRepeatable: Boolean, locations: List[DirectiveLocation])` | +| `Directive` | `case class Directive(name: String, args: List[Binding])` | + +Read applied directives off the model via `Type.directives`, `Field.directives`, etc. — for example `schema.ref("User").dealias.fieldInfo("email").get.directives`. To define your own, see [Define and use schema directives](../how-to/schema-directives.md). + +The companion `object DirectiveDef` holds the **five built-in directive definitions** that are appended to every parsed schema, and `validateDirectivesForSchema` checks every applied occurrence against its definition's locations, repeatability, and argument types: + +| Built-in | Purpose | First-class accessor | +| --- | --- | --- | +| `DirectiveDef.Skip` (`@skip`) | conditionally omit a field | — | +| `DirectiveDef.Include` (`@include`) | conditionally include a field | — | +| `DirectiveDef.Deprecated` (`@deprecated`) | mark a field/enum value deprecated | `isDeprecated`, `deprecationReason` (via `Deprecatable`) | +| `DirectiveDef.SpecifiedBy` (`@specifiedBy`) | record a scalar's specification URL | `ScalarType.specifiedByURL` | +| `DirectiveDef.OneOf` (`@oneOf`) | mark an input object as exactly-one-of | `InputObjectType.isOneOf` | + +`DirectiveDef.builtIns` is the `List[DirectiveDef]` of all five. They are always present in `schema.directives` even though `SchemaRenderer` deliberately omits them from `toString`. + +| Validation entry point | Signature | +| --- | --- | +| `Directive.fromAst` | `def fromAst(d: Ast.Directive): Result[Directive]` | +| `Directive.validateDirectivesForSchema` | `def validateDirectivesForSchema(schema: Schema): List[Problem]` | +| `Directive.validateDirectives` | `def validateDirectives(schema: Schema, location: Ast.DirectiveLocation, directives: List[Directive], vars: Vars): List[Problem]` | + +### `Ast.DirectiveLocation` + +The complete set of locations a directive may be declared `on`, defined in `modules/core/src/main/scala/ast.scala`: + +```text +Executable locations Type-system locations +──────────────────── ───────────────────── +QUERY SCHEMA +MUTATION SCALAR +SUBSCRIPTION OBJECT +FIELD FIELD_DEFINITION +FRAGMENT_DEFINITION ARGUMENT_DEFINITION +FRAGMENT_SPREAD INTERFACE +INLINE_FRAGMENT UNION +VARIABLE_DEFINITION ENUM + ENUM_VALUE + INPUT_OBJECT + INPUT_FIELD_DEFINITION +``` + +## Validation + +`SchemaParser.parseDocument` runs `SchemaValidator.validateSchema` on construction, accumulating a `Problem` per structural failure. Both `schema"..."` (at compile time) and `Schema(text)` (into a `Result.Failure(NonEmptyChain[Problem])`) report these. The checks include: + +| Check | Example failure | +| --- | --- | +| References to undefined types | `Reference to undefined type 'Episod'` | +| Duplicate type / field / enum-value definitions | duplicate declaration rejected | +| Non-object or duplicate union members | `Non-object type ... included in union ...` | +| Interface implementation conformance | missing field, non-`<:<` field type, or mismatched args | +| Transitive-interface obligations | `Type X does not directly implement transitively implemented interface Y` | +| Interface implements-cycles | `Interface cycle starting from ...` | +| Empty composite types | `object type X must define at least one field` | +| Type-extension target mismatches | cannot apply an object extension to a non-object | +| Directive validity | location / repeatability / argument-type checks | + +> Interface conformance checks field arguments by exact match (name and type, via `==` not `<:<`). `@oneOf` input objects may not declare any non-nullable field — doing so fails at parse time (`oneOf input object type X may not have non-nullable field(s): ...`). For working with the resulting `Problem`s, see the [`Result`, `Problem` & `ResultT` reference](result-problem.md), and to validate a *mapping* against its schema see [Validate a mapping and read the failures](../how-to/validate-mappings.md). + +## Custom scalars + +A custom scalar is declared in SDL with `scalar Name` (optionally `@specifiedBy(url: ...)`). It produces a `ScalarType` with `isBuiltIn == false`. **The schema layer records only the scalar's name** — it does not know how to encode or decode the scalar's values. That belongs to the mapping layer, supplied via `LeafMapping[A]` plus `Value` extractors in the mapping/elaborator. Defining `scalar UUID` alone does nothing at runtime until a `LeafMapping` and the relevant `Value` pattern-matching are provided. + +The five built-in scalar names — `Int`, `Float`, `String`, `Boolean`, `ID` — are always available without declaration; re-declaring one via `scalar Int` is accepted and maps to the built-in. A reference to an undeclared, non-built-in scalar (a typo such as `Long`) fails validation with `Reference to undefined type 'Long'`. See [Define custom scalars and enums](../how-to/custom-scalars-enums.md) for the end-to-end wiring. + +## Parser configuration + +The SDL/query parser is `GraphQLParser` (cats-parse based), in `modules/core/src/main/scala/parser.scala`. `SchemaParser` lowers parsed SDL into a validated `Schema`. + +| Type | Signature | +| --- | --- | +| `GraphQLParser` | `trait GraphQLParser { def parseText(text: String): Result[Ast.Document] }` | +| `GraphQLParser` companion | `def apply(config: Config): GraphQLParser`; `val defaultConfig: Config` | +| `SchemaParser` | `trait SchemaParser { def parseText(text: String)(implicit pos: SourcePos): Result[Schema]; def parseDocument(doc: Ast.Document)(implicit sourcePos: SourcePos): Result[Schema] }` | +| `SchemaParser` companion | `def apply(parser: GraphQLParser): SchemaParser` | + +`GraphQLParser.Config` bounds parse depth and width. Exceeding any limit is a parse failure; supply a custom `Config` to raise them. + +| `Config` field | Default | Bounds | +| --- | --- | --- | +| `maxSelectionDepth` | `100` | nesting depth of a selection set | +| `maxSelectionWidth` | `1000` | number of selections at one level | +| `maxInputValueDepth` | `5` | nesting depth of an input value literal | +| `maxListTypeDepth` | `5` | nesting depth of `[...]` list type modifiers | +| `terseError` | `true` | shorter error rendering | + +To build the stack explicitly: + +```scala mdoc:silent +import grackle.{GraphQLParser, SchemaParser} + +val parser = GraphQLParser(GraphQLParser.defaultConfig) +val schemaParser = SchemaParser(parser) +``` + +> `grackle.Ast` is the untyped parse tree that `GraphQLParser.parseText` produces (`Document = List[Definition]`, `TypeDefinition`, `FieldDefinition`, `Ast.Type.{Named,List,NonNull}`, `Ast.Value`, `DirectiveLocation`, …). `SchemaParser.parseDocument` lowers an `Ast.Document` into the typed `Schema`. The `doc"..."` interpolator yields a raw `Ast.Document`. + +## Rendering: `SchemaRenderer` and `Schema.toString` + +`Schema.toString` delegates to `SchemaRenderer.renderSchema`, producing SDL. Built-in directive definitions are omitted from the output, so a round-tripped schema will not show `@skip`/`@include`/`@deprecated`/`@specifiedBy`/`@oneOf` definitions even though `schema.directives` contains them. + +| Renderer member | Signature | +| --- | --- | +| `renderSchema` | `def renderSchema(schema: Schema): String` | +| `renderType` | `def renderType(tpe: Type): String` | +| `renderTypeDefn` | `def renderTypeDefn(tpe: NamedType): String` | +| `renderDirective` | `def renderDirective(d: Directive): String` | +| `renderValue` | `def renderValue(value: Value): String` | + +The round trip — parse SDL to a `Schema`, then render it back — is exercised directly by the test suite. This case parses a three-type schema (including a default-valued argument `author(id: Int! = 23)`) and asserts that `schemaParser.parseText(schema).map(_.toString)` reproduces the original SDL: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/core/src/test/scala/sdl/SDLSuite.scala", "#sdl_roundtrip")) +``` + +The default value survives the round trip, and the rendered output matches the input byte-for-byte (`assertEquals(ser, schema.success)`). + +## See also + +- [Quick start: your first query](../getting-started/quick-start.md) — define a schema and run a query end to end. +- [The schema model](../concepts/schema-model.md) — the rationale behind `Type`, `NamedType`, and `TypeRef`. +- [Nullability and lists](../concepts/nullability-lists.md) — why the model inverts SDL nullability, in depth. +- [Define custom scalars and enums](../how-to/custom-scalars-enums.md) — wire `scalar`/`enum` declarations to JVM types. +- [Define and use schema directives](../how-to/schema-directives.md) — declare, apply, and read directives. +- [`Result`, `Problem` & `ResultT` reference](result-problem.md) — the error model that validation reports through. diff --git a/docs/reference/sql-mapping.md b/docs/reference/sql-mapping.md new file mode 100644 index 00000000..7c350fe3 --- /dev/null +++ b/docs/reference/sql-mapping.md @@ -0,0 +1,262 @@ +# SqlMapping reference + +This page is the information-oriented reference for Grackle's SQL field mappings: the field-mapping types (`SqlField`, `SqlObject`, `SqlJson`), the `Join` forms, the `ColumnRef`/`TableDef`/`RootDef`/`col` column vocabulary, the `SqlInterfaceMapping`/`SqlUnionMapping`/`SqlDiscriminator` constructors, the `FailedJoin` sentinel, the per-backend `col` signatures for doobie and skunk, and the consistency rules the mapping validator enforces. It is for developers building SQL-backed mappings who want exact signatures rather than a walkthrough — for task-oriented recipes see [Choose and configure a SQL backend](../how-to/sql-backends.md). Every type below lives in the database-agnostic `SqlMappingLike[F]` trait in `modules/sql-core/src/main/scala/SqlMapping.scala` unless noted. + +## SqlField + +`SqlField` maps a GraphQL leaf (scalar/enum) field to a single SQL column. + +```scala +case class SqlField( + fieldName: String, + columnRef: ColumnRef, + key: Boolean = false, + discriminator: Boolean = false, + hidden: Boolean = false, + associative: Boolean = false +)(implicit val pos: SourcePos) extends SqlFieldMapping +``` + +The four booleans are the flags you tune per column: + +| Flag | Default | Meaning | +| --- | --- | --- | +| `key` | `false` | The column is an identity column used to group and assemble rows. Every object-type mapping must have at least one key field (direct or inherited). | +| `hidden` | `false` | The column is fetched and usable for joins/keys/discriminators but omitted from the GraphQL response. Use it on columns that exist only to support the mapping (foreign keys, discriminator columns). | +| `discriminator` | `false` | The column is read by an interface/union [`SqlDiscriminator`](#sqldiscriminator) to choose the concrete type of a row. | +| `associative` | `false` | The key is *not* a database primary key and may repeat across rows (for example a language code shared by many countries), so rows still group correctly. Only valid together with `key = true`. | + +## SqlObject + +`SqlObject` maps a GraphQL sub-object or list field. Its behaviour is decided entirely by whether you pass any [`Join`](#join)s. + +```scala +case class SqlObject(fieldName: String, joins: List[Join])(implicit val pos: SourcePos) + extends SqlFieldMapping + +object SqlObject { + def apply(fieldName: String, joins: Join*)(implicit pos: SourcePos): SqlObject +} +``` + +| Form | Semantics | +| --- | --- | +| `SqlObject("synopses")` (no `Join`) | *Embedding* — the child object is built from columns on the parent's own row/table. The child's `SqlField`s point at the parent table, and the child mapping repeats the parent key (usually `hidden = true`) so rows group. | +| `SqlObject("cities", Join(a, b))` | *Joining* — Grackle emits a SQL join from parent columns to child columns and assembles the child from the joined rows. | +| `SqlObject("x", j1, j2, ...)` | *Chained joins* — several `Join`s pass through intermediate (associative) tables. | + +The canonical multi-table example is the world mapping. The snip below shows `SqlField` key/hidden flags, one-to-many and many-to-one `SqlObject` joins, and an `associative` key on `Language.language`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlWorldMapping.scala", "#world_typemappings")) +``` + +Here `Country.code` and `City.id` are `key = true, hidden = true` — fetched to assemble rows, absent from GraphQL output. `SqlObject("cities", Join(country.code, city.countrycode))` is a one-to-many join and `SqlObject("country", Join(city.countrycode, country.code))` is the reverse many-to-one join reusing the same column pair. `Language.language` is `associative = true` because a language code repeats across many countries. + +## SqlJson + +`SqlJson` maps a GraphQL object/interface field onto a single `jsonb` column. Unlike `SqlObject`, the JSON sub-tree below it is navigated entirely in-process by a `CirceCursor` — no further SQL is issued for nested objects, enums, arrays, inline fragments, or `__typename`. + +```scala +case class SqlJson(fieldName: String, columnRef: ColumnRef)(implicit val pos: SourcePos) + extends SqlFieldMapping { + def subtree: Boolean = true +} +``` + +- `subtree = true` signals that child selections are resolved from the decoded JSON, not SQL-mapped. +- Because the sub-tree is a Circe value, you **cannot** join out of, filter on, or order by fields inside it. +- A non-nullable `SqlJson` field whose column is actually NULL/absent errors with `expected jsonb value`. +- The same `jsonb` column can back both a nullable and a non-null GraphQL field (the `record`/`nonNullRecord` pattern in the jsonb tests). + +For a worked recipe see [Map a jsonb column with SqlJson](../how-to/jsonb-columns.md). + +## Join + +A `Join` is one join's ON conditions, expressed as parent → child column pairs. + +```scala +case class Join(conditions: List[(ColumnRef, ColumnRef)]) + +object Join { + def apply(parent: ColumnRef, child: ColumnRef): Join // single-pair sugar +} +``` + +| Form | Produces | +| --- | --- | +| `Join(parentCol, childCol)` | A single ON equality `parentCol = childCol`. | +| `Join(List((p1, c1), (p2, c2)))` | A composite-key join — all pairs are ANDed in the ON clause. Each parent column is usually `key = true`. | +| `SqlObject("x", j1, j2, ...)` | A chain of joins through intermediate tables; each `Join`'s parent/child tables must line up with its neighbours. | + +`conditions.head` determines a join's parent and child tables, so an empty condition list is invalid (`NoJoinConditions`), and chained joins whose tables do not line up fail with `MisalignedJoins`/`InconsistentJoinConditions`. + +## ColumnRef, TableDef, RootDef, TableName and col + +Every mapped column is a `ColumnRef`. You never construct it directly — you call `col(name, codec)` inside a `TableDef` or `RootDef`, which supplies the table name (via an implicit `TableName`), the Scala type name, and the source position. + +```scala +case class ColumnRef( + table: String, + column: String, + codec: Codec, + scalaTypeName: String, + pos: SourcePos) +``` + +```scala +case class TableName(name: String) +object TableName { + val rootName = "" + val rootTableName = TableName(rootName) +} + +class TableDef(name: String) { + implicit val tableName: TableName = TableName(name) +} + +class RootDef { + implicit val tableName: TableName = TableName.rootTableName +} +``` + +| Type | Purpose | +| --- | --- | +| `ColumnRef` | A reference to one SQL column plus its codec. **Equality and `hashCode` use only `(table, column)`** — the codec is ignored, so the same physical column referenced with two different codecs compares as equal. | +| `TableDef(name)` | Base class for a per-table column-definition object. Its implicit `TableName` binds enclosed `col(...)` calls to `name`. You subclass it: `object country extends TableDef("country") { ... }`. | +| `RootDef` | Like `TableDef` but binds columns to the synthetic `` table (`TableName.rootTableName`), for computed/aggregate root fields such as `numCountries`. | +| `TableName` | The implicit that `col` reads to know which table a column belongs to. | + +The generic `col` and the abstract codec surface it consumes are defined in the backend-neutral test base `SqlTestMapping`; concrete backends fill `TestCodec`/`Codec` with real types. This snip is the full abstract vocabulary (`bool`, `text`, `int4`, `jsonb`, `nullable`, `list`, …) plus the generic `col`: + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/sql-core/src/test/scala/SqlTestMapping.scala", "#sql_codecs")) +``` + +Note that `col[T](colName, codec)` requires an implicit `TableName` (from the enclosing `TableDef`/`RootDef`), a `TypeName[T]`, and a `SourcePos`, and returns a `ColumnRef`. `nullable(c)` wraps a codec to mark the column optional, and `list(c)` lifts it to an array column. + +## SqlInterfaceMapping + +An interface is mapped with `SqlInterfaceMapping` over a shared (super-) table, plus an [`SqlDiscriminator`](#sqldiscriminator). Concrete subtypes are ordinary `ObjectMapping`s that add their extra columns on the **same** table. + +```scala +sealed trait SqlInterfaceMapping extends ObjectMapping with SqlDiscriminatedType + +object SqlInterfaceMapping { + def apply(tpe: NamedType, discriminator: SqlDiscriminator) + (fieldMappings: FieldMapping*)(implicit pos: SourcePos): ObjectMapping + def apply(tpe: NamedType, fieldMappings: List[FieldMapping], discriminator: SqlDiscriminator) + (implicit pos: SourcePos): ObjectMapping + def apply(path: Path, discriminator: SqlDiscriminator) + (fieldMappings: FieldMapping*)(implicit pos: SourcePos): ObjectMapping + def apply(predicate: MappingPredicate, discriminator: SqlDiscriminator) + (fieldMappings: FieldMapping*)(implicit pos: SourcePos): ObjectMapping +} +``` + +Note the two common arities: varargs `fieldMappings` with `discriminator` first, **or** the named-argument form `(tpe, fieldMappings: List, discriminator)` used in the tests. The interface mapping holds the shared columns and exactly one `SqlField(..., discriminator = true)`. + +## SqlUnionMapping + +A union is mapped with `SqlUnionMapping`. All members must share one table; the union mapping holds **only** hidden key and hidden discriminator fields, and each member `ObjectMapping` maps its own column on the shared table. + +```scala +sealed trait SqlUnionMapping extends ObjectMapping with SqlDiscriminatedType + +object SqlUnionMapping { + def apply(tpe: NamedType, discriminator: SqlDiscriminator) + (fieldMappings: FieldMapping*)(implicit pos: SourcePos): ObjectMapping + def apply(tpe: NamedType, fieldMappings: List[FieldMapping], discriminator: SqlDiscriminator) + (implicit pos: SourcePos): ObjectMapping + def apply(path: Path, discriminator: SqlDiscriminator) + (fieldMappings: FieldMapping*)(implicit pos: SourcePos): ObjectMapping + def apply(predicate: MappingPredicate, discriminator: SqlDiscriminator) + (fieldMappings: FieldMapping*)(implicit pos: SourcePos): ObjectMapping +} +``` + +Every field in the union mapping must be `hidden = true`, and you may not put `SqlObject` sub-objects or `SqlJson` fields directly in it — map those on the member `ObjectMapping`s. See [Map interfaces and unions to SQL](../how-to/interfaces-unions.md) for full examples. + +## SqlDiscriminator + +Both interface and union mappings take an `SqlDiscriminator`. It has two responsibilities: filtering rows to a subtype (via a `Predicate`) and computing a fetched row's concrete `Type`. + +```scala +trait SqlDiscriminator { + /** A predicate selecting only rows of the given subtype. */ + def narrowPredicate(tpe: Type): Result[Predicate] + + /** The concrete type of the value at the cursor. */ + def discriminate(cursor: Cursor): Result[Type] +} +``` + +`discriminate` typically reads the discriminator column with `cursor.fieldAs[...]` and maps the value to a subtype `Type`; `narrowPredicate` returns an `Eql(... / "discriminatorField", Const(value))` predicate, and `Result.internalError(...)` for an unknown subtype. An internal error here is raised into the effect `F` — it does not appear in the GraphQL `errors` array. See [Construct, accumulate and report errors](../how-to/errors.md) for the distinction between `InternalError` and the `Problem`s that surface in the response. + +## FailedJoin + +When an outer join produces no child row, Grackle decodes the non-nullable child columns to the `FailedJoin` sentinel rather than `null`, so it can distinguish a genuinely-absent join target from a SQL `NULL` value. + +```scala +// modules/sql-core/src/main/scala/FailedJoin.scala +package grackle.sql + +case object FailedJoin +``` + +Code that inspects raw decoded row arrays must account for `FailedJoin` appearing in place of a missing non-nullable value. + +## Backend col signatures: doobie vs skunk + +`SqlMappingLike` leaves the `Codec`/`Encoder`/`Fragment` types and the `col` helper abstract; each backend trait fills them. In both, the `Codec` is a `(codec, Boolean)` tuple where the boolean is the nullability flag — but the backends differ in how you specify nullability. + +| | doobie (`DoobieMappingLike`) | skunk (`SkunkMappingLike`) | +| --- | --- | --- | +| `Codec` | `(Meta[_], Boolean)` | `(skunk.Codec[_], Boolean)` | +| `Encoder` | `(Put[_], Boolean)` | `skunk.Encoder[_]` | +| `Fragment` | `DoobieFragment` | `skunk.AppliedFragment` | +| `col` | `col(name, Meta[T], nullable = false)` | `col(name, skunk.Codec[T])` | +| Nullability | explicit `nullable` boolean argument | inferred from `Option[T]` via an implicit `IsNullable` (use the codec's `.opt`) | + +```scala +// DoobieMappingLike +def col[T](colName: String, codec: Meta[T], nullable: Boolean = false) + (implicit tableName: TableName, typeName: TypeName[T], pos: SourcePos): ColumnRef + +// SkunkMappingLike +def col[T](colName: String, codec: skunk.Codec[T]) + (implicit tableName: TableName, typeName: NullableTypeName[T], + isNullable: IsNullable[T], pos: SourcePos): ColumnRef +``` + +So with doobie you write `col("indepyear", Meta[Int], nullable = true)`, while with skunk you write `col("indepyear", int4.opt)` and the optionality is derived from the `Option`-valued codec. + +## Validator consistency rules + +The mapping validator checks each SQL mapping against the schema and rejects inconsistent mappings with a named error. The most common ones: + +| Error | Triggered when | +| --- | --- | +| `NoKeyInObjectTypeMapping` | An object-type mapping has no `key = true` field, direct or inherited (*"Object type mappings must include at least one direct or inherited key field mapping"*). | +| `SplitObjectTypeMapping` / `SplitEmbeddedObjectTypeMapping` | The `SqlField`/`SqlObject` columns of one (non-interface, non-union) object mapping come from more than one table. To span tables, use `SqlObject` + `Join`. | +| `SplitInterfaceTypeMapping` / `SplitUnionTypeMapping` | An interface/union subtype is mapped onto a different table from the interface/union mapping. Subtypes must add columns on the **same** shared table. | +| `NonHiddenUnionFieldMapping` | A field in a union mapping is not `hidden = true`. | +| `IllegalSubobjectInUnionTypeMapping` | An `SqlObject` sub-object is placed directly in a union mapping. | +| `IllegalJsonInUnionTypeMapping` | An `SqlJson` field is placed directly in a union mapping. | +| `NoDiscriminatorInObjectTypeMapping` | An interface/union mapping has no discriminator field. | +| `IllegalPolymorphicDiscriminatorFieldMapping` | The discriminator field is itself polymorphic. | +| `AssocFieldNotKey` | `associative = true` is set on a field that is not also `key = true`. | +| `NoJoinConditions` | An `SqlObject` is given a `Join` with an empty condition list. | +| `MisalignedJoins` / `InconsistentJoinConditions` | Chained multi-`Join` parent → child tables do not line up. | + +To run the validator and read these errors, see [Validate a mapping and read the failures](../how-to/validate-mappings.md). + +## See also + +- [Choose and configure a SQL backend](../how-to/sql-backends.md) — task recipes for doobie/skunk mappings. +- [Map interfaces and unions to SQL](../how-to/interfaces-unions.md) — full interface/union worked examples. +- [Map a jsonb column with SqlJson](../how-to/jsonb-columns.md) — `SqlJson` in practice. +- [Filter, sort and page a field](../how-to/filtering-ordering-paging.md) — predicates and paging over SQL. +- [Validate a mapping and read the failures](../how-to/validate-mappings.md) — running the validator and reading the errors listed above. +- [Mapping types reference](mapping-types.md) — the core (non-SQL) mapping vocabulary. +- [Predicates & terms reference](predicates.md) — building the `Predicate`s a discriminator returns. diff --git a/docs/tutorial/db-backed-model.md b/docs/tutorial/db-backed-model.md index 1741d0d1..b55a46e2 100644 --- a/docs/tutorial/db-backed-model.md +++ b/docs/tutorial/db-backed-model.md @@ -73,8 +73,8 @@ as a valid GraphQL schema at compile time. ## Database mapping The API is backed by mapping to database tables. Grackle contains ready to use integration with -[doobie](https://tpolecat.github.io/doobie/) for accessing SQL database via JDBC and with -[Skunk](https://tpolecat.github.io/skunk/) for accessing PostgreSQL via its native API. In this example we will use +[doobie](https://typelevel.org/doobie/) for accessing SQL database via JDBC and with +[Skunk](https://typelevel.org/skunk/) for accessing PostgreSQL via its native API. In this example we will use doobie. Let's start with defining what tables and columns are available in the database model, @@ -127,3 +127,16 @@ println(grackle.docs.Output.snip("demo/src/main/scala/demo/Main.scala", "#main") ```scala mdoc:passthrough println(grackle.docs.Output.snip("demo/src/main/scala/demo/DemoServer.scala", "#server")) ``` + +## Where to next + +You now have a GraphQL API served from PostgreSQL. The next tutorial adds write paths and live +updates: [Mutations & subscriptions](mutations-subscriptions.md). + +To go deeper on the SQL machinery this tutorial relies on: + +- [Choose and configure a SQL backend](../how-to/sql-backends.md) — pick between Doobie and Skunk and wire up a transactor or session pool. +- [SqlMapping reference](../reference/sql-mapping.md) — the full set of `SqlObject`, column and `Join` mapping constructors. +- [Filter, sort and page a field](../how-to/filtering-ordering-paging.md) — add arguments that turn into SQL `WHERE`/`ORDER BY`/`LIMIT`/`OFFSET`. +- [Serve interfaces and unions](../how-to/interfaces-unions.md) — map polymorphic types across tables. +- [Filtering and paging nodes](../reference/filtering-paging-nodes.md) and [Predicates](../reference/predicates.md) — the query-algebra nodes that drive SQL predicates and paging. diff --git a/docs/tutorial/directory.conf b/docs/tutorial/directory.conf index 0fdc7d61..460af06a 100644 --- a/docs/tutorial/directory.conf +++ b/docs/tutorial/directory.conf @@ -1,6 +1,7 @@ -laika.title = Tutorial +laika.title = Tutorials laika.navigationOrder = [ intro.md in-memory-model.md db-backed-model.md + mutations-subscriptions.md ] diff --git a/docs/tutorial/in-memory-model.md b/docs/tutorial/in-memory-model.md index 22c0b1f6..4a1687a5 100644 --- a/docs/tutorial/in-memory-model.md +++ b/docs/tutorial/in-memory-model.md @@ -58,9 +58,9 @@ Click the play button in the centre and you should see the following response on The Star Wars API is described by a GraphQL schema, -```yaml +```graphql type Query { - hero(episode: Episode!): Character + hero(episode: Episode!): Character! character(id: ID!): Character human(id: ID!): Human droid(id: ID!): Droid @@ -73,27 +73,27 @@ enum Episode { } interface Character { - id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! -} - -type Droid implements Character { - id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! - primaryFunction: String + id: String! + name: String + friends: [Character!] + appearsIn: [Episode!] } type Human implements Character { - id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! + id: String! + name: String + friends: [Character!] + appearsIn: [Episode!] homePlanet: String } + +type Droid implements Character { + id: String! + name: String + friends: [Character!] + appearsIn: [Episode!] + primaryFunction: String +} ``` Any one of the parametrized fields in the `Query` type may be used as the top level query, with nested queries over @@ -180,8 +180,12 @@ yields, { "name": "Luke Skywalker" }, - ... + { + "name": "Leia Organa" + } + ] } + ] } } } @@ -203,83 +207,19 @@ are then transformed in a variety of ways, resulting in a program which can be i produce the query result. The process of transforming these values is called _elaboration_, and each elaboration step simplifies or expands the term to bring it into a form which can be executed directly by the query interpreter. -Grackle's query algebra consists of the following elements, - -```scala -case class UntypedSelect( - name: String, alias: Option[String], - args: List[Binding], directives: List[Directive], - child: Query -) -case class Select(name: String, alias: Option[String], child: Query) -case class Group(queries: List[Query]) -case class Unique(child: Query) -case class Filter(pred: Predicate, child: Query) -case class Introspect(schema: Schema, child: Query) -case class Environment(env: Env, child: Query) -case class Narrow(subtpe: TypeRef, child: Query) -case class Limit(num: Int, child: Query) -case class Offset(num: Int, child: Query) -case class OrderBy(selections: OrderSelections, child: Query) -case class Count(child: Query) -case class TransformCursor(f: Cursor => Result[Cursor], child: Query) -case class Component[F[_]](mapping: Mapping[F], ...) -case class Effect[F[_]](handler: EffectHandler[F], child: Query) -case object Empty -``` - -A simple query like this, - -```yaml -query { - character(id: 1000) { - name - } -} -``` - -is first translated into a term in the query algebra of the form, - -```scala -UntypedSelect("character", None, List(IntBinding("id", 1000)), Nil, - UntypedSelect("name", None, Nil, Nil, Empty) -) -``` - -This first step is performed without reference to a GraphQL schema, hence the `id` argument is initially inferred to -be of GraphQL type `Int` rather than the type `ID` which the schema expects. - -Following this initial translation the Star Wars example has a single elaboration step whose role is to translate the -selection into something executable. Elaboration uses the GraphQL schema and so is able to translate an input value -parsed as an `Int` into a GraphQL `ID`. The semantics associated with this (i.e. what an `id` is and how it relates to -the model) is specific to this model, so we have to provide that semantic via some model-specific code, +For the Star Wars example a single elaboration step is all we need: it rewrites the `character(id: …)` selector +(whose meaning is specific to this model) into a `Filter`/`Unique` pair the interpreter can run directly against the +data. The `Filter` refines the root list to the elements whose `id` matches, and `Unique` then picks out the single +result. That model-specific semantics is supplied by the `selectElaborator`, ```scala mdoc:passthrough println(grackle.docs.Output.snip("demo/src/main/scala/demo/starwars/StarWarsMapping.scala", "#elaborator")) ``` -Extracting out the case for the `character` selector, - -```scala -case (QueryType, "character", List(Binding("id", IDValue(id)))) => - Elab.transformChild { child => - Unique(Filter(Eql(CharacterType / "id", Const(id)), child)) - } -``` - -the previous term is transformed as follows, - -```scala -Select("character", None, - Unique(Eql(CharacterType / "id"), Const("1000")), Select("name", None, Empty)) -) -``` - -Here the original `UntypedSelect` terms have been converted to typed `Select` terms with the argument to the -`character` selector translated into a predicate which refines the root data of the model to the single element which -satisfies it via `Unique`. The remainder of the query (`Select("name", None, Nil)`) is then within the scope of that -constraint. We have eliminated something with model-specific semantics (`character(id: 1000)`) in favour of something -universal which can be interpreted directly against the model. +You don't need to know the full query algebra to follow this tutorial. If you want the details of how a query string +becomes an executable term — the `UntypedSelect` to `Select` transformation, and the complete set of algebra nodes — +see [The compiler and elaboration](../concepts/compiler-elaboration.md) and the +[Query algebra reference](../reference/query-algebra.md). ## The query interpreter and cursor @@ -302,6 +242,11 @@ The first argument of the `GenericField` constructor corresponds to the top-leve schema above) and the second argument is the initial model value for which a `Cursor` will be derived. When the query is executed, navigation will start with that `Cursor` and the corresponding GraphQL type. +> **How this uses generic derivation.** The `Cursor`s for the Star Wars model aren't hand-written: a `GenericMapping` +> derives them automatically from your case classes and sealed traits, so the plain Scala ADT above is enough to serve +> the schema. For how this derivation works — `deriveObjectCursorBuilder`, `GenericField`, resolving id references to +> nested objects, and custom scalars — see [Serve Scala ADTs with generic derivation](../how-to/generic-derivation.md). + ## The service What we've seen so far allows us to compile and execute GraphQL queries against our in-memory model. We now need to @@ -348,3 +293,23 @@ object Main extends IOApp { ```scala mdoc:passthrough println(grackle.docs.Output.snip("demo/src/main/scala/demo/DemoServer.scala", "#server")) ``` + +## Next steps + +You now have an end-to-end in-memory GraphQL service. The next tutorial in this series swaps the in-memory model for a +real database: + +- Next in this series: [DB-backed model](db-backed-model.md) — serve the same kind of API from PostgreSQL. +- Then: [Mutations & subscriptions](mutations-subscriptions.md) — add write operations and streaming updates. + +## See also + +To go deeper on the concepts and APIs this tutorial used: + +- [The compiler and elaboration](../concepts/compiler-elaboration.md) — how a query string becomes an executable term. +- [Mappings and cursors](../concepts/mappings-cursors.md) — how a `Mapping` ties the schema to your data and how + `Cursor`s navigate it. +- [How the query interpreter works](../concepts/query-interpreter.md) — how the interpreter turns a `Query` and a + `Cursor` into the JSON response. +- [Query algebra reference](../reference/query-algebra.md) — the full set of `Query` algebra nodes. +- [Serve Scala ADTs with generic derivation](../how-to/generic-derivation.md) — the generic backend used here in depth. diff --git a/docs/tutorial/intro.md b/docs/tutorial/intro.md index 6e25b1a0..6f1ff853 100644 --- a/docs/tutorial/intro.md +++ b/docs/tutorial/intro.md @@ -1,6 +1,19 @@ # Introduction -This section contains instructions and examples to get you started. It's written in tutorial style, intended to be read start to finish. +This section contains worked, end-to-end examples that you can read start to finish. Each tutorial builds a +complete, runnable GraphQL service, introducing Grackle's ideas in the order you meet them when building something +real. If you just want to get a query running as fast as possible, start with the +[quick start](../getting-started/quick-start.md) instead. -* [In-memory model](in-memory-model.md) -* [DB Backed model](in-memory-model.md) +The tutorials build on each other, so we recommend reading them in order: + +* **[In-memory model](in-memory-model.md)** — serve a GraphQL API from a plain Scala data structure (the Star Wars + example). Introduces schemas, mappings, cursors, the query algebra and elaboration. +* **[DB-backed model](db-backed-model.md)** — serve the same style of API from PostgreSQL, letting Grackle compile + queries directly to SQL (the World example). +* **[Mutations & subscriptions](mutations-subscriptions.md)** — go beyond queries: run effectful writes with + mutations and stream live updates with subscriptions. + +Once you have worked through these, the [how-to guides](../how-to/filtering-ordering-paging.md) cover specific tasks, +the [concepts](../concepts/architecture.md) section explains how Grackle works under the hood, and the +[reference](../reference/schema-sdl.md) section documents every type and signature. diff --git a/docs/tutorial/mutations-subscriptions.md b/docs/tutorial/mutations-subscriptions.md new file mode 100644 index 00000000..23fcc863 --- /dev/null +++ b/docs/tutorial/mutations-subscriptions.md @@ -0,0 +1,216 @@ +# Mutations & Subscriptions + +So far your mappings have only *read* data. In this tutorial you extend an in-memory mapping +with a GraphQL **mutation** (a field that writes) and a GraphQL **subscription** (a field that +streams responses over time), wiring both to a single `fs2.concurrent.SignallingRef` cell of +state. Everything here compiles and runs without a database, so you can copy each step and try +it yourself. It assumes you have finished the [in-memory tutorial](in-memory-model.md) and are +comfortable with cats-effect basics. When you are done, the same patterns carry over to a real +database, which the SQL how-to pages pick up. + +## Mutations are just `RootEffect` fields + +Grackle has no separate "mutation engine". A GraphQL mutation is an ordinary root field on the +schema's `Mutation` object type whose field mapping is a `RootEffect`. A `RootEffect` runs an +effect in your `F[_]` — the actual write — *before* the rest of the query is interpreted, and +then yields the value that the client's selection set is rendered against. The same machinery +backs query roots, mutation roots, and (in its streaming form, `RootStream`) subscription roots. + +`RootEffect`'s primary constructor is private, so you always build one through a companion +factory. The three you will meet are: + +- `RootEffect.computeUnit` — perform a write and leave the elaborated query and default root + cursor unchanged. +- `RootEffect.computeCursor` — perform a write and hand back a cursor you build yourself; this is + the form in-memory `ValueMapping`s use, and the one in this tutorial. +- `RootEffect.computeChild` — perform a write and then rewrite the child query using data only + known *after* the write (for example the id of a freshly inserted row). + +Arguments do not reach the effect directly. They are captured into the elaboration `Env` by the +`SelectElaborator` with `Elab.env(...)`, and read back inside the effect with `env.get[T](name)` +(an `Option[T]`) or `env.getR[T](name)` (a `Result[T]`). Forgetting the `Elab.env` step is the +classic mistake — `env.get` then returns `None` (or `env.getR` fails) at runtime. + +## The mapping: `get`, `put`, and `watch` + +Here is the complete mapping. A single `SignallingRef[IO, Int]` holds the state. The `Query` type +exposes a `get` field that reads it, the `Mutation` type exposes a `put(n: Int): Int!` field that +writes it, and the `Subscription` type exposes a `watch: Int!` field that streams it. + +```scala mdoc:passthrough +println(grackle.docs.Output.snip("modules/docs/src/main/scala/grackle/MutationSubscriptionMapping.scala", "#ms_mapping")) +``` + +Walking through the three field mappings: + +- **`get`** is a `RootEffect.computeCursor("get")` whose effect is `ref.get`. It reads the current + value and wraps it in a root cursor with `valueCursor(path, env, n)`, the helper a `ValueMapping` + uses to point a `Cursor` at an in-memory value. `get` is on `Query`, not `Mutation` — a root + effect is not inherently a mutation, it is just an effectful root field. +- **`put`** is a `RootEffect.computeCursor("put")` on the `Mutation` type. It reads the `n` + argument out of the environment with `env.get[Int]("n")`, calls `ref.set(n)` to perform the + write, and returns a cursor carrying `n` so the client can select the new value straight back. + If `n` is somehow absent it returns a `Result.failure`, which surfaces as a GraphQL error. +- **`watch`** is a `RootStream.computeCursor("watch")` on the `Subscription` type — the streaming + sibling of `RootEffect`. Its effect returns the `fs2.Stream` `ref.discrete`, which emits the + ref's current value and then every subsequent distinct value. Each emitted `n` becomes one root + cursor, and therefore one GraphQL response. + +The `selectElaborator` is the other half of `put`. When it sees `put` selected with an integer +binding `n`, it runs `Elab.env("n" -> n)`, threading that argument into the `Env` the effect later +reads. (`Elab.env("n" -> n)` is the tuple-pair overload; there are also `Elab.env(name, value)` +and overloads that take a whole `Env`.) `get` and `watch` take no arguments, so they need no +elaborator case. + +## Running a mutation + +To exercise the mapping you need an instance and a `SignallingRef` to back it. `Mapping#compileAndRun` +compiles a single query or mutation document and returns `F[Json]` — exactly one response. Wire up +the ref, run a `put`, and inspect the JSON. (`compileAndRun` is for single-shot operations; the +subscription below uses a different entry point.) + +```scala mdoc:silent +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import fs2.concurrent.SignallingRef +import grackle.docs.MutationSubscriptionMapping + +val program: IO[io.circe.Json] = + for { + ref <- SignallingRef[IO, Int](0) + map = MutationSubscriptionMapping.mapping(ref) + res <- map.compileAndRun("mutation { put(n: 42) }") + } yield res +``` + +```scala mdoc +program.unsafeRunSync() +``` + +The mutation writes `42` into the ref and renders it back through the cursor `put` returned, so the +response is `{ "data" : { "put" : 42 } }`. A `query { get }` against the same `ref` would now read +`42` too. Because the write and the read-back both go through the one `SignallingRef`, the state +change is immediately visible. + +### Serial execution of mutation roots + +GraphQL requires that the top-level fields of a single mutation operation run **serially**, in the +order the client wrote them, and Grackle honours this: within one `compileAndRun`, effectful root +selections are executed in client order rather than in parallel. So a document that calls `put` +three times with aliases — + +```graphql +mutation { + one: put(n: 1) + two: put(n: 2) + three: put(n: 3) +} +``` + +— leaves the ref holding `3`, never `1` or `2`, because `three` always runs last. Do not rely on +parallelism between sibling mutation roots. + +## Running a subscription + +A subscription is not a single response; it is a *stream* of responses, one per upstream event. The +entry point is `Mapping#compileAndRunSubscription`, which returns `fs2.Stream[F, Json]` instead of +`F[Json]`. (In fact `compileAndRun` is built on top of it and asserts the stream has exactly one +element — which is why pointing `compileAndRun` at a real subscription fails.) + +Subscriptions are also more constrained than queries: an operation may select **exactly one** root +field, and `RootStream` is legal **only** under the `Subscription` type. Putting a `RootStream` on +`Query` or `Mutation`, or selecting two subscription roots at once, is a runtime error. + +The following program subscribes to `watch`, then drives the ref through a sequence of `put`s and +collects the emissions. Because delivery is asynchronous — the subscriber must attach to +`ref.discrete` before a value is produced for it to be observed — the driver starts the subscriber +in a fiber and spaces the mutations out, mirroring the approach in Grackle's own +`SubscriptionSuite`: + +```scala mdoc:compile-only +import scala.concurrent.duration._ +import cats.effect._ +import fs2.concurrent.SignallingRef +import io.circe.Json +import grackle.docs.MutationSubscriptionMapping + +val watched: IO[List[Json]] = + for { + ref <- SignallingRef[IO, Int](0) + map = MutationSubscriptionMapping.mapping(ref) + fib <- map + .compileAndRunSubscription("subscription { watch }") + .take(4) + .compile + .toList + .start + _ <- IO.sleep(100.millis) // let the subscriber attach first + _ <- map.compileAndRun("mutation { put(n: 123) }") + _ <- IO.sleep(100.millis) + _ <- map.compileAndRun("mutation { put(n: 42) }") + _ <- IO.sleep(100.millis) + _ <- map.compileAndRun("mutation { put(n: 77) }") + _ <- IO.sleep(100.millis) + out <- fib.join + res <- out.embedNever + } yield res +``` + +`take(4)` collects four responses: the ref's initial `0` (emitted as soon as the subscriber +attaches, because `discrete` replays the current value), followed by `123`, `42`, and `77`. Each is +a full GraphQL document: + +```json +[ + { "data": { "watch": 0 } }, + { "data": { "watch": 123 } }, + { "data": { "watch": 42 } }, + { "data": { "watch": 77 } } +] +``` + +The `IO.sleep` calls are not part of the mapping — they only paper over the race between starting +the listener and producing values, which is inherent to any push-based subscription. The Grackle +test that this example is drawn from notes the same caveat; in a real application your transport +layer (not Grackle) owns the lifecycle of the stream, and events produced before a subscriber is +attached are simply not seen by it. + +## How root effects fit the bigger picture + +`RootEffect` and `RootStream` are two of the ways Grackle attaches effects to a query. They run +*once, up front*, at the root. Grackle also supports effects deep inside the result tree, through +`EffectField` and an `EffectHandler`, and there it does something `RootEffect` does not: it +**batches** every occurrence of a nested effect field into a single handler call, collapsing what +would otherwise be an N+1 problem. Sibling root effects, by contrast, are not batched — each root +field runs its own effect. The mechanics of nested effects and batching are a topic of their own; +see the links below when you need them. + +Two more things worth stating plainly, because they bite people coming from other GraphQL servers: + +- **Grackle does not manage transactions.** Nothing wraps the write in `put` (or a SQL `INSERT`) in + a transaction for you. The effect value is whatever you supply; if you need the write and a + read-back to be atomic, arrange that yourself in your `F[_]` (for example a doobie `.transact(xa)` + or a skunk session). +- **There is no built-in websocket or `graphql-ws` transport.** A subscription is a plain + `fs2.Stream` you connect to a transport yourself. Grackle gives you the stream; carrying it to a + client over websockets (or SSE, or anything else) is your integration's job. + +## Where to next + +You now have a working in-memory mutation and subscription. The same `RootEffect` and `RootStream` +patterns scale to a real database: + +- [Run effects and batch nested fields](../how-to/effects-batching.md) — the how-to companion to this + tutorial. It covers the remaining `RootEffect` constructors against SQL (`computeUnit` for an update + keyed on an argument, `computeChild` for an insert whose id the database generates) and `EffectField` + + `EffectHandler` for nested fields that batch to avoid N+1. +- [Effects and batching internals](../concepts/effects-batching.md) — why deferred effects exist and + how batching works under the hood. +- [Effects reference](../reference/effects.md) — exact signatures for `RootEffect`, `RootStream`, + and `EffectHandler`. +- [Choose and configure a SQL backend](../how-to/sql-backends.md) — pick Doobie or Skunk and wire a + mapping to a transactor or session pool, the foundation for backing these effects with a database. +- [Serve a mapping over HTTP](../how-to/serve-over-http.md) — wiring `compileAndRun` / + `compileAndRunSubscription` into a transport, including a `graphql-ws`-style stream. +- [How the query interpreter works](../concepts/query-interpreter.md) — how `runOneShot` and + `runSubscription` discover and execute root effects and streams. diff --git a/modules/circe/src/test/scala/CirceData.scala b/modules/circe/src/test/scala/CirceData.scala index 2ff09e28..44e7de96 100644 --- a/modules/circe/src/test/scala/CirceData.scala +++ b/modules/circe/src/test/scala/CirceData.scala @@ -26,6 +26,7 @@ import grackle.QueryCompiler._ import grackle.circe.CirceMapping import grackle.syntax._ +// #circe_mapping object TestCirceMapping extends CirceMapping[IO] { val schema = schema""" @@ -127,3 +128,4 @@ object TestCirceMapping extends CirceMapping[IO] { Elab.transformChild(_ => Count(Select("children"))) } } +// #circe_mapping diff --git a/modules/circe/src/test/scala/CirceEffectData.scala b/modules/circe/src/test/scala/CirceEffectData.scala index 302244ea..177e5801 100644 --- a/modules/circe/src/test/scala/CirceEffectData.scala +++ b/modules/circe/src/test/scala/CirceEffectData.scala @@ -24,6 +24,7 @@ import grackle._ import grackle.circe.CirceMapping import grackle.syntax._ +// #circe_effects class TestCirceEffectMapping[F[_]: Sync](ref: SignallingRef[F, Int]) extends CirceMapping[F] { val schema = schema""" @@ -111,3 +112,4 @@ class TestCirceEffectMapping[F[_]: Sync](ref: SignallingRef[F, Int]) extends Cir ) ) } +// #circe_effects diff --git a/modules/circe/src/test/scala/CircePrioritySuite.scala b/modules/circe/src/test/scala/CircePrioritySuite.scala index efd37d39..fcb3e02d 100644 --- a/modules/circe/src/test/scala/CircePrioritySuite.scala +++ b/modules/circe/src/test/scala/CircePrioritySuite.scala @@ -23,6 +23,7 @@ import munit.CatsEffectSuite import grackle.circe.CirceMapping import grackle.syntax._ +// #circe_priority object CircePriorityMapping extends CirceMapping[IO] { val schema = schema""" @@ -63,6 +64,7 @@ object CircePriorityMapping extends CirceMapping[IO] { ) } +// #circe_priority final class CircePrioritySuite extends CatsEffectSuite { diff --git a/modules/core/src/main/scala/composedmapping.scala b/modules/core/src/main/scala/composedmapping.scala index a68c104c..9f13185c 100644 --- a/modules/core/src/main/scala/composedmapping.scala +++ b/modules/core/src/main/scala/composedmapping.scala @@ -20,6 +20,7 @@ import cats.MonadThrow import grackle.Cursor.AbstractCursor import grackle.syntax._ +// #composed_base abstract class ComposedMapping[F[_]](implicit val M: MonadThrow[F]) extends Mapping[F] { override def mkCursorForMappedField( parent: Cursor, @@ -37,3 +38,4 @@ abstract class ComposedMapping[F[_]](implicit val M: MonadThrow[F]) extends Mapp mkCursorForField(this, fieldName, resultName) } } +// #composed_base diff --git a/modules/core/src/main/scala/queryinterpreter.scala b/modules/core/src/main/scala/queryinterpreter.scala index 5cad9eff..fc33822d 100644 --- a/modules/core/src/main/scala/queryinterpreter.scala +++ b/modules/core/src/main/scala/queryinterpreter.scala @@ -405,6 +405,7 @@ class QueryInterpreter[F[_]](mapping: Mapping[F]) { } yield res } + // #run_value /** * Interpret `query` against `cursor` with expected type `tpe`. * @@ -498,6 +499,7 @@ class QueryInterpreter[F[_]](mapping: Mapping[F]) { } } } + // #run_value } object QueryInterpreter { @@ -711,6 +713,7 @@ object QueryInterpreter { .map(_.map(_.head)) // result is 1:1 with the argument, so head is safe } + // #complete_all /** * Complete a collection of possibly deferred results. * @@ -812,4 +815,5 @@ object QueryInterpreter { pjs.map(pj => scatterResults(pj, subst)) }).value } + // #complete_all } diff --git a/modules/core/src/main/scala/valuemapping.scala b/modules/core/src/main/scala/valuemapping.scala index 0daf828e..0c5cd163 100644 --- a/modules/core/src/main/scala/valuemapping.scala +++ b/modules/core/src/main/scala/valuemapping.scala @@ -137,6 +137,7 @@ trait ValueMappingLike[F[_]] extends Mapping[F] { case _ => super.unpackPrefixedMapping(prefix, om) } + // #value_cursor case class ValueCursor( context: Context, focus: Any, @@ -214,4 +215,5 @@ trait ValueMappingLike[F[_]] extends Mapping[F] { def field(fieldName: String, resultName: Option[String]): Result[Cursor] = mkCursorForField(this, fieldName, resultName) } + // #value_cursor } diff --git a/modules/core/src/test/scala/compiler/CompilerSuite.scala b/modules/core/src/test/scala/compiler/CompilerSuite.scala index b3c4c732..94f5b1e9 100644 --- a/modules/core/src/test/scala/compiler/CompilerSuite.scala +++ b/modules/core/src/test/scala/compiler/CompilerSuite.scala @@ -33,6 +33,7 @@ final class CompilerSuite extends CatsEffectSuite { val queryParser = QueryParser( GraphQLParser(GraphQLParser.defaultConfig.copy(terseError = false))) + // #compile_simple test("simple query") { val query = """ query { @@ -54,6 +55,7 @@ final class CompilerSuite extends CatsEffectSuite { val res = queryParser.parseText(query).map(_._1) assertEquals(res, Result.Success(List(UntypedQuery(None, expected, Nil, Nil)))) } + // #compile_simple test("simple mutation") { val query = """ @@ -271,6 +273,7 @@ final class CompilerSuite extends CatsEffectSuite { Result.Success(List(UntypedQuery(Some("IntrospectionQuery"), expected, Nil, Nil)))) } + // #compile_elaborated test("simple selector elaborated query") { val query = """ query { @@ -303,6 +306,7 @@ final class CompilerSuite extends CatsEffectSuite { assertEquals(res.map(_.query), Result.Success(expected)) } + // #compile_elaborated test("invalid: object subselection set empty") { val query = """ @@ -558,6 +562,7 @@ final class CompilerSuite extends CatsEffectSuite { } } +// #atomic_elaborator object AtomicMapping extends TestMapping { val schema = schema""" @@ -579,6 +584,7 @@ object AtomicMapping extends TestMapping { Elab.transformChild(child => Unique(Filter(Eql(CharacterType / "id", Const(id)), child))) } } +// #atomic_elaborator trait DummyComponent extends TestMapping { val schema = schema"type Query { dummy: Int }" } diff --git a/modules/core/src/test/scala/compiler/EnvironmentSuite.scala b/modules/core/src/test/scala/compiler/EnvironmentSuite.scala index d7d1d795..d1f916cb 100644 --- a/modules/core/src/test/scala/compiler/EnvironmentSuite.scala +++ b/modules/core/src/test/scala/compiler/EnvironmentSuite.scala @@ -25,6 +25,7 @@ import grackle.QueryCompiler._ import grackle.Value._ import grackle.syntax._ +// #env_mapping object EnvironmentMapping extends ValueMapping[IO] { val schema = schema""" @@ -97,6 +98,7 @@ object EnvironmentMapping extends ValueMapping[IO] { Elab.env("x" -> x, "y" -> y) } } +// #env_mapping final class EnvironmentSuite extends CatsEffectSuite { test("field computed from arguments (1)") { diff --git a/modules/core/src/test/scala/compiler/FragmentSuite.scala b/modules/core/src/test/scala/compiler/FragmentSuite.scala index fa52476b..5fa594c4 100644 --- a/modules/core/src/test/scala/compiler/FragmentSuite.scala +++ b/modules/core/src/test/scala/compiler/FragmentSuite.scala @@ -331,6 +331,7 @@ final class FragmentSuite extends CatsEffectSuite { } test("typed fragment query") { + // #fragment_typed val query = """ query FragmentTyping { profiles { @@ -364,6 +365,7 @@ final class FragmentSuite extends CatsEffectSuite { Narrow(Page, Select("title")) )) ) + // #fragment_typed val expectedResult = json""" { diff --git a/modules/core/src/test/scala/compiler/PreserveArgsElaborator.scala b/modules/core/src/test/scala/compiler/PreserveArgsElaborator.scala index 695d08de..0d78e4d5 100644 --- a/modules/core/src/test/scala/compiler/PreserveArgsElaborator.scala +++ b/modules/core/src/test/scala/compiler/PreserveArgsElaborator.scala @@ -19,6 +19,7 @@ import grackle._ import grackle.Query._ import grackle.QueryCompiler._ +// #preserve_args object PreserveArgsElaborator extends SelectElaborator { case class Preserved(args: List[Binding], directives: List[Directive]) @@ -61,3 +62,4 @@ object PreserveArgsElaborator extends SelectElaborator { directives: List[Directive]): Elab[Unit] = Elab.env("preserved", Preserved(args, directives)) } +// #preserve_args diff --git a/modules/core/src/test/scala/compiler/ProblemSuite.scala b/modules/core/src/test/scala/compiler/ProblemSuite.scala index a6903678..e88fa5fd 100644 --- a/modules/core/src/test/scala/compiler/ProblemSuite.scala +++ b/modules/core/src/test/scala/compiler/ProblemSuite.scala @@ -70,6 +70,7 @@ final class ProblemSuite extends CatsEffectSuite { ) } + // #problem_encoding test("encoding (no locations)") { assertEquals( Problem("foo", Nil, List("bar", "baz")).asJson, @@ -102,6 +103,7 @@ final class ProblemSuite extends CatsEffectSuite { "foo (at bar/baz: 1..2, 5..6)" ) } + // #problem_encoding test("toString (no path)") { assertEquals( diff --git a/modules/core/src/test/scala/compiler/ScalarsSuite.scala b/modules/core/src/test/scala/compiler/ScalarsSuite.scala index 2dc7126b..4f617efb 100644 --- a/modules/core/src/test/scala/compiler/ScalarsSuite.scala +++ b/modules/core/src/test/scala/compiler/ScalarsSuite.scala @@ -181,6 +181,7 @@ object MovieData { object MovieMapping extends ValueMapping[IO] { import MovieData._ + // #scalars_schema val schema = schema""" type Query { @@ -211,6 +212,7 @@ object MovieMapping extends ValueMapping[IO] { duration: Interval! } """ + // #scalars_schema val QueryType = schema.ref("Query") val MovieType = schema.ref("Movie") @@ -246,14 +248,17 @@ object MovieMapping extends ValueMapping[IO] { ValueField("duration", _.duration) ) ), + // #scalars_leafmappings LeafMapping[UUID](UUIDType), LeafMapping[Genre](GenreType), LeafMapping[LocalDate](DateType), LeafMapping[LocalTime](TimeType), LeafMapping[ZonedDateTime](DateTimeType), LeafMapping[Duration](IntervalType) + // #scalars_leafmappings ) + // #scalars_values object UUIDValue { def unapply(s: StringValue): Option[UUID] = Try(UUID.fromString(s.value)).toOption @@ -283,6 +288,7 @@ object MovieMapping extends ValueMapping[IO] { def unapply(s: StringValue): Option[Duration] = Try(Duration.parse(s.value)).toOption } + // #scalars_values override val selectElaborator = SelectElaborator { case (QueryType, "movieById", List(Binding("id", UUIDValue(id)))) => diff --git a/modules/core/src/test/scala/compiler/SkipIncludeSuite.scala b/modules/core/src/test/scala/compiler/SkipIncludeSuite.scala index ea874afb..3581809e 100644 --- a/modules/core/src/test/scala/compiler/SkipIncludeSuite.scala +++ b/modules/core/src/test/scala/compiler/SkipIncludeSuite.scala @@ -23,6 +23,7 @@ import grackle.Query._ import grackle.syntax._ final class SkipIncludeSuite extends CatsEffectSuite { + // #skip_include test("skip/include field") { val query = """ query ($yup: Boolean, $nope: Boolean) { @@ -59,6 +60,7 @@ final class SkipIncludeSuite extends CatsEffectSuite { assertEquals(compiled.map(_.query), Result.Success(expected)) } + // #skip_include test("skip/include fragment spread") { val query = """ diff --git a/modules/core/src/test/scala/composed/ComposedData.scala b/modules/core/src/test/scala/composed/ComposedData.scala index 0c777d7a..27e45ac9 100644 --- a/modules/core/src/test/scala/composed/ComposedData.scala +++ b/modules/core/src/test/scala/composed/ComposedData.scala @@ -27,6 +27,7 @@ import grackle.syntax._ /* Currency component */ +// #composed_currency object CurrencyData { case class Currency( code: String, @@ -79,9 +80,11 @@ object CurrencyMapping extends ValueMapping[IO] { Unique(Filter(Eql(CurrencyType / "code", Const(code)), child))) } } +// #composed_currency /* Country component */ +// #composed_country object CountryData { case class Country( code: String, @@ -138,9 +141,11 @@ object CountryMapping extends ValueMapping[IO] { Unique(Filter(Eql(CurrencyMapping.CurrencyType / "code", Const(code)), child))) } } +// #composed_country /* Composition */ +// #composed_mapping object ComposedMapping extends ComposedMapping[IO] { val schema = schema""" @@ -202,3 +207,4 @@ object ComposedMapping extends ComposedMapping[IO] { } } +// #composed_mapping diff --git a/modules/core/src/test/scala/composed/ComposedListSuite.scala b/modules/core/src/test/scala/composed/ComposedListSuite.scala index 0a109a04..3b755d0a 100644 --- a/modules/core/src/test/scala/composed/ComposedListSuite.scala +++ b/modules/core/src/test/scala/composed/ComposedListSuite.scala @@ -177,6 +177,7 @@ object ComposedListMapping extends ComposedMapping[IO] { Unique(Filter(Eql(CollectionType / "name", Const(name)), child))) } + // #composed_list_join def collectionItemJoin(q: Query, c: Cursor): Result[Query] = (c.focus, q) match { case (c: CollectionData.Collection, Select("items", _, child)) => @@ -187,6 +188,7 @@ object ComposedListMapping extends ComposedMapping[IO] { case _ => Result.internalError(s"Unexpected cursor focus type in collectionItemJoin") } + // #composed_list_join } final class ComposedListSuite extends CatsEffectSuite { diff --git a/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala b/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala index 8db4783e..4ccaf139 100644 --- a/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala +++ b/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala @@ -114,6 +114,7 @@ final class QueryDirectivesSuite extends CatsEffectSuite { } test("query with directive (3)") { + // #upper_query val query = """ query { user { @@ -145,9 +146,11 @@ final class QueryDirectivesSuite extends CatsEffectSuite { // res.flatMap(IO.println) *> assertIO(res, expected) + // #upper_query } } +// #upper_mapping object QueryDirectivesMapping extends ValueMapping[IO] { val schema = schema""" @@ -183,6 +186,7 @@ object QueryDirectivesMapping extends ValueMapping[IO] { ) ) + // #upper_phase object upperCaseElaborator extends Phase { override def transform(query: Query): Elab[Query] = query match { @@ -207,7 +211,9 @@ object QueryDirectivesMapping extends ValueMapping[IO] { def toUpperCase(c: Cursor): Result[Cursor] = FieldTransformCursor[String](c, _.toUpperCase.success).success } + // #upper_phase override def compilerPhases: List[QueryCompiler.Phase] = List(upperCaseElaborator, selectElaborator, componentElaborator, effectElaborator) } +// #upper_mapping diff --git a/modules/core/src/test/scala/directives/SchemaDirectivesSuite.scala b/modules/core/src/test/scala/directives/SchemaDirectivesSuite.scala index e16feaec..f76153ea 100644 --- a/modules/core/src/test/scala/directives/SchemaDirectivesSuite.scala +++ b/modules/core/src/test/scala/directives/SchemaDirectivesSuite.scala @@ -344,6 +344,7 @@ final class SchemaDirectivesSuite extends CatsEffectSuite { } object SchemaDirectivesMapping extends ValueMapping[IO] { + // #schema_directives val schema = schema""" type Query { @@ -370,6 +371,7 @@ object SchemaDirectivesMapping extends ValueMapping[IO] { USER } """ + // #schema_directives val QueryType = schema.ref("Query") val MutationType = schema.ref("Mutation") @@ -401,6 +403,7 @@ object SchemaDirectivesMapping extends ValueMapping[IO] { ) ) + // #permissions_phase case class AuthStatus(role: String) object permissionsElaborator extends Phase { @@ -454,6 +457,8 @@ object SchemaDirectivesMapping extends ValueMapping[IO] { } } + // #permissions_phase + override def compilerPhases: List[QueryCompiler.Phase] = List(permissionsElaborator, selectElaborator, componentElaborator, effectElaborator) } diff --git a/modules/core/src/test/scala/mapping/MappingValidatorSuite.scala b/modules/core/src/test/scala/mapping/MappingValidatorSuite.scala index 905258de..d47cb337 100644 --- a/modules/core/src/test/scala/mapping/MappingValidatorSuite.scala +++ b/modules/core/src/test/scala/mapping/MappingValidatorSuite.scala @@ -94,6 +94,7 @@ final class ValidatorSuite extends CatsEffectSuite { } + // #validator test("missing field mapping") { object M extends TestMapping { @@ -132,6 +133,7 @@ final class ValidatorSuite extends CatsEffectSuite { } } + // #validator test("inapplicable type (object mapping for scalar)") { diff --git a/modules/core/src/test/scala/sdl/SDLSuite.scala b/modules/core/src/test/scala/sdl/SDLSuite.scala index d69cb75e..9516d3d6 100644 --- a/modules/core/src/test/scala/sdl/SDLSuite.scala +++ b/modules/core/src/test/scala/sdl/SDLSuite.scala @@ -257,6 +257,7 @@ final class SDLSuite extends CatsEffectSuite { assertEquals(ser, schema.success) } + // #sdl_roundtrip test("deserialize schema (1)") { val schema = """|type Author { @@ -281,6 +282,7 @@ final class SDLSuite extends CatsEffectSuite { assertEquals(ser, schema.success) } + // #sdl_roundtrip test("deserialize schema (2)") { val schema = diff --git a/modules/core/src/test/scala/subscription/SubscriptionSuite.scala b/modules/core/src/test/scala/subscription/SubscriptionSuite.scala index d94ecbe4..cf444199 100644 --- a/modules/core/src/test/scala/subscription/SubscriptionSuite.scala +++ b/modules/core/src/test/scala/subscription/SubscriptionSuite.scala @@ -30,6 +30,7 @@ import grackle.syntax._ final class SubscriptionSuite extends CatsEffectSuite { + // #subscription_mapping def mapping(ref: SignallingRef[IO, Int]): Mapping[IO] = new ValueMapping[IO] { @@ -83,6 +84,7 @@ final class SubscriptionSuite extends CatsEffectSuite { Elab.env("n" -> n) } } + // #subscription_mapping test("sanity check get") { val prog: IO[Json] = @@ -197,6 +199,7 @@ final class SubscriptionSuite extends CatsEffectSuite { test("subscription") { + // #run_ops val prog: IO[List[Json]] = for { ref <- SignallingRef[IO, Int](0) @@ -219,6 +222,7 @@ final class SubscriptionSuite extends CatsEffectSuite { out <- fib.join res <- out.embedNever } yield res + // #run_ops assertIO( prog, diff --git a/modules/docs/src/main/scala/grackle/FilterMapping.scala b/modules/docs/src/main/scala/grackle/FilterMapping.scala new file mode 100644 index 00000000..2e9c3bda --- /dev/null +++ b/modules/docs/src/main/scala/grackle/FilterMapping.scala @@ -0,0 +1,91 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle.docs + +import cats.effect.IO + +import grackle._ +import grackle.Predicate._ +import grackle.Query._ +import grackle.QueryCompiler._ +import grackle.Value._ +import grackle.syntax._ + +// #filter +object FilterMapping extends ValueMapping[IO] { + + case class Item(label: String, tags: List[String]) + + val items = + List( + Item("A", List("A")), + Item("AB", List("A", "B")), + Item("BC", List("B", "C")), + Item("C", List("C")) + ) + + val schema = + schema""" + type Query { + itemsByTag(tag: ID!): [Item!]! + itemsByTagCount(count: Int!): [Item!]! + } + type Item { + label: String! + tags: [String!]! + tagCount: Int! + } + """ + + val QueryType = schema.ref("Query") + val ItemType = schema.ref("Item") + + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = + List( + ValueField("itemsByTag", _ => items), + ValueField("itemsByTagCount", _ => items) + ) + ), + ValueObjectMapping[Item]( + tpe = ItemType, + fieldMappings = + List( + ValueField("label", _.label), + ValueField("tags", _.tags), + // `tagCount` has no backing field; it is computed from the cursor. + CursorField("tagCount", tagCount) + ) + ) + ) + + def tagCount(c: Cursor): Result[Int] = + c.fieldAs[List[String]]("tags").map(_.size) + + override val selectElaborator = + SelectElaborator { + // `Contains` tests membership of a list-valued field. + case (QueryType, "itemsByTag", List(Binding("tag", IDValue(tag)))) => + Elab.transformChild(child => Filter(Contains(ItemType / "tags", Const(tag)), child)) + // `Eql` compares a (here computed) field against a constant. + case (QueryType, "itemsByTagCount", List(Binding("count", IntValue(count)))) => + Elab.transformChild(child => Filter(Eql(ItemType / "tagCount", Const(count)), child)) + } +} +// #filter diff --git a/modules/docs/src/main/scala/grackle/MutationSubscriptionMapping.scala b/modules/docs/src/main/scala/grackle/MutationSubscriptionMapping.scala new file mode 100644 index 00000000..141bec5b --- /dev/null +++ b/modules/docs/src/main/scala/grackle/MutationSubscriptionMapping.scala @@ -0,0 +1,89 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle.docs + +import cats.effect._ +import cats.implicits._ +import fs2.concurrent.SignallingRef + +import grackle._ +import grackle.QueryCompiler._ +import grackle.syntax._ + +object MutationSubscriptionMapping { + + // #ms_mapping + // A single mutable cell of state shared by the query, mutation and subscription. + def mapping(ref: SignallingRef[IO, Int]): Mapping[IO] = + new ValueMapping[IO] { + + val schema: Schema = + schema""" + type Query { + get: Int! + } + type Mutation { + put(n: Int): Int! + } + type Subscription { + watch: Int! + } + """ + + val QueryType = schema.ref("Query") + val MutationType = schema.ref("Mutation") + val SubscriptionType = schema.ref("Subscription") + + val typeMappings = + List( + // A query reads the current value. + ObjectMapping( + QueryType, + List( + RootEffect.computeCursor("get")((path, env) => + ref.get.map(n => Result(valueCursor(path, env, n)))) + ) + ), + // A mutation runs an effect (here, a write) and returns a value to select over. + ObjectMapping( + MutationType, + List( + RootEffect.computeCursor("put")((path, env) => + env.get[Int]("n") match { + case None => Result.failure(s"Implementation error: `n: Int` not found in $env").pure[IO] + case Some(n) => ref.set(n).map(_ => Result(valueCursor(path, env, n))) + }) + ) + ), + // A subscription is a stream of cursors, one per emitted value. + ObjectMapping( + SubscriptionType, + List( + RootStream.computeCursor("watch")((path, env) => + ref.discrete.map(n => Result(valueCursor(path, env, n)))) + ) + ) + ) + + // Lift the `n` argument of `put` into the environment so the effect can read it. + override val selectElaborator: SelectElaborator = + SelectElaborator { + case (MutationType, "put", List(Query.Binding("n", Value.IntValue(n)))) => + Elab.env("n" -> n) + } + } + // #ms_mapping +} diff --git a/modules/docs/src/main/scala/grackle/QuickStartMapping.scala b/modules/docs/src/main/scala/grackle/QuickStartMapping.scala new file mode 100644 index 00000000..2ef844f3 --- /dev/null +++ b/modules/docs/src/main/scala/grackle/QuickStartMapping.scala @@ -0,0 +1,86 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle.docs + +import cats.effect.IO + +import grackle._ +import grackle.Predicate._ +import grackle.Query._ +import grackle.QueryCompiler._ +import grackle.Value._ +import grackle.syntax._ + +// #quickstart +object QuickStartMapping extends ValueMapping[IO] { + + // The data: an ordinary Scala value, with no Grackle dependencies. + case class Book(id: Int, title: String, author: String) + + val books = + List( + Book(1, "The Left Hand of Darkness", "Ursula K. Le Guin"), + Book(2, "Kindred", "Octavia E. Butler"), + Book(3, "Hyperion", "Dan Simmons") + ) + + // The schema, validated at compile time by the `schema` interpolator. + val schema = + schema""" + type Query { + books: [Book!]! + book(id: Int!): Book + } + type Book { + id: Int! + title: String! + author: String! + } + """ + + val QueryType = schema.ref("Query") + val BookType = schema.ref("Book") + + // The mapping: how each GraphQL field is served from the data. + val typeMappings = + List( + ValueObjectMapping[Unit]( + tpe = QueryType, + fieldMappings = + List( + ValueField("books", _ => books), + ValueField("book", _ => books) + ) + ), + ValueObjectMapping[Book]( + tpe = BookType, + fieldMappings = + List( + ValueField("id", _.id), + ValueField("title", _.title), + ValueField("author", _.author) + ) + ) + ) + + // The elaborator: turn `book(id: 2)` into a predicate that selects one book. + override val selectElaborator = + SelectElaborator { + case (QueryType, "book", List(Binding("id", IntValue(id)))) => + Elab.transformChild(child => Unique(Filter(Eql(BookType / "id", Const(id)), child))) + } +} +// #quickstart diff --git a/modules/generic/src/test/scala/DerivationSuite.scala b/modules/generic/src/test/scala/DerivationSuite.scala index 73b0c4a5..7834dd77 100644 --- a/modules/generic/src/test/scala/DerivationSuite.scala +++ b/modules/generic/src/test/scala/DerivationSuite.scala @@ -233,6 +233,7 @@ object StarWarsMapping extends GenericMapping[IO] { } final class DerivationSuite extends CatsEffectSuite { + // #cursor_primitive test("primitive types have leaf cursor builders") { val i = for { @@ -266,6 +267,7 @@ final class DerivationSuite extends CatsEffectSuite { } yield l assertEquals(f, Result.Success(Json.fromDouble(13.0).get)) } + // #cursor_primitive test("types with a Circe Encoder instance have leaf cursor builders") { val z = @@ -291,6 +293,7 @@ final class DerivationSuite extends CatsEffectSuite { assertEquals(e, Result.Success(Json.fromString("JEDI"))) } + // #cursor_product test("product types have cursor builders") { val name = for { @@ -301,7 +304,9 @@ final class DerivationSuite extends CatsEffectSuite { } yield l assertEquals(name, Result.Success(Json.fromString("Luke Skywalker"))) } + // #cursor_product + // #cursor_list test("cursor builders can be resolved for nested types") { val appearsIn = for { @@ -317,6 +322,7 @@ final class DerivationSuite extends CatsEffectSuite { Result.Success( List(Json.fromString("NEWHOPE"), Json.fromString("EMPIRE"), Json.fromString("JEDI")))) } + // #cursor_list test("default cursor builders can be customised by mapping fields") { val friends = @@ -339,6 +345,7 @@ final class DerivationSuite extends CatsEffectSuite { Json.fromString("R2-D2")))) } + // #cursor_narrow test("sealed ADTs have narrowable cursor builders") { val homePlanets = for { @@ -351,6 +358,7 @@ final class DerivationSuite extends CatsEffectSuite { } yield l assertEquals(homePlanets, Result.Success(Json.fromString("Tatooine"))) } + // #cursor_narrow test("simple query") { val query = """ diff --git a/modules/generic/src/test/scala/RecursionSuite.scala b/modules/generic/src/test/scala/RecursionSuite.scala index 1eefd0da..b51f5983 100644 --- a/modules/generic/src/test/scala/RecursionSuite.scala +++ b/modules/generic/src/test/scala/RecursionSuite.scala @@ -27,6 +27,7 @@ import grackle.QueryCompiler._ import grackle.Value._ import grackle.syntax._ +// #recursion_data object MutualRecursionData { import MutualRecursionMapping._ import semiauto._ @@ -62,7 +63,9 @@ object MutualRecursionData { val programmes = List(Programme("prog1", Some(List("prod1")))) val productions = List(Production("prod1", "prog1")) } +// #recursion_data +// #recursion_mapping object MutualRecursionMapping extends GenericMapping[IO] { import MutualRecursionData._ @@ -102,6 +105,7 @@ object MutualRecursionMapping extends GenericMapping[IO] { Elab.transformChild(child => Unique(Filter(Eql(ProgrammeType / "id", Const(id)), child))) } } +// #recursion_mapping final class RecursionSuite extends CatsEffectSuite { test("simple query") { diff --git a/modules/generic/src/test/scala/ScalarsSuite.scala b/modules/generic/src/test/scala/ScalarsSuite.scala index 5a1cf6d0..e8e939f1 100644 --- a/modules/generic/src/test/scala/ScalarsSuite.scala +++ b/modules/generic/src/test/scala/ScalarsSuite.scala @@ -38,6 +38,7 @@ object MovieData { import MovieMapping._ import semiauto._ + // #generic_scalars sealed trait Genre extends Product with Serializable object Genre { case object Drama extends Genre @@ -91,6 +92,7 @@ object MovieData { implicit val cursorBuilder: CursorBuilder[Movie] = deriveObjectCursorBuilder[Movie](MovieType) } + // #generic_scalars val movies = List( diff --git a/modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala b/modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala index cbcc8c66..d16a7b4d 100644 --- a/modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala +++ b/modules/sql-core/src/test/scala/SqlComposedWorldMapping.scala @@ -111,6 +111,7 @@ class CurrencyMapping[F[_]: Sync](dataRef: Ref[F, CurrencyData], countRef: Ref[F ) ) + // #composed_combine override def combineAndRun(queries: List[(Query, Cursor)]): F[Result[List[ProtoJson]]] = { import SimpleCurrencyQuery.unpackResults @@ -200,6 +201,7 @@ class CurrencyMapping[F[_]: Sync](dataRef: Ref[F, CurrencyData], countRef: Ref[F case _ => Nil } } + // #composed_combine } object CurrencyMapping { @@ -219,6 +221,7 @@ object CurrencyMapping { /* Composition */ +// #composed_sql class SqlComposedMapping[F[_]: Sync](world: Mapping[F], currency: Mapping[F]) extends ComposedMapping[F] { val schema = @@ -297,3 +300,4 @@ class SqlComposedMapping[F[_]: Sync](world: Mapping[F], currency: Mapping[F]) Elab.transformChild(child => Filter(Like(CityType / "name", namePattern, true), child)) } } +// #composed_sql diff --git a/modules/sql-core/src/test/scala/SqlCompositeKeyMapping.scala b/modules/sql-core/src/test/scala/SqlCompositeKeyMapping.scala index da09736c..b342f418 100644 --- a/modules/sql-core/src/test/scala/SqlCompositeKeyMapping.scala +++ b/modules/sql-core/src/test/scala/SqlCompositeKeyMapping.scala @@ -19,6 +19,7 @@ import grackle.syntax._ trait SqlCompositeKeyMapping[F[_]] extends SqlTestMapping[F] { + // #composite_key object compositeKeyParent extends TableDef("composite_key_parent") { val key1 = col("key_1", int4) val key2 = col("key_2", varchar) @@ -81,4 +82,5 @@ trait SqlCompositeKeyMapping[F[_]] extends SqlTestMapping[F] { ) ) ) + // #composite_key } diff --git a/modules/sql-core/src/test/scala/SqlCursorJsonMapping.scala b/modules/sql-core/src/test/scala/SqlCursorJsonMapping.scala index 7740c95d..eb98769c 100644 --- a/modules/sql-core/src/test/scala/SqlCursorJsonMapping.scala +++ b/modules/sql-core/src/test/scala/SqlCursorJsonMapping.scala @@ -28,6 +28,7 @@ import grackle.syntax._ trait SqlCursorJsonMapping[F[_]] extends SqlTestMapping[F] { + // #cursor_json object brands extends TableDef("brands") { val id = col("id", int4) val category = col("categories", int4) @@ -103,4 +104,5 @@ trait SqlCursorJsonMapping[F[_]] extends SqlTestMapping[F] { case (QueryType, "brands", List(Binding("id", IntValue(id)))) => Elab.transformChild(child => Unique(Filter(Eql(BrandType / "id", Const(id)), child))) } + // #cursor_json } diff --git a/modules/sql-core/src/test/scala/SqlEmbeddingMapping.scala b/modules/sql-core/src/test/scala/SqlEmbeddingMapping.scala index 4e0f514f..97986877 100644 --- a/modules/sql-core/src/test/scala/SqlEmbeddingMapping.scala +++ b/modules/sql-core/src/test/scala/SqlEmbeddingMapping.scala @@ -38,6 +38,7 @@ trait SqlEmbeddingMapping[F[_]] extends SqlTestMapping[F] { val synopsisLong = col("synopsis_long", nullable(text)) } + // #embedding val schema = schema""" type Query { @@ -134,4 +135,5 @@ trait SqlEmbeddingMapping[F[_]] extends SqlTestMapping[F] { ) ) ) + // #embedding } diff --git a/modules/sql-core/src/test/scala/SqlFilterOrderOffsetLimitMapping.scala b/modules/sql-core/src/test/scala/SqlFilterOrderOffsetLimitMapping.scala index 00bbeb01..64c4aca6 100644 --- a/modules/sql-core/src/test/scala/SqlFilterOrderOffsetLimitMapping.scala +++ b/modules/sql-core/src/test/scala/SqlFilterOrderOffsetLimitMapping.scala @@ -128,6 +128,7 @@ trait SqlFilterOrderOffsetLimitMapping[F[_]] extends SqlTestMapping[F] { ) ) + // #fool_mk object FilterValue { def unapply(input: ObjectValue): Option[String] = { input.fields match { @@ -174,7 +175,9 @@ trait SqlFilterOrderOffsetLimitMapping[F[_]] extends SqlTestMapping[F] { } case _ => Result.failure(s"Expected sort value, found $order") } + // #fool_mk + // #fool_elab override val selectElaborator = SelectElaborator { case ( QueryType, @@ -224,4 +227,5 @@ trait SqlFilterOrderOffsetLimitMapping[F[_]] extends SqlTestMapping[F] { lc <- mkLimit(oc, limit) } yield lc) } + // #fool_elab } diff --git a/modules/sql-core/src/test/scala/SqlInterfacesMapping.scala b/modules/sql-core/src/test/scala/SqlInterfacesMapping.scala index 7daeb920..0368f75d 100644 --- a/modules/sql-core/src/test/scala/SqlInterfacesMapping.scala +++ b/modules/sql-core/src/test/scala/SqlInterfacesMapping.scala @@ -105,6 +105,7 @@ trait SqlInterfacesMapping[F[_]] extends SqlTestMapping[F] { self => val EpisodeType = schema.ref("Episode") val SynopsesType = schema.ref("Synopses") + // #interfaces val typeMappings = List( ObjectMapping( @@ -186,7 +187,9 @@ trait SqlInterfacesMapping[F[_]] extends SqlTestMapping[F] { self => ), LeafMapping[EntityType](EntityTypeType) ) + // #interfaces + // #discriminator lazy val entityTypeDiscriminator = new SqlDiscriminator { def discriminate(c: Cursor): Result[Type] = { for { @@ -208,6 +211,7 @@ trait SqlInterfacesMapping[F[_]] extends SqlTestMapping[F] { self => } } } + // #discriminator def mkSeriesImageUrl(c: Cursor): Result[Option[String]] = c.fieldAs[Option[String]]("hiddenImageUrl") diff --git a/modules/sql-core/src/test/scala/SqlJsonbMapping.scala b/modules/sql-core/src/test/scala/SqlJsonbMapping.scala index dad216ef..d025d751 100644 --- a/modules/sql-core/src/test/scala/SqlJsonbMapping.scala +++ b/modules/sql-core/src/test/scala/SqlJsonbMapping.scala @@ -25,6 +25,7 @@ import grackle.syntax._ trait SqlJsonbMapping[F[_]] extends SqlTestMapping[F] { + // #jsonb object records extends TableDef("records") { val id = col("id", int4) val record = col("record", nullable(jsonb)) @@ -97,4 +98,5 @@ trait SqlJsonbMapping[F[_]] extends SqlTestMapping[F] { case (QueryType, "record", List(Binding("id", IntValue(id)))) => Elab.transformChild(child => Unique(Filter(Eql(RowType / "id", Const(id)), child))) } + // #jsonb } diff --git a/modules/sql-core/src/test/scala/SqlJsonbSuite.scala b/modules/sql-core/src/test/scala/SqlJsonbSuite.scala index cb329796..1c503322 100644 --- a/modules/sql-core/src/test/scala/SqlJsonbSuite.scala +++ b/modules/sql-core/src/test/scala/SqlJsonbSuite.scala @@ -105,6 +105,7 @@ trait SqlJsonbSuite extends CatsEffectSuite { assertWeaklyEqualIO(res, expected) } + // #jsonb_query test("objects") { val query = """ query { @@ -138,6 +139,7 @@ trait SqlJsonbSuite extends CatsEffectSuite { assertWeaklyEqualIO(res, expected) } + // #jsonb_query test("arrays") { val query = """ diff --git a/modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala b/modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala index 95de34e6..7a6ee04c 100644 --- a/modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala +++ b/modules/sql-core/src/test/scala/SqlNestedEffectsMapping.scala @@ -151,6 +151,7 @@ trait SqlNestedEffectsMapping[F[_]] extends SqlTestMapping[F] { val LanguageType = schema.ref("Language") val CurrencyType = schema.ref("Currency") + // #effect_typemappings val typeMappings = List( ObjectMapping( @@ -211,7 +212,9 @@ trait SqlNestedEffectsMapping[F[_]] extends SqlTestMapping[F] { ) ) ) + // #effect_typemappings + // #currency_handler object CurrencyQueryHandler extends EffectHandler[F] { def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] = { val countryCodes = queries.map(_._2.fieldAs[String]("code2").toOption) @@ -249,7 +252,9 @@ trait SqlNestedEffectsMapping[F[_]] extends SqlTestMapping[F] { }).value.widen } } + // #currency_handler + // #country_handler object CountryQueryHandler extends EffectHandler[F] { val toCode = Map("BR" -> "BRA", "GB" -> "GBR", "NL" -> "NLD") def runEffects(queries: List[(Query, Cursor)]): F[Result[List[Cursor]]] = { @@ -310,6 +315,7 @@ trait SqlNestedEffectsMapping[F[_]] extends SqlTestMapping[F] { groupedResults.sequence.map(_.sequence.map(_.flatten.sortBy(_._2).map(_._1))) } } + // #country_handler override val selectElaborator = SelectElaborator { case (QueryType, "cities", List(Binding("namePattern", StringValue(namePattern)))) => diff --git a/modules/sql-core/src/test/scala/SqlPaging1Mapping.scala b/modules/sql-core/src/test/scala/SqlPaging1Mapping.scala index ea3a83c6..9ddc4235 100644 --- a/modules/sql-core/src/test/scala/SqlPaging1Mapping.scala +++ b/modules/sql-core/src/test/scala/SqlPaging1Mapping.scala @@ -127,6 +127,7 @@ trait SqlPaging1Mapping[F[_]] extends SqlTestMapping[F] { def genValue(key: String)(c: Cursor): Result[Int] = c.env[Int](key).toResultOrError(s"Missing key '$key'") + // #paging1 abstract class PagingConfig(key: String, countAttr: String, orderTerm: Term[String]) { def setup(offset: Int, limit: Int): Elab[Unit] = Elab.env(key -> new PagingInfo(offset, limit)) @@ -184,4 +185,5 @@ trait SqlPaging1Mapping[F[_]] extends SqlTestMapping[F] { case (PagedCityType, "total", Nil) => CityPaging.elabTotal } + // #paging1 } diff --git a/modules/sql-core/src/test/scala/SqlPaging3Mapping.scala b/modules/sql-core/src/test/scala/SqlPaging3Mapping.scala index c8f9b3cc..6163602f 100644 --- a/modules/sql-core/src/test/scala/SqlPaging3Mapping.scala +++ b/modules/sql-core/src/test/scala/SqlPaging3Mapping.scala @@ -154,6 +154,7 @@ trait SqlPaging3Mapping[F[_]] extends SqlTestMapping[F] { c0 <- info.genHasMore(c) } yield c0 + // #paging3 case class PagingInfo( offset: Option[Int], limit: Option[Int], @@ -199,6 +200,7 @@ trait SqlPaging3Mapping[F[_]] extends SqlTestMapping[F] { } yield num > offset.getOrElse(0) + limit.getOrElse(num.toInt) } } + // #paging3 } object CountryPaging diff --git a/modules/sql-core/src/test/scala/SqlTestMapping.scala b/modules/sql-core/src/test/scala/SqlTestMapping.scala index 15e47246..60fb76c6 100644 --- a/modules/sql-core/src/test/scala/SqlTestMapping.scala +++ b/modules/sql-core/src/test/scala/SqlTestMapping.scala @@ -25,6 +25,7 @@ import org.tpolecat.typename.TypeName import grackle.sql.SqlMappingLike trait SqlTestMapping[F[_]] extends SqlMappingLike[F] { outer => + // #sql_codecs type TestCodec[T] <: Codec def bool: TestCodec[Boolean] @@ -55,4 +56,5 @@ trait SqlTestMapping[F[_]] extends SqlMappingLike[F] { outer => typeName: TypeName[T], pos: SourcePos): ColumnRef = ColumnRef(tableName.name, colName, codec, typeName.value, pos) + // #sql_codecs } diff --git a/modules/sql-core/src/test/scala/SqlUnionSuite.scala b/modules/sql-core/src/test/scala/SqlUnionSuite.scala index b4df4c38..cc6697fa 100644 --- a/modules/sql-core/src/test/scala/SqlUnionSuite.scala +++ b/modules/sql-core/src/test/scala/SqlUnionSuite.scala @@ -60,6 +60,7 @@ trait SqlUnionSuite extends CatsEffectSuite { } test("union query with introspection") { + // #union_query val query = """ query { collection { @@ -91,6 +92,7 @@ trait SqlUnionSuite extends CatsEffectSuite { } } """ + // #union_query val res = mapping.compileAndRun(query) diff --git a/modules/sql-core/src/test/scala/SqlUnionsMapping.scala b/modules/sql-core/src/test/scala/SqlUnionsMapping.scala index d02e3e41..a8697f61 100644 --- a/modules/sql-core/src/test/scala/SqlUnionsMapping.scala +++ b/modules/sql-core/src/test/scala/SqlUnionsMapping.scala @@ -21,6 +21,7 @@ import grackle.syntax._ trait SqlUnionsMapping[F[_]] extends SqlTestMapping[F] { + // #unions object collections extends TableDef("collections") { val id = col("id", text) val itemType = col("item_type", text) @@ -99,4 +100,5 @@ trait SqlUnionsMapping[F[_]] extends SqlTestMapping[F] { } } } + // #unions } diff --git a/modules/sql-core/src/test/scala/SqlWorldMapping.scala b/modules/sql-core/src/test/scala/SqlWorldMapping.scala index 6409abbb..bb3c0197 100644 --- a/modules/sql-core/src/test/scala/SqlWorldMapping.scala +++ b/modules/sql-core/src/test/scala/SqlWorldMapping.scala @@ -115,6 +115,7 @@ trait SqlWorldMapping[F[_]] extends SqlTestMapping[F] { val CityType = schema.ref("City") val LanguageType = schema.ref("Language") + // #world_typemappings val typeMappings = List( ObjectMapping( @@ -177,6 +178,7 @@ trait SqlWorldMapping[F[_]] extends SqlTestMapping[F] { ) ) ) + // #world_typemappings object StringListValue { def unapply(value: Value): Option[List[String]] = @@ -190,6 +192,7 @@ trait SqlWorldMapping[F[_]] extends SqlTestMapping[F] { } } + // #world_elaborator override val selectElaborator = SelectElaborator { case (QueryType, "country", List(Binding("code", StringValue(code)))) => Elab.transformChild(child => @@ -277,4 +280,5 @@ trait SqlWorldMapping[F[_]] extends SqlTestMapping[F] { case (CountryType, "city", List(Binding("id", IntValue(id)))) => Elab.transformChild(child => Unique(Filter(Eql(CityType / "id", Const(id)), child))) } + // #world_elaborator }