Skip to content
Merged
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
28 changes: 14 additions & 14 deletions .claude/skills/verify-return-value-unwrapping/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: verify-return-value-unwrapping
description: Exhaustively check, up to a bound, that the value of a resolver method, a field with no matching getter or a typed Map reaches graphql-java fully unwrapped. Futures are awaited, generic wrappers are transformed, Optional and DataFetcherResult are handled, and list order, nulls and failures are kept, for every return type nested up to 3 containers deep. Use it before pushing any change to TypeShape (the type walk, ContainerKind, the generic wrapper rules or the Unwrapper), the data fetchers in MethodFieldResolver, PropertyFieldResolver or MapFieldResolver, or the GenericWrapper defaults, and whenever a bug report says graphql-java got a CompletableFuture, Optional or wrapper object as a field value.
description: Exhaustively check, up to a bound, that the value of a resolver method, a field with no matching getter or a typed Map reaches graphql-java fully unwrapped. Futures are awaited, generic wrappers are transformed, Optional and DataFetcherResult are handled, and list order, nulls and failures are kept, for every return type nested up to 3 containers deep. Use it before pushing any change to TypeShape (the type walk, ContainerKind, the generic wrapper rules or the Unwrapper), the data fetchers in ResolverPlan, or the GenericWrapper defaults, and whenever a bug report says graphql-java got a CompletableFuture, Optional or wrapper object as a field value.
---

# Verify return value unwrapping
Expand Down Expand Up @@ -38,11 +38,13 @@ and an all-non-null GraphQL type.

Each return type runs as three kinds of resolver, each compiled as a Java class at runtime:

| Kind | Generated resolver | Data fetcher |
| Kind | Generated resolver | Member and data fetcher in `ResolverPlan.kt` |
|---|---|---|
| `method` | `public <type> field()` on the query resolver | `MethodFieldResolver` |
| `property` | `public <type> field;` on the query resolver, which is also what a Kotlin `@JvmField` compiles to | `PropertyFieldResolver` |
| `map` | `extends HashMap<String, <type>>`, bound to `type Holder` and returned by `Query.holder` | `MapFieldResolver` |
| `method` | `public <type> field()` on the query resolver | `MethodMember`, `LightMethodFieldResolverDataFetcher` |
| `property` | `public <type> field;` on the query resolver, which is also what a Kotlin `@JvmField` compiles to | `PropertyMember`, `PropertyFieldResolverDataFetcher` |
| `map` | `extends HashMap<String, <type>>`, bound to `type Holder` and returned by `Query.holder` | `MapMember`, `MapFieldResolverDataFetcher` |

Each kind's `resolverKind` is the noun of a `ResolverKind`, and a `ResolverKind` without a kind here ends in `MODEL ERROR`.

The check builds each schema through the public `SchemaParser` API, which rejects about a fifth of the combinations, such as
`Optional` below the top level. For every accepted one it executes the query once for each value that makes at most
Expand Down Expand Up @@ -73,7 +75,7 @@ as `-Dunwrapping.args`, and prints the report. On a failure it also prints the p
|---|---|
| 0 | `PASS`, or `PASS apart from the known findings above` |
| 1 | `FAIL`: a return type that isn't a known finding breaks the property. `NEW`: a known finding matches more accepted return types than its line records |
| 2 | `MODEL ERROR`: the checkout or the check didn't compile, the build rejected a return type the check must exercise, or a `ContainerKind` in `TypeShape.kt` has no `Container` in `Check.java`, so a pass would prove nothing |
| 2 | `MODEL ERROR`: the checkout or the check didn't compile, the build rejected a return type the check must exercise, a `ContainerKind` in `TypeShape.kt` has no `Container` in `Check.java`, or a `ResolverKind` in `ResolverPlan.kt` has no `Kind`, so a pass would prove nothing |
| 3 | `STALE`: a known finding matches a return type that no longer fails, or fewer accepted return types than its line records. Or the run's coverage differs from `pinnedCoverage` in `Check.java` |

Options: `--depth N`, `--budget N`, `--property-budget N`, `--show N` (counterexamples and stale return types printed, default 15), and `--rejected`, which lists
Expand Down Expand Up @@ -154,7 +156,7 @@ or delete it when none fail, so that a regression shows up as `FAIL`.
## What this does not cover

- Suspend functions, subscriptions and `Publisher`, arrays, and Kotlin-declared resolver methods. The generated resolvers
are Java methods without parameters, so only `LightMethodFieldResolverDataFetcher` runs, not
are Java methods without parameters, so only `LightMethodFieldResolverDataFetcher` in `ResolverPlan.kt` runs, not
`MethodFieldResolverDataFetcher`.
- Wrappers picked by a subclass of the value (`Box` and `SpecialBox` in `NestedGenericWrapperTest`), wrappers with a type
argument index other than 0, custom `schemaWrapper`s other than the list one, transformers that return a `FutureTask`,
Expand All @@ -169,24 +171,22 @@ or delete it when none fail, so that a regression shows up as `FAIL`.

## Coverage and sync

Last confirmed to describe the code at commit `5ce3d1db` (2026-11-05). It covers:
Last confirmed to describe the code at commit `1b351934` (2026-11-05). It covers:

| File | What |
|---|---|
| `src/main/kotlin/graphql/kickstart/tools/resolver/TypeShape.kt` | all of it: the walk, `ContainerKind`, `GenericWrappers`, `resolveTypeVariables`, the `Unwrapper` and its steps |
| `src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt` | `createDataFetcher` (the `TypeShape.Unwrapper`), `returnShape`, `LightMethodFieldResolverDataFetcher` |
| `src/main/kotlin/graphql/kickstart/tools/resolver/TypeShape.kt` | the walk, `ContainerKind`, `GenericWrappers`, `resolveTypeVariables`, the `Unwrapper` and its steps |
| `src/main/kotlin/graphql/kickstart/tools/resolver/ResolverPlan.kt` | `dataFetcher` (the `TypeShape.Unwrapper` per member), `returnShape`, `ResolverKind`, the members' `declaredValueType`, and the `ResolverDataFetcher` subclasses |
| `src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt` | `GenericWrapper` and the default wrappers in `build()` |
| `src/main/kotlin/graphql/kickstart/tools/resolver/PropertyFieldResolver.kt` | all of it |
| `src/main/kotlin/graphql/kickstart/tools/resolver/MapFieldResolver.kt` | all of it, for Map subclasses with a typed value |

To see whether a change needs another look:

```bash
git log --oneline 5ce3d1db..HEAD -- src/main/kotlin/graphql/kickstart/tools/resolver/TypeShape.kt src/main/kotlin/graphql/kickstart/tools/resolver/MethodFieldResolver.kt src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt src/main/kotlin/graphql/kickstart/tools/resolver/PropertyFieldResolver.kt src/main/kotlin/graphql/kickstart/tools/resolver/MapFieldResolver.kt
git log --oneline 1b351934..HEAD -- src/main/kotlin/graphql/kickstart/tools/resolver/TypeShape.kt src/main/kotlin/graphql/kickstart/tools/resolver/ResolverPlan.kt src/main/kotlin/graphql/kickstart/tools/SchemaParserOptions.kt
```

Move the sync commit forward only after you've read those changes and confirmed that the containers, variants and
expected values in `Check.java` still describe them. For example, a new default wrapper or a new kind of unwrapping
step needs a new `Container` with its variants and its plain value in `build`. A new `ContainerKind` ends in
`MODEL ERROR` until it has one. After an intended change, rerun the check,
`MODEL ERROR` until it has one, and so does a new `ResolverKind` until `Check.java` has a `Kind` with its noun. After an intended change, rerun the check,
and break the new code on purpose once in a scratch worktree to see the check fail before you trust its pass.
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ resolver classes. Published to Maven Central. Kotlin, with a few Java files, tar

- `src/main/kotlin/graphql/kickstart/tools`: `SchemaParser` and its builder and options, `MergedDefinitions.kt` (merges each
SDL type with its extensions into a `MergedType`. Other code, apart from the Relay factory, reads SDL types only through
it), `SchemaClassScanner` (matches schema types to classes), `resolver/` (field resolvers and data fetchers), `directive/`,
it), `SchemaClassScanner` (matches schema types to classes), `resolver/` (`ResolverPlan.kt` finds the Java member behind each field and builds its data fetcher, `TypeShape.kt` walks
declared types and unwraps values, `ArgumentConverter.kt` converts arguments), `directive/`,
`relay/`, `proxy/` (finds the class behind a Spring, Guice, Weld or Javassist proxied resolver), `util/`
- `src/test/kotlin`: JUnit tests, one class per feature. A test for an issue goes into the class for its feature.
`EndToEndTest` and `EndToEndSpecHelper` hold the shared schema and resolvers
Expand Down Expand Up @@ -93,13 +94,13 @@ happening. "docs only" means nothing does yet, so the rule depends on you readin
| Rule | Enforced by |
|---|---|
| The value of a resolver method, a field with no matching getter or a typed Map is unwrapped by one rule that the schema build and the data fetcher share | `TypeShape`, whose one walk yields both the class the schema build matches and the layers that `TypeShape.Unwrapper` unwraps in the method, field and Map data fetchers. An untyped Map's values reach graphql-java as they are. A container kind can't be added without its placement rule and its value step, since both are abstract on the enum, and `ReturnValueUnwrappingTest` fails until the bounded check builds values for it |
| A resolver method's parameter layout, type variables and source are decided once, at schema build | docs only |
| A resolver method's parameter layout, type variables and source are decided once, at schema build | `ResolverPlan`, which reads the field's member once and hands the scan its type shapes, the data fetcher its parameters and `UnmappedInputFieldChecker` its argument targets. Its member variants are private to `ResolverPlan.kt`, each decides its `ResolverKind`, and none builds a data fetcher. `ResolverPlan.dataFetcher` gives every member a `ResolverDataFetcher`, a sealed class whose constructor takes the `TypeShape.Unwrapper`, so a lambda or a fetcher without one doesn't compile. A new `ResolverKind` doesn't compile until the lookup's `when` handles it, a new `ExtraParameter` doesn't compile without its binder, the fixture tests in `ResolverCompatibilityTest` fail, naming the fixture to add, until they build a sample of each and see its value unwrapped, and `ReturnValueUnwrappingTest` ends in `MODEL ERROR` until the bounded check runs the new kind. `ResolverOwnershipTest` fails on member reflection outside `ResolverPlan.kt` (`java.lang.reflect` calls and signature reads, `MethodHandles`, `kotlin.reflect.full` and `kotlin.reflect.jvm` calls, `KClass` member lookups, a value typed as a `KFunction` or `KProperty`, and commons-lang3's field and method utilities, apart from the type reads in `ScanDictionary.findInputValueTypeInType(String, Class)`), on a member that depends on a data fetcher, on a library data fetcher that isn't a `ResolverDataFetcher` declared in `ResolverPlan.kt`, and on generic-wrapper or `DataFetcherResult` handling outside `TypeShape.kt`. Its exemptions name the exact classes each owning file declares. It doesn't see `Future`, `CompletionStage` or `Optional` checks outside `TypeShape.kt`, `Class.forName` lookups, `Method.getReturnType`, a `KFunction` held as `Any` and called through a cast, reflection a library does for you (graphql-java's `PropertyDataFetcher`, `java.beans.Expression`), a new helper in an owning file that reflects for another file, or a `DataFetcher { }` lambda outside `ResolverPlan.kt`. The fixture tests and the bounded check catch a value these leave wrapped only in the fetchers `ResolverPlan` builds |
| A change to how resolvers are found, bound or unwrapped keeps what a method, a property and a Map field return | `ResolverCompatibilityTest`, which compares field and Map values with a method's, and the bounded check in `ReturnValueUnwrappingTest`, which compares all three with the plain value. Neither has known findings, so a value that differs on a return type they run fails the build. The bounded check also pins how many return types it generates, and for each kind how many the build rejects and how many executions run, so a change that stops running some fails until the same diff updates `pinnedCoverage`. It doesn't judge whether a newly rejected return type should build |
| One output has one builder. Don't add a second path that builds the same thing | docs only |
| Read an SDL type's members merged with its extensions, never from the raw definition | `MergedType` in `MergedDefinitions.kt` and `DefinitionsBoundaryTest`, which fails on a raw member, directive or child-node read, an extension class, an SDL node walk or a `TypeDefinitionRegistry` call outside it. A read by reflection gets past it. `TypeExtensionTest` builds and queries a schema with an extension of every kind, so a build that leaves an extension's fields, interfaces, members, values, input fields or directives out of the built type fails it, however the code read them |
| Every kind of SDL type starts its graphql-java type from the same recipe | `MergedType.prepare`, `DefinitionsBoundaryTest` and `TypeKindRecipeTest` |
| Reuse an existing structure before you add a parallel one | docs only |
| Argument conversion goes through one entry point | docs only |
| Argument conversion goes through one entry point | `ArgumentConverter`, which `ResolverPlan` builds once per argument. `ResolverOwnershipTest` fails on a TokenBuffer or a Jackson `readValue`, `readValueAs`, `convertValue`, `updateValue`, `treeToValue` or `valueToTree` outside `ArgumentConverter.kt`, and on an `ArgumentConverter` built or called outside `ResolverPlan.kt`. Jackson's per-call retry for scalars it can't convert stays inside `ArgumentConverter` |
| An input field's Java type comes from the mapping Jackson uses, not from guessed getters | docs only |
| Every recursive walk over the type graph handles cycles, and a test covers a cycle | docs only |
| Public signatures stay binary compatible, data class `copy()` and `componentN` included | japicmp with `build/japicmp-kotlin-public.groovy` at `mvn verify`, which allows excludes and other narrowing options only at an X.0.0 version |
Expand Down
34 changes: 23 additions & 11 deletions src/main/kotlin/graphql/kickstart/tools/ResolverInfo.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package graphql.kickstart.tools

import graphql.kickstart.tools.resolver.FieldResolverScanner
import graphql.kickstart.tools.util.GraphQLRootResolver
import graphql.kickstart.tools.util.JavaType
import graphql.kickstart.tools.util.eraseUnboundedWildcards
import org.apache.commons.lang3.reflect.TypeUtils
import java.lang.reflect.ParameterizedType

internal abstract class ResolverInfo {
abstract fun getFieldSearches(): List<FieldResolverScanner.Search>
abstract fun getFieldSearches(): List<ResolverSearch>

fun getRealResolverClass(resolver: GraphQLResolver<*>, options: SchemaParserOptions) =
options.proxyHandlers.find { it.canHandle(resolver) }
Expand Down Expand Up @@ -43,10 +42,10 @@ internal class NormalResolverInfo(
return type
}

override fun getFieldSearches(): List<FieldResolverScanner.Search> {
override fun getFieldSearches(): List<ResolverSearch> {
return listOf(
FieldResolverScanner.Search(resolverType, this, resolver, dataClassType),
FieldResolverScanner.Search(dataClassType, this, null)
ResolverSearch(resolverType, this, resolver, dataClassType),
ResolverSearch(dataClassType, this, null)
)
}
}
Expand All @@ -60,11 +59,11 @@ internal class MultiResolverInfo(
private val dataClass: JavaType
) : ResolverInfo() {

override fun getFieldSearches(): List<FieldResolverScanner.Search> {
override fun getFieldSearches(): List<ResolverSearch> {
return resolverInfoList
.asSequence()
.map { FieldResolverScanner.Search(it.resolverType, this, it.resolver, it.dataClassType) }
.plus(FieldResolverScanner.Search(dataClass, this, null))
.map { ResolverSearch(it.resolverType, this, it.resolver, it.dataClassType) }
.plus(ResolverSearch(dataClass, this, null))
.toList()
}
}
Expand All @@ -75,16 +74,29 @@ internal class RootResolverInfo(
val isSubscription: Boolean = false
) : ResolverInfo() {
override fun getFieldSearches() =
resolvers.map { FieldResolverScanner.Search(getRealResolverClass(it, options), this, it) }
resolvers.map { ResolverSearch(getRealResolverClass(it, options), this, it) }
}

internal class DataClassResolverInfo(private val dataClass: JavaType) : ResolverInfo() {
override fun getFieldSearches() =
listOf(FieldResolverScanner.Search(dataClass, this, null))
listOf(ResolverSearch(dataClass, this, null))
}

internal class MissingResolverInfo : ResolverInfo() {
override fun getFieldSearches(): List<FieldResolverScanner.Search> = listOf()
override fun getFieldSearches(): List<ResolverSearch> = listOf()
}

internal data class ResolverSearch(
val type: JavaType,
val resolverInfo: ResolverInfo,
val source: Any?,
val requiredFirstParameterType: Class<*>? = null
) {
val isSubscription get() = resolverInfo is RootResolverInfo && resolverInfo.isSubscription

val firstArgumentIndex get() = if (requiredFirstParameterType != null) 1 else 0

fun withoutSourceParameter() = copy(requiredFirstParameterType = null)
}

internal class ResolverError(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package graphql.kickstart.tools

import graphql.kickstart.tools.resolver.FieldResolver
import graphql.kickstart.tools.resolver.ResolverPlan
import graphql.kickstart.tools.util.JavaType
import graphql.language.FieldDefinition
import graphql.schema.GraphQLScalarType
Expand All @@ -13,7 +13,7 @@ internal data class ScannedSchemaObjects(
val definitions: MergedDefinitions,
val types: Set<MergedType<*>>,
val customScalars: CustomScalarMap,
val fieldResolversByType: Map<MergedType.Object, MutableMap<FieldDefinition, FieldResolver>>,
val resolverPlansByType: Map<MergedType.Object, Map<FieldDefinition, ResolverPlan>>,
val unusedTypes: Set<MergedType<*>>,
val directiveOnlyEnums: Set<MergedType.Enum>
)
Expand Down
Loading
Loading