From d28d18064b176af543ec7197239d0b3d97a50113 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 3 Oct 2026 18:20:07 -0400 Subject: [PATCH] Index resolver methods by name when scanning fields Schema scanning got very slow for large schemas with many root resolvers. Every root field is searched on every root resolver, and each search recomputed all methods of the resolver class and then made up to five linear passes over them, building the candidate method name again for every method. That made scanning roughly fields x resolvers x methods, so a schema with thousands of root fields took minutes to start. FieldResolverScanner now groups the methods of each class by name once per scan, keyed by class and subscription filter, and looks up the candidate names (name, isName, getName, getFieldName, snake_case variant) directly. The resolution order and argument checks are unchanged. The cache lives on the scanner, so it is discarded with the scan and nothing is shared between schemas. Fixes #541 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tools/resolver/FieldResolverScanner.kt | 29 ++++++++++++------- .../tools/FieldResolverScannerTest.kt | 25 ++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt b/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt index 8939bd00..589ebc48 100644 --- a/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/resolver/FieldResolverScanner.kt @@ -29,6 +29,8 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { private val allowedLastArgumentTypes = listOfNotNull(DataFetchingEnvironment::class.java, GraphQLContext::class.java, options.contextClass) + private val methodsByNameCache = mutableMapOf, Boolean>, Map>>() + fun findFieldResolver(field: FieldDefinition, resolverInfo: ResolverInfo): FieldResolver { val searches = resolverInfo.getFieldSearches() @@ -86,9 +88,10 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { } private fun findResolverMethod(field: FieldDefinition, search: Search): Method? { - val methods = getAllMethods(search) + val methodsByName = getMethodsByName(search) val argumentCount = field.inputValueDefinitions.size + if (search.requiredFirstParameterType != null) 1 else 0 val name = field.name + val capitalizedName = name.replaceFirstChar(Char::titlecase) // Check for the following one by one: // 1. Method with exact field name @@ -96,19 +99,23 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) { // 3. Method with "get" style getter // 4. Method with "getField" style getter // 5. Method with "get" style getter with the field name converted from snake_case to camelCased. ex: key_ops -> getKeyOps() - return methods.find { - it.name == name && verifyMethodArguments(it, argumentCount, search) - } ?: methods.find { - (isBoolean(field.type) && it.name == "is${name.replaceFirstChar(Char::titlecase)}") && verifyMethodArguments(it, argumentCount, search) - } ?: methods.find { - it.name == "get${name.replaceFirstChar(Char::titlecase)}" && verifyMethodArguments(it, argumentCount, search) - } ?: methods.find { - it.name == "getField${name.replaceFirstChar(Char::titlecase)}" && verifyMethodArguments(it, argumentCount, search) - } ?: methods.find { - it.name == "get${name.snakeToCamelCase()}" && verifyMethodArguments(it, argumentCount, search) + return listOfNotNull( + name, + if (isBoolean(field.type)) "is$capitalizedName" else null, + "get$capitalizedName", + "getField$capitalizedName", + "get${name.snakeToCamelCase()}" + ).firstNotNullOfOrNull { methodName -> + methodsByName[methodName]?.find { verifyMethodArguments(it, argumentCount, search) } } } + // root fields are searched on every root resolver, so each class's methods are only indexed once + private fun getMethodsByName(search: Search): Map> = + methodsByNameCache.getOrPut(search.type.unwrap() to search.isSubscription) { + getAllMethods(search).groupBy { it.name } + } + private fun getAllMethods(search: Search): List { val type = search.type.unwrap() val declaredMethods = type.declaredNonProxyMethods diff --git a/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt b/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt index e5631844..4ca5fa54 100644 --- a/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/FieldResolverScannerTest.kt @@ -98,6 +98,19 @@ class FieldResolverScannerTest { Locale.setDefault(default) } + @Test + fun `scanner finds field resolver methods in priority order`() { + val resolverInfo = RootResolverInfo(listOf(PriorityQuery()), options) + + fun methodName(name: String, type: String) = + (scanner.findFieldResolver(FieldDefinition(name, TypeName(type)), resolverInfo) as MethodFieldResolver).method.name + + assertEquals(methodName("name", "String"), "name") + assertEquals(methodName("active", "Boolean"), "isActive") + assertEquals(methodName("enabled", "String"), "getEnabled") + assertEquals(methodName("count", "Int"), "getFieldCount") + } + class RootQuery1 : GraphQLQueryResolver { fun field1() {} } @@ -118,6 +131,18 @@ class FieldResolverScannerTest { fun getId(): HullType = HullType() } + class PriorityQuery : GraphQLQueryResolver { + fun name(): String = "name" + fun getName(): String = "name" + fun getFieldName(): String = "name" + fun active(flag: Boolean): Boolean = flag + fun isActive(): Boolean = true + fun getActive(): Boolean = true + fun isEnabled(): Boolean = true + fun getEnabled(): String = "enabled" + fun getFieldCount(): Int = 1 + } + class HullType open class ParentPropertyQuery {