Skip to content

Commit cdc3046

Browse files
oryan-blockclaude
andcommitted
Apply raw and star-projected resolvers to generic data classes
A resolver declared for a raw generic data class, such as GraphQLResolver<Page> in Java, was never linked to a field returning Page<Item>. Resolvers were matched by comparing their data class to the field's Java type, and a ParameterizedType never equals a Class, so the resolver's methods were reported as missing. Resolvers for a supertype or interface were skipped for the same reason. Kotlin can't express raw types, and GraphQLResolver<Page<*>> failed earlier with "Unable to determine data class" because the type argument isn't a Class. Match resolvers, including supertype resolvers, against the raw class of the data class type, and keep the parameterized type for the data class search so that fields typed by a type variable still resolve. A type whose type arguments are all unbounded wildcards is now treated as its raw type, both for the resolver's data class and for the source parameter of its methods, so Page<*> and Page<?> behave like Page. Resolvers for a specific parameterization are still not supported: a GraphQLResolver<Page<Item>> now fails with an error pointing to the raw type or unbounded wildcards instead of reporting a library bug, and a method taking Page<Item> as its source is not matched, since it would also be called for other parameterizations. Fixes #308 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 62c5146 commit cdc3046

6 files changed

Lines changed: 183 additions & 7 deletions

File tree

‎src/main/kotlin/graphql/kickstart/tools/ResolverInfo.kt‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package graphql.kickstart.tools
33
import graphql.kickstart.tools.resolver.FieldResolverScanner
44
import graphql.kickstart.tools.util.GraphQLRootResolver
55
import graphql.kickstart.tools.util.JavaType
6+
import graphql.kickstart.tools.util.eraseUnboundedWildcards
7+
import graphql.kickstart.tools.util.unwrap
68
import org.apache.commons.lang3.reflect.TypeUtils
9+
import java.lang.reflect.ParameterizedType
710

811
internal abstract class ResolverInfo {
912
abstract fun getFieldSearches(): List<FieldResolverScanner.Search>
@@ -28,6 +31,11 @@ internal class NormalResolverInfo(
2831

2932
private fun findDataClass(): Class<out Any> {
3033
val type = TypeUtils.getTypeArguments(resolverType, GraphQLResolver::class.java)[GraphQLResolver::class.java.typeParameters[0]]
34+
?.eraseUnboundedWildcards()
35+
36+
if (type is ParameterizedType) {
37+
throw ResolverError("Resolver '${resolverType.name}' may not have a parameterized type (${type.typeName}) as its type, use the raw type or unbounded wildcards (<?> in Java, <*> in Kotlin) instead.")
38+
}
3139

3240
if (type == null || type !is Class<*>) {
3341
throw ResolverError("Unable to determine data class for resolver '${resolverType.name}' from generic interface! This is most likely a bug with graphql-java-tools.")
@@ -54,14 +62,16 @@ internal class NormalResolverInfo(
5462
*/
5563
internal class MultiResolverInfo(
5664
val resolverInfoList: List<NormalResolverInfo>,
57-
override val dataClassType: Class<out Any>
65+
private val dataClass: JavaType
5866
) : DataClassTypeResolverInfo, ResolverInfo() {
5967

68+
override val dataClassType = dataClass.unwrap()
69+
6070
override fun getFieldSearches(): List<FieldResolverScanner.Search> {
6171
return resolverInfoList
6272
.asSequence()
6373
.map { FieldResolverScanner.Search(it.resolverType, this, it.resolver, it.dataClassType) }
64-
.plus(FieldResolverScanner.Search(dataClassType, this, null))
74+
.plus(FieldResolverScanner.Search(dataClass, this, null))
6575
.toList()
6676
}
6777
}

‎src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,19 +308,20 @@ internal class SchemaClassScanner(
308308
* Find all resolvers for the data class or any of its supertypes, most specific first.
309309
*/
310310
private fun getResolverInfoFromDataClass(dataClass: JavaType): ResolverInfo {
311+
val rawDataClass = dataClass.unwrap()
311312
val resolverInfoList = resolverInfos
312-
.filter { it.dataClassType == dataClass || isResolverForSupertype(it, dataClass) }
313+
.filter { it.dataClassType == rawDataClass || isResolverForSupertype(it, rawDataClass) }
313314
.sortedByDescending { ClassUtils.getAllSuperclasses(it.dataClassType).size + ClassUtils.getAllInterfaces(it.dataClassType).size }
314315

315316
return when {
316317
resolverInfoList.isEmpty() -> DataClassResolverInfo(dataClass)
317318
resolverInfoList.size == 1 && resolverInfoList.single().dataClassType == dataClass -> resolverInfoList.single()
318-
else -> MultiResolverInfo(resolverInfoList, dataClass.unwrap())
319+
else -> MultiResolverInfo(resolverInfoList, dataClass)
319320
}
320321
}
321322

322-
private fun isResolverForSupertype(resolverInfo: NormalResolverInfo, dataClass: JavaType) =
323-
dataClass is Class<*> && resolverInfo.dataClassType != Object::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass)
323+
private fun isResolverForSupertype(resolverInfo: NormalResolverInfo, dataClass: Class<*>) =
324+
resolverInfo.dataClassType != Object::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass)
324325

325326
private fun scanResolverInfoForPotentialMatches(type: ObjectTypeDefinition, resolverInfo: ResolverInfo) {
326327
type.getExtendedFieldDefinitions(extensionDefinitions).forEach { field ->

‎src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
157157
private fun verifyMethodArguments(method: Method, requiredCount: Int, search: Search): Boolean {
158158
val appropriateFirstParameter = if (search.requiredFirstParameterType != null) {
159159
method.genericParameterTypes.firstOrNull()?.let {
160-
it == search.requiredFirstParameterType || method.declaringClass.typeParameters.contains(it)
160+
it.eraseUnboundedWildcards() == search.requiredFirstParameterType || method.declaringClass.typeParameters.contains(it)
161161
} ?: false
162162
} else {
163163
true

‎src/main/kotlin/graphql/kickstart/tools/util/Utils.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ internal fun JavaType.unwrap(): Class<out Any> =
4040
this as Class<*>
4141
}
4242

43+
/**
44+
* Replaces a parameterized type whose type arguments are all unbounded wildcards, e.g. Kotlin's Page<*>, by its raw type.
45+
*/
46+
internal fun JavaType.eraseUnboundedWildcards(): JavaType =
47+
if (this is ParameterizedType && this.actualTypeArguments.all { TypeUtils.equals(it, TypeUtils.WILDCARD_ALL) }) {
48+
this.rawType
49+
} else {
50+
this
51+
}
52+
4353
internal fun JavaType.typeArgument(type: Class<*>): JavaType? =
4454
TypeUtils.getTypeArguments(this, type)?.get(type.typeParameters.first())
4555

‎src/test/kotlin/graphql/kickstart/tools/GenericResolverTest.kt‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package graphql.kickstart.tools
22

3+
import graphql.GraphQL
4+
import graphql.kickstart.tools.resolver.FieldResolverError
5+
import org.junit.Assert.assertThrows
36
import org.junit.Test
47

58
class GenericResolverTest {
@@ -64,4 +67,99 @@ class GenericResolverTest {
6467
class Car
6568

6669
class CarResolver : FooGraphQLResolver<Car>()
70+
71+
@Test
72+
fun `star projected resolvers are applied to parameterized data classes`() {
73+
val gql = GraphQL.newGraphQL(pageSchema(PageResolver())).build()
74+
75+
val data = assertNoGraphQlErrors(gql) {
76+
"""
77+
query {
78+
page {
79+
content { name }
80+
size
81+
}
82+
}
83+
"""
84+
}
85+
86+
assertEquals(data["page"], mapOf("content" to listOf(mapOf("name" to "item")), "size" to 1))
87+
}
88+
89+
@Test
90+
fun `supertype resolvers are applied to parameterized data classes`() {
91+
val gql = GraphQL.newGraphQL(pageSchema(CountableResolver())).build()
92+
93+
val data = assertNoGraphQlErrors(gql) {
94+
"""
95+
query {
96+
page {
97+
content { name }
98+
size
99+
}
100+
}
101+
"""
102+
}
103+
104+
assertEquals(data["page"], mapOf("content" to listOf(mapOf("name" to "item")), "size" to 1))
105+
}
106+
107+
@Test
108+
fun `resolvers for a specific parameterization of a data class are rejected`() {
109+
assertThrows(FieldResolverError::class.java) { pageSchema(ItemPageSourceResolver()) }
110+
111+
val error = assertThrows(ResolverError::class.java) { pageSchema(ItemPageResolver()) }
112+
assertEquals(error.message, "Resolver '${ItemPageResolver::class.java.name}' may not have a parameterized type " +
113+
"(${Page::class.java.name}<${Item::class.java.name}>) as its type, use the raw type or unbounded wildcards (<?> in Java, <*> in Kotlin) instead.")
114+
}
115+
116+
private fun pageSchema(resolver: GraphQLResolver<*>) = SchemaParser.newParser()
117+
.schemaString(
118+
"""
119+
type Query {
120+
page: ItemPage!
121+
}
122+
123+
type ItemPage {
124+
content: [Item!]!
125+
size: Int!
126+
}
127+
128+
type Item {
129+
name: String!
130+
}
131+
""")
132+
.resolvers(QueryResolver3(), resolver)
133+
.build()
134+
.makeExecutableSchema()
135+
136+
class QueryResolver3 : GraphQLQueryResolver {
137+
fun getPage(): Page<Item> = Page(listOf(Item("item")))
138+
}
139+
140+
interface Countable {
141+
fun count(): Int
142+
}
143+
144+
class Page<T>(val content: List<T>) : Countable {
145+
override fun count(): Int = content.size
146+
}
147+
148+
class Item(val name: String)
149+
150+
class PageResolver : GraphQLResolver<Page<*>> {
151+
fun getSize(page: Page<*>): Int = page.content.size
152+
}
153+
154+
class CountableResolver : GraphQLResolver<Countable> {
155+
fun getSize(countable: Countable): Int = countable.count()
156+
}
157+
158+
class ItemPageSourceResolver : GraphQLResolver<Page<*>> {
159+
fun getSize(page: Page<Item>): Int = page.content.size
160+
}
161+
162+
class ItemPageResolver : GraphQLResolver<Page<Item>> {
163+
fun getSize(page: Page<Item>): Int = page.content.size
164+
}
67165
}

‎src/test/kotlin/graphql/kickstart/tools/ResolverMethodsTest.java‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import graphql.schema.GraphQLSchema;
77
import org.junit.Test;
88

9+
import java.util.List;
910
import java.util.Map;
1011

1112
import static org.junit.Assert.assertEquals;
@@ -76,9 +77,65 @@ public String name(Product product) {
7677
assertEquals(Map.of("name", "product"), data.get("product"));
7778
}
7879

80+
// Raw types can't be expressed in Kotlin, so this resolver must stay in Java.
81+
@Test
82+
public void testRawResolverForParameterizedDataClass() {
83+
GraphQLSchema schema = SchemaParser.newParser()
84+
.schemaString("type Query { page: ItemPage! } type ItemPage { content: [Item!]! size: Int! } type Item { name: String! }")
85+
.resolvers(new PageQueryResolver(), new RawPageResolver())
86+
.build()
87+
.makeExecutableSchema();
88+
89+
GraphQL gql = GraphQL.newGraphQL(schema).build();
90+
91+
ExecutionResult result = gql
92+
.execute(ExecutionInput.newExecutionInput()
93+
.query("query { page { content { name } size } }")
94+
.root(new Object()));
95+
96+
assertTrue(result.getErrors().isEmpty());
97+
Map<?, ?> data = result.getData();
98+
assertEquals(Map.of("content", List.of(Map.of("name", "item")), "size", 1), data.get("page"));
99+
}
100+
79101
static class Product {
80102
}
81103

104+
static class Page<T> {
105+
private final List<T> content;
106+
107+
Page(List<T> content) {
108+
this.content = content;
109+
}
110+
111+
public List<T> getContent() {
112+
return content;
113+
}
114+
}
115+
116+
static class Item {
117+
public String getName() {
118+
return "item";
119+
}
120+
}
121+
122+
static class PageQueryResolver implements GraphQLQueryResolver {
123+
124+
@SuppressWarnings("unused")
125+
public Page<Item> page() {
126+
return new Page<>(List.of(new Item()));
127+
}
128+
}
129+
130+
@SuppressWarnings("rawtypes")
131+
static class RawPageResolver implements GraphQLResolver<Page> {
132+
133+
@SuppressWarnings("unused")
134+
public int size(Page page) {
135+
return page.getContent().size();
136+
}
137+
}
138+
82139
static class Resolver implements GraphQLQueryResolver {
83140

84141
@SuppressWarnings("unused")

0 commit comments

Comments
 (0)