Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,35 @@
</build>

<profiles>
<profile>
<!-- Tests of Java sealed classes, which need Java 17 to compile -->
<id>java17-tests</id>
<activation>
<jdk>[17,)</jdk>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>java17-test-compile</id>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<compileSourceRoots>
<compileSourceRoot>${project.basedir}/src/test/java17</compileSourceRoot>
</compileSourceRoots>
<release>17</release>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>release</id>
<build>
Expand Down
102 changes: 100 additions & 2 deletions src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -299,14 +301,104 @@ internal class SchemaClassScanner(
}

private fun handleDictionaryTypes(types: List<TypeDefinition<*>>, failureMessage: (TypeDefinition<*>) -> String) {
val typesWithoutClass = mutableListOf<TypeDefinition<*>>()
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<ObjectTypeDefinition>().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<Pet>` 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<JavaType> =
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<JavaType>): List<SealedSubclass> =
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<Class<*>> =
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<Pet>` for
* `Result<Pet>`, 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
}

/**
Expand Down Expand Up @@ -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<Reference>()
var javaType: JavaType? = null
Expand Down Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<Pet> 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<String, Object> 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<Animal> 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<String, Object> 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();
}
}
Loading
Loading