From 078b185a190d59b0b4401e66ead195dcf369d7a3 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 12 Oct 2026 15:38:28 -0400 Subject: [PATCH 1/3] Find union members and interface implementors among sealed subclasses 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 --- .../kickstart/tools/SchemaClassScanner.kt | 102 +++- .../SchemaClassScannerSealedTypesTest.kt | 485 ++++++++++++++++++ 2 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 8844ba6b..205cf600 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -7,8 +7,10 @@ import graphql.language.* import graphql.schema.GraphQLScalarType import graphql.schema.idl.ScalarInfo import org.apache.commons.lang3.ClassUtils +import org.apache.commons.lang3.reflect.TypeUtils import org.slf4j.LoggerFactory import java.lang.reflect.Method +import java.lang.reflect.ParameterizedType /** * @author Andrew Potter @@ -299,14 +301,104 @@ internal class SchemaClassScanner( } private fun handleDictionaryTypes(types: List>, failureMessage: (TypeDefinition<*>) -> String) { + val typesWithoutClass = mutableListOf>() types.forEach { type -> val dictionaryContainsType = dictionary.filter { it.key.name == type.name }.isNotEmpty() if (!unvalidatedTypes.contains(type) && !dictionaryContainsType) { val initialEntry = initialDictionary[type.name] - ?: throw SchemaClassScannerError(failureMessage(type)) - handleFoundType(type, initialEntry.get(), DictionaryReference()) + if (initialEntry != null) { + handleFoundType(type, initialEntry.get(), DictionaryReference()) + } else { + typesWithoutClass.add(type) + } + } + } + + // Scanning the sealed subclasses found for some of these types may find classes for the rest, so only fail if there are none + val sealedSubclasses = typesWithoutClass.filterIsInstance().mapNotNull { type -> findSealedSubclass(type)?.let { type to it } } + if (sealedSubclasses.isEmpty()) { + typesWithoutClass.firstOrNull()?.let { throw SchemaClassScannerError(failureMessage(it) + sealedSubclassHint(it)) } + } + + // Enter them all before any is scanned, so a different class found for one of them is reported whatever order the types are in + sealedSubclasses.forEach { (type, sealedSubclass) -> handleFoundType(type, sealedSubclass.subclass, SealedSubclassReference(sealedSubclass.sealedType)) } + } + + /** + * A member of a union or an implementor of an interface may never be returned directly, e.g. `NormalPet` when only + * `pets(): List` returns `union Pet`. If the class found for the union or interface is sealed, its only subclass with + * the object type's name is used for it. + */ + private fun findSealedSubclass(type: ObjectTypeDefinition): SealedSubclass? = + sealedSubclassesNamedAfter(type, sealedTypesOf(type)).singleOrNull() + + /** + * Explains why no sealed subclass was used for the type, if any of its unions or interfaces has a sealed class. + */ + private fun sealedSubclassHint(type: TypeDefinition<*>): String { + if (type !is ObjectTypeDefinition) { + return "" + } + + val sealedTypes = sealedTypesOf(type) + if (sealedTypes.isEmpty()) { + return "" + } + + val sealedTypeNames = sealedTypes.joinToString(" or ") { it.unwrap().name } + val subclasses = sealedSubclassesNamedAfter(type, sealedTypes) + return if (subclasses.isEmpty()) { + " No subclass of sealed type $sealedTypeNames is named '${type.name}'." + } else { + " More than one subclass of sealed type $sealedTypeNames is named '${type.name}': ${subclasses.joinToString { it.subclass.unwrap().name }}." + } + } + + /** + * The classes found for the type's unions and interfaces that are sealed. + */ + private fun sealedTypesOf(type: ObjectTypeDefinition): List = + dictionary + .filterKeys { definition -> + when (definition) { + is UnionTypeDefinition -> definition.memberTypes.any { it is TypeName && it.name == type.name } + is InterfaceTypeDefinition -> type.implements.any { it is TypeName && it.name == definition.name } + else -> false + } } + .values.mapNotNull { it.javaType } + .distinct() + .filter { it.unwrap().kotlin.isSealed } + + private fun sealedSubclassesNamedAfter(type: ObjectTypeDefinition, sealedTypes: List): List = + sealedTypes + .flatMap { sealedType -> + sealedSubclasses(sealedType.unwrap()) + .filter { it.simpleName == type.name } + .map { SealedSubclass(sealedType, parameterize(it, sealedType)) } + } + .distinctBy { it.subclass.unwrap() } + + /** + * All subclasses of a sealed class, including those of its sealed subclasses. kotlin-reflect also returns the permitted + * subclasses of Java sealed classes. + */ + private fun sealedSubclasses(clazz: Class<*>): List> = + clazz.kotlin.sealedSubclasses.flatMap { listOf(it.java) + sealedSubclasses(it.java) }.distinct() + + /** + * Passes the type arguments of a parameterized sealed class on to its generic subclass, e.g. `Success` for + * `Result`, so the types of the subclass's fields can be resolved. + */ + private fun parameterize(subclass: Class<*>, sealedType: JavaType): JavaType { + if (sealedType !is ParameterizedType || subclass.typeParameters.isEmpty()) { + return subclass } + + return TypeUtils.determineTypeArguments(subclass, sealedType) + ?.takeIf { it.keys.containsAll(subclass.typeParameters.asList()) } + ?.let { TypeUtils.parameterize(subclass, it) } + ?: subclass } /** @@ -478,6 +570,8 @@ internal class SchemaClassScanner( private data class QueueItem(val type: ObjectTypeDefinition, val clazz: JavaType) + private class SealedSubclass(val sealedType: JavaType, val subclass: JavaType) + private class DictionaryEntry { private val references = mutableListOf() var javaType: JavaType? = null @@ -529,6 +623,10 @@ internal class SchemaClassScanner( override fun getDescription() = "interface declarations of ${type.name}" } + private class SealedSubclassReference(private val sealedType: JavaType) : Reference() { + override fun getDescription() = "subclass of sealed type ${sealedType.typeName}" + } + private class InputObjectReference(private val type: InputValueDefinition) : Reference() { override fun getDescription() = "input object $type" } diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt new file mode 100644 index 00000000..91fef563 --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt @@ -0,0 +1,485 @@ +package graphql.kickstart.tools + +import graphql.GraphQL +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLUnionType +import org.junit.Assert.assertThrows +import org.junit.Test + +class SchemaClassScannerSealedTypesTest { + + @Test + fun `scanner finds members of unions among the subclasses of sealed classes`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + pets: [Pet] + } + + union Pet = NormalPet | WeirdPet + + type NormalPet { + name: String + } + + type WeirdPet { + classification: String + } + """) + .resolvers(SealedClassUnion()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Pet") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("NormalPet", "WeirdPet")) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + pets { + __typename + ... on NormalPet { name } + ... on WeirdPet { classification } + } + } + """ + } + + assertEquals(data["pets"], listOf( + mapOf("__typename" to "NormalPet", "name" to "Rex"), + mapOf("__typename" to "WeirdPet", "classification" to "axolotl") + )) + } + + class SealedClassUnion : GraphQLQueryResolver { + fun pets(): List = listOf(Pet.NormalPet("Rex"), Pet.WeirdPet("axolotl")) + + sealed class Pet { + class NormalPet(val name: String?) : Pet() + + class WeirdPet(val classification: String?) : Pet() + } + } + + @Test + fun `scanner finds implementors of interfaces among the subclasses of sealed interfaces`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + animals: [Animal] + } + + interface Animal { + name: String + } + + type Dog implements Animal { + name: String + barks: Boolean + } + + type Cat implements Animal { + name: String + lives: Int + } + """) + .resolvers(SealedInterfaceImplementors()) + .build() + .makeExecutableSchema() + + assertEquals(schema.getImplementations(schema.getType("Animal") as GraphQLInterfaceType)!!.map { it.name }.toSet(), setOf("Dog", "Cat")) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + animals { + __typename + name + ... on Dog { barks } + ... on Cat { lives } + } + } + """ + } + + assertEquals(data["animals"], listOf( + mapOf("__typename" to "Dog", "name" to "Rex", "barks" to true), + mapOf("__typename" to "Cat", "name" to "Tom", "lives" to 9) + )) + } + + class SealedInterfaceImplementors : GraphQLQueryResolver { + fun animals(): List = listOf(Dog("Rex", true), Cat("Tom", 9)) + + sealed interface Animal { + val name: String? + } + + class Dog(override val name: String?, val barks: Boolean?) : Animal + + class Cat(override val name: String?, val lives: Int?) : Animal + } + + @Test + fun `scanner finds members of unions among the subclasses of nested sealed classes`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + pets: [Pet] + } + + union Pet = Dog | Fish + + type Dog { + name: String + } + + type Fish { + fins: Int + } + """) + .resolvers(NestedSealedClasses()) + .build() + .makeExecutableSchema() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + pets { + __typename + ... on Dog { name } + ... on Fish { fins } + } + } + """ + } + + assertEquals(data["pets"], listOf( + mapOf("__typename" to "Dog", "name" to "Rex"), + mapOf("__typename" to "Fish", "fins" to 2) + )) + } + + class NestedSealedClasses : GraphQLQueryResolver { + fun pets(): List = listOf(Pet.Mammal.Dog("Rex"), Pet.Fish(2)) + + sealed class Pet { + sealed class Mammal : Pet() { + class Dog(val name: String?) : Mammal() + } + + class Fish(val fins: Int?) : Pet() + } + } + + @Test + fun `scanner passes the type arguments of generic sealed classes on to their subclasses`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + results: [PetResult] + } + + union PetResult = Success | Failure + + type Success { + value: Pet + } + + type Failure { + message: String + } + + type Pet { + name: String + } + """) + .resolvers(GenericSealedClass()) + .build() + .makeExecutableSchema() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + results { + __typename + ... on Success { value { name } } + ... on Failure { message } + } + } + """ + } + + assertEquals(data["results"], listOf( + mapOf("__typename" to "Success", "value" to mapOf("name" to "Rex")), + mapOf("__typename" to "Failure", "message" to "Not found") + )) + } + + class GenericSealedClass : GraphQLQueryResolver { + fun results(): List> = listOf(Result.Success(Pet("Rex")), Result.Failure("Not found")) + + class Pet(val name: String?) + + sealed class Result { + class Success(val value: T) : Result() + + class Failure(val message: String?) : Result() + } + } + + @Test + fun `scanner finds the other members of unions by scanning the subclasses of sealed classes`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + results: [SearchResult] + } + + union SearchResult = Book | Review + + type Book { + title: String + review: Review + } + + type Review { + text: String + } + """) + .resolvers(MemberReachableThroughSealedSubclass()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("SearchResult") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Book", "Review")) + } + + class MemberReachableThroughSealedSubclass : GraphQLQueryResolver { + fun results(): List = listOf() + + sealed class SearchResult { + class Book(val title: String?, val review: Review?) : SearchResult() + } + + class Review(val text: String?) + } + + @Test + fun `scanner prefers the dictionary over the subclasses of sealed classes`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + pets: [Pet] + } + + union Pet = NormalPet | WeirdPet + + type NormalPet { + name: String + } + + type WeirdPet { + classification: String + } + """) + .resolvers(DictionaryOverridesSealedSubclass()) + .dictionary("WeirdPet", DictionaryOverridesSealedSubclass.ClassifiedWeirdPet::class) + .build() + .makeExecutableSchema() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + pets { + __typename + ... on NormalPet { name } + ... on WeirdPet { classification } + } + } + """ + } + + assertEquals(data["pets"], listOf( + mapOf("__typename" to "NormalPet", "name" to "Rex"), + mapOf("__typename" to "WeirdPet", "classification" to "axolotl") + )) + } + + class DictionaryOverridesSealedSubclass : GraphQLQueryResolver { + fun pets(): List = listOf(Pet.NormalPet("Rex"), ClassifiedWeirdPet("axolotl")) + + sealed class Pet { + class NormalPet(val name: String?) : Pet() + + // no classification, so the build fails if this is scanned instead of the class in the dictionary + open class WeirdPet : Pet() + } + + class ClassifiedWeirdPet(val classification: String?) : Pet.WeirdPet() + } + + @Test + fun `scanner reports a class found for a member that differs from its sealed subclass`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + results: [SearchResult] + } + + union SearchResult = Book | Author + + type Book { + title: String + author: Author + } + + type Author { + name: String + } + """) + .resolvers(ConflictingSealedSubclass()) + .build() + .makeExecutableSchema() + } + + val message = error.message.orEmpty() + assert(message.startsWith("Two different classes used for type Author:\n- ${ConflictingSealedSubclass.SearchResult.Author::class.java.name}:\n| subclass of sealed type ${ConflictingSealedSubclass.SearchResult::class.java.name}")) { message } + assert(message.contains("- ${ConflictingSealedSubclass.AuthorDto::class.java.name}:")) { message } + } + + class ConflictingSealedSubclass : GraphQLQueryResolver { + fun results(): List = listOf() + + sealed class SearchResult { + class Book(val title: String?, val author: AuthorDto?) : SearchResult() + + class Author(val name: String?) : SearchResult() + } + + class AuthorDto(val name: String?) + } + + @Test + fun `scanner still requires classes for members of unions whose class isn't sealed`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + pets: [Pet] + } + + union Pet = NormalPet | WeirdPet + + type NormalPet { + name: String + } + + type WeirdPet { + classification: String + } + """) + .resolvers(NonSealedInterfaceUnion()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "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.") + } + + class NonSealedInterfaceUnion : GraphQLQueryResolver { + fun pets(): List = listOf() + + interface Pet + + class NormalPet(val name: String?) : Pet + + class WeirdPet(val classification: String?) : Pet + } + + @Test + fun `scanner explains that no subclass of the sealed class is named after a member`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + pet: PetResult + } + + union PetResult = PetSuccess | PetNotFound + + type PetSuccess { + name: String + } + + type PetNotFound { + id: ID + } + """) + .resolvers(ScopedSealedSubclassNames()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'PetSuccess' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'PetSuccess' in the parser's dictionary." + + " No subclass of sealed type ${ScopedSealedSubclassNames.PetResult::class.java.name} is named 'PetSuccess'.") + } + + class ScopedSealedSubclassNames : GraphQLQueryResolver { + fun pet(): PetResult? = null + + sealed class PetResult { + class Success(val name: String?) : PetResult() + + class NotFound(val id: String?) : PetResult() + } + } + + @Test + fun `scanner explains that more than one subclass of the sealed class is named after a member`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + pets: [Pet] + } + + union Pet = Baby + + type Baby { + name: String + } + """) + .resolvers(AmbiguousSealedSubclassNames()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'Baby' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Baby' in the parser's dictionary." + + " More than one subclass of sealed type ${AmbiguousSealedSubclassNames.Pet::class.java.name} is named 'Baby': " + + "${AmbiguousSealedSubclassNames.Pet.Cat.Baby::class.java.name}, ${AmbiguousSealedSubclassNames.Pet.Dog.Baby::class.java.name}.") + } + + class AmbiguousSealedSubclassNames : GraphQLQueryResolver { + fun pets(): List = listOf() + + sealed class Pet { + sealed class Cat : Pet() { + class Baby(val name: String?) : Cat() + } + + sealed class Dog : Pet() { + class Baby(val name: String?) : Dog() + } + } + } +} From 4fe9feea53936d2c9a47dc2d310c6be71dd60636 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 12 Oct 2026 15:38:31 -0400 Subject: [PATCH 2/3] Test Java sealed classes on Java 17 and later 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 --- pom.xml | 29 ++++ ...SchemaClassScannerSealedJavaTypesTest.java | 144 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/test/java17/graphql/kickstart/tools/SchemaClassScannerSealedJavaTypesTest.java diff --git a/pom.xml b/pom.xml index c49370d1..dec07b35 100644 --- a/pom.xml +++ b/pom.xml @@ -355,6 +355,35 @@ + + + java17-tests + + [17,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + java17-test-compile + + testCompile + + + + ${project.basedir}/src/test/java17 + + 17 + + + + + + + release diff --git a/src/test/java17/graphql/kickstart/tools/SchemaClassScannerSealedJavaTypesTest.java b/src/test/java17/graphql/kickstart/tools/SchemaClassScannerSealedJavaTypesTest.java new file mode 100644 index 00000000..26d26ddf --- /dev/null +++ b/src/test/java17/graphql/kickstart/tools/SchemaClassScannerSealedJavaTypesTest.java @@ -0,0 +1,144 @@ +package graphql.kickstart.tools; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.schema.GraphQLSchema; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +// Sealed classes need Java 17, so the java17-tests profile only compiles and runs this on Java 17 and later. +public class SchemaClassScannerSealedJavaTypesTest { + + @Test + public void scannerFindsMembersOfUnionsAmongThePermittedSubclassesOfSealedInterfaces() { + GraphQLSchema schema = SchemaParser.newParser() + .schemaString(""" + type Query { + pets: [Pet] + } + + union Pet = Dog | Fish + + type Dog { + name: String + } + + type Fish { + fins: Int + } + """) + .resolvers(new PetQuery()) + .build() + .makeExecutableSchema(); + + Map data = execute(schema, "query { pets { __typename ... on Dog { name } ... on Fish { fins } } }"); + + assertEquals(List.of(Map.of("__typename", "Dog", "name", "Rex"), Map.of("__typename", "Fish", "fins", 2)), data.get("pets")); + } + + public static class PetQuery implements GraphQLQueryResolver { + public List pets() { + return List.of(new Dog("Rex"), new Fish(2)); + } + } + + public sealed interface Pet permits Mammal, Fish { + } + + public sealed interface Mammal extends Pet permits Dog { + } + + public record Dog(String name) implements Mammal { + } + + public record Fish(Integer fins) implements Pet { + } + + @Test + public void scannerFindsImplementorsOfInterfacesAmongThePermittedSubclassesOfSealedClasses() { + GraphQLSchema schema = SchemaParser.newParser() + .schemaString(""" + type Query { + animals: [Animal] + } + + interface Animal { + name: String + } + + type Cat implements Animal { + name: String + lives: Int + } + + type Bird implements Animal { + name: String + wings: Int + } + """) + .resolvers(new AnimalQuery()) + .build() + .makeExecutableSchema(); + + Map data = execute(schema, "query { animals { __typename name ... on Cat { lives } ... on Bird { wings } } }"); + + assertEquals(List.of(Map.of("__typename", "Cat", "name", "Tom", "lives", 9), Map.of("__typename", "Bird", "name", "Tweety", "wings", 2)), data.get("animals")); + } + + public static class AnimalQuery implements GraphQLQueryResolver { + public List animals() { + return List.of(new Cat("Tom", 9), new Bird("Tweety", 2)); + } + } + + public abstract static sealed class Animal permits Cat, Bird { + private final String name; + + Animal(String name) { + this.name = name; + } + + public String getName() { + return name; + } + } + + public static final class Cat extends Animal { + private final Integer lives; + + Cat(String name, Integer lives) { + super(name); + this.lives = lives; + } + + public Integer getLives() { + return lives; + } + } + + public static final class Bird extends Animal { + private final Integer wings; + + Bird(String name, Integer wings) { + super(name); + this.wings = wings; + } + + public Integer getWings() { + return wings; + } + } + + private static Map execute(GraphQLSchema schema, String query) { + ExecutionResult result = GraphQL.newGraphQL(schema).build().execute(ExecutionInput.newExecutionInput().query(query)); + + assertTrue(result.getErrors().toString(), result.getErrors().isEmpty()); + return result.getData(); + } +} From a16b37452d69de437f39cbcffa939b9051de6846 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 12 Oct 2026 15:46:13 -0400 Subject: [PATCH 3/3] Test sealed subclass hints for a partly matched union and an interface Co-Authored-By: Claude Opus 5.5 --- .../SchemaClassScannerSealedTypesTest.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt index 91fef563..8f20de82 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt @@ -443,6 +443,80 @@ class SchemaClassScannerSealedTypesTest { } } + @Test + fun `scanner still requires classes for members of unions that no subclass of the sealed class is named after`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + pets: [Pet] + } + + union Pet = NormalPet | WeirdPet + + type NormalPet { + name: String + } + + type WeirdPet { + classification: String + } + """) + .resolvers(PartlySealedUnion()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'WeirdPet' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'WeirdPet' in the parser's dictionary." + + " No subclass of sealed type ${PartlySealedUnion.Pet::class.java.name} is named 'WeirdPet'.") + } + + class PartlySealedUnion : GraphQLQueryResolver { + fun pets(): List = listOf() + + sealed class Pet { + class NormalPet(val name: String?) : Pet() + } + } + + @Test + fun `scanner explains that no subclass of the sealed interface is named after an implementor`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + animals: [Animal] + } + + interface Animal { + name: String + } + + type Doggo implements Animal { + name: String + } + """) + .resolvers(MisnamedSealedInterfaceImplementor()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'Doggo' implements a known interface, but no class could be found for that type name. Please pass a class for type 'Doggo' in the parser's dictionary." + + " No subclass of sealed type ${MisnamedSealedInterfaceImplementor.Animal::class.java.name} is named 'Doggo'.") + } + + class MisnamedSealedInterfaceImplementor : GraphQLQueryResolver { + fun animals(): List = listOf() + + sealed interface Animal { + val name: String? + } + + class Dog(override val name: String?) : Animal + } + @Test fun `scanner explains that more than one subclass of the sealed class is named after a member`() { val error = assertThrows(SchemaClassScannerError::class.java) {