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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions modules/core/src/main/scala/compiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions modules/core/src/main/scala/query.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions modules/core/src/main/scala/queryinterpreter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In think this is going to mean that __typename always appears first in the result. eg. suppose we had { foo __typename } instead of { __typename foo }. I think there maybe similar pre-existing issues for non-top-level uses, but I'm pretty sure this is introducing a new instance.

}

val effectfulResults: F[List[Result[ProtoJson]]] =
Expand Down Expand Up @@ -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'")
Expand Down
29 changes: 29 additions & 0 deletions modules/core/src/test/scala/directives/QueryDirectivesSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the directive has been silently ignored here ... I'm pretty sure on main it would have been an error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main this would fail (in nested fields) with Undefined directive 'upperCase'.. The directive not changing __typename or other introspection fields was an intentional change here.
The GraphQL spec says "it (typename) returns the name of the concrete Object type at that point during execution". So I don't think any user elaborators should be able to change introspection-level fields.

@milessabin milessabin Oct 19, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but I think we should still fail here in the same way as on main.

"user" : {
"__typename" : "User",
"name" : "MARY"
}
}
}
"""

val res = QueryDirectivesMapping.compileAndRun(query)

assertIO(res, expected)
}

test("query with directive (1)") {
val query = """
query {
Expand Down
178 changes: 178 additions & 0 deletions modules/core/src/test/scala/introspection/IntrospectionSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import io.circe.literal._
import munit.CatsEffectSuite

import grackle._
import grackle.Query._
import grackle.QueryCompiler.IntrospectionLevel._
import grackle.syntax._

Expand Down Expand Up @@ -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 = """
{
Expand Down Expand Up @@ -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] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually used?

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