Skip to content

Split SchemaClassScanner and SchemaParser into smaller classes - #906

Merged
oryan-block merged 7 commits into
masterfrom
refactor/split-scanner-parser
Oct 23, 2026
Merged

oryan-block merged 7 commits into
masterfrom
refactor/split-scanner-parser

Conversation

@oryan-block

Copy link
Copy Markdown
Collaborator

Checklist

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

Description

Last part of the cleanup of code paths that drifted apart, no issue. Nothing should change for users.

SchemaClassScanner.kt was 870 lines and SchemaParser.kt 739. Every commit after the first moves one group of functions out verbatim, so git show --color-moved=dimmed-zebra shows it as moved. Each commit message lists the edits it needed to compile.

  • ScanDictionary gets the scanner's type dictionary together with everything that writes it: handleFoundType, handleNewType, the input object field walk, scanInterfacesOfType and the Reference classes. The scanner reads it as a Map<TypeDefinition<*>, EntryView>, so it can't add a type without going through handleFoundType. The first commit adds EntryView and is the only one that changes code.
  • SealedSubclassMatcher only reads the dictionary.
  • TypeReferenceErrors has the messages for a type used where it can't be.
  • SdlDirectives has all the directive code. It builds the directive definitions in its constructor, so nothing can apply a directive before they exist. The parser builds one per parseSchemaObjects(), where it used to call createDirectives(). One per parser would move directive errors to build(), and a second makeExecutableSchema() would point the first schema's directive arguments at the second schema's types.

The plan was a shared scan-state object first, passed to the moved functions. But every piece of scanner state already has one writer, so passing it around would let every file that gets the object write it. Moving the writer with its state keeps that in one place. Classes that log take the owner's logger, so warnings keep the SchemaClassScanner and SchemaParser logger names.

Not moved: directive scanning, root types, the field scan, the loop stages and type determination. They call back into the dictionary code, hold the stage order, or would only make a file shorter. SchemaClassScanner.kt is now 580 lines and SchemaParser.kt 466.

NestedGenericWrapperTest only changes its ReturnValueReference qualifier. mvn clean verify passes on JDK 25 against graphql-java 26.1 and 25.1 (410 tests), and on JDK 11 (409).

Also ran all 33 verify-tools scenarios through the installed jar on master and on this branch, with graphql-java 26.1 and 25.1 at runtime. Printed schemas, operation results, errors and warnings (logger names included) are identical. The only diff is an identity hash in a 25.1 warning that prints NodeUtil$DirectivesHolder@..., which changes with any build. Schema build time in the JMH benchmark is the same as master within noise (65.7 vs 65.6 ms for the edealer schema, 34.9 vs 33.6 for the large one). javap of SchemaParser, SchemaError and SchemaParserKt against 14.3.0 only differs in synthetic members Kotlin generates, and GRAPHQL_SCALARS and DEFAULT_DEPRECATION_MESSAGE stay in SchemaParserKt.

The scanner's type dictionary moves into its own class in the next commit,
and the scanner keeps reading it. Two reads need a shape that doesn't reach
into the mutable map:

- The enum-without-class check at the start of validateAndCreateResult
  becomes throwIfEnumWithoutClass(), so it can move with enumsWithoutClass.
- DictionaryEntry implements a new EntryView with javaType and typeClass(),
  the only parts the rest of the scanner reads. setTypeIfMissing and
  addReference aren't on it.

No behaviour change.
Moves the dictionary map, enumsWithoutClass, interfaceDefinitionsByName, both
handleFoundType functions, handleNewType, scanInterfacesOfType,
throwIfEnumWithoutClass, the input object field walk (handleInputObjectFields,
handleInputValueWithoutJavaType, isPropertyMap, isLibraryClass,
enumWithoutClassMessage, findInputValueTypeInType), EntryView, DictionaryEntry
and InitialDictionaryEntry to ScanDictionary.kt.

ScanDictionary is the only writer of the map. The scanner reads it as a
Map<TypeDefinition<*>, EntryView>, so the only way to add a type is
handleFoundType, which enters the type before it follows the type's class to
more types.

Edits to compile:
- ScanDictionary's header. It delegates Map to the map it owns and takes the
  scanner's logger, so its warnings keep the SchemaClassScanner logger name.
- The scanner passes its enqueue and handleFoundScalarType in as functions,
  so the queue and unvalidatedTypes keep one owner and their order.
- private dropped on both handleFoundType functions, scanInterfacesOfType,
  throwIfEnumWithoutClass, DictionaryEntry and InitialDictionaryEntry, and
  for this commit only on InterfaceReference and InputObjectReference.
- The scanner calls these through dictionary.
- Imports.
Moves Reference and its subclasses next to DictionaryEntry, which stores them
and prints them in the "Two different classes" error. TypeClassMatcher and the
field resolvers no longer name SchemaClassScanner.

Edits to compile:
- InterfaceReference and InputObjectReference are private again.
- private dropped on DictionaryReference, UnusedTypeReference,
  UnionMemberReference and SealedSubclassReference, which the scanner creates.
- SchemaClassScanner.XReference becomes ScanDictionary.XReference in
  TypeClassMatcher, MethodFieldResolver, PropertyFieldResolver,
  MapFieldResolver and NestedGenericWrapperTest.
- Imports.
Moves sealedSubclassHint, sealedSubclassMatches, sealedTypesOf,
sealedSubclasses and withSealedTypeArguments to SealedSubclassMatcher.kt. It
reads the dictionary as a Map<TypeDefinition<*>, EntryView> and never changes
it.

Edits to compile:
- SealedSubclassMatcher's header.
- private dropped on sealedSubclassHint and sealedSubclassMatches.
- The scanner holds a sealedSubclassMatcher and calls both through it.
- Imports.
SchemaParser uses it, and so do the error messages and the directive code
that move out of SchemaParser next.

Edits to compile: private becomes internal, and SchemaParser imports it.
Moves unexpectedTypeError, unreachedTypeHint, findUsage and fieldUsages to
TypeReferenceErrors.kt. determineType stays in SchemaParser, because for input
types it builds missing input objects through createInputObject.

Edits to compile:
- TypeReferenceErrors' header, with definitions, unusedDefinitions and
  typeExtensions.
- private dropped on unexpectedTypeError.
- SchemaParser holds a typeReferenceErrors, and determineType throws through
  it.
- Imports.
Moves directivesOf, isNonRepeatableByFirstDeclaration, createDirectives,
createDirective, createDirectiveArgument, withSdlDirectives, the functions that
resolve and render applied directives, ResolvedDirective, ResolvedArgument,
missingArgumentsWithDefault, getDeprecated, the schemaDirectives map and
BUILT_IN_DIRECTIVES to SdlDirectives.kt.

SdlDirectives builds the schema's directive definitions in its constructor, so
nothing can apply a directive before they exist. SchemaParser builds one in
parseSchemaObjects() where it used to call createDirectives(), so directive
errors are still thrown from there, and every schema it builds gets its own
definitions.

Edits to compile:
- SdlDirectives' header with directiveDefinitions, options, runtimeWiring,
  SchemaParser's logger (its warnings keep that logger name) and inputType,
  the type lookup createDirectiveArgument called. The lookup's comment moves
  with it to SchemaParser.
- init { createDirectives() }, and a definitions property for SchemaObjects.
- schemaAppliedDirectives, the line SchemaParser used for the schema's own
  directives, so the resolving functions stay private.
- private dropped on directivesOf, withSdlDirectives and getDeprecated.
- SchemaParser: lateinit var sdlDirectives replaces the map, directivesOf and
  getDeprecated are called through it, and a private withSdlDirectives calls
  the one in SdlDirectives, so the builder chains don't change.
- Imports.
@sonarqubecloud

Copy link
Copy Markdown

@oryan-block

Copy link
Copy Markdown
Collaborator Author

Verifier verdict for 3488a31: PASS
Ran 43 scenarios through the installed jar on graphql-java 26.1 and 25.1, at head 3488a31 and at parent d56121f (88 parent/head pairs, 176 runs). The target dir was removed before each install. The head jar holds ScanDictionary, SdlDirectives, SealedSubclassMatcher and TypeReferenceErrors, the parent jar none of them, and every run.txt has a source fingerprint equal to its install stamp (head ea799b89646baaea, parent aa9f2ef7bbfea5e7). Each pair got a diff -r over summary.txt, stdout and stderr, schema.graphqls, every operation JSON, inspect.txt, build-error.txt and probe.log, skipping stack frames, maven.log and run.txt. Exit codes match in every pair, read from each run's console output.
The 14 existing scenarios picked for this change are identical on both versions. The harness copy I was given has no directive-drift-duplicate-definition, so I took it, directive-drift-duplicate-definition-reversed, directive-drift-unknown-argument and directive-drift-unguessable from .local/skill-harvest/refactor-directive-builders/verify-tools and added them untracked to both sides. Those three and directive-drift-undeclared (from the harness copy) are identical too. directive-drift-duplicate-definition builds, and inspect.txt lists applied @dup(n: 2) and @dup(n: 3) with one legacy @dup that has repeatable=false, on both sides. The reversed one fails with the same graphql-java InvalidSchemaException. bookstore-missing-resolver exits 2 on both sides.
The only difference in all 88 pairs is an identity hash. In v906-two-classes-enum-input on 25.1, the "Two different classes used for type Color" error prints directives=graphql.language.NodeUtil$DirectivesHolder@545607f2 at head and @48c3205a at parent (20261023-142018-v906-two-classes-enum-input-gj25.1 against 20261023-142610-v906-two-classes-enum-input-gj25.1, in build-error.txt, stdout.txt and summary.txt). graphql-java 25.1 prints the identity hash there, so it changes per JVM and is not a behaviour change. The same scenario on 26.1 is identical.
I wrote 25 scenarios of my own, untracked under probe/ in the verifier worktree, aimed at the code that moved. Six build and run (v906-enum-map-dict, v906-sealed-generic, v906-extension-repeat, v906-duplicate-definition-built, v906-double-build, v906-prefer-resolver). The other 19 fail the build on purpose, and I compared the message text. They cover the "Enum type 'Color' ... can't be determined" error for an input bound to a Map, the sealed hints "No subclass of sealed type ... is named 'Bird'" and "More than one subclass ... is named 'Wolf'", every branch of TypeReferenceErrors (undefined type, object type used as input, input object used as output, union as a directive argument, and unreached enum, input, object and scalar with their hints), and "Two different classes used for type X" with return, parameter, input object, interface declaration, property field and Map field references. All identical at both SHAs.
On directives, v906-extension-repeat prints eight "isn't repeatable but an extension of type ... applies it again" warnings (one for the built-in @specifiedBy) and one for extend schema @once, from the SchemaParser logger in the same order at both SHAs. v906-prefer-resolver prints its "is a GraphQLResolver so ignoring this one" warning from the SchemaClassScanner logger, text unchanged. v906-double-build calls makeExecutableSchema() twice on one parser. At both SHAs the two printed schemas and directive dumps are equal, each schema's @tag(kind) argument resolves to its own Kind type, and the schemas share no Kind instance. A directive defined twice with different repeatability (v906-duplicate-definition-conflict, 20261023-143256 against 20261023-143346) fails graphql-java's validation on both sides. From the code, the legacy copy takes repeatability from the first definition while the schema keeps the last. That is not new.
Not checked: mvn verify, the JMH numbers, JDK 11 and javap of the public API. Only 14 of the 33 scenarios in probe.sh list ran, so async, bookstore, context-, jdk-proxy, nested-wrappers*, read-only-schema, scalar-args, sourceless*, wiring-shapes and generic-input-resolver did not. Also not run: other graphql-java versions, concurrent builds from one parser, includeUnusedTypes, and the error text for the dictionary, unused-type, union-member and sealed-subclass references.

@oryan-block
oryan-block merged commit 08b9be7 into master Oct 23, 2026
7 checks passed
@oryan-block
oryan-block deleted the refactor/split-scanner-parser branch October 23, 2026 18:36
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