Skip to content

Commit d06c859

Browse files
authored
Merge branch 'master' into bugfix/446
2 parents d050dd7 + ee213e5 commit d06c859

7 files changed

Lines changed: 294 additions & 17 deletions

File tree

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,16 @@ internal class TypeClassMatcher(private val definitionsByName: Map<String, TypeD
6565
match(potentialMatch, graphQLType.type, realType)
6666
}
6767

68-
is ListType -> {
69-
if (realType is ParameterizedType && isListType(realType, potentialMatch)) {
68+
is ListType -> when {
69+
realType is ParameterizedType && isListType(realType, potentialMatch) ->
7070
match(potentialMatch, graphQLType.type, realType.actualTypeArguments.first())
71-
} else if (realType is Class<*> && realType.isArray) {
71+
realType is Class<*> && realType.isArray ->
7272
match(potentialMatch, graphQLType.type, realType.componentType)
73-
} else {
73+
// e.g. a property map value: the element type is unknown, so match it as Object too
74+
realType == Object::class.java ->
75+
match(potentialMatch, graphQLType.type, realType)
76+
else ->
7477
throw error(potentialMatch, "Java class is not a List or generic type information was lost: $realType")
75-
}
7678
}
7779

7880
is TypeName -> {

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
2929

3030
private val allowedLastArgumentTypes = listOfNotNull(DataFetchingEnvironment::class.java, GraphQLContext::class.java, options.contextClass)
3131

32+
private val methodsByNameCache = mutableMapOf<Pair<Class<out Any>, Boolean>, Map<String, List<Method>>>()
33+
3234
fun findFieldResolver(field: FieldDefinition, resolverInfo: ResolverInfo): FieldResolver {
3335
val searches = resolverInfo.getFieldSearches()
3436

@@ -86,29 +88,34 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
8688
}
8789

8890
private fun findResolverMethod(field: FieldDefinition, search: Search): Method? {
89-
val methods = getAllMethods(search)
91+
val methodsByName = getMethodsByName(search)
9092
val argumentCount = field.inputValueDefinitions.size + if (search.requiredFirstParameterType != null) 1 else 0
9193
val name = field.name
94+
val capitalizedName = name.replaceFirstChar(Char::titlecase)
9295

9396
// Check for the following one by one:
9497
// 1. Method with exact field name
9598
// 2. Method that returns a boolean with "is" style getter
9699
// 3. Method with "get" style getter
97100
// 4. Method with "getField" style getter
98101
// 5. Method with "get" style getter with the field name converted from snake_case to camelCased. ex: key_ops -> getKeyOps()
99-
return methods.find {
100-
it.name == name && verifyMethodArguments(it, argumentCount, search)
101-
} ?: methods.find {
102-
(isBoolean(field.type) && it.name == "is${name.replaceFirstChar(Char::titlecase)}") && verifyMethodArguments(it, argumentCount, search)
103-
} ?: methods.find {
104-
it.name == "get${name.replaceFirstChar(Char::titlecase)}" && verifyMethodArguments(it, argumentCount, search)
105-
} ?: methods.find {
106-
it.name == "getField${name.replaceFirstChar(Char::titlecase)}" && verifyMethodArguments(it, argumentCount, search)
107-
} ?: methods.find {
108-
it.name == "get${name.snakeToCamelCase()}" && verifyMethodArguments(it, argumentCount, search)
102+
return listOfNotNull(
103+
name,
104+
if (isBoolean(field.type)) "is$capitalizedName" else null,
105+
"get$capitalizedName",
106+
"getField$capitalizedName",
107+
"get${name.snakeToCamelCase()}"
108+
).firstNotNullOfOrNull { methodName ->
109+
methodsByName[methodName]?.find { verifyMethodArguments(it, argumentCount, search) }
109110
}
110111
}
111112

113+
// root fields are searched on every root resolver, so each class's methods are only indexed once
114+
private fun getMethodsByName(search: Search): Map<String, List<Method>> =
115+
methodsByNameCache.getOrPut(search.type.unwrap() to search.isSubscription) {
116+
getAllMethods(search).groupBy { it.name }
117+
}
118+
112119
private fun getAllMethods(search: Search): List<Method> {
113120
val type = search.type.unwrap()
114121
val declaredMethods = type.declaredNonProxyMethods

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import graphql.schema.DataFetchingEnvironment
1515
import graphql.schema.GraphQLFieldDefinition
1616
import graphql.schema.GraphQLTypeUtil.isScalar
1717
import graphql.schema.LightDataFetcher
18+
import kotlinx.coroutines.CoroutineStart
19+
import kotlinx.coroutines.ensureActive
1820
import kotlinx.coroutines.future.future
1921
import org.apache.commons.lang3.reflect.TypeUtils
2022
import org.reactivestreams.Publisher
@@ -211,7 +213,10 @@ internal class MethodFieldResolverDataFetcher(
211213
val args = this.args.map { it(environment) }.toTypedArray()
212214

213215
return if (isSuspendFunction) {
214-
environment.coroutineScope().future(options.coroutineContextProvider.provide()) {
216+
// start undispatched so DataLoader loads are queued before graphql-java dispatches them,
217+
// which runs the block even if the context is already cancelled, hence ensureActive
218+
environment.coroutineScope().future(options.coroutineContextProvider.provide(), CoroutineStart.UNDISPATCHED) {
219+
ensureActive()
215220
invokeSuspend(source, method, args)?.transformWithGenericWrapper(options.genericWrappers) { environment }
216221
}
217222
} else {

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ class FieldResolverScannerTest {
9898
Locale.setDefault(default)
9999
}
100100

101+
@Test
102+
fun `scanner finds field resolver methods in priority order`() {
103+
val resolverInfo = RootResolverInfo(listOf(PriorityQuery()), options)
104+
105+
fun methodName(name: String, type: String) =
106+
(scanner.findFieldResolver(FieldDefinition(name, TypeName(type)), resolverInfo) as MethodFieldResolver).method.name
107+
108+
assertEquals(methodName("name", "String"), "name")
109+
assertEquals(methodName("active", "Boolean"), "isActive")
110+
assertEquals(methodName("enabled", "String"), "getEnabled")
111+
assertEquals(methodName("count", "Int"), "getFieldCount")
112+
}
113+
101114
class RootQuery1 : GraphQLQueryResolver {
102115
fun field1() {}
103116
}
@@ -118,6 +131,18 @@ class FieldResolverScannerTest {
118131
fun getId(): HullType = HullType()
119132
}
120133

134+
class PriorityQuery : GraphQLQueryResolver {
135+
fun name(): String = "name"
136+
fun getName(): String = "name"
137+
fun getFieldName(): String = "name"
138+
fun active(flag: Boolean): Boolean = flag
139+
fun isActive(): Boolean = true
140+
fun getActive(): Boolean = true
141+
fun isEnabled(): Boolean = true
142+
fun getEnabled(): String = "enabled"
143+
fun getFieldCount(): Int = 1
144+
}
145+
121146
class HullType
122147

123148
open class ParentPropertyQuery {

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import kotlinx.coroutines.channels.ReceiveChannel
2121
import org.junit.Test
2222
import org.reactivestreams.Publisher
2323
import org.reactivestreams.tck.TestEnvironment
24+
import java.util.concurrent.CancellationException
2425
import java.util.concurrent.CompletableFuture
2526
import kotlin.coroutines.coroutineContext
2627

@@ -57,6 +58,33 @@ class MethodFieldResolverDataFetcherTest {
5758
}
5859
}
5960

61+
@Test
62+
fun `data fetcher does not invoke suspend function if coroutineContext defined by options is cancelled`() {
63+
// setup
64+
val cancelledClass = CancelledClass()
65+
66+
val resolver = createFetcher("active", cancelledClass, options = cancelledClass.options)
67+
68+
// expect
69+
val future = resolver.get(createEnvironment(DataClass())) as CompletableFuture<*>
70+
assert(runCatching { future.get() }.exceptionOrNull() is CancellationException)
71+
assert(!cancelledClass.invoked)
72+
}
73+
74+
class CancelledClass : GraphQLResolver<DataClass> {
75+
var invoked = false
76+
77+
val options = SchemaParserOptions.Builder()
78+
.coroutineContext(Dispatchers.Default + Job().apply { cancel() })
79+
.build()
80+
81+
@Suppress("UNUSED_PARAMETER")
82+
suspend fun isActive(data: DataClass): Boolean {
83+
invoked = true
84+
return true
85+
}
86+
}
87+
6088
@ExperimentalCoroutinesApi
6189
@Test
6290
fun `canceling subscription Publisher also cancels underlying Kotlin coroutine channel`() {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package graphql.kickstart.tools
2+
3+
import graphql.GraphQL
4+
import org.junit.Assert.assertThrows
5+
import org.junit.Test
6+
7+
class PropertyMapListTest {
8+
9+
@Test
10+
fun `property maps should support list fields`() {
11+
val schema = SchemaParser.newParser()
12+
.schemaString(
13+
"""
14+
type Query {
15+
greeting: Greeting!
16+
}
17+
18+
type Greeting {
19+
name: String!
20+
tags: [String!]!
21+
matrix: [[Int]]
22+
key: Key
23+
keys: [Key!]
24+
}
25+
26+
type Key {
27+
a: String
28+
b: [String]
29+
}
30+
""")
31+
.resolvers(object : GraphQLQueryResolver {
32+
fun greeting(): Map<String, Any> = mapOf(
33+
"name" to "hello",
34+
"tags" to listOf("x", "y"),
35+
"matrix" to listOf(listOf(1, 2), listOf(3)),
36+
"key" to hashMapOf("a" to "one", "b" to listOf("ss")),
37+
"keys" to listOf(hashMapOf("a" to "two", "b" to listOf("tt")))
38+
)
39+
})
40+
.dictionary("Key", HashMap::class)
41+
.build()
42+
.makeExecutableSchema()
43+
val gql = GraphQL.newGraphQL(schema).build()
44+
45+
val data = assertNoGraphQlErrors(gql) {
46+
"""
47+
{
48+
greeting {
49+
name
50+
tags
51+
matrix
52+
key { a b }
53+
keys { a b }
54+
}
55+
}
56+
"""
57+
}
58+
59+
assertEquals(data["greeting"], mapOf(
60+
"name" to "hello",
61+
"tags" to listOf("x", "y"),
62+
"matrix" to listOf(listOf(1, 2), listOf(3)),
63+
"key" to mapOf("a" to "one", "b" to listOf("ss")),
64+
"keys" to listOf(mapOf("a" to "two", "b" to listOf("tt")))
65+
))
66+
}
67+
68+
@Test
69+
fun `property maps with typed list values should support list fields`() {
70+
val schema = SchemaParser.newParser()
71+
.schemaString(
72+
"""
73+
type Query {
74+
tags: Tags!
75+
}
76+
77+
type Tags {
78+
names: [String!]!
79+
}
80+
""")
81+
.resolvers(object : GraphQLQueryResolver {
82+
fun tags(): TagsMap = TagsMap().apply { put("names", listOf("x", "y")) }
83+
})
84+
.build()
85+
.makeExecutableSchema()
86+
val gql = GraphQL.newGraphQL(schema).build()
87+
88+
val data = assertNoGraphQlErrors(gql) {
89+
"""
90+
{
91+
tags {
92+
names
93+
}
94+
}
95+
"""
96+
}
97+
98+
assertEquals(data["tags"], mapOf("names" to listOf("x", "y")))
99+
}
100+
101+
@Test
102+
fun `property maps with object list fields should require a dictionary entry`() {
103+
val error = assertThrows(SchemaClassScannerError::class.java) {
104+
SchemaParser.newParser()
105+
.schemaString(
106+
"""
107+
type Query {
108+
greeting: Greeting!
109+
}
110+
111+
type Greeting {
112+
keys: [Key!]
113+
}
114+
115+
type Key {
116+
a: String
117+
}
118+
""")
119+
.resolvers(object : GraphQLQueryResolver {
120+
fun greeting(): Map<String, Any> = mapOf()
121+
})
122+
.build()
123+
}
124+
125+
assertEquals(error.message, "The GraphQL schema type 'Key' maps to a field of type java.lang.Object however there is no matching entry for this type in the type dictionary. You may need to add this type to the dictionary before building the schema.")
126+
}
127+
128+
class TagsMap : HashMap<String, List<String>>()
129+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package graphql.kickstart.tools
2+
3+
import graphql.ExecutionInput
4+
import graphql.GraphQL
5+
import graphql.schema.DataFetchingEnvironment
6+
import kotlinx.coroutines.future.await
7+
import org.dataloader.DataLoaderFactory
8+
import org.dataloader.DataLoaderRegistry
9+
import org.junit.Test
10+
import java.util.concurrent.CompletableFuture
11+
import java.util.concurrent.TimeUnit
12+
13+
class SuspendFunctionDataLoaderTest {
14+
15+
private val schema = SchemaParser.newParser()
16+
.schemaString(
17+
"""
18+
type Query {
19+
user(id: Int!): User!
20+
users(ids: [Int!]!): [User!]!
21+
}
22+
23+
type User {
24+
id: Int!
25+
friend: User!
26+
}
27+
""")
28+
.resolvers(Query(), UserResolver())
29+
.build()
30+
.makeExecutableSchema()
31+
private val gql = GraphQL.newGraphQL(schema).build()
32+
33+
@Test
34+
fun `root suspend function can await a data loader`() {
35+
repeat(20) {
36+
val data = execute("{ users(ids: [1, 2, 3]) { id } }")
37+
38+
assertEquals(data, mapOf("users" to listOf(mapOf("id" to 1), mapOf("id" to 2), mapOf("id" to 3))))
39+
}
40+
}
41+
42+
@Test
43+
fun `nested suspend function can await a data loader`() {
44+
repeat(20) {
45+
val data = execute("{ a: user(id: 1) { friend { id } } b: user(id: 2) { friend { id } } }")
46+
47+
assertEquals(data, mapOf(
48+
"a" to mapOf("friend" to mapOf("id" to 2)),
49+
"b" to mapOf("friend" to mapOf("id" to 3))
50+
))
51+
}
52+
}
53+
54+
private fun execute(query: String): Any? {
55+
val userLoader = DataLoaderFactory.newDataLoader<Int, User> { ids ->
56+
CompletableFuture.supplyAsync { ids.map { User(it) } }
57+
}
58+
val registry = DataLoaderRegistry.newRegistry().register("user", userLoader).build()
59+
60+
// a resolver that awaits a load before it is dispatched hangs, so don't wait forever
61+
val result = gql.executeAsync(ExecutionInput.newExecutionInput(query).dataLoaderRegistry(registry))
62+
.get(5, TimeUnit.SECONDS)
63+
64+
assert(result.errors.isEmpty()) { result.errors.joinToString { it.message } }
65+
return result.getData<Any>()
66+
}
67+
68+
class Query : GraphQLQueryResolver {
69+
fun user(id: Int): User = User(id)
70+
71+
suspend fun users(ids: List<Int>, env: DataFetchingEnvironment): List<User> =
72+
env.getDataLoader<Int, User>("user")!!.loadMany(ids).await()
73+
}
74+
75+
class UserResolver : GraphQLResolver<User> {
76+
suspend fun friend(user: User, env: DataFetchingEnvironment): User =
77+
env.getDataLoader<Int, User>("user")!!.load(user.id + 1).await()
78+
}
79+
80+
data class User(val id: Int)
81+
}

0 commit comments

Comments
 (0)