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/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/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(); + } +} 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..8f20de82 --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt @@ -0,0 +1,559 @@ +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 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) { + 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() + } + } + } +}