From 8259abad53d4cdb8243fd11ff635a6eb6bb51338 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Sat, 17 Oct 2026 15:06:35 +0200 Subject: [PATCH] Fix __typename at the query root Queries with `__typename` at the root failed with `No field '__typename' for type Query`. The compiler used the introspection schema's `Query` type to resolve it. That type has no `__typename` field. This change resolves `__typename` against the enclosing type instead. --- modules/core/src/main/scala/compiler.scala | 19 +- modules/core/src/main/scala/query.scala | 10 + .../src/main/scala/queryinterpreter.scala | 16 +- .../directives/QueryDirectivesSuite.scala | 29 +++ .../introspection/IntrospectionSuite.scala | 178 ++++++++++++++++++ .../src/test/scala/SqlCoalesceSuite.scala | 76 +++++++- 6 files changed, 314 insertions(+), 14 deletions(-) diff --git a/modules/core/src/main/scala/compiler.scala b/modules/core/src/main/scala/compiler.scala index cc46ce44..c8e0d55e 100644 --- a/modules/core/src/main/scala/compiler.scala +++ b/modules/core/src/main/scala/compiler.scala @@ -1226,15 +1226,26 @@ object QueryCompiler { _ <- Elab.pop } yield i.copy(child = ec) + // `__typename` uses the enclosing type. `__schema` and `__type` use the query root + // of the introspection schema. `Introspect` with a skipped child is dropped. case i @ Introspect(_, child) => + val typename = Query.isTypename(child) for { s <- Elab.schema c <- Elab.context - iTpe = if (c.tpe =:= s.queryType) Introspection.schema.queryType else TypenameType - _ <- Elab.push(Introspection.schema, c.asType(iTpe), child) + _ <- + if (typename || c.tpe =:= s.queryType) Elab.unit + else { + val fieldName = Query.rootName(child).fold(child.render)(_._1) + Elab.failure(s"No field '$fieldName' for type ${c.tpe.underlying}") + } + _ <- + if (typename) Elab.push(c.asType(TypenameType), child) + else + Elab.push(Introspection.schema, c.asType(Introspection.schema.queryType), child) ec <- transform(child) _ <- Elab.pop - } yield i.copy(child = ec) + } yield if (ec == Empty) Empty else i.copy(child = ec) case u @ Unique(child) => for { @@ -1553,6 +1564,8 @@ object QueryCompiler { _ <- if (s eq Introspection.schema) elaborateIntrospection(Introspection.schema.uncheckedRef(obj), fieldName, eArgs) + // `__typename` isn't a field of the mapping, so it never reaches `select` + else if (obj == TypenameType) Elab.unit else select(s.uncheckedRef(obj), fieldName, eArgs, dirs) elab <- Elab.transform env <- Elab.localEnv diff --git a/modules/core/src/main/scala/query.scala b/modules/core/src/main/scala/query.scala index c732c550..4790ac73 100644 --- a/modules/core/src/main/scala/query.scala +++ b/modules/core/src/main/scala/query.scala @@ -318,6 +318,7 @@ object Query { * otherwise. */ def rootName(q: Query): Option[(String, Option[String])] = { + @tailrec def loop(q: Query): Option[(String, Option[String])] = q match { case UntypedSelect(name, alias, _, _, _, _) => Some((name, alias)) @@ -329,11 +330,18 @@ object Query { loop(q) } + /** + * Whether `q` selects `__typename`, possibly wrapped in an `Environment` or + * `TransformCursor`. + */ + def isTypename(q: Query): Boolean = rootName(q).exists(_._1 == "__typename") + /** * Computes the possibly aliased result name of the supplied query if it is unique, `None` * otherwise. */ def resultName(q: Query): Option[String] = { + @tailrec def loop(q: Query): Option[String] = q match { case UntypedSelect(name, alias, _, _, _, _) => Some(alias.getOrElse(name)) @@ -386,6 +394,7 @@ object Query { * Yields the top-level field selections of the supplied query. */ def children(q: Query): List[Query] = { + @tailrec def loop(q: Query): List[Query] = q match { case UntypedSelect(_, _, _, _, child, _) => ungroup(child) @@ -402,6 +411,7 @@ object Query { * otherwise. */ def extractChild(query: Query): Option[Query] = { + @tailrec def loop(q: Query): Option[Query] = q match { case UntypedSelect(_, _, _, _, child, _) => Some(child) diff --git a/modules/core/src/main/scala/queryinterpreter.scala b/modules/core/src/main/scala/queryinterpreter.scala index 61abaf77..01840b68 100644 --- a/modules/core/src/main/scala/queryinterpreter.scala +++ b/modules/core/src/main/scala/queryinterpreter.scala @@ -118,11 +118,19 @@ class QueryInterpreter[F[_]](mapping: Mapping[F]) { val pureResults: F[List[Result[ProtoJson]]] = if (pureQueries.isEmpty) Nil.pure[F].widen else { - val (introQueries, nonIntroQueries) = pureQueries.partitionMap { + val (allIntroQueries, nonIntroQueries) = pureQueries.partitionMap { case PureQuery(i: Introspect) => Left(i) case PureQuery(other) => Right(other) } + // Like introspection, root `__typename` needs no root cursor, which may run a query + val (typenameQueries, introQueries) = + allIntroQueries.partition(i => isTypename(i.child)) + + val typenameResults: List[Result[ProtoJson]] = + if (typenameQueries.isEmpty) Nil + else List(runValue(Group(typenameQueries), rootTpe, rootCursor)) + val introResults: List[Result[ProtoJson]] = introQueries.flatMap { case Introspect(schema, query) => @@ -140,7 +148,7 @@ class QueryInterpreter[F[_]](mapping: Mapping[F]) { case qs => runRootValue(Group(qs), rootTpe, rootCursor).map(List(_)) } - nonIntroResults.map(_ ++ introResults) + nonIntroResults.map(typenameResults ++ _ ++ introResults) } val effectfulResults: F[List[Result[ProtoJson]]] = @@ -241,6 +249,10 @@ class QueryInterpreter[F[_]](mapping: Mapping[F]) { case Group(siblings) => siblings.flatTraverse(query => runFields(query, tpe, cursor, path)) + // Move wrappers from earlier phases outside `Introspect` + case Introspect(schema, child @ (_: Group | _: Environment | _: TransformCursor)) => + runFields(mapFields(child)(Introspect(schema, _)), tpe, cursor, path) + case Introspect(schema, s @ Select("__typename", _, Empty, _)) if tpe.isNamed => val fail = Result.failure(s"'__typename' cannot be applied to non-selectable type '$tpe'") diff --git a/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala b/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala index 3b2858e9..048c6566 100644 --- a/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala +++ b/modules/core/src/test/scala/directives/QueryDirectivesSuite.scala @@ -55,6 +55,35 @@ final class QueryDirectivesSuite extends CatsEffectSuite { assertIO(res, expected) } + test("query with cursor transforming directive on __typename") { + val query = """ + query { + __typename @upperCase + user { + __typename @upperCase + name @upperCase + } + } + """ + + // The cursor transform rewrites field values, not the type name + val expected = json""" + { + "data" : { + "__typename" : "Query", + "user" : { + "__typename" : "User", + "name" : "MARY" + } + } + } + """ + + val res = QueryDirectivesMapping.compileAndRun(query) + + assertIO(res, expected) + } + test("query with directive (1)") { val query = """ query { diff --git a/modules/core/src/test/scala/introspection/IntrospectionSuite.scala b/modules/core/src/test/scala/introspection/IntrospectionSuite.scala index d41b256f..a1e2ca3e 100644 --- a/modules/core/src/test/scala/introspection/IntrospectionSuite.scala +++ b/modules/core/src/test/scala/introspection/IntrospectionSuite.scala @@ -21,6 +21,7 @@ import io.circe.literal._ import munit.CatsEffectSuite import grackle._ +import grackle.Query._ import grackle.QueryCompiler.IntrospectionLevel._ import grackle.syntax._ @@ -1762,6 +1763,102 @@ final class IntrospectionSuite extends CatsEffectSuite { assertIO(res, expected) } + test("typename query at the root") { + val query = """ + { + __typename + } + """ + + val expected = json""" + { + "data" : { + "__typename" : "QueryRoot" + } + } + """ + + val mapping = new RenamedRootMapping + val res = mapping.compileAndRun(query).map(json => (json, mapping.rootCursorFieldNames.get)) + + // Only `__typename`, so the mapping's root cursor should not be built + assertIO(res, (expected, Nil)) + } + + test("typename query at the root with other root fields") { + val query = """ + { + __typename + foo + __schema { + queryType { + name + } + } + } + """ + + val expected = json""" + { + "data" : { + "__typename" : "QueryRoot", + "foo" : 0, + "__schema" : { + "queryType" : { + "name" : "QueryRoot" + } + } + } + } + """ + + val mapping = new RenamedRootMapping + val res = mapping.compileAndRun(query).map(json => (json, mapping.rootCursorFieldNames.get)) + + // The mapping's root cursor should only be asked for `foo` + assertIO(res, (expected, List("foo"))) + } + + test("typename query at the mutation root") { + val query = """ + mutation { + __typename + } + """ + + val expected = json""" + { + "data" : { + "__typename" : "MutationRoot" + } + } + """ + + val res = (new RenamedRootMapping).compileAndRun(query) + + assertIO(res, expected) + } + + test("aliased typename query at the root") { + val query = """ + { + t: __typename + } + """ + + val expected = json""" + { + "data" : { + "t" : "QueryRoot" + } + } + """ + + val res = (new RenamedRootMapping).compileAndRun(query) + + assertIO(res, expected) + } + test("typename query with narrowing") { val query = """ { @@ -2255,3 +2352,84 @@ object DescribedMapping extends ValueMapping[IO] { ) ) } + +class RenamedRootMapping extends ValueMapping[IO] { + val schema = + schema""" + schema { + query: QueryRoot + mutation: MutationRoot + } + type QueryRoot { + foo: Int! + } + type MutationRoot { + baz: Int! + } + """ + + val QueryType = schema.queryType + val MutationType = schema.ref("MutationRoot") + + // The names of the root fields passed to `defaultRootCursor`, in call order + val rootCursorFieldNames = new java.util.concurrent.atomic.AtomicReference(List.empty[String]) + + override def defaultRootCursor( + query: Query, + tpe: Type, + parentCursor: Option[Cursor]): IO[Result[(Query, Cursor)]] = + IO(rootCursorFieldNames.updateAndGet(_ ++ ungroup(query).map(q => + rootName(q).fold(q.render)(_._1)))) *> + super.defaultRootCursor(query, tpe, parentCursor) + + val typeMappings = + List( + ObjectMapping( + tpe = QueryType, + fieldMappings = List( + ValueField.fromValue("foo", 0) + ) + ), + ObjectMapping( + tpe = MutationType, + fieldMappings = List( + RootEffect.computeCursor("baz")((p, e) => IO.pure(Result(valueCursor(p, e, 1)))) + ) + ) + ) +} + +class TypenameShapesMapping extends ValueMapping[IO] { + val schema = + schema""" + type Query { + foo: Int! + bar: Bar! + } + type Bar { + baz: Int! + } + """ + + val QueryType = schema.queryType + val BarType = schema.ref("Bar") + + val typeMappings = + List( + ObjectMapping( + tpe = QueryType, + fieldMappings = List( + ValueField.fromValue("foo", 0), + ValueField.fromValue("bar", ()) + ) + ), + ObjectMapping( + tpe = BarType, + fieldMappings = List( + ValueField.fromValue("baz", 1) + ) + ) + ) +} + +object TypenameShapesMapping extends TypenameShapesMapping diff --git a/modules/sql-core/src/test/scala/SqlCoalesceSuite.scala b/modules/sql-core/src/test/scala/SqlCoalesceSuite.scala index c7e1a3f2..0e5e2ea2 100644 --- a/modules/sql-core/src/test/scala/SqlCoalesceSuite.scala +++ b/modules/sql-core/src/test/scala/SqlCoalesceSuite.scala @@ -30,6 +30,14 @@ trait SqlCoalesceSuite extends CatsEffectSuite { type Fragment def mapping: IO[(Mapping[IO], SqlStatsMonitor[IO, Fragment])] + def runWithStats(query: String): IO[(Json, List[SqlStatsMonitor.SqlStats])] = + for { + mm <- mapping + (map, mon) = mm + res <- map.compileAndRun(query) + ss <- mon.take + } yield (res, ss) + test("simple coalesced query") { val query = """ query { @@ -105,15 +113,7 @@ trait SqlCoalesceSuite extends CatsEffectSuite { } """ - val prog: IO[(Json, List[SqlStatsMonitor.SqlStats])] = - for { - mm <- mapping - (map, mon) = mm - res <- map.compileAndRun(query) - ss <- mon.take - } yield (res, ss) - - prog.map { + runWithStats(query).map { case (res, stats) => assertWeaklyEqual(res, expected) @@ -125,6 +125,64 @@ trait SqlCoalesceSuite extends CatsEffectSuite { } } + test("typename query at the root runs no SQL query") { + val query = """ + query { + __typename + } + """ + + val expected = json""" + { + "data" : { + "__typename" : "Query" + } + } + """ + + runWithStats(query).map { + case (res, stats) => + assertWeaklyEqual(res, expected) + assert(stats.isEmpty) + } + } + + test("typename query at the root with an SQL root field") { + val query = """ + query { + __typename + r { + id + } + } + """ + + val expected = json""" + { + "data" : { + "__typename" : "Query", + "r" : [ + { + "id" : "R1" + }, + { + "id" : "R2" + }, + { + "id" : "R3" + } + ] + } + } + """ + + runWithStats(query).map { + case (res, stats) => + assertWeaklyEqual(res, expected) + assert(stats.length == 1) + } + } + test("offset-date-time coalesced query") { val query = """ query {