From e1425f7ca15caf2227f85a21f30cfdff3fe8ef2a Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Tue, 15 Sep 2026 12:53:51 +0200 Subject: [PATCH] Add parser and validation cache Split `QueryCompiler.compile` into `prepare` (parsing and document-level validation, depends only on document text and schema) and `compilePrepared` (variable coercion, directive validation, elaboration, depends on the per-request `Env`). Add `CachingQueryCompiler` and `QueryCache`, which cache the `prepare` result by document text, including parse failures. Default: 1024 documents, one-hour TTL, LRU eviction. Both are configurable, and callers can supply their own `QueryCache`. Add docs for setup and configuration. --- build.sbt | 3 +- docs/howto/directory.conf | 1 + docs/howto/parser-validation-caching.md | 78 +++++++ modules/core/src/main/scala/cache.scala | 147 +++++++++++++ modules/core/src/main/scala/compiler.scala | 109 +++++++--- modules/core/src/main/scala/prepared.scala | 82 +++++++ .../cache/CachingQueryCompilerSuite.scala | 204 ++++++++++++++++++ .../test/scala/cache/QueryCacheSuite.scala | 145 +++++++++++++ .../test/scala/compiler/PrepareSuite.scala | 86 ++++++++ 9 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 docs/howto/parser-validation-caching.md create mode 100644 modules/core/src/main/scala/cache.scala create mode 100644 modules/core/src/main/scala/prepared.scala create mode 100644 modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala create mode 100644 modules/core/src/test/scala/cache/QueryCacheSuite.scala create mode 100644 modules/core/src/test/scala/compiler/PrepareSuite.scala diff --git a/build.sbt b/build.sbt index 7bd3f458..ef6ba924 100644 --- a/build.sbt +++ b/build.sbt @@ -279,7 +279,8 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) "io.circe" %%% "circe-core" % circeVersion, "org.tpolecat" %%% "typename" % typenameVersion, "org.tpolecat" %%% "sourcepos" % sourcePosVersion, - "co.fs2" %%% "fs2-core" % fs2Version + "co.fs2" %%% "fs2-core" % fs2Version, + "org.typelevel" %%% "cats-effect-testkit" % catsEffectVersion % "test" ) ) .jsSettings( diff --git a/docs/howto/directory.conf b/docs/howto/directory.conf index 32f3f697..4a7baef0 100644 --- a/docs/howto/directory.conf +++ b/docs/howto/directory.conf @@ -1,4 +1,5 @@ laika.title = How-to Guides laika.navigationOrder = [ interfaces-across-tables.md + parser-validation-caching.md ] diff --git a/docs/howto/parser-validation-caching.md b/docs/howto/parser-validation-caching.md new file mode 100644 index 00000000..a69148d9 --- /dev/null +++ b/docs/howto/parser-validation-caching.md @@ -0,0 +1,78 @@ +# Parser/Validation Caching + +`CachingQueryCompiler` caches the parse and the document-level validation of a GraphQL query. It skips this work on repeat requests where only the variables change. This is useful for long-running servers or applications. + +## Quick start + +Build the compiler _once_, at server startup. Reuse it for every request. + +```scala +import cats.effect.{IO, IOApp} +import grackle.CachingQueryCompiler + +object Server extends IOApp.Simple { + + def run: IO[Unit] = + for { + compiler <- CachingQueryCompiler[IO](myMapping.compiler) // built once + _ <- serve(compiler) + } yield () +} +``` + +Pass the compiler into your handler: + +```scala +def handle(compiler: CachingQueryCompiler[IO], document: String, variables: Json, requestEnv: Env): IO[Json] = + for { + op <- compiler.compile(document, untypedVars = Some(variables), env = requestEnv) + res <- op.flatTraverse(o => myMapping.interpreter.run(o.query, o.rootTpe, requestEnv).compile.lastOrError) + json <- myMapping.mkResponse(res) + } yield json +``` + +`.compile.lastOrError` fits a one-shot query. For subscriptions, use the `Stream` that `Mapping.compileAndRun` returns instead. + +By default, the cache holds 1024 documents. It drops a document one hour after its last use, and it drops the least recently used document when full. + +## What gets cached + +The cache key is the document text, matched exactly. Whitespace differences create separate entries. + +Cached: parsing, fragment and variable validation, field mergeability, compiled variable definitions, and root type checks. + +Not cached: variable coercion, directive validation, and elaboration. These depend on the per-request `Env` and variable values, so caching them would leak state between requests. + +Parse failures are also cached, so repeat malformed documents cost one lookup and are not re-parsed. + +## Change the size limit or TTL + +```scala +import scala.concurrent.duration._ +import grackle.{CachingQueryCompiler, QueryCache} + +for { + cache <- QueryCache[IO](maxSize = 4096, ttl = 15.minutes) +} yield CachingQueryCompiler[IO](myMapping.compiler, cache) +``` + +The size limit counts documents, not bytes. + +## Use your own store + +```scala +import grackle.{CachingQueryCompiler, PreparedDocument, QueryCache, Result} + +val myCache: QueryCache[IO] = + new QueryCache[IO] { + def get(key: String): IO[Option[Result[PreparedDocument]]] = ??? + def put(key: String, value: Result[PreparedDocument]): IO[Unit] = ??? + } + +val compiler = CachingQueryCompiler[IO](myMapping.compiler, myCache) +``` + +Rules for a custom store: + +- Use one store per compiler. Compilers with different schemas must not share a store. +- `PreparedDocument` holds references to the compiler and thus cannot be serialized. diff --git a/modules/core/src/main/scala/cache.scala b/modules/core/src/main/scala/cache.scala new file mode 100644 index 00000000..9cb8737a --- /dev/null +++ b/modules/core/src/main/scala/cache.scala @@ -0,0 +1,147 @@ +// 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 + +import scala.concurrent.duration.* + +import cats.Monad +import cats.effect.kernel.{Clock, Ref, Temporal} +import cats.implicits.* +import io.circe.Json + +import grackle.QueryCompiler.IntrospectionLevel +import grackle.QueryCompiler.IntrospectionLevel.Full + +/** + * Store of prepared GraphQL documents, keyed on the raw document text. + * + * A store belongs to one `QueryCompiler`, because a `PreparedDocument` depends on the schema of + * the compiler that produced it. Do not share one store across two compilers with different + * schemas. + * + * The store holds both parse successes and failures, as a `Result`. Repeat of malformed + * documents therefore costs one lookup. + */ +trait QueryCache[F[_]] { + def get(key: String): F[Option[Result[PreparedDocument]]] + def put(key: String, value: Result[PreparedDocument]): F[Unit] +} + +object QueryCache { + + private final case class Entry(doc: Result[PreparedDocument], expiry: FiniteDuration) + + /** + * An in-memory store that holds up to `maxSize` documents (default 1024), each for `ttl` + * (default one hour) after its last use (sliding-window). + * + * `maxSize` must be greater than zero. + */ + def apply[F[_]: Temporal]( + maxSize: Int = 1024, + ttl: FiniteDuration = 1.hour): F[QueryCache[F]] = { + require(maxSize > 0, "maxSize must be greater than zero") + + Ref.of[F, Map[String, Entry]](Map.empty).map { ref => + new QueryCache[F] { + + def get(key: String): F[Option[Result[PreparedDocument]]] = + Clock[F].monotonic.flatMap { now => + ref.modify { entries => + entries.get(key) match { + case Some(Entry(doc, expiry)) if expiry > now => + (entries.updated(key, Entry(doc, now + ttl)), Some(doc)) + case _ => + (entries - key, None) + } + } + } + + def put(key: String, value: Result[PreparedDocument]): F[Unit] = + Clock[F].monotonic.flatMap { now => + ref.update { entries => + val room = + if (entries.sizeIs < maxSize || entries.contains(key)) entries + else evict(entries, now) + room.updated(key, Entry(value, now + ttl)) + } + } + + private def evict( + entries: Map[String, Entry], + now: FiniteDuration): Map[String, Entry] = { + val (live, oldest) = + entries.foldLeft((Map.empty[String, Entry], Option.empty[(String, Entry)])) { + case ((live, oldest), kv @ (k, entry)) => + if (entry.expiry <= now) (live, oldest) + else + ( + live.updated(k, entry), + if (oldest.forall(_._2.expiry > entry.expiry)) Some(kv) else oldest) + } + + if (live.sizeIs < entries.size) live + else oldest.fold(live)(kv => live - kv._1) + } + } + } + } +} + +/** + * A `QueryCompiler` with a cache in front of the variable-free half of compilation. + * + * A repeat request with the same document text skips the parse and the document-level + * validation. + * + * Build one instance for the life of the server, and one per `QueryCompiler`. + */ +final class CachingQueryCompiler[F[_]: Monad](compiler: QueryCompiler, cache: QueryCache[F]) { + + /** + * Compiles the GraphQL document `text` to a query algebra term which can be directly + * executed. Skips the parse and validation if the same `text` has been compiled before. + */ + def compile( + text: String, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): F[Result[Operation]] = + cache + .get(text) + .flatMap { + case Some(prepared) => + prepared.pure[F] + case None => + val prepared = compiler.prepare(text) + cache.put(text, prepared).as(prepared) + } + .map(_.flatMap( + compiler.compilePrepared(_, name, untypedVars, introspectionLevel, reportUnused, env))) +} + +object CachingQueryCompiler { + + def apply[F[_]: Temporal](compiler: QueryCompiler): F[CachingQueryCompiler[F]] = + QueryCache[F]().map(new CachingQueryCompiler(compiler, _)) + + def apply[F[_]: Monad]( + compiler: QueryCompiler, + cache: QueryCache[F]): CachingQueryCompiler[F] = + new CachingQueryCompiler(compiler, cache) +} diff --git a/modules/core/src/main/scala/compiler.scala b/modules/core/src/main/scala/compiler.scala index cc46ce44..259fec80 100644 --- a/modules/core/src/main/scala/compiler.scala +++ b/modules/core/src/main/scala/compiler.scala @@ -433,6 +433,21 @@ object VariableUsage { class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { import IntrospectionLevel._ + /** + * Compiles the GraphQL document `text` as far as the variable values allow. + * + * Depends on the document text and on the schema only. It does not depend on the variable + * values, on the `Env`, on the operation name, or on the introspection level, so a caller can + * cache it under the document text. See `CachingQueryCompiler`. + * + * GraphQL errors and warnings are accumulated in the result. + */ + def prepare(text: String): Result[PreparedDocument] = + parser.parseText(text).map { + case (ops, frags) => + new PreparedDocument(this, ops.map(op => prepareOperation(op, frags)), frags) + } + /** * Compiles the GraphQL query `text` to a query algebra term which can be directly executed. * @@ -445,35 +460,46 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { introspectionLevel: IntrospectionLevel = Full, reportUnused: Boolean = true, env: Env = Env.empty): Result[Operation] = - parser.parseText(text).flatMap { - case (ops, frags) => - for { - _ <- Result.fromProblems(validateVariablesAndFragments(ops, frags, reportUnused)) - _ <- Result.fromProblems(validateFieldMergeability(ops, frags)) - ops0 <- ops.traverse(op => - compileOperation(op, untypedVars, frags, introspectionLevel, env) - .map(op0 => (op.name, op0))) - res <- (ops0, name) match { - case (List((_, op)), None) => + prepare(text).flatMap( + compilePrepared(_, name, untypedVars, introspectionLevel, reportUnused, env)) + + /** + * Compiles a prepared document to a query algebra term which can be directly executed. + */ + def compilePrepared( + prepared: PreparedDocument, + name: Option[String] = None, + untypedVars: Option[Json] = None, + introspectionLevel: IntrospectionLevel = Full, + reportUnused: Boolean = true, + env: Env = Env.empty): Result[Operation] = + for { + _ <- Result.fromProblems(prepared.varAndFragProblems(reportUnused)) + _ <- Result.fromProblems(prepared.mergeProblems) + ops0 <- prepared + .ops + .traverse(op => + compileOperation(op, untypedVars, introspectionLevel, env).tupleLeft(op.name)) + res <- (ops0, name) match { + case (List((_, op)), None) => + op.success + case (Nil, _) => + Result.failure("At least one operation required") + case (_, None) => + Result.failure("Operation name required to select unique operation") + case (ops, _) if ops.lengthCompare(1) > 0 && ops.exists(_._1.isEmpty) => + Result.failure("Query shorthand cannot be combined with multiple operations") + case (ops, on @ Some(name)) => + ops.filter(_._1 == on) match { + case List((_, op)) => op.success - case (Nil, _) => - Result.failure("At least one operation required") - case (_, None) => - Result.failure("Operation name required to select unique operation") - case (ops, _) if ops.lengthCompare(1) > 0 && ops.exists(_._1.isEmpty) => - Result.failure("Query shorthand cannot be combined with multiple operations") - case (ops, on @ Some(name)) => - ops.filter(_._1 == on) match { - case List((_, op)) => - op.success - case Nil => - Result.failure(s"No operation named '$name'") - case _ => - Result.failure(s"Multiple operations named '$name'") - } + case Nil => + Result.failure(s"No operation named '$name'") + case _ => + Result.failure(s"Multiple operations named '$name'") } - } yield res - } + } + } yield res /** * Compiles the provided operation AST to a query algebra term which can be directly executed. @@ -485,17 +511,31 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { untypedVars: Option[Json], frags: List[UntypedFragment], introspectionLevel: IntrospectionLevel = Full, - env: Env = Env.empty): Result[Operation] = { + env: Env = Env.empty): Result[Operation] = + compileOperation(prepareOperation(op, frags), untypedVars, introspectionLevel, env) + + /** + * Completes a prepared operation to a query algebra term which can be directly executed. + * + * GraphQL errors and warnings are accumulated in the result. + */ + private def compileOperation( + prepared: PreparedOperation, + untypedVars: Option[Json], + introspectionLevel: IntrospectionLevel, + env: Env): Result[Operation] = { + val op = prepared.op + val frags = prepared.frags val allPhases = IntrospectionElaborator( introspectionLevel).toList ++ (VariablesSkipAndFragmentElaborator :: MergeFields :: phases) for { - varDefs <- compileVarDefs(op.variables) + varDefs <- prepared.varDefs vars <- compileVars(varDefs, untypedVars) _ <- Directive.validateDirectivesForQuery(schema, op, frags, vars) - rootTpe <- op.rootTpe(schema) - _ <- VariableUsage.validateVariableUsages(schema, rootTpe, op, frags, varDefs) + rootTpe <- prepared.rootTpe + _ <- prepared.usages res <- ( for { query <- allPhases.foldLeftM(op.query) { (acc, phase) => @@ -508,7 +548,7 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { schema, Context(rootTpe), vars, - frags.map(f => (f.name, f)).toMap, + prepared.fragMap, op.query, env, List.empty, @@ -518,6 +558,11 @@ class QueryCompiler(parser: QueryParser, schema: Schema, phases: List[Phase]) { } yield res } + private def prepareOperation( + op: UntypedOperation, + frags: List[UntypedFragment]): PreparedOperation = + new PreparedOperation(this, schema, op, frags) + /** * Compiles variable definition ASTs to variable definitions for the target schema. * diff --git a/modules/core/src/main/scala/prepared.scala b/modules/core/src/main/scala/prepared.scala new file mode 100644 index 00000000..328d1ce4 --- /dev/null +++ b/modules/core/src/main/scala/prepared.scala @@ -0,0 +1,82 @@ +// 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 + +import grackle.Query.{UntypedFragment, VarDefs} + +/** + * The variable-free result of compiling a GraphQL document. + * + * A `PreparedDocument` depends on the document text and on the schema of the compiler that + * produced it. It does not depend on the variable values, on the `Env`, on the operation name, + * or on the introspection level, so one instance serves every request that sends the same + * document text. + * + * A `PreparedDocument` belongs to one `QueryCompiler`. Do not share one across two compilers + * with different schemas. + */ +final class PreparedDocument private[grackle] ( + compiler: QueryCompiler, + val ops: List[PreparedOperation], + val frags: List[UntypedFragment]) { + + private def untypedOps: List[UntypedOperation] = ops.map(_.op) + + /** + * The problems from `validateVariablesAndFragments` for the given `reportUnused` flag. Each + * value of the flag is computed at most once, so one prepared document serves both. + */ + private[grackle] def varAndFragProblems(reportUnused: Boolean): List[Problem] = + if (reportUnused) varAndFragProblemsReportingUnused else varAndFragProblemsIgnoringUnused + + private lazy val varAndFragProblemsReportingUnused: List[Problem] = + compiler.validateVariablesAndFragments(untypedOps, frags, true) + + private lazy val varAndFragProblemsIgnoringUnused: List[Problem] = + compiler.validateVariablesAndFragments(untypedOps, frags, false) + + /** + * The problems from `validateFieldMergeability`. + */ + private[grackle] lazy val mergeProblems: List[Problem] = + compiler.validateFieldMergeability(untypedOps, frags) +} + +/** + * The variable-free result of compiling one operation of a GraphQL document. + */ +final class PreparedOperation private[grackle] ( + compiler: QueryCompiler, + schema: Schema, + val op: UntypedOperation, + private[grackle] val frags: List[UntypedFragment]) { + + def name: Option[String] = op.name + + private[grackle] lazy val varDefs: Result[VarDefs] = compiler.compileVarDefs(op.variables) + + private[grackle] lazy val rootTpe: Result[NamedType] = op.rootTpe(schema) + + private[grackle] lazy val usages: Result[Unit] = + for { + rt <- rootTpe + vds <- varDefs + _ <- VariableUsage.validateVariableUsages(schema, rt, op, frags, vds) + } yield () + + private[grackle] lazy val fragMap: Map[String, UntypedFragment] = + frags.map(frag => (frag.name, frag)).toMap +} diff --git a/modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala b/modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala new file mode 100644 index 00000000..83a34a09 --- /dev/null +++ b/modules/core/src/test/scala/cache/CachingQueryCompilerSuite.scala @@ -0,0 +1,204 @@ +// 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 + +import scala.concurrent.duration._ + +import cats.effect.IO +import cats.effect.testkit.TestControl +import io.circe.literal._ +import munit.CatsEffectSuite + +import grackle.Query.UntypedFragment +import grackle.QueryCompiler.IntrospectionLevel + +/** + * A parser that counts the documents it parses. + */ +final class CountingQueryParser(underlying: QueryParser) extends QueryParser { + var count: Int = 0 + + def parseText(text: String): Result[(List[UntypedOperation], List[UntypedFragment])] = { + count += 1 + underlying.parseText(text) + } + + def parseDocument( + doc: Ast.Document): Result[(List[UntypedOperation], List[UntypedFragment])] = + underlying.parseDocument(doc) +} + +final class CachingQueryCompilerSuite extends CatsEffectSuite { + + def counting: (CountingQueryParser, QueryCompiler) = { + val parser = new CountingQueryParser(CacheTestMapping.queryParser) + val compiler = + new QueryCompiler(parser, CacheTestMapping.schema, CacheTestMapping.compilerPhases) + (parser, compiler) + } + + /** + * Runs `body` against a fresh counting parser and a caching compiler with the default store. + */ + def withCache(body: (CountingQueryParser, CachingQueryCompiler[IO]) => IO[Unit]): IO[Unit] = { + val (parser, compiler) = counting + TestControl.executeEmbed(CachingQueryCompiler[IO](compiler).flatMap(body(parser, _))) + } + + test("repeated documents parse once") { + withCache { (parser, cached) => + for { + first <- cached.compile("query { foo }") + second <- cached.compile("query { foo }") + } yield { + assert(first.hasValue) + assert(second.hasValue) + assertEquals(parser.count, 1) + } + } + } + + test("two different documents parse twice") { + withCache { (parser, cached) => + for { + _ <- cached.compile("query { foo }") + _ <- cached.compile("query { bar }") + } yield assertEquals(parser.count, 2) + } + } + + test("the same document with two different whitespace layouts parses twice") { + withCache { (parser, cached) => + for { + _ <- cached.compile("query { foo }") + _ <- cached.compile("query { foo }") + } yield assertEquals(parser.count, 2) + } + } + + test("one cached document serves two different variable values") { + val doc = "query ($n: Int!) { withArg(n: $n) }" + withCache { (parser, cached) => + for { + one <- cached.compile(doc, untypedVars = Some(json"""{ "n": 1 }""")) + two <- cached.compile(doc, untypedVars = Some(json"""{ "n": 2 }""")) + } yield { + assertEquals(parser.count, 1) + assert(one.hasValue) + assert(two.hasValue) + assertNotEquals(one.toOption.map(_.query), two.toOption.map(_.query)) + } + } + } + + test("one cached document does not leak the Env of the first request") { + val doc = "query { secret }" + withCache { (parser, cached) => + for { + alice <- cached.compile(doc, env = Env("user" -> "alice")) + bob <- cached.compile(doc, env = Env("user" -> "bob")) + aliceAgain <- cached.compile(doc, env = Env("user" -> "alice")) + } yield { + assertEquals(parser.count, 1) + assert(alice.hasValue, "the permitted user must compile") + assert(!bob.hasValue, "the other user must not compile") + assert(aliceAgain.hasValue, "a rejection must not poison the entry") + } + } + } + + test("one cached document serves two different values of reportUnused") { + val doc = "query ($unused: Int) { foo }" + withCache { (parser, cached) => + for { + reported <- cached.compile(doc, reportUnused = true) + quiet <- cached.compile(doc, reportUnused = false) + } yield { + assertEquals(parser.count, 1) + assert(reported.toProblems.exists(_.message.contains("is unused"))) + assert(!quiet.toProblems.exists(_.message.contains("is unused"))) + } + } + } + + test("a malformed document parses once and fails twice with the same problems") { + val doc = "query { foo" + withCache { (parser, cached) => + for { + first <- cached.compile(doc) + second <- cached.compile(doc) + } yield { + assertEquals(parser.count, 1) + assert(!first.hasValue) + assertEquals(first.toProblems.toList, second.toProblems.toList) + } + } + } + + test("a cached result matches the result of the uncached compiler") { + val (_, compiler) = counting + val doc = "query ($n: Int!) { withArg(n: $n) }" + val vars = json"""{ "n": 7 }""" + TestControl.executeEmbed { + for { + cached <- CachingQueryCompiler[IO](compiler) + _ <- cached.compile(doc, untypedVars = Some(vars)) + hot <- cached.compile(doc, untypedVars = Some(vars)) + } yield assertEquals(hot, compiler.compile(doc, untypedVars = Some(vars))) + } + } + + test("an entry which expires is parsed again") { + withCache { (parser, cached) => + for { + _ <- cached.compile("query { foo }") + _ <- IO.sleep(61.minutes) + _ <- cached.compile("query { foo }") + } yield assertEquals(parser.count, 2) + } + } + + test("CachingQueryCompiler built with a caller-supplied store repeats a document once") { + val (parser, compiler) = counting + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 4, ttl = 1.hour) + cached = CachingQueryCompiler[IO](compiler, cache) + first <- cached.compile("query { foo }") + second <- cached.compile("query { foo }") + } yield { + assert(first.hasValue) + assert(second.hasValue) + assertEquals(parser.count, 1) + } + } + } + + test("one cached document does not fix the introspection level") { + val doc = "query { __schema { queryType { name } } }" + withCache { (parser, cached) => + for { + full <- cached.compile(doc, introspectionLevel = IntrospectionLevel.Full) + restricted <- cached.compile(doc, introspectionLevel = IntrospectionLevel.TypenameOnly) + } yield { + assertEquals(parser.count, 1) + assert(full.hasValue, "introspection must succeed when the level is Full") + assert(!restricted.hasValue, "introspection must fail when the level is TypenameOnly") + assert(restricted.toProblems.exists(_.message.contains("Introspection is disabled"))) + } + } + } +} diff --git a/modules/core/src/test/scala/cache/QueryCacheSuite.scala b/modules/core/src/test/scala/cache/QueryCacheSuite.scala new file mode 100644 index 00000000..91026120 --- /dev/null +++ b/modules/core/src/test/scala/cache/QueryCacheSuite.scala @@ -0,0 +1,145 @@ +// 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 + +import scala.concurrent.duration.* + +import cats.effect.IO +import cats.effect.testkit.TestControl +import compiler.TestMapping +import munit.CatsEffectSuite + +import grackle.QueryCompiler.* +import grackle.syntax.* + +final class QueryCacheSuite extends CatsEffectSuite { + + val docA: Result[PreparedDocument] = CacheTestMapping.compiler.prepare("query { foo }") + val docB: Result[PreparedDocument] = CacheTestMapping.compiler.prepare("query { bar }") + val docC: Result[PreparedDocument] = CacheTestMapping.compiler.prepare("query { baz }") + + test("stored document comes back") { + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 4, ttl = 1.hour) + _ <- cache.put("a", docA) + hit <- cache.get("a") + } yield assertEquals(hit, Option(docA)) + } + } + + test("absent document misses") { + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 4, ttl = 1.hour) + miss <- cache.get("a") + } yield assertEquals(miss, None) + } + } + + test("entry which is never read expires after the time to live") { + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 4, ttl = 1.hour) + _ <- cache.put("a", docA) + _ <- IO.sleep(61.minutes) + miss <- cache.get("a") + } yield assertEquals(miss, None) + } + } + + test("read refreshes the expiry") { + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 4, ttl = 1.hour) + _ <- cache.put("a", docA) + _ <- IO.sleep(30.minutes) + first <- cache.get("a") + _ <- IO.sleep(31.minutes) + second <- cache.get("a") + } yield { + assert(first.isDefined) + assert(second.isDefined) + } + } + } + + test("write past the size limit evicts the least recently used entry") { + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 2, ttl = 1.hour) + _ <- cache.put("a", docA) + _ <- IO.sleep(1.minute) + _ <- cache.put("b", docB) + _ <- IO.sleep(1.minute) + _ <- cache.get("a") // "a" is now newer than "b" + _ <- IO.sleep(1.minute) + _ <- cache.put("c", docC) + a <- cache.get("a") + b <- cache.get("b") + c <- cache.get("c") + } yield { + assertEquals(a, Option(docA), "the entry which was read must survive") + assertEquals(b, None, "the least recently used entry must go") + assertEquals(c, Option(docC), "the new entry must be present") + } + } + } + + test("write to a present key does not evict another entry") { + TestControl.executeEmbed { + for { + cache <- QueryCache[IO](maxSize = 2, ttl = 1.hour) + _ <- cache.put("a", docA) + _ <- IO.sleep(1.minute) + _ <- cache.put("b", docB) + _ <- IO.sleep(1.minute) + _ <- cache.put("b", docB) + a <- cache.get("a") + b <- cache.get("b") + } yield { + assertEquals(a, Option(docA)) + assertEquals(b, Option(docB)) + } + } + } +} + +object CacheTestMapping extends TestMapping { + val schema = + schema""" + type Query { + foo: Int + bar: Int + baz: Int + withArg(n: Int!): Int + secret: Int + } + """ + + val QueryType = schema.ref("Query") + + override val selectElaborator = SelectElaborator { + case (QueryType, "secret", Nil) => + Elab.env[String]("user").flatMap { + case Some("alice") => Elab.unit + case other => Elab.liftR(Result.failure(s"Not permitted for $other")) + } + + case (QueryType, "withArg", List(Query.Binding("n", Value.IntValue(n)))) => + Elab.env("n" -> n) + } +} diff --git a/modules/core/src/test/scala/compiler/PrepareSuite.scala b/modules/core/src/test/scala/compiler/PrepareSuite.scala new file mode 100644 index 00000000..6d33ff62 --- /dev/null +++ b/modules/core/src/test/scala/compiler/PrepareSuite.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 compiler + +import io.circe.literal.* +import munit.CatsEffectSuite + +import grackle.syntax.* + +final class PrepareSuite extends CatsEffectSuite { + + // A document with an undefined variable and an unused variable. The undefined variable is + // always an error. The unused variable is an error only when `reportUnused` is true. + val mixedVars = """ + query ($used: Int, $unused: Int) { + foo(n: $undefined) + } + """ + + test("one prepared document serves both values of reportUnused") { + val prepared = PrepareMapping.compiler.prepare(mixedVars) + def viaSplit(reportUnused: Boolean) = + prepared + .flatMap(PrepareMapping.compiler.compilePrepared(_, reportUnused = reportUnused)) + .toProblems + .toList + .map(_.message) + def viaCompile(reportUnused: Boolean) = + PrepareMapping + .compiler + .compile(mixedVars, reportUnused = reportUnused) + .toProblems + .toList + .map(_.message) + + val withUnused = + List( + "Variable 'undefined' is undefined", + "Variable 'used' is unused", + "Variable 'unused' is unused" + ) + val withoutUnused = List("Variable 'undefined' is undefined") + + assertEquals(viaSplit(true), withUnused) + assertEquals(viaCompile(true), withUnused) + assertEquals(viaSplit(false), withoutUnused) + assertEquals(viaCompile(false), withoutUnused) + } + + test("a variable value error is reported before a variable usage error") { + // `$size` is declared nullable and used at a non-null argument, which rule 5.8.5 rejects. + // No value is supplied for `$n`, which is non-null, so variable coercion fails first. + val query = """ + query ($n: Int!, $size: Int) { + bar(n: $n, size: $size) + } + """ + val res = PrepareMapping.compiler.compile(query, untypedVars = Some(json"""{}""")) + val messages = res.toProblems.toList.map(_.message) + // If `usages` runs before `compileVars`, the first problem names 'size' instead. + assertEquals(messages.head, "Value of type Int required for 'n' in variable values") + } +} + +object PrepareMapping extends TestMapping { + val schema = + schema""" + type Query { + foo(n: Int): Int + bar(n: Int!, size: Int!): Int + } + """ +}