Repository navigation
Find union members and interface implementors among sealed subclasses - #854
Merged
Merged
Conversation
A member of a union or an implementor of an interface that's never returned directly needed a class in the dictionary. When the class found for the union or interface is a Kotlin or Java sealed class, its subclass named after the object type is used instead, with the type arguments of a parameterized sealed class passed on to it. This only runs where the scanner used to fail, after the dictionary, so schemas that build today don't change. When no single subclass matches, the existing error says which sealed class was searched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Java sealed classes need Java 17 to compile, so a profile that's active on Java 17 and later compiles src/test/java17 with release 17. The Java 11 build skips these tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SealedSubclass now carries its object type, so the lookup is one function instead of findSealedSubclass plus a helper taking the sealed types. The tests share the pet schema and query, and drop the two hint tests that went through the same code as the partly matched union test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sealed subclass lookup returns a TypeClassMatcher.ValidMatch, so the SealedSubclass class is gone and the matches go through the existing handleFoundType overload. The Kotlin tests go from 10 to 6: the nested sealed class is part of the #200 union test, the scanned member and the conflicting class share one schema, and the three error messages are one test on the pet schema. The two Java tests are now one schema and one query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interface test's Dog now extends a sealed Mammal and implements the sealed Animal directly, so it's found twice among Animal's subclasses and the test fails without the distinctBy. The lookup is renamed to sealedSubclassMatches and the lists of matches to sealedMatches and matches, so they don't read like sealedSubclasses, which returns classes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This was referenced Oct 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Partially addresses #200
Checklist
Description
A union member or interface implementor that no resolver returns directly fails the build. In #200 the schema has
union Pet = NormalPet | WeirdPet, but onlypets: [Pet]returns it. With the current version the build fails with:gustavkarlsson hit the same thing with interfaces ("implements a known interface, but no class could be found"). The workarounds in the thread are a dummy query that returns each member, or a dictionary entry per member.
SchemaClassScanneronly finds classes by following resolver return types, plus whatever's in the dictionary. It knows the class it matched toPet, but never looks at that class's subclasses, so once the queue is emptyhandleDictionaryTypesthrows for every member it didn't reach.This only fixes the case where that class is sealed, which is why it doesn't close the issue.
handleDictionaryTypesstill checks the dictionary first. For each object type that's still without a class,sealedSubclassMatcheslooks at the classes found for its unions and the interfaces it implements, keeps the sealed ones, and finds their subclasses whosesimpleNamematches the type name. A single match is used. That's the same naming conventionSchemaParserDictionaryand the runtime type resolver use. Subclasses of nested sealed classes are included (Pet.Exotic.WeirdPet). If the sealed class is parameterized, a generic subclass gets its arguments viaTypeUtils, soSuccess<T>underResult<Pet>is scanned asSuccess<Pet>. Matches are registered with a newSealedSubclassReferenceand then scanned like any other class, so a member reachable only from another member's fields is found too.All matches in a step are registered before any of them is scanned. Otherwise the order of the union's members would decide whether a different class found later for one of them gets reported. The step only throws when none of the missing types got a match. Types still missing after scanning the matches fail on the next pass. I put the lookup inside the step that used to throw, not in a separate final step, so nothing changes for schemas that build today, and kotlin-reflect isn't called for them.
I used
KClass.sealedSubclassesrather thanClass#getPermittedSubclasses. The build baseline is Java 11, and Kotlin classes compiled forjvmTarget1.8 have noPermittedSubclassesattribute, so the JDK API would miss them. kotlin-reflect also returns the permitted subclasses of Java sealed types, which the Java 17 test checks.When a union or interface has a sealed class but no single subclass matches, the existing message now ends with a hint: "No subclass of sealed type X is named 'Y'." or "More than one subclass of sealed type X is named 'Y': a, b." It's appended after two spaces, so the start of the message is unchanged.
Left out:
@JsonSubTypes. I didn't take it. The JSON type id isn't the GraphQL type name, and it would make a serialization annotation part of the schema mapping. It's the only route that would help non-sealed classes on Java 11, though, so it'd be a reasonable follow-up feeding the same lookup.includeUnusedTypesor interface fields still have no class to inspect.SchemaClassScannerSealedTypesTesthas 6 tests. Most run a real query against the built schema. They cover a union over a Kotlin sealed class with one member in a nested sealed class (the #200 case), an interface over a sealed interface with an implementor that's found twice among its subclasses, a generic sealed class, and the dictionary overriding a sealed subclass. One test covers scanning the matches: a member only reachable from another member's fields is found, and a conflicting class for a member gives "Two different classes used for type Author". The last one checks the messages: a non-sealed class still gets the exact existing message, a partly matched union gets the "No subclass ... is named" hint, and two subclasses with the same name get the "More than one subclass" hint.SchemaClassScannerSealedJavaTypesTesthas one JUnit test, with a Java sealed interface of records behind a union and a Java sealed abstract class behind an interface. It lives insrc/test/java17and is compiled by a newjava17-testsprofile inpom.xml, active on JDK 17 and later, with its owntestCompileexecution usingrelease 17. That execution keeps thefailOnWarningfrom #849. The default javac and Kotlin compiles don't see that directory. With master'sSchemaClassScanner, all 7 tests fail. Only the non-sealed assertion passes there.clean verifypasses on JDK 25 against graphql-java 26.1 and 25.1 (295 tests each, including the Java 17 one), and on JDK 11 with the profile inactive (294 tests). CI builds on 11, 17, 21 and 25, so the 11 build runs with the profile inactive.Master is merged in, including #842. There were no conflicts, since #842 changed the input object scan and this changes the dictionary step.
Behaviour change: a union or interface whose class is a Kotlin sealed class or interface, or a Java 17 sealed type, now builds when members or implementors are only reachable through it. Schemas that build today are unchanged, and failing schemas without a sealed class get exactly the same error. Failing schemas with one get the hint appended. The precedence is classes already found by scanning, then the dictionary, then sealed subclasses. A different class found later for a sealed match fails with the usual "Two different classes used for type X", naming "subclass of sealed type ...". No public API or options change.
🤖 Generated with Claude Code