Skip to content

Find union members and interface implementors among sealed subclasses - #854

Merged
oryan-block merged 9 commits into
masterfrom
bugfix/200
Oct 17, 2026
Merged

oryan-block merged 9 commits into
masterfrom
bugfix/200

Conversation

@oryan-block

@oryan-block oryan-block commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Partially addresses #200

Checklist

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

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 only pets: [Pet] returns it. With the current version the build fails with:

Object type 'NormalPet' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'NormalPet' in the parser's dictionary.

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.

SchemaClassScanner only finds classes by following resolver return types, plus whatever's in the dictionary. It knows the class it matched to Pet, but never looks at that class's subclasses, so once the queue is empty handleDictionaryTypes throws 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. handleDictionaryTypes still checks the dictionary first. For each object type that's still without a class, sealedSubclassMatches looks at the classes found for its unions and the interfaces it implements, keeps the sealed ones, and finds their subclasses whose simpleName matches the type name. A single match is used. That's the same naming convention SchemaParserDictionary and 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 via TypeUtils, so Success<T> under Result<Pet> is scanned as Success<Pet>. Matches are registered with a new SealedSubclassReference and 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.sealedSubclasses rather than Class#getPermittedSubclasses. The build baseline is Java 11, and Kotlin classes compiled for jvmTarget 1.8 have no PermittedSubclasses attribute, 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:

  • briankrug's patch in the thread reads Jackson's @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.
  • Plain class hierarchies aren't helped. The dictionary is still the answer there, and it keeps priority over sealed subclasses.
  • Unions only reached through includeUnusedTypes or interface fields still have no class to inspect.

SchemaClassScannerSealedTypesTest has 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. SchemaClassScannerSealedJavaTypesTest has one JUnit test, with a Java sealed interface of records behind a union and a Java sealed abstract class behind an interface. It lives in src/test/java17 and is compiled by a new java17-tests profile in pom.xml, active on JDK 17 and later, with its own testCompile execution using release 17. That execution keeps the failOnWarning from #849. The default javac and Kotlin compiles don't see that directory. With master's SchemaClassScanner, all 7 tests fail. Only the non-sealed assertion passes there. clean verify passes 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

oryan-block and others added 9 commits October 12, 2026 15:38
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>
@sonarqubecloud

Copy link
Copy Markdown

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