Skip to content

Commit 3b7b018

Browse files
authored
Merge branch 'master' into bugfix/308
2 parents cdc3046 + 908e0f4 commit 3b7b018

12 files changed

Lines changed: 540 additions & 49 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
name: Release
22
on: [ workflow_dispatch ]
3+
permissions:
4+
contents: read
35
jobs:
46
build:
57
runs-on: ubuntu-latest
@@ -17,6 +19,9 @@ jobs:
1719
publish:
1820
runs-on: ubuntu-latest
1921
needs: build
22+
permissions:
23+
contents: write
24+
pull-requests: write
2025
steps:
2126
- name: Checkout
2227
uses: actions/checkout@v7

‎github-build.sh‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ getVersion() {
55
grep -m1 -o "<version>.*</version>$" pom.xml | awk -F'[><]' '{print $3}'
66
}
77

8+
requireSnapshot() {
9+
local APP_VERSION=$(getVersion)
10+
if [[ ${APP_VERSION} != *-SNAPSHOT ]]; then
11+
echo "Version in pom.xml must end with -SNAPSHOT to be released: '${APP_VERSION}'"
12+
exit 1
13+
fi
14+
}
15+
816
removeSnapshots() {
917
sed -i 's/-SNAPSHOT//' pom.xml
1018
}
@@ -15,6 +23,15 @@ commitRelease() {
1523
git tag -a "v${APP_VERSION}" -m "Tag release version"
1624
}
1725

26+
publishTag() {
27+
local APP_VERSION=$(getVersion)
28+
git push origin "v${APP_VERSION}"
29+
# Release tags aren't on master's first-parent history, so GitHub can't find the previous one on its own
30+
local PREVIOUS_TAG=$(gh release view --json tagName --jq .tagName)
31+
gh release create "v${APP_VERSION}" --verify-tag --draft --generate-notes --notes-start-tag "${PREVIOUS_TAG}" --title "v${APP_VERSION}" \
32+
|| echo "::warning::Draft release for v${APP_VERSION} was not created, create it manually"
33+
}
34+
1835
bumpVersion() {
1936
echo "Bump version number"
2037
local APP_VERSION=$(getVersion | xargs)
@@ -43,11 +60,14 @@ commitNextVersion() {
4360
git config --global user.email "actions@github.com"
4461
git config --global user.name "GitHub Actions"
4562

63+
requireSnapshot
64+
4665
echo "Deploying release to Maven Central"
4766
removeSnapshots
4867

4968
mvn --batch-mode -Prelease deploy
5069

5170
commitRelease
71+
publishTag
5272
bumpVersion
5373
commitNextVersion

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,13 +437,15 @@ internal class SchemaClassScanner(
437437
private fun findInputValueTypeInType(name: String, clazz: Class<*>): JavaType? {
438438
val methods = clazz.methods
439439

440-
val filteredMethods = methods.filter {
440+
val (getters, methodsWithParameters) = methods.filter {
441441
it.name == name || it.name == "get${name.replaceFirstChar(Char::titlecase)}"
442-
}.sortedBy { it.name.length }
442+
}.sortedBy { it.name.length }.partition { it.parameterCount == 0 }
443443

444-
return filteredMethods.find { !it.isSynthetic }?.genericReturnType
445-
?: filteredMethods.firstOrNull()?.genericReturnType
444+
return getters.find { !it.isSynthetic }?.genericReturnType
445+
?: getters.firstOrNull()?.genericReturnType
446446
?: clazz.fields.find { it.name == name }?.genericType
447+
?: methodsWithParameters.find { !it.isSynthetic }?.genericReturnType
448+
?: methodsWithParameters.firstOrNull()?.genericReturnType
447449
}
448450

449451
private data class QueueItem(val type: ObjectTypeDefinition, val clazz: JavaType)

‎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: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import org.apache.commons.lang3.reflect.TypeUtils
1616
import org.reactivestreams.Publisher
1717
import org.slf4j.LoggerFactory
1818
import java.lang.reflect.*
19-
import kotlin.reflect.full.valueParameters
20-
import kotlin.reflect.jvm.javaType
19+
import kotlin.reflect.full.extensionReceiverParameter
2120
import kotlin.reflect.jvm.kotlinFunction
2221

2322
/**
@@ -29,6 +28,8 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
2928

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

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

@@ -86,29 +87,34 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
8687
}
8788

8889
private fun findResolverMethod(field: FieldDefinition, search: Search): Method? {
89-
val methods = getAllMethods(search)
90+
val methodsByName = getMethodsByName(search)
9091
val argumentCount = field.inputValueDefinitions.size + if (search.requiredFirstParameterType != null) 1 else 0
9192
val name = field.name
93+
val capitalizedName = name.replaceFirstChar(Char::titlecase)
9294

9395
// Check for the following one by one:
9496
// 1. Method with exact field name
9597
// 2. Method that returns a boolean with "is" style getter
9698
// 3. Method with "get" style getter
9799
// 4. Method with "getField" style getter
98100
// 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)
101+
return listOfNotNull(
102+
name,
103+
if (isBoolean(field.type)) "is$capitalizedName" else null,
104+
"get$capitalizedName",
105+
"getField$capitalizedName",
106+
"get${name.snakeToCamelCase()}"
107+
).firstNotNullOfOrNull { methodName ->
108+
methodsByName[methodName]?.find { verifyMethodArguments(it, argumentCount, search) }
109109
}
110110
}
111111

112+
// root fields are searched on every root resolver, so each class's methods are only indexed once
113+
private fun getMethodsByName(search: Search): Map<String, List<Method>> =
114+
methodsByNameCache.getOrPut(search.type.unwrap() to search.isSubscription) {
115+
getAllMethods(search).groupBy { it.name }
116+
}
117+
112118
private fun getAllMethods(search: Search): List<Method> {
113119
val type = search.type.unwrap()
114120
val declaredMethods = type.declaredNonProxyMethods
@@ -160,31 +166,23 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
160166
it.eraseUnboundedWildcards() == search.requiredFirstParameterType || method.declaringClass.typeParameters.contains(it)
161167
} ?: false
162168
} else {
163-
true
169+
// an extension receiver can only take the source object
170+
!isExtensionFunction(method)
164171
}
165172

166-
val methodParameterCount = getMethodParameterCount(method)
167-
val methodLastParameter = getMethodLastParameter(method)
173+
val methodParameterCount = method.parameterCountWithoutContinuation()
174+
val methodLastParameter = method.parameterTypes.getOrNull(methodParameterCount - 1)
168175

169176
val correctParameterCount = methodParameterCount == requiredCount ||
170177
(methodParameterCount == (requiredCount + 1) && allowedLastArgumentTypes.contains(methodLastParameter))
171178
return correctParameterCount && appropriateFirstParameter
172179
}
173180

174-
private fun getMethodParameterCount(method: Method): Int {
175-
return try {
176-
method.kotlinFunction?.valueParameters?.size ?: method.parameterCount
177-
} catch (e: InternalError) {
178-
method.parameterCount
179-
}
180-
}
181-
182-
private fun getMethodLastParameter(method: Method): Type? {
181+
private fun isExtensionFunction(method: Method): Boolean {
183182
return try {
184-
method.kotlinFunction?.valueParameters?.lastOrNull()?.type?.javaType
185-
?: method.parameterTypes.lastOrNull()
183+
method.kotlinFunction?.extensionReceiverParameter != null
186184
} catch (e: InternalError) {
187-
method.parameterTypes.lastOrNull()
185+
false
188186
}
189187
}
190188

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

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import graphql.kickstart.tools.SchemaParserOptions.GenericWrapper
77
import graphql.kickstart.tools.util.JavaType
88
import graphql.kickstart.tools.util.coroutineScope
99
import graphql.kickstart.tools.util.futureValueType
10+
import graphql.kickstart.tools.util.isSuspendFunction
11+
import graphql.kickstart.tools.util.parameterCountWithoutContinuation
1012
import graphql.kickstart.tools.util.typeArgument
1113
import graphql.kickstart.tools.util.unwrap
1214
import graphql.language.*
@@ -15,6 +17,8 @@ import graphql.schema.DataFetchingEnvironment
1517
import graphql.schema.GraphQLFieldDefinition
1618
import graphql.schema.GraphQLTypeUtil.isScalar
1719
import graphql.schema.LightDataFetcher
20+
import kotlinx.coroutines.CoroutineStart
21+
import kotlinx.coroutines.ensureActive
1822
import kotlinx.coroutines.future.future
1923
import org.apache.commons.lang3.reflect.TypeUtils
2024
import org.reactivestreams.Publisher
@@ -24,7 +28,6 @@ import java.lang.reflect.Method
2428
import java.util.*
2529
import java.util.function.Supplier
2630
import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
27-
import kotlin.reflect.full.valueParameters
2831
import kotlin.reflect.jvm.javaType
2932
import kotlin.reflect.jvm.kotlinFunction
3033

@@ -41,7 +44,7 @@ internal class MethodFieldResolver(
4144
private val log = LoggerFactory.getLogger(javaClass)
4245

4346
private val isSuspendFunction = method.isSuspendFunction()
44-
private val numberOfParameters = method.kotlinFunction?.valueParameters?.size ?: method.parameterCount
47+
private val numberOfParameters = method.parameterCountWithoutContinuation()
4548
private val hasAdditionalParameter = numberOfParameters == (field.inputValueDefinitions.size + getIndexOffset() + 1)
4649

4750
override fun createDataFetcher(): DataFetcher<*> {
@@ -100,7 +103,8 @@ internal class MethodFieldResolver(
100103

101104
// Add DataFetchingEnvironment/Context argument
102105
if (this.hasAdditionalParameter) {
103-
when (this.method.parameterTypes.last()) {
106+
// suspend functions have a trailing Continuation parameter
107+
when (this.method.parameterTypes[numberOfParameters - 1]) {
104108
null -> throw ResolverError("Expected at least one argument but got none, this is most likely a bug with graphql-java-tools")
105109
options.contextClass -> args.add { environment ->
106110
val context: Any? = environment.graphQlContext[options.contextClass]
@@ -211,7 +215,10 @@ internal class MethodFieldResolverDataFetcher(
211215
val args = this.args.map { it(environment) }.toTypedArray()
212216

213217
return if (isSuspendFunction) {
214-
environment.coroutineScope().future(options.coroutineContextProvider.provide()) {
218+
// start undispatched so DataLoader loads are queued before graphql-java dispatches them,
219+
// which runs the block even if the context is already cancelled, hence ensureActive
220+
environment.coroutineScope().future(options.coroutineContextProvider.provide(), CoroutineStart.UNDISPATCHED) {
221+
ensureActive()
215222
invokeSuspend(source, method, args)?.transformWithGenericWrapper(options.genericWrappers) { environment }
216223
}
217224
} else {
@@ -277,14 +284,6 @@ private class CompareGenericWrappers {
277284
}
278285
}
279286

280-
private fun Method.isSuspendFunction(): Boolean {
281-
return try {
282-
this.kotlinFunction?.isSuspend == true
283-
} catch (e: InternalError) {
284-
false
285-
}
286-
}
287-
288287
private suspend inline fun invokeSuspend(target: Any, resolverMethod: Method, args: Array<Any?>): Any? {
289288
return suspendCoroutineUninterceptedOrReturn { continuation ->
290289
invoke(resolverMethod, target, args + continuation)

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import java.lang.reflect.ParameterizedType
1212
import java.lang.reflect.Proxy
1313
import java.util.concurrent.CompletionStage
1414
import java.util.concurrent.Future
15+
import kotlin.reflect.jvm.kotlinFunction
1516

1617
/**
1718
* @author Andrew Potter
@@ -69,6 +70,18 @@ internal val Class<*>.declaredNonProxyMethods: List<JavaMethod>
6970
}
7071
}
7172

73+
internal fun JavaMethod.isSuspendFunction(): Boolean {
74+
return try {
75+
this.kotlinFunction?.isSuspend == true
76+
} catch (e: InternalError) {
77+
false
78+
}
79+
}
80+
81+
// the trailing Continuation of a suspend function isn't a resolver argument
82+
internal fun JavaMethod.parameterCountWithoutContinuation(): Int =
83+
if (isSuspendFunction()) parameterCount - 1 else parameterCount
84+
7285
internal fun getDocumentation(node: AbstractNode<*>, options: SchemaParserOptions): String? =
7386
when {
7487
node is AbstractDescribedNode<*> && node.description != null -> node.description?.content

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import graphql.kickstart.tools.resolver.FieldResolverScanner
66
import graphql.kickstart.tools.resolver.MethodFieldResolver
77
import graphql.kickstart.tools.resolver.PropertyFieldResolver
88
import graphql.language.FieldDefinition
9+
import graphql.language.InputValueDefinition
910
import graphql.language.TypeName
1011
import graphql.relay.Connection
1112
import graphql.relay.DefaultConnection
1213
import graphql.relay.DefaultPageInfo
14+
import graphql.schema.DataFetchingEnvironment
1315
import kotlinx.coroutines.ExperimentalCoroutinesApi
1416
import org.junit.Test
1517
import java.util.*
@@ -98,6 +100,34 @@ class FieldResolverScannerTest {
98100
Locale.setDefault(default)
99101
}
100102

103+
@Test
104+
fun `scanner ignores extension functions on root resolvers`() {
105+
val resolver = RootResolverInfo(listOf(UserQuery(), ExtensionQuery()), options)
106+
val field = FieldDefinition.newFieldDefinition()
107+
.name("user")
108+
.type(TypeName("String"))
109+
.inputValueDefinition(InputValueDefinition("id", TypeName("ID")))
110+
.build()
111+
112+
val user = scanner.findFieldResolver(field, resolver)
113+
114+
assert(user is MethodFieldResolver)
115+
assertEquals((user as MethodFieldResolver).method.declaringClass, UserQuery::class.java)
116+
}
117+
118+
@Test
119+
fun `scanner finds field resolver methods in priority order`() {
120+
val resolverInfo = RootResolverInfo(listOf(PriorityQuery()), options)
121+
122+
fun methodName(name: String, type: String) =
123+
(scanner.findFieldResolver(FieldDefinition(name, TypeName(type)), resolverInfo) as MethodFieldResolver).method.name
124+
125+
assertEquals(methodName("name", "String"), "name")
126+
assertEquals(methodName("active", "Boolean"), "isActive")
127+
assertEquals(methodName("enabled", "String"), "getEnabled")
128+
assertEquals(methodName("count", "Int"), "getFieldCount")
129+
}
130+
101131
class RootQuery1 : GraphQLQueryResolver {
102132
fun field1() {}
103133
}
@@ -110,6 +140,14 @@ class FieldResolverScannerTest {
110140
fun field1() {}
111141
}
112142

143+
class UserQuery : GraphQLQueryResolver {
144+
fun user(id: String): String = id
145+
}
146+
147+
class ExtensionQuery : GraphQLQueryResolver {
148+
fun DataFetchingEnvironment.user(): String = field.name
149+
}
150+
113151
class CamelCaseQuery1 : GraphQLQueryResolver {
114152
fun getHullType(): HullType = HullType()
115153
}
@@ -118,6 +156,18 @@ class FieldResolverScannerTest {
118156
fun getId(): HullType = HullType()
119157
}
120158

159+
class PriorityQuery : GraphQLQueryResolver {
160+
fun name(): String = "name"
161+
fun getName(): String = "name"
162+
fun getFieldName(): String = "name"
163+
fun active(flag: Boolean): Boolean = flag
164+
fun isActive(): Boolean = true
165+
fun getActive(): Boolean = true
166+
fun isEnabled(): Boolean = true
167+
fun getEnabled(): String = "enabled"
168+
fun getFieldCount(): Int = 1
169+
}
170+
121171
class HullType
122172

123173
open class ParentPropertyQuery {

0 commit comments

Comments
 (0)