Skip to content

Commit 29c87af

Browse files
oryan-blockclaude
andcommitted
Discover unions and interfaces used only by interface fields
An implementation may narrow an interface field to one member of the field's union (or one implementor of its interface), which is valid GraphQL. The scanner only discovers types through object fields and "implements" clauses, so a union referenced only from an interface field was never added to the definitions. The schema build then failed in createInterfaceObject with "Expected type 'TestUnion' to be a GraphQLOutputType, but it wasn't!", and the union was logged as unused. Once nothing else is left to scan, the discovery loop now also walks the field types of every discovered interface and adds any union or interface that hasn't been found yet. The existing loops then require classes for the union's members and the interface's implementors, as they do for types reached through object fields. Running this step last keeps the discovery order unchanged for every type reachable some other way, so a union whose members are only found by scanning dictionary types is still found after those types are scanned. Unions only used by interface fields no longer need a dummy field or includeUnusedTypes(true) to be built. The step is skipped when includeUnusedTypes is on. The unused types loop already picks these unions and interfaces up one at a time, scanning in between, so a member that is only reachable through another member's class is still found there. Checking all members at once would reject schemas that build today with the includeUnusedTypes workaround. Fixes #597, the same problem as #382. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 54aae49 commit 29c87af

2 files changed

Lines changed: 526 additions & 0 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ internal class SchemaClassScanner(
8686
handleDictionaryTypes(getAllObjectTypeMembersOfDiscoveredUnions()) { "Object type '${it.name}' is a member of a known union, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." }
8787
if (scanQueue()) continue
8888

89+
// Look for unions and interfaces used only by interface fields last, so types that can be reached some other way
90+
// are still discovered in the same order as before. With includeUnusedTypes the unused types loop below already
91+
// picks these up one at a time, scanning in between, so leave that path unchanged.
92+
if (!options.includeUnusedTypes && handleInterfaceFieldTypes()) continue
93+
8994
break
9095
}
9196

@@ -273,6 +278,26 @@ internal class SchemaClassScanner(
273278
}.flatten().distinct()
274279
}
275280

281+
/**
282+
* Implementations can narrow an interface field to a member of its union or an implementor of its interface, so such a
283+
* union or interface may not be reachable from any object field. Returns whether any new types were found.
284+
*/
285+
private fun handleInterfaceFieldTypes(): Boolean {
286+
val newTypes = dictionary.keys.filterIsInstance<InterfaceTypeDefinition>().flatMap { iface ->
287+
iface.fieldDefinitions.mapNotNull { field ->
288+
val type = definitionsByName[(field.type.unwrap() as TypeName).name]
289+
if ((type is UnionTypeDefinition || type is InterfaceTypeDefinition) && !dictionary.containsKey(type)) {
290+
type to FieldTypeReference("${iface.name}.${field.name}")
291+
} else {
292+
null
293+
}
294+
}
295+
}
296+
297+
newTypes.forEach { (type, reference) -> handleFoundType(type, null, reference) }
298+
return newTypes.isNotEmpty()
299+
}
300+
276301
private fun handleDictionaryTypes(types: List<TypeDefinition<*>>, failureMessage: (TypeDefinition<*>) -> String) {
277302
types.forEach { type ->
278303
val dictionaryContainsType = dictionary.filter { it.key.name == type.name }.isNotEmpty()

0 commit comments

Comments
 (0)