Skip to content

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

Open
oryan-block wants to merge 3 commits into
masterfrom
bugfix/200
Open

oryan-block wants to merge 3 commits into
masterfrom
bugfix/200

Conversation

@oryan-block

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, and 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 registers dictionary entries first, exactly as before. For each object type that's still without a class, findSealedSubclass looks at the classes found for its unions and the interfaces it implements, keeps the sealed ones, and picks the single subclass whose simpleName matches the type name. That's the same naming convention SchemaParserDictionary and the runtime type resolver use. Subclasses of nested sealed classes are included (Pet.Mammal.Dog). 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 tests check.

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, like the ones the reporter, orvillelim and eburi have, 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 12 tests. Where the schema builds, they run a real query. They cover a union over a Kotlin sealed class (the #200 case), an interface over a sealed interface, nested sealed classes, a generic sealed class, a member only reachable by scanning a sealed subclass, the dictionary overriding a sealed subclass, a conflicting scanned class giving "Two different classes used for type Author" whatever the member order, the hint for a union, a partly matched union and an interface implementor, and the ambiguous-name hint. One test is a guard that a non-sealed class still gets the exact existing message. SchemaClassScannerSealedJavaTypesTest has two JUnit tests, a Java sealed interface with records and a Java sealed abstract class. 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. The default javac and Kotlin compiles don't see that directory. I ran the first ten Kotlin tests and both Java tests against master before changing the scanner: all of them failed except the non-sealed guard. The two hint tests added afterwards assert the exact message with the hint, which master doesn't append. clean verify passes on JDK 25 (272 tests, including the Java 17 ones) and passed on JDK 11 before the last two Kotlin tests were added, with the profile inactive.

It touches SchemaClassScanner like #842, but in a different function, and it merges cleanly with that branch.

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 3 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>
@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