Skip to content
Merged
29 changes: 29 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,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
99 changes: 97 additions & 2 deletions src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ 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.Member
import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType

/**
* @author Andrew Potter
Expand Down Expand Up @@ -359,14 +361,103 @@ 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 a sealed subclass can find the classes of the other types, so only fail when no type has exactly one sealed
// subclass named after it. Types still without a class fail on the next pass.
val sealedMatches = typesWithoutClass.filterIsInstance<ObjectTypeDefinition>().mapNotNull { sealedSubclassMatches(it).singleOrNull() }
if (sealedMatches.isEmpty()) {
typesWithoutClass.firstOrNull()?.let { throw SchemaClassScannerError(failureMessage(it) + sealedSubclassHint(it)) }
}

sealedMatches.forEach { handleFoundType(it) }
}

/**
* 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 matches = sealedSubclassMatches(type)
return if (matches.isEmpty()) {
" No subclass of sealed type $sealedTypeNames is named '${type.name}'."
} else {
" More than one subclass of sealed type $sealedTypeNames is named '${type.name}': ${matches.joinToString { it.javaType.unwrap().name }}."
}
}

/**
* 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 subclass named
* after the object type can be used instead.
*/
private fun sealedSubclassMatches(type: ObjectTypeDefinition): List<TypeClassMatcher.ValidMatch> =
sealedTypesOf(type)
.flatMap { sealedType ->
sealedSubclasses(sealedType.unwrap())
.filter { it.simpleName == type.name }
.map { TypeClassMatcher.ValidMatch(type, parameterize(it, sealedType), SealedSubclassReference(sealedType)) }
}
// a subclass is found twice when it's under two of these sealed types, or extends both a sealed type and one of its
// sealed subclasses
.distinctBy { it.javaType.unwrap() }

/**
* The sealed classes found for the unions the type is a member of and the interfaces it implements.
*/
private fun sealedTypesOf(type: ObjectTypeDefinition): List<JavaType> =
dictionary
.filterKeys { definition ->
when (definition) {
is UnionTypeDefinition -> definition.memberTypes.filterIsInstance<TypeName>().any { it.name == type.name }
is InterfaceTypeDefinition -> type.implements.filterIsInstance<TypeName>().any { it.name == definition.name }
else -> false
}
}
.values.mapNotNull { it.javaType }
.distinct()
.filter { it.unwrap().kotlin.isSealed }

/**
* 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) }

/**
* 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 @@ -636,6 +727,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,118 @@
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 scannerFindsUnionMembersAndInterfaceImplementorsAmongThePermittedSubclassesOfSealedTypes() {
GraphQLSchema schema = SchemaParser.newParser()
.schemaString("""
type Query {
pets: [Pet]
animals: [Animal]
}
union Pet = Dog | Fish
type Dog {
name: String
}
type Fish {
fins: Int
}
interface Animal {
name: String
}
type Cat implements Animal {
name: String
lives: Int
}
type Bird implements Animal {
name: String
wings: Int
}
""")
.resolvers(new PetQuery(), new AnimalQuery())
.build()
.makeExecutableSchema();

ExecutionResult result = GraphQL.newGraphQL(schema).build().execute(ExecutionInput.newExecutionInput().query("""
query {
pets { __typename ... on Dog { name } ... on Fish { fins } }
animals { __typename name ... on Cat { lives } ... on Bird { wings } }
}
"""));

assertTrue(result.getErrors().toString(), result.getErrors().isEmpty());
assertEquals(Map.of(
"pets", List.of(Map.of("__typename", "Dog", "name", "Rex"), Map.of("__typename", "Fish", "fins", 2)),
"animals", List.of(Map.of("__typename", "Cat", "name", "Tom", "lives", 9), Map.of("__typename", "Bird", "name", "Tweety", "wings", 2))
), result.getData());
}

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 {
}

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 {
final String name;

Animal(String name) {
this.name = name;
}
}

public static final class Cat extends Animal {
final Integer lives;

Cat(String name, Integer lives) {
super(name);
this.lives = lives;
}
}

public static final class Bird extends Animal {
final Integer wings;

Bird(String name, Integer wings) {
super(name);
this.wings = wings;
}
}
}
Loading
Loading