Skip to content

Build one resolver plan per field and delete the resolver hierarchy - #955

Merged
oryan-block merged 10 commits into
agent-friendlyfrom
agent-friendly-p12-resolver-plan
Nov 6, 2026
Merged

oryan-block merged 10 commits into
agent-friendlyfrom
agent-friendly-p12-resolver-plan

Conversation

@oryan-block

@oryan-block oryan-block commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Agents working in graphql-java-tools keep repeating the same mistakes, and the repo should change so a change that looks right from one file is right for the whole repo. The most common one: the scan and the data fetcher each worked out the same facts about a resolver's signature. FieldResolverScanner picked the method and checked its trailing parameters against a list, then MethodFieldResolver.createDataFetcher re-read the Method to work out the parameter types, which ones are Optional, which scalars skip conversion and what each trailing parameter binds to (with a when instead of the list). SchemaClassScanner and UnmappedInputFieldChecker read the parameter types a third and fourth time, each handling a missing one differently. Fixing a rule in one of them left the others on the old rule, and nothing failed.

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

FieldResolverScanner, FieldResolver, MethodFieldResolver, PropertyFieldResolver, MapFieldResolver and MissingFieldResolver are gone. ResolverPlan.kt has one ResolverPlan per field, built by ResolverPlan.Finder (the old scanner's lookup, moved). The plan reads the member once and gives the scan its type shapes, the parser its data fetcher, the proxy and "no parameter for its argument" checks what they need, and UnmappedInputFieldChecker the argument types and mapper. Nothing outside the file reflects on the member. ScanDictionary's references still hold the Method for error text and to check whether a return type came from a GraphQLResolver, but only read its declaring class.

  • the method, field and Map members are private to ResolverPlan.kt, each one decides its ResolverKind, and none of them builds a data fetcher. ResolverPlan.dataFetcher gives every member a ResolverDataFetcher, a sealed class whose constructor takes the TypeShape.Unwrapper. So a DataFetcher { } lambda inside fetcherOf, or a fetcher without an unwrapper, doesn't compile, and a fetcher that holds one but skips it fails the per-kind fixture test
  • the data fetchers keep their names: MethodFieldResolverDataFetcher and LightMethodFieldResolverDataFetcher for methods, PropertyFieldResolverDataFetcher and MapFieldResolverDataFetcher for fields and Maps. They're internal but public in bytecode, like before, so instanceof and class name checks still work, and getWrappedFetchingObject on the two method fetchers is reachable by reflection again (apps call it from Instrumentation.instrumentDataFetcher to get to the resolver behind a field, Can't get the wrapped dataFetcher #164). WrappedFetchingObjectTest does that from another package
  • the lookup order is ResolverKind.entries (method, field, Map key) with an exhaustive when, and the trailing parameters are an ExtraParameter enum with an abstract binder. Adding either without the rest doesn't compile. The fixture tests in ResolverCompatibilityTest iterate the entries and fail with the fixture to add, instead of a compile error that offers an else branch. A new ResolverKind also ends the bounded check in MODEL ERROR until Check.java has a kind for it, the same way a new ContainerKind does
  • an untyped Map's values still reach graphql-java as they are. That's now ResolverKind.MAP_VALUE.unwrapsUntypedValues = false, read by the Unwrapper, instead of a null unwrapper
  • one argument array is filled in JVM order per fetch, with a slot for a suspend function's Continuation, instead of mapping a list of closures and copying the array again
  • the subscription Publisher check, the raw Optional check and the List-only concrete scalar check move into TypeShape.kt, next to asPublisher and the walk. Each keeps its own rule (erased type, raw class, List only), since merging them into the walk would change which shapes build and which arguments get converted
  • SourceResolver stays public and unchanged, in its own file. japicmp prints No changes.

ResolverOwnershipTest (ArchUnit) fails on:

  • member reflection outside ResolverPlan.kt: Method.invoke, Field reads and writes (get, getInt, set, ...), setAccessible, signature reads (generic and raw parameter types, Field.getType, Parameter types, annotated types), MethodHandles lookups and calls, kotlin-reflect's member lookups (members, memberProperties, declaredMemberFunctions, ...), callSuspend and JVM mappings (any kotlin.reflect.full or kotlin.reflect.jvm call), a value typed as a KFunction, KProperty or one of their getters (so f(t) on a KFunction1 and getter.invoke(t) fail too), and commons-lang3 FieldUtils/MethodUtils reads and invokes. The one exception is the type reads in ScanDictionary.findInputValueTypeInType(String, Class), matched by its full signature so an overload doesn't get it, which still guesses input field types from getters
  • a member that depends on a data fetcher or an unwrapper, a library DataFetcher that isn't a ResolverDataFetcher, and a ResolverDataFetcher declared outside ResolverPlan.kt (Kotlin lets a sealed class have subclasses in other files of the package)
  • generic wrapper or DataFetcherResult handling outside TypeShape.kt, TokenBuffer conversion (now including updateValue and readValueAs) outside ArgumentConverter.kt, and an ArgumentConverter built outside ResolverPlan.kt

The exemptions name the exact top-level classes each owning file declares, and nested and lambda classes count through their enclosing class. So another file called ResolverPlan.kt in the same package, or a class named ArgumentConverter$Something, doesn't get an exemption. A test fails when a listed class isn't compiled from that file, or the file declares a class the list misses.

Two rounds of bypass hunting showed a bytecode check can't be airtight, so this is where I stopped. What it doesn't see:

  • is CompletableFuture, CompletionStage or Optional checks outside TypeShape.kt. On purpose, they're ordinary JDK types main code uses for its own work
  • Class.forName lookups. ArchUnit can't see a class named by a string, and the proxy handlers use it for optional dependencies
  • Method.getReturnType. TypeShape reads it on a generic wrapper's transformer, so banning it needs an exemption for main code
  • a KFunction held as Any and called through a cast, like (f as KFunction1<Any, Any?>)(t). ArchUnit doesn't count a cast as a dependency. Getting the KFunction in the first place needs a kotlin-reflect call the test does catch
  • reflection a library does for you: graphql-java's PropertyDataFetcher.fetching, java.beans.Expression and Statement
  • a new helper inside an owning file that does the reflection for another file, since the owning file is exempt as a whole
  • a DataFetcher { } lambda outside ResolverPlan.kt. It compiles to invokedynamic, so there's no class to check. The per-kind fixture tests and the bounded check catch a value it leaves wrapped, but only for fetchers ResolverPlan builds

AGENTS.md's ownership row lists the same gaps.

Kept on purpose, so nothing a user sees changes:

  • every message, including the duplicate-resolver error that prints MethodFieldResolver{method=...} and PropertyFieldResolver{property=...}, now pinned by a test. 137 FieldResolverError rows in the compat corpus still match
  • the missing-resolver and inaccessible-field WARNs still log under graphql.kickstart.tools.resolver.FieldResolverScanner, since apps may silence them by that name. MissingFieldResolverTest checks the logger names
  • the laziness: argument shapes then the return shape are walked one at a time as the scan matches them, and the ObjectMapper provider runs at parse time, once per method field
  • the two source checks and their messages, the Map light path that never checks the source's class, and the context class lookup when it's DataFetchingEnvironment

One small difference: calling get(environment) on a field or Map fetcher with an environment that has no fieldDefinition used to throw an NPE from Kotlin's null check on a parameter the fetcher never reads. Now all four fetchers take it as nullable, like the method fetchers already did. graphql-java always sets it, so only hand-built environments see this. I kept it lenient rather than adding the check back.

AGENTS.md's rows for deciding a method's signature once and for one conversion entry point name what enforces them, and the verify-return-value-unwrapping skill names the fetchers and its sync commit.

Testing:

  • mise exec [email protected]+9 -- mvn -B -ntp clean verify passes at this head, 493 tests, japicmp No changes.. mvn -B -ntp clean verify on JDK 25 passes too
  • the bounded check prints PASS with the same counts as agent-friendly: 7,020 return types, 1,365 rejected, 5,655 accepted, 226,277 executions, pinnedCoverage unchanged
  • verify-tools, all 40 scenarios at 0f235257 and at agent-friendly: same exit codes, and schemas, results, warnings and build errors identical apart from stack frames. The last rounds only touched the test and AGENTS.md
  • broke it on purpose in one scratch build: the 30 attempts the first round caught still fail, and so do 16 new forms built from what the verifier found after it (Kotlin invoke, getter.invoke, callSuspend/callSuspendBy, KParameter types, Field.getType, Parameter and annotated types, a findInputValueTypeInType overload, a ResolverDataFetcher subclass in another file). Each failure names the line and the fix. The ones that get past it are the list above. A clean head passes
  • the lambda and the null-unwrapper fetcher don't compile in fetcherOf, a sealed fetcher that skips its unwrapper fails the fixture test, a scratch ResolverKind ends the bounded check in MODEL ERROR, and a property fetcher that drops the unwrapping fails the per-kind test
  • the adversary's WrappedProbe (Java, unnamed module) gets the Holder back from both method fetchers again, under the old class names
  • not run here: the JMH comparison. Per fetch there's one fewer list per method call, and an untyped Map value goes through one Unwrapper.unwrap call that returns it as is

ResolverPlan.kt replaces FieldResolverScanner, FieldResolver and the method,
property, Map and missing resolvers. Its nested Finder picks the member behind
a field, and the plan reads that member's signature once: the argument types,
the extra parameters, the source and the return shape. The scan, the data
fetcher, the proxy and parameter checks and UnmappedInputFieldChecker read
the plan instead of the Method.

Member variants are private to the file and only expose a raw read. The plan
builds every data fetcher and unwraps every value through TypeShape. The
resolver kinds and the extra parameters are enums whose entries the finder
and the binding iterate, and the subscription Publisher check and the
argument Optional and concrete scalar checks move next to the type walk in
TypeShape.kt. SourceResolver keeps its public interface in its own file.

ResolverOwnershipTest keeps member reflection in ResolverPlan.kt, container
dispatch in TypeShape.kt and TokenBuffer conversion in ArgumentConverter.kt.
Messages, lookup order and laziness are unchanged.
… member lookup order

Argument shapes are walked when the scan asks for them instead of being kept for the schema's lifetime, the method fetcher's slots are an array, and the source check captures only the search type. A test pins that a method wins over a field and a field over a Map key, and ResolverOwnershipTest keeps ArgumentConverter construction in ResolverPlan.kt.
The rows for deciding a method's signature once and for one argument conversion entry point now name ResolverPlan, ArgumentConverter and ResolverOwnershipTest. The bounded check skill names ResolverPlan.kt where it named the deleted resolver files.
The probe diff against trunk showed the missing-resolver and inaccessible-field WARNs moving from graphql.kickstart.tools.resolver.FieldResolverScanner to ResolverPlan$Finder, which would change what an app's logging config raises or silences. MissingFieldResolverTest now checks the logger names.
Apps call getWrappedFetchingObject by reflection from Instrumentation.instrumentDataFetcher, and some check a fetcher's class, so the fetchers keep their names and stay public in bytecode. Each member's fetcher is a ResolverDataFetcher, whose constructor takes the unwrapper, and the member's class decides its ResolverKind. The bounded check now fails with MODEL ERROR when a ResolverKind has no kind in Check.java, and the fixture tests name the fixture to add instead of offering an else branch.
…eflection gaps

Exemptions now name the top-level classes each owning file declares and count nested and lambda classes through their enclosing class, so neither a file of the same name elsewhere nor a '$' in a class name inherits one. The ScanDictionary exception covers only the type reads findInputValueTypeInType makes. The reflection rule also covers MethodHandles, kotlin-reflect calls and return types, Field writes and primitive reads, and commons-lang3 field and method utilities, and the TokenBuffer rule covers updateValue and readValueAs. Every library data fetcher must be a ResolverDataFetcher.
…from its owning file

Comment pass and /simplify: drop comments the types and tests already say, rename the Map flag to unwrapsUntypedValues, move the ScanDictionary exemption's reason into its description so a failure prints it, use ArchUnit's own not, and, or and isTopLevelClass, and fail when an OwningFile lists a class compiled from another file.
…rPlan.kt in the ownership test

Any use of a KFunction or KProperty, kotlin-reflect's member lookups and JVM mappings, and the Field, Parameter and annotated type reads now fail outside ResolverPlan.kt. A ResolverDataFetcher subclass must be declared in ResolverPlan.kt, and the ScanDictionary exemption matches findInputValueTypeInType(String, Class) by signature, so an overload doesn't inherit it. AGENTS.md lists what the test still doesn't see.
@sonarqubecloud

sonarqubecloud Bot commented Nov 6, 2026

Copy link
Copy Markdown

@oryan-block
oryan-block merged commit 7199aa2 into agent-friendly Nov 6, 2026
5 checks passed
@oryan-block
oryan-block deleted the agent-friendly-p12-resolver-plan branch November 6, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant