From c402949660bfa7b058aabf13e943164dd539ab33 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 12 Oct 2026 15:36:53 -0400 Subject: [PATCH 01/11] Merge type extensions into the types they extend Union, enum, interface and scalar extensions subclass their base definitions, so they replaced the base type (whichever came last won) instead of being merged into it. Directives and implemented interfaces on object and input extensions were dropped. The scanner now keeps the base definitions and groups every extension by type name. It reads union members, interface fields and implemented interfaces from the extensions too. The parser merges members, values, fields, interfaces and directives like graphql-java's SchemaGenerator, and sets extensionDefinitions on every type. graphql-java rejects an extension that repeats a value, field or non-repeatable directive of its type. Here enum values, interface fields and directives keep the first one, so schemas that repeated them to work around this keep building. Object and input fields are handled as before. An extension of a different kind than its type is now an error. A union, enum, interface or scalar extension without a definition still builds as the type itself, as before. Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/ScannedSchemaObjects.kt | 3 + .../kickstart/tools/SchemaClassScanner.kt | 53 +- .../graphql/kickstart/tools/SchemaParser.kt | 71 +- .../graphql/kickstart/tools/util/Utils.kt | 4 + .../kickstart/tools/TypeExtensionTest.kt | 947 ++++++++++++++++++ 5 files changed, 1052 insertions(+), 26 deletions(-) create mode 100644 src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt diff --git a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt index 6874504f..208d7f8c 100644 --- a/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt +++ b/src/main/kotlin/graphql/kickstart/tools/ScannedSchemaObjects.kt @@ -15,6 +15,7 @@ import graphql.schema.GraphQLScalarType internal data class ScannedSchemaObjects( val dictionary: TypeClassDictionary, val definitions: Set>, + val typeExtensions: TypeExtensions, val customScalars: CustomScalarMap, val rootInfo: RootTypeInfo, val fieldResolversByType: Map>, @@ -23,3 +24,5 @@ internal data class ScannedSchemaObjects( internal typealias TypeClassDictionary = BiMap, JavaType> internal typealias CustomScalarMap = Map +// the extensions of each type by type name, in the order they're declared +internal typealias TypeExtensions = Map>> diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 8844ba6b..a44c64d8 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -36,10 +36,21 @@ internal class SchemaClassScanner( private val directiveDefinitions = allDefinitions.filterIsInstance() private val scalarDefinitions = allDefinitions.filterIsInstance() - private val definitionsByName = (allDefinitions.filterIsInstance>() - extensionDefinitions - inputExtensionDefinitions).associateBy { it.name } + private val typeDefinitions = allDefinitions.filterIsInstance>() + private val baseTypeNames = typeDefinitions.filter { it !is SDLExtensionDefinition }.map { it.name }.toSet() + + // The spec doesn't allow extending a type that isn't defined. Union, enum, interface and scalar extensions are subclasses of + // their definitions though, so one without a definition used to be built as the type itself. To keep those schemas working the + // first one still is, and the others extend it. Object and input extensions without a definition are still ignored. + private val loneExtensions = typeDefinitions + .filter { it is SDLExtensionDefinition && it !is ObjectTypeDefinition && it !is InputObjectTypeDefinition && it.name !in baseTypeNames } + .distinctBy { it.name } + + private val definitionsByName = typeDefinitions.filter { it !is SDLExtensionDefinition || it in loneExtensions }.associateBy { it.name } + private val typeExtensions: TypeExtensions = typeDefinitions.filter { it is SDLExtensionDefinition && it !in loneExtensions }.groupBy { it.name } private val objectDefinitions = (allDefinitions.filterIsInstance() - extensionDefinitions) private val objectDefinitionsByName = objectDefinitions.associateBy { it.name } - private val interfaceDefinitionsByName = allDefinitions.filterIsInstance().associateBy { it.name } + private val interfaceDefinitionsByName = definitionsByName.values.filterIsInstance().associateBy { it.name } private val fieldResolverScanner = FieldResolverScanner(options) private val typeClassMatcher = TypeClassMatcher(definitionsByName) @@ -56,6 +67,13 @@ internal class SchemaClassScanner( } } + typeExtensions.values.flatten().forEach { extension -> + val definition = definitionsByName[extension.name] + if (definition != null && definition.kind() != extension.kind()) { + throw SchemaClassScannerError("Type '${extension.name}' is defined as ${definition.kind()}, but extended as ${extension.kind()}.") + } + } + if (options.allowUnimplementedResolvers) { log.warn("Option 'allowUnimplementedResolvers' should only be set to true during development, as it can cause schema errors to be moved to query time instead of schema creation time. Make sure this is turned off in production.") } @@ -222,6 +240,7 @@ internal class SchemaClassScanner( .description(getDocumentation(definition, options) ?: provided.description) .coercing(provided.coercing) .definition(definition) + .extensionDefinitions(typeExtensions.extensionsOf(definition.name)) .build() }.associateBy { it.name } @@ -246,7 +265,7 @@ internal class SchemaClassScanner( val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions - return ScannedSchemaObjects(dictionary, definitions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) + return ScannedSchemaObjects(dictionary, definitions, typeExtensions, scalars, rootInfo, fieldResolversByType.toMap(), unusedDefinitions) } private fun validateRootResolversWereUsed(rootType: RootType?, fieldResolvers: List) { @@ -264,16 +283,18 @@ internal class SchemaClassScanner( private fun getAllObjectTypesImplementingDiscoveredInterfaces(): List { return dictionary.keys.filterIsInstance().map { iface -> - objectDefinitions.filter { obj -> obj.implements.filterIsInstance().any { it.name == iface.name } } + objectDefinitions.filter { obj -> obj.getExtendedImplements().filterIsInstance().any { it.name == iface.name } } }.flatten().distinctBy { it.name } } private fun getAllObjectTypeMembersOfDiscoveredUnions(): List { val unionTypeNames = dictionary.keys.filterIsInstance().map { union -> union.name }.toSet() return dictionary.keys.filterIsInstance().map { union -> - union.memberTypes.filterIsInstance().filter { !unionTypeNames.contains(it.name) }.map { - objectDefinitionsByName[it.name] - ?: throw SchemaClassScannerError("No object type found with name '${it.name}' for union: $union") + (listOf(union) + typeExtensions.extensionsOf(union.name)).flatMap { definition -> + definition.memberTypes.filterIsInstance().filter { !unionTypeNames.contains(it.name) }.map { + objectDefinitionsByName[it.name] + ?: throw SchemaClassScannerError("No object type found with name '${it.name}' for union: $definition") + } } }.flatten().distinct() } @@ -284,7 +305,8 @@ internal class SchemaClassScanner( */ private fun handleInterfaceFieldTypes(): Boolean { val newTypes = dictionary.keys.filterIsInstance().flatMap { iface -> - iface.fieldDefinitions.mapNotNull { field -> + val fieldDefinitions = iface.fieldDefinitions + typeExtensions.extensionsOf(iface.name).flatMap { it.fieldDefinitions } + fieldDefinitions.mapNotNull { field -> val type = definitionsByName[(field.type.unwrap() as TypeName).name] if ((type is UnionTypeDefinition || type is InterfaceTypeDefinition) && !dictionary.containsKey(type)) { type to FieldTypeReference("${iface.name}.${field.name}") @@ -441,7 +463,7 @@ internal class SchemaClassScanner( } private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { - graphQLType.implements.forEach { + graphQLType.getExtendedImplements().forEach { if (it is TypeName) { handleFoundType(interfaceDefinitionsByName[it.name] ?: throw SchemaClassScannerError("Object type ${graphQLType.name} declared interface ${it.name}, but no interface with that name was found in the schema!"), null, InterfaceReference(graphQLType)) @@ -449,6 +471,19 @@ internal class SchemaClassScanner( } } + private fun ObjectTypeDefinition.getExtendedImplements(): List> = + implements + typeExtensions.extensionsOf(name).flatMap { it.implements } + + private fun TypeDefinition<*>.kind(): String = when (this) { + is ObjectTypeDefinition -> "an object type" + is InterfaceTypeDefinition -> "an interface" + is UnionTypeDefinition -> "a union" + is EnumTypeDefinition -> "an enum" + is InputObjectTypeDefinition -> "an input object type" + is ScalarTypeDefinition -> "a scalar" + else -> javaClass.simpleName + } + private fun enqueue(graphQLType: ObjectTypeDefinition, javaType: JavaType) { queue.add(QueueItem(graphQLType, javaType)) } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 7417e54c..4bb9dd2b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -5,6 +5,7 @@ import graphql.Scalars import graphql.introspection.Introspection import graphql.introspection.Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION import graphql.kickstart.tools.directive.DirectiveWiringHelper +import graphql.kickstart.tools.util.extensionsOf import graphql.kickstart.tools.util.getDocumentation import graphql.kickstart.tools.util.getExtendedFieldDefinitions import graphql.kickstart.tools.util.unwrap @@ -35,6 +36,7 @@ class SchemaParser internal constructor( private val dictionary = scanResult.dictionary private val definitions = scanResult.definitions + private val typeExtensions = scanResult.typeExtensions private val customScalars = scanResult.customScalars private val rootInfo = scanResult.rootInfo private val fieldResolversByType = scanResult.fieldResolversByType @@ -124,21 +126,24 @@ class SchemaParser internal constructor( private fun createObject(objectDefinition: ObjectTypeDefinition, interfaces: List, inputObjects: List): GraphQLObjectType { val name = objectDefinition.name + val extensions = typeExtensions.extensionsOf(name) + val directives = directivesOf(objectDefinition, extensions) val builder = GraphQLObjectType.newObject() .name(name) .definition(objectDefinition) + .extensionDefinitions(extensions) .description(getDocumentation(objectDefinition, options)) - .withAppliedDirectives(*buildAppliedDirectives(objectDefinition.directives)) - .withDirectives(*buildDirectives(objectDefinition.directives, Introspection.DirectiveLocation.OBJECT)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.OBJECT)) .apply { - objectDefinition.implements.forEach { implementsDefinition -> + (objectDefinition.implements + extensions.flatMap { it.implements }).forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(interfaces.find { it.name == interfaceName } ?: throw SchemaError("Expected interface type with name '$interfaceName' but found none!")) } } .apply { - objectDefinition.getExtendedFieldDefinitions(extensionDefinitions).forEach { fieldDefinition -> + objectDefinition.getExtendedFieldDefinitions(extensions).forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) codeRegistryBuilder.dataFetcher( @@ -160,7 +165,8 @@ class SchemaParser internal constructor( private fun createInputObject(definition: InputObjectTypeDefinition, inputObjects: List, referencingInputObjects: MutableSet): GraphQLInputObjectType { - val extensionDefinitions = inputExtensionDefinitions.filter { it.name == definition.name } + val extensionDefinitions = typeExtensions.extensionsOf(definition.name) + val directives = directivesOf(definition, extensionDefinitions) referencingInputObjects.add(definition.name) @@ -169,9 +175,10 @@ class SchemaParser internal constructor( .definition(definition) .extensionDefinitions(extensionDefinitions) .description(getDocumentation(definition, options)) - .withAppliedDirectives(*buildAppliedDirectives(definition.directives)) - .withDirectives(*buildDirectives(definition.directives, Introspection.DirectiveLocation.INPUT_OBJECT)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INPUT_OBJECT)) .apply { + // unlike graphql-java the fields of the extensions come first, which is kept so the order of the fields doesn't change (extensionDefinitions + definition).forEach { typeDefinition -> typeDefinition.inputValueDefinitions.forEach { fieldDefinition -> field( @@ -199,14 +206,19 @@ class SchemaParser internal constructor( ?: throw SchemaError("Expected enum with name '$name' but found none!") if (!type.unwrap().isEnum) throw SchemaError("Type '$name' is declared as an enum in the GraphQL schema but is not a Java enum!") + val extensions = typeExtensions.extensionsOf(name) + val directives = directivesOf(definition, extensions) val builder = GraphQLEnumType.newEnum() .name(name) .definition(definition) + .extensionDefinitions(extensions) .description(getDocumentation(definition, options)) - .withAppliedDirectives(*buildAppliedDirectives(definition.directives)) - .withDirectives(*buildDirectives(definition.directives, Introspection.DirectiveLocation.ENUM)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.ENUM)) .apply { - definition.enumValueDefinitions.forEach { valueDefinition -> + // graphql-java rejects a value that an extension repeats. Here it's kept once, with its first definition, so schemas + // that repeated the values of the enum in an extension to work around #552 keep building. + (definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions }).distinctBy { it.name }.forEach { valueDefinition -> val enumName = valueDefinition.name val enumValue = type.unwrap().enumConstants.find { (it as Enum<*>).name == enumName } ?: throw SchemaError("Expected value for name '$enumName' in enum '${type.unwrap().simpleName}' but found none!") @@ -229,19 +241,24 @@ class SchemaParser internal constructor( } private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { + val extensions = typeExtensions.extensionsOf(interfaceDefinition.name) + val directives = directivesOf(interfaceDefinition, extensions) val builder = GraphQLInterfaceType.newInterface() .name(interfaceDefinition.name) .definition(interfaceDefinition) + .extensionDefinitions(extensions) .description(getDocumentation(interfaceDefinition, options)) - .withAppliedDirectives(*buildAppliedDirectives(interfaceDefinition.directives)) - .withDirectives(*buildDirectives(interfaceDefinition.directives, Introspection.DirectiveLocation.INTERFACE)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INTERFACE)) .apply { - interfaceDefinition.fieldDefinitions.forEach { fieldDefinition -> + // graphql-java rejects a field that an extension repeats. Here it's kept once, with its first definition, so schemas + // that repeated the fields of the interface in an extension to work around #552 keep building. + (interfaceDefinition.fieldDefinitions + extensions.flatMap { it.fieldDefinitions }).distinctBy { it.name }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) } } } .apply { - interfaceDefinition.implements.forEach { implementsDefinition -> + (interfaceDefinition.implements + extensions.flatMap { it.implements }).forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(GraphQLTypeReference(interfaceName)) } @@ -251,12 +268,15 @@ class SchemaParser internal constructor( } private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { + val extensions = typeExtensions.extensionsOf(definition.name) + val directives = directivesOf(definition, extensions) val builder = GraphQLUnionType.newUnionType() .name(definition.name) .definition(definition) + .extensionDefinitions(extensions) .description(getDocumentation(definition, options)) - .withAppliedDirectives(*buildAppliedDirectives(definition.directives)) - .withDirectives(*buildDirectives(definition.directives, Introspection.DirectiveLocation.UNION)) + .withAppliedDirectives(*buildAppliedDirectives(directives)) + .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.UNION)) .apply { getLeafUnionObjects(definition, types).forEach { possibleType(it) } } @@ -268,7 +288,7 @@ class SchemaParser internal constructor( val name = definition.name val leafObjects = mutableListOf() - definition.memberTypes.forEach { + (definition.memberTypes + typeExtensions.extensionsOf(name).flatMap { it.memberTypes }).forEach { val typeName = (it as TypeName).name // Is this a nested union? If so, expand @@ -284,6 +304,23 @@ class SchemaParser internal constructor( return leafObjects } + // Like graphql-java, the directives of the extensions follow those of the type. graphql-java rejects a non-repeatable directive + // that an extension applies again. Here the repeat is dropped and the first one kept, so those schemas still build, as they did + // when extension directives were ignored, and the applied directives agree with the legacy ones (see buildDirectives). + // Undeclared directives may be repeatable, so they're all kept. + private fun directivesOf(definition: TypeDefinition<*>, extensions: List>): List { + val applied = definition.directives.mapTo(mutableSetOf()) { it.name } + return definition.directives + extensions.flatMap { extension -> + val (repeated, kept) = extension.directives.partition { it.name in applied && isNonRepeatable(it.name) } + repeated.forEach { log.warn("Directive @${it.name} isn't repeatable but an extension of type '${definition.name}' applies it again, only the first one is kept.") } + kept.mapTo(applied) { it.name } + kept + } + } + + private fun isNonRepeatable(directiveName: String): Boolean = + directiveDefinitions.find { it.name == directiveName }?.let { !it.isRepeatable } ?: BUILT_IN_DIRECTIVES.containsKey(directiveName) + private fun createField(field: GraphQLFieldDefinition.Builder, fieldDefinition: FieldDefinition, inputObjects: List): GraphQLFieldDefinition.Builder { return field .name(fieldDefinition.name) diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index ba22062b..a1535baf 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -2,6 +2,7 @@ package graphql.kickstart.tools.util import graphql.kickstart.tools.GraphQLResolver import graphql.kickstart.tools.SchemaParserOptions +import graphql.kickstart.tools.TypeExtensions import graphql.language.* import graphql.schema.DataFetchingEnvironment import kotlinx.coroutines.CoroutineScope @@ -38,6 +39,9 @@ internal fun ObjectTypeDefinition.getExtendedFieldDefinitions(extensions: List> TypeExtensions.extensionsOf(name: String): List = + this[name].orEmpty().filterIsInstance() + internal fun JavaType.unwrap(): Class = if (this is ParameterizedType) { this.rawType as Class<*> diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt new file mode 100644 index 00000000..000f7c97 --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -0,0 +1,947 @@ +package graphql.kickstart.tools + +import graphql.GraphQL +import graphql.Scalars +import graphql.language.EnumTypeDefinition +import graphql.language.InterfaceTypeDefinition +import graphql.language.ObjectTypeDefinition +import graphql.language.ScalarTypeDefinition +import graphql.language.UnionTypeDefinition +import graphql.schema.* +import graphql.schema.idl.SchemaDirectiveWiring +import graphql.schema.idl.SchemaDirectiveWiringEnvironment +import graphql.schema.idl.SchemaPrinter +import graphql.schema.validation.InvalidSchemaException +import org.junit.Assert.assertThrows +import org.junit.Test +import kotlin.reflect.KClass + +class TypeExtensionTest { + + @Test + fun `union extensions add members to the union`() { + val schema = buildUnionSchema( + """ + union FooBar = Foo | Bar + + extend union FooBar = Baz + """) + + val fooBar = schema.getType("FooBar") as GraphQLUnionType + assertEquals(fooBar.types.map { it.name }, listOf("Foo", "Bar", "Baz")) + assertEquals(definitionClass(fooBar), UnionTypeDefinition::class.java) + assertEquals(fooBar.extensionDefinitions.size, 1) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + fooBar { + __typename + } + } + """ + } + + assertEquals(data["fooBar"], mapOf("__typename" to "Baz")) + } + + @Test + fun `union extensions declared before the union add members to it`() { + val schema = buildUnionSchema( + """ + extend union FooBar = Baz + + union FooBar = Foo | Bar + """) + + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `scanner requires classes for members of the union and its extensions`() { + val baseMemberError = assertThrows(SchemaClassScannerError::class.java) { + buildUnionSchema( + """ + union FooBar = Foo | Bar + + extend union FooBar = Baz + """, + UnionQuery.Bar::class, UnionQuery.Baz::class) + } + + assertEquals(baseMemberError.message, "Object type 'Foo' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Foo' in the parser's dictionary.") + + val extensionMemberError = assertThrows(SchemaClassScannerError::class.java) { + buildUnionSchema( + """ + extend union FooBar = Baz + + union FooBar = Foo | Bar + """, + UnionQuery.Foo::class, UnionQuery.Bar::class) + } + + assertEquals(extensionMemberError.message, "Object type 'Baz' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Baz' in the parser's dictionary.") + } + + private fun buildUnionSchema(union: String, vararg dictionary: KClass<*> = arrayOf(UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class)) = + SchemaParser.newParser() + .schemaString( + """ + type Query { + fooBar: FooBar + } + + $union + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + type Baz { + baz: String + } + """) + .resolvers(UnionQuery()) + .dictionary(*dictionary) + .build() + .makeExecutableSchema() + + class UnionQuery : GraphQLQueryResolver { + fun fooBar(): FooBar = Baz("baz") + + interface FooBar + + class Foo(val foo: String?) : FooBar + + class Bar(val bar: String?) : FooBar + + class Baz(val baz: String?) : FooBar + } + + @Test + fun `union extensions can add nested unions`() { + // Audio is also returned by a field, since the scanner only expands nested unions it found some other way. That's the + // same for the members of the union itself. + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + media: Media + audio: Audio + } + + union Media = Book + + extend union Media = Audio + + union Audio = Song | Podcast + + type Book { + title: String + } + + type Song { + title: String + } + + type Podcast { + title: String + } + """) + .resolvers(MediaQuery()) + .dictionary(MediaQuery.Book::class, MediaQuery.Song::class, MediaQuery.Podcast::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Media") as GraphQLUnionType).types.map { it.name }, listOf("Book", "Song", "Podcast")) + } + + class MediaQuery : GraphQLQueryResolver { + fun media(): Media? = null + fun audio(): Audio? = null + + interface Media + + interface Audio + + class Book(val title: String?) : Media + + class Song(val title: String?) : Audio + + class Podcast(val title: String?) : Audio + } + + private val colorSchema = """ + type Query { + color: Color + } + + enum Color { + RED + } + + extend enum Color { + GREEN + } + """ + + @Test + fun `enum extensions add values to the enum`() { + val schema = SchemaParser.newParser() + .schemaString(colorSchema) + .resolvers(ColorQuery()) + .build() + .makeExecutableSchema() + + val color = schema.getType("Color") as GraphQLEnumType + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(definitionClass(color), EnumTypeDefinition::class.java) + assertEquals(color.extensionDefinitions.size, 1) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + color + } + """ + } + + assertEquals(data["color"], "GREEN") + } + + @Test + fun `parser requires enum constants for the values of the enum and its extensions`() { + val baseValueError = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString(colorSchema) + .resolvers(object : GraphQLQueryResolver { + fun color(): GreenOnly? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(baseValueError.message, "Expected value for name 'RED' in enum 'GreenOnly' but found none!") + + val extensionValueError = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString(colorSchema) + .resolvers(object : GraphQLQueryResolver { + fun color(): RedOnly? = null + }) + .build() + .makeExecutableSchema() + } + + assertEquals(extensionValueError.message, "Expected value for name 'GREEN' in enum 'RedOnly' but found none!") + } + + class ColorQuery : GraphQLQueryResolver { + fun color(): Color = Color.GREEN + + enum class Color { RED, GREEN } + } + + enum class GreenOnly { GREEN } + + enum class RedOnly { RED } + + @Test + fun `interface extensions add fields and interfaces to the interface`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + node: Node + } + + interface Entity { + id: ID! + } + + interface Node { + id: ID! + } + + extend interface Node implements Entity { + name: String + } + + type Item implements Node & Entity { + id: ID! + name: String + } + """) + .resolvers(NodeQuery()) + .dictionary(NodeQuery.Item::class) + .build() + .makeExecutableSchema() + + val node = schema.getType("Node") as GraphQLInterfaceType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.interfaces.map { it.name }, listOf("Entity")) + assertEquals(definitionClass(node), InterfaceTypeDefinition::class.java) + assertEquals(node.extensionDefinitions.size, 1) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + node { + id + name + } + } + """ + } + + assertEquals(data["node"], mapOf("id" to "1", "name" to "item")) + } + + @Test + fun `implementations need the fields of the interface and not only those of its extensions`() { + val error = assertThrows(InvalidSchemaException::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + node: Node + } + + interface Node { + id: ID! + } + + extend interface Node { + name: String + } + + type Item implements Node { + name: String + } + """) + .resolvers(NodeQuery()) + .dictionary(NodeQuery.Item::class) + .build() + .makeExecutableSchema() + } + + assert(error.message!!.contains("object type 'Item' does not implement interface 'Node' because field 'id' is missing")) { error.message!! } + } + + class NodeQuery : GraphQLQueryResolver { + fun node(): Node = Item("1", "item") + + interface Node + + class Item(val id: String, val name: String?) : Node + } + + @Test + fun `scanner handles unions used only by fields of interface extensions`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Item { + id: ID! + } + + extend interface Item { + content: Content + } + + type Book implements Item { + id: ID! + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + """) + .resolvers(InterfaceExtensionUnion()) + .dictionary(InterfaceExtensionUnion.Book::class, InterfaceExtensionUnion.Image::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Item") as GraphQLInterfaceType).fieldDefinitions.map { it.name }, listOf("id", "content")) + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) + } + + class InterfaceExtensionUnion : GraphQLQueryResolver { + fun item(): Item? = null + + interface Item + + class Book(val id: String, val content: Text?) : Item + + class Text(val text: String?) + + class Image(val url: String?) + } + + @Test + fun `scalar extensions keep the description of the scalar`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @format(pattern: String!) on SCALAR + + type Query { + today: Date + } + + "A date" + scalar Date + + extend scalar Date @format(pattern: "yyyy-MM-dd") + """) + .resolvers(object : GraphQLQueryResolver { + fun today(): String = "2026-10-12" + }) + .scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) + .build() + .makeExecutableSchema() + + val date = schema.getType("Date") as GraphQLScalarType + assertEquals(date.description, "A date") + assertEquals(definitionClass(date), ScalarTypeDefinition::class.java) + assertEquals(date.extensionDefinitions.size, 1) + } + + @Test + fun `object extensions apply their directives`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @key(fields: String!) repeatable on OBJECT | INTERFACE + + type Query { + product: Product + } + + type Product @key(fields: "upc") { + upc: String + } + + extend type Product @key(fields: "name") { + name: String + } + """) + .resolvers(ProductQuery()) + .build() + .makeExecutableSchema() + + val product = schema.getObjectType("Product")!! + assertEquals(product.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) + assertEquals(product.directives.map { it.name }, listOf("key", "key")) + assertEquals(product.fieldDefinitions.map { it.name }, listOf("upc", "name")) + assertEquals(definitionClass(product), ObjectTypeDefinition::class.java) + assertEquals(product.extensionDefinitions.size, 1) + + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""type Product @key(fields : "upc") @key(fields : "name") {""")) { printed } + } + + @Test + fun `non-repeatable directives that an extension applies again keep the first one`() { + val tagDirective = TagDirective() + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @tag(name: String!) on OBJECT + + type Query { + product: Product + } + + type Product @tag(name: "catalog") { + upc: String + } + + extend type Product @tag(name: "pricing") + """) + .resolvers(ProductQuery()) + .directive("tag", tagDirective) + .build() + .makeExecutableSchema() + + // graphql-java's SchemaGenerator rejects this schema + val product = schema.getObjectType("Product")!! + assertEquals(product.appliedDirectives.map { it.getArgument("name")?.getValue() }, listOf("catalog")) + assertEquals(product.directives.map { it.name }, listOf("tag")) + assertEquals(tagDirective.tagged, listOf("Product=catalog")) + + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""type Product @tag(name : "catalog") {""")) { printed } + } + + @Test + fun `input object extensions apply their directives`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @a on INPUT_OBJECT + directive @b on INPUT_OBJECT + + type Query { + search(filter: Filter): String + } + + input Filter @a { + id: ID + } + + extend input Filter @b + """) + .resolvers(object : GraphQLQueryResolver { + fun search(filter: TaggedQuery.Filter?): String? = null + }) + .build() + .makeExecutableSchema() + + val filter = schema.getType("Filter") as GraphQLInputObjectType + assertEquals(filter.appliedDirectives.map { it.name }, listOf("a", "b")) + assertEquals(filter.directives.map { it.name }, listOf("a", "b")) + assertEquals(filter.extensionDefinitions.size, 1) + } + + @Test + fun `undeclared directives on type extensions are rejected unless allowed`() { + val schemaString = """ + type Query { + product: Product + } + + type Product { + upc: String + } + + extend type Product @key(fields: "upc") + """ + + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString(schemaString) + .resolvers(ProductQuery()) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Found applied directive key without corresponding directive definition.") + + val schema = SchemaParser.newParser() + .schemaString(schemaString) + .resolvers(ProductQuery()) + .options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) + .build() + .makeExecutableSchema() + + assertEquals(schema.getObjectType("Product")!!.getAppliedDirective("key")?.getArgument("fields")?.getValue(), "upc") + } + + @Test + fun `undeclared directives that an extension applies again are all kept`() { + // e.g. federation's @key, which is repeatable + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + product: Product + } + + type Product @key(fields: "upc") { + upc: String + } + + extend type Product @key(fields: "name") { + name: String + } + """) + .resolvers(ProductQuery()) + .options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) + .build() + .makeExecutableSchema() + + assertEquals(schema.getObjectType("Product")!!.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) + } + + class ProductQuery : GraphQLQueryResolver { + fun product(): Product? = null + + class Product(val upc: String?, val name: String?) + } + + @Test + fun `directives on extensions of every kind of type are wired`() { + val tagDirective = TagDirective() + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @tag(name: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + + type Query { + node(filter: Filter): Node + result: Result + color: Color + } + + interface Node { + id: ID! + } + + extend interface Node @tag(name: "node") + + type Item implements Node { + id: ID! + } + + extend type Item @tag(name: "item") + + union Result = Item + + extend union Result @tag(name: "result") + + enum Color { + RED + } + + extend enum Color @tag(name: "color") + + input Filter { + id: ID + } + + extend input Filter @tag(name: "filter") + """) + .resolvers(TaggedQuery()) + .dictionary(TaggedQuery.Item::class) + .directive("tag", tagDirective) + .build() + .makeExecutableSchema() + + listOf("Node", "Item", "Result", "Color", "Filter").forEach { name -> + assertEquals((schema.getType(name) as GraphQLDirectiveContainer).appliedDirectives.map { it.name }, listOf("tag")) + } + assertEquals(tagDirective.tagged.toSet(), setOf("Node=node", "Item=item", "Result=result", "Color=color", "Filter=filter")) + } + + class TaggedQuery : GraphQLQueryResolver { + fun node(filter: Filter?): Node? = null + fun result(): Result? = null + fun color(): Color? = null + + interface Node + + interface Result + + enum class Color { RED } + + class Filter(val id: String?) + + class Item(val id: String) : Node, Result + } + + private class TagDirective : SchemaDirectiveWiring { + val tagged = mutableListOf() + + override fun onObject(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onInterface(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onUnion(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onEnum(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + + private fun tag(environment: SchemaDirectiveWiringEnvironment): T { + tagged.add("${environment.element.name}=${environment.appliedDirective.getArgument("name")?.getValue()}") + return environment.element + } + } + + @Test + fun `object extensions add interfaces to the object`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Node { + id: ID! + } + + interface Named { + name: String + } + + type Item implements Node { + id: ID! + name: String + } + + extend type Item implements Named + """) + .resolvers(object : GraphQLQueryResolver { + fun item(): NodeQuery.Item? = null + }) + .build() + .makeExecutableSchema() + + assertEquals(schema.getObjectType("Item")!!.interfaces.map { it.name }, listOf("Node", "Named")) + assertEquals(schema.getImplementations(schema.getType("Named") as GraphQLInterfaceType)!!.map { it.name }, listOf("Item")) + } + + @Test + fun `scanner requires classes for objects that implement an interface through an extension`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + node: Node + } + + interface Node { + id: ID! + } + + type Item implements Node { + id: ID! + } + + type Other { + id: ID! + } + + extend type Other implements Node + """) + .resolvers(NodeQuery()) + .dictionary(NodeQuery.Item::class) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'Other' implements a known interface, but no class could be found for that type name. Please pass a class for type 'Other' in the parser's dictionary.") + } + + private val unusedUnionSchema = """ + type Query { + name: String + } + + union Unused = A | B + + extend union Unused = C + + type A { + a: String + } + + type B { + b: String + } + + type C { + c: String + } + """ + + @Test + fun `unused unions get the members of their extensions when including unused types`() { + val schema = SchemaParser.newParser() + .schemaString(unusedUnionSchema) + .resolvers(UnusedUnion()) + .dictionary(UnusedUnion.A::class, UnusedUnion.B::class, UnusedUnion.C::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Unused") as GraphQLUnionType).types.map { it.name }, listOf("A", "B", "C")) + } + + @Test + fun `unused definitions hold the definition of an extended type`() { + val parser = SchemaParser.newParser() + .schemaString(unusedUnionSchema) + .resolvers(UnusedUnion()) + .build() + + assertEquals(parser.getUnusedDefinitions().map { it.javaClass.simpleName to it.name }.toSet(), setOf( + "UnionTypeDefinition" to "Unused", + "ObjectTypeDefinition" to "A", + "ObjectTypeDefinition" to "B", + "ObjectTypeDefinition" to "C" + )) + } + + class UnusedUnion : GraphQLQueryResolver { + fun name(): String? = null + + class A(val a: String?) + + class B(val b: String?) + + class C(val c: String?) + } + + @Test + fun `values, fields and members repeated in an extension keep their first definition`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + color: Color + node: Node + fooBar: FooBar + } + + enum Color { + "The base red" + RED + } + + extend enum Color { + RED + GREEN + } + + interface Node { + "The base id" + id: ID! + } + + extend interface Node { + id: ID! + name: String + } + + type Item implements Node { + id: ID! + name: String + } + + union FooBar = Foo | Bar + + extend union FooBar = Foo | Bar | Baz + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + type Baz { + baz: String + } + """) + .resolvers(ColorQuery(), NodeQuery(), UnionQuery()) + .dictionary(NodeQuery.Item::class, UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class) + .build() + .makeExecutableSchema() + + val color = schema.getType("Color") as GraphQLEnumType + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(color.getValue("RED")?.description, "The base red") + + val node = schema.getType("Node") as GraphQLInterfaceType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.getFieldDefinition("id").description, "The base id") + + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `enums and unions defined only by extensions are built from all of them`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + color: Color + fooBar: FooBar + } + + extend enum Color { + RED + } + + extend enum Color { + GREEN + } + + extend union FooBar = Foo | Bar + + extend union FooBar = Baz + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + type Baz { + baz: String + } + """) + .resolvers(ColorQuery(), UnionQuery()) + .dictionary(UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Color") as GraphQLEnumType).values.map { it.name }, listOf("RED", "GREEN")) + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) + } + + @Test + fun `extensions of a different kind than the type are rejected`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + type Query { + foo: Foo + } + + type Foo { + foo: String + } + + type Bar { + bar: String + } + + extend union Foo = Bar + """) + .resolvers(object : GraphQLQueryResolver { + fun foo(): UnionQuery.Foo? = null + }) + .build() + } + + assertEquals(error.message, "Type 'Foo' is defined as an object type, but extended as a union.") + } + + // through the interface, since graphql-java 25 and 26 disagree on which types have a nullable definition + private fun definitionClass(type: GraphQLNamedSchemaElement) = type.definition?.javaClass +} From 0ffcf1373d0d4119992507571327b34234907e0e Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 12:00:11 -0400 Subject: [PATCH 02/11] Name the fields of interface extensions in type error messages Co-Authored-By: Claude Opus 5.5 --- .../graphql/kickstart/tools/SchemaParser.kt | 2 +- .../kickstart/tools/TypeExtensionTest.kt | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index ff99e107..078daf57 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -684,7 +684,7 @@ class SchemaParser internal constructor( } // determineType only gets the type reference, so the definition using it is looked up for the error message - private fun findUsage(typeName: TypeName): String? = definitions.asSequence().flatMap { definition -> + private fun findUsage(typeName: TypeName): String? = (definitions + typeExtensions.values.flatten()).asSequence().flatMap { definition -> when (definition) { is ObjectTypeDefinition -> fieldUsages(definition.name, definition.fieldDefinitions) is InterfaceTypeDefinition -> fieldUsages(definition.name, definition.fieldDefinitions) diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt index 000f7c97..6017c9ed 100644 --- a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -942,6 +942,41 @@ class TypeExtensionTest { assertEquals(error.message, "Type 'Foo' is defined as an object type, but extended as a union.") } + @Test + fun `type errors name the fields of interface extensions that use the type`() { + val error = assertThrows(SchemaError::class.java) { + SchemaParser.newParser() + .schemaString( + """ + |type Query { + | today: String + |} + | + |interface Searchable { + | id: ID! + |} + | + |extend interface Searchable { + | search(criteria: Criteria): String + |} + | + |input Criteria { + | text: String + |} + """.trimMargin()) + .resolvers(object : GraphQLQueryResolver { + fun today(): String? = null + }) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + } + + assert(error.message.orEmpty().startsWith("Type 'Criteria' used by argument 'criteria' of field 'Searchable.search' at line 10, column 22 is defined")) { + error.message.orEmpty() + } + } + // through the interface, since graphql-java 25 and 26 disagree on which types have a nullable definition private fun definitionClass(type: GraphQLNamedSchemaElement) = type.definition?.javaClass } From a77428ae967d0c327aab8f6104f2ac3f17cf3584 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 12:01:43 -0400 Subject: [PATCH 03/11] Read object and input extensions from typeExtensions The scanner no longer passes them to the parser among the definitions. The enums used by directives are now also checked against the fields of interface extensions. Co-Authored-By: Claude Opus 5.5 --- .../graphql/kickstart/tools/SchemaClassScanner.kt | 15 ++++++--------- .../graphql/kickstart/tools/SchemaParser.kt | 10 ++++------ .../kotlin/graphql/kickstart/tools/util/Utils.kt | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 473ad8ae..1f7de2d3 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -33,8 +33,6 @@ internal class SchemaClassScanner( private val resolverInfos = resolvers.asSequence().minus(queryResolvers).minus(mutationResolvers).minus(subscriptionResolvers).map { NormalResolverInfo(it, options) }.toList() private val initialDictionary = initialDictionary.mapValues { InitialDictionaryEntry(it.value) } - private val extensionDefinitions = allDefinitions.filterIsInstance() - private val inputExtensionDefinitions = allDefinitions.filterIsInstance() private val directiveDefinitions = allDefinitions.filterIsInstance() private val scalarDefinitions = allDefinitions.filterIsInstance() @@ -50,7 +48,7 @@ internal class SchemaClassScanner( private val definitionsByName = typeDefinitions.filter { it !is SDLExtensionDefinition || it in loneExtensions }.associateBy { it.name } private val typeExtensions: TypeExtensions = typeDefinitions.filter { it is SDLExtensionDefinition && it !in loneExtensions }.groupBy { it.name } - private val objectDefinitions = (allDefinitions.filterIsInstance() - extensionDefinitions) + private val objectDefinitions = allDefinitions.filterIsInstance().filter { it !is ObjectTypeExtensionDefinition } private val objectDefinitionsByName = objectDefinitions.associateBy { it.name } private val interfaceDefinitionsByName = definitionsByName.values.filterIsInstance().associateBy { it.name } @@ -221,9 +219,8 @@ internal class SchemaClassScanner( * The names of the types used by the fields, arguments and input fields of [definitions], including fields added by extensions. */ private fun typeNamesUsedByFields(definitions: Set>): Set { - val names = definitions.map { it.name }.toSet() - return (definitions + extensionDefinitions + inputExtensionDefinitions) - .filter { it.name in names } + return definitions + .flatMap { listOf(it) + typeExtensions[it.name].orEmpty() } .flatMap { definition -> when (definition) { is ImplementingTypeDefinition<*> -> definition.fieldDefinitions.flatMap { field -> @@ -306,7 +303,7 @@ internal class SchemaClassScanner( validateRootResolversWereUsed(rootTypeHolder.mutation, fieldResolvers) validateRootResolversWereUsed(rootTypeHolder.subscription, fieldResolvers) - val definitions = observedDefinitions + extensionDefinitions + inputExtensionDefinitions + directiveDefinitions + val definitions = observedDefinitions + directiveDefinitions // The enums in unvalidatedTypes come from directives without a class, so they can only be built when no field uses them. // A field hands the enum's values to user code even when the scanner found no class through it, e.g. an input field bound @@ -423,7 +420,7 @@ internal class SchemaClassScanner( resolverInfo.dataClassType != Any::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass) private fun scanResolverInfoForPotentialMatches(type: ObjectTypeDefinition, resolverInfo: ResolverInfo) { - type.getExtendedFieldDefinitions(extensionDefinitions).forEach { field -> + type.getExtendedFieldDefinitions(typeExtensions.extensionsOf(type.name)).forEach { field -> val fieldResolver = fieldResolverScanner.findFieldResolver(field, resolverInfo) fieldResolversByType.getOrPut(type) { mutableMapOf() }[fieldResolver.field] = fieldResolver @@ -493,7 +490,7 @@ internal class SchemaClassScanner( */ private fun handleInputObjectFields(graphQLType: InputObjectTypeDefinition, javaType: JavaType?) { val clazz = javaType?.unwrap() - val inputObjectTypes = listOf(graphQLType) + inputExtensionDefinitions.filter { it.name == graphQLType.name } + val inputObjectTypes = listOf(graphQLType) + typeExtensions.extensionsOf(graphQLType.name) inputObjectTypes .flatMap { it.inputValueDefinitions } .forEach { inputValueDefinition -> diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 078daf57..0f2d2574 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -43,11 +43,8 @@ class SchemaParser internal constructor( private val unusedDefinitions = scanResult.unusedDefinitions private val directiveOnlyEnums = scanResult.directiveOnlyEnums - private val extensionDefinitions = definitions.filterIsInstance() - private val inputExtensionDefinitions = definitions.filterIsInstance() - - private val objectDefinitions = (definitions.filterIsInstance() - extensionDefinitions) - private val inputObjectDefinitions = (definitions.filterIsInstance() - inputExtensionDefinitions) + private val objectDefinitions = definitions.filterIsInstance() + private val inputObjectDefinitions = definitions.filterIsInstance() private val enumDefinitions = definitions.filterIsInstance() private val interfaceDefinitions = definitions.filterIsInstance() private val directiveDefinitions = definitions.filterIsInstance() @@ -641,9 +638,10 @@ class SchemaParser internal constructor( private fun unexpectedTypeError(typeName: TypeName, expectedType: KClass<*>): SchemaError { val isInput = expectedType == GraphQLInputType::class - // unused ones first, since the extensions of an unused type are still among the definitions val definition = unusedDefinitions.find { it.name == typeName.name } ?: definitions.filterIsInstance>().find { it.name == typeName.name } + // an object or input extension without a definition, which isn't built + ?: typeExtensions[typeName.name]?.first() val outputOnlyKind = when (definition) { is ObjectTypeDefinition -> "an object type" is InterfaceTypeDefinition -> "an interface" diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index a1535baf..8ce0c340 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -36,7 +36,7 @@ internal fun Type<*>.unwrap(): Type<*> = when (this) { } internal fun ObjectTypeDefinition.getExtendedFieldDefinitions(extensions: List): List { - return this.fieldDefinitions + extensions.filter { it.name == this.name }.flatMap { it.fieldDefinitions } + return this.fieldDefinitions + extensions.flatMap { it.fieldDefinitions } } internal inline fun > TypeExtensions.extensionsOf(name: String): List = From fb402fa5f2cb1749c6ea0d07c9cafa747c96ec3d Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 12:05:59 -0400 Subject: [PATCH 04/11] Shorten the type extension comments Co-Authored-By: Claude Opus 5.5 --- .../graphql/kickstart/tools/SchemaClassScanner.kt | 6 +++--- .../graphql/kickstart/tools/SchemaParser.kt | 15 ++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 1f7de2d3..185abede 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -39,9 +39,9 @@ internal class SchemaClassScanner( private val typeDefinitions = allDefinitions.filterIsInstance>() private val baseTypeNames = typeDefinitions.filter { it !is SDLExtensionDefinition }.map { it.name }.toSet() - // The spec doesn't allow extending a type that isn't defined. Union, enum, interface and scalar extensions are subclasses of - // their definitions though, so one without a definition used to be built as the type itself. To keep those schemas working the - // first one still is, and the others extend it. Object and input extensions without a definition are still ignored. + // Extending a type that isn't defined isn't valid, but union, enum, interface and scalar extensions are subclasses of their + // definitions, so one without a definition was built as the type before #552. To keep those schemas building, the first one + // is the definition and the others extend it. Object and input extensions without a definition are ignored. private val loneExtensions = typeDefinitions .filter { it is SDLExtensionDefinition && it !is ObjectTypeDefinition && it !is InputObjectTypeDefinition && it.name !in baseTypeNames } .distinctBy { it.name } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 0f2d2574..7648a467 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -218,8 +218,7 @@ class SchemaParser internal constructor( .withAppliedDirectives(*buildAppliedDirectives(directives)) .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.ENUM)) .apply { - // graphql-java rejects a value that an extension repeats. Here it's kept once, with its first definition, so schemas - // that repeated the values of the enum in an extension to work around #552 keep building. + // graphql-java rejects values that an extension repeats, but that was the workaround for #552, so the first one is kept (definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions }).distinctBy { it.name }.forEach { valueDefinition -> val enumName = valueDefinition.name val enumValue = if (enumClass == null) { @@ -257,8 +256,7 @@ class SchemaParser internal constructor( .withAppliedDirectives(*buildAppliedDirectives(directives)) .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INTERFACE)) .apply { - // graphql-java rejects a field that an extension repeats. Here it's kept once, with its first definition, so schemas - // that repeated the fields of the interface in an extension to work around #552 keep building. + // graphql-java rejects fields that an extension repeats, but that was the workaround for #552, so the first one is kept (interfaceDefinition.fieldDefinitions + extensions.flatMap { it.fieldDefinitions }).distinctBy { it.name }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) } } @@ -310,16 +308,15 @@ class SchemaParser internal constructor( return leafObjects } - // Like graphql-java, the directives of the extensions follow those of the type. graphql-java rejects a non-repeatable directive - // that an extension applies again. Here the repeat is dropped and the first one kept, so those schemas still build, as they did - // when extension directives were ignored, and the applied directives agree with the legacy ones (see buildDirectives). - // Undeclared directives may be repeatable, so they're all kept. + // The directives of the extensions follow those of the type, like in graphql-java. A non-repeatable directive that an extension + // applies again is dropped, where graphql-java rejects the schema, so those schemas keep building and the applied directives + // match the legacy ones (see buildDirectives). Undeclared directives may be repeatable, so they're all kept. private fun directivesOf(definition: TypeDefinition<*>, extensions: List>): List { val applied = definition.directives.mapTo(mutableSetOf()) { it.name } return definition.directives + extensions.flatMap { extension -> val (repeated, kept) = extension.directives.partition { it.name in applied && isNonRepeatable(it.name) } repeated.forEach { log.warn("Directive @${it.name} isn't repeatable but an extension of type '${definition.name}' applies it again, only the first one is kept.") } - kept.mapTo(applied) { it.name } + applied += kept.map { it.name } kept } } From 40e3f860a426714a052bef2c0eb30ef4347c700c Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 12:06:56 -0400 Subject: [PATCH 05/11] Fold the type extension tests into fewer cases with shared fixtures Also checks that enums used only by directives get the values of their extensions. Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/TypeExtensionTest.kt | 1141 +++++------------ 1 file changed, 318 insertions(+), 823 deletions(-) diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt index 6017c9ed..631dfce4 100644 --- a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -2,6 +2,7 @@ package graphql.kickstart.tools import graphql.GraphQL import graphql.Scalars +import graphql.kickstart.tools.NestedInputTypesTest.Color import graphql.language.EnumTypeDefinition import graphql.language.InterfaceTypeDefinition import graphql.language.ObjectTypeDefinition @@ -14,647 +15,446 @@ import graphql.schema.idl.SchemaPrinter import graphql.schema.validation.InvalidSchemaException import org.junit.Assert.assertThrows import org.junit.Test -import kotlin.reflect.KClass class TypeExtensionTest { @Test - fun `union extensions add members to the union`() { - val schema = buildUnionSchema( + fun `extensions add to the types they extend`() { + val schema = parser( """ - union FooBar = Foo | Bar + directive @format(pattern: String!) on SCALAR + type Query { + fooBar: FooBar + color: Color + node: Node + today: Date + } + + union FooBar = Foo | Bar extend union FooBar = Baz - """) + + # the order doesn't matter + extend enum Color { GREEN } + enum Color { RED } + + interface Entity { id: ID! } + interface Node { id: ID! } + extend interface Node implements Entity { name: String } + + type Item implements Node { id: ID! name: String } + extend type Item implements Entity + + "A date" + scalar Date + extend scalar Date @format(pattern: "yyyy-MM-dd") + + $fooBarBaz + """) { + dictionary(Foo::class, Bar::class, Baz::class, Item::class) + scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) + }.makeExecutableSchema() val fooBar = schema.getType("FooBar") as GraphQLUnionType + val color = schema.getType("Color") as GraphQLEnumType + val node = schema.getType("Node") as GraphQLInterfaceType + val item = schema.getType("Item") as GraphQLObjectType + val date = schema.getType("Date") as GraphQLScalarType assertEquals(fooBar.types.map { it.name }, listOf("Foo", "Bar", "Baz")) - assertEquals(definitionClass(fooBar), UnionTypeDefinition::class.java) - assertEquals(fooBar.extensionDefinitions.size, 1) + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.interfaces.map { it.name }, listOf("Entity")) + assertEquals(item.interfaces.map { it.name }, listOf("Node", "Entity")) + assertEquals(schema.getImplementations(schema.getType("Entity") as GraphQLInterfaceType)?.map { it.name }, listOf("Item")) + assertEquals(date.description, "A date") + + // through GraphQLNamedSchemaElement, since graphql-java 25 and 26 disagree on which types have a nullable definition + assertEquals(listOf(fooBar, color, node, item, date).map { it.definition?.javaClass }, listOf( + UnionTypeDefinition::class.java, + EnumTypeDefinition::class.java, + InterfaceTypeDefinition::class.java, + ObjectTypeDefinition::class.java, + ScalarTypeDefinition::class.java + )) + val extensionDefinitions = listOf(fooBar.extensionDefinitions, color.extensionDefinitions, node.extensionDefinitions, item.extensionDefinitions, date.extensionDefinitions) + assertEquals(extensionDefinitions.map { it.size }, listOf(1, 1, 1, 1, 1)) val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { """ query { - fooBar { - __typename - } + fooBar { __typename } + color + node { id name } } """ } - assertEquals(data["fooBar"], mapOf("__typename" to "Baz")) + assertEquals(data, mapOf("fooBar" to mapOf("__typename" to "Baz"), "color" to "GREEN", "node" to mapOf("id" to "1", "name" to "item"))) } @Test - fun `union extensions declared before the union add members to it`() { - val schema = buildUnionSchema( + fun `union extensions can add nested unions`() { + // BarBaz has a field of its own, since the scanner only expands nested unions it finds some other way + val schema = parser( """ - extend union FooBar = Baz + type Query { + fooBar: FooBar + barBaz: BarBaz + } - union FooBar = Foo | Bar - """) + union FooBar = Foo + extend union FooBar = BarBaz + union BarBaz = Bar | Baz + + $fooBarBaz + """) { + dictionary(Foo::class, Bar::class, Baz::class) + }.makeExecutableSchema() assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) } @Test - fun `scanner requires classes for members of the union and its extensions`() { + fun `scanner finds unions used only by fields of interface extensions`() { + val schema = parser( + """ + type Query { + node: Node + } + + interface Node { id: ID! } + extend interface Node { fooBar: FooBar } + + type Item implements Node { id: ID! fooBar: Foo } + + union FooBar = Foo | Bar + + $fooBarBaz + """) { + dictionary(Item::class, Bar::class) + }.makeExecutableSchema() + + assertEquals((schema.getType("Node") as GraphQLInterfaceType).fieldDefinitions.map { it.name }, listOf("id", "fooBar")) + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Foo", "Bar")) + } + + @Test + fun `scanner requires classes for the members and implementations from the type and its extensions`() { val baseMemberError = assertThrows(SchemaClassScannerError::class.java) { - buildUnionSchema( + parser( """ + type Query { fooBar: FooBar } union FooBar = Foo | Bar - extend union FooBar = Baz - """, - UnionQuery.Bar::class, UnionQuery.Baz::class) + $fooBarBaz + """) { + dictionary(Bar::class, Baz::class) + } } assertEquals(baseMemberError.message, "Object type 'Foo' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Foo' in the parser's dictionary.") val extensionMemberError = assertThrows(SchemaClassScannerError::class.java) { - buildUnionSchema( + parser( """ + type Query { fooBar: FooBar } extend union FooBar = Baz - union FooBar = Foo | Bar - """, - UnionQuery.Foo::class, UnionQuery.Bar::class) + $fooBarBaz + """) { + dictionary(Foo::class, Bar::class) + } } assertEquals(extensionMemberError.message, "Object type 'Baz' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'Baz' in the parser's dictionary.") - } - private fun buildUnionSchema(union: String, vararg dictionary: KClass<*> = arrayOf(UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class)) = - SchemaParser.newParser() - .schemaString( + val implementationError = assertThrows(SchemaClassScannerError::class.java) { + parser( """ - type Query { - fooBar: FooBar - } - - $union - - type Foo { - foo: String - } - - type Bar { - bar: String - } - - type Baz { - baz: String - } - """) - .resolvers(UnionQuery()) - .dictionary(*dictionary) - .build() - .makeExecutableSchema() - - class UnionQuery : GraphQLQueryResolver { - fun fooBar(): FooBar = Baz("baz") - - interface FooBar - - class Foo(val foo: String?) : FooBar - - class Bar(val bar: String?) : FooBar + type Query { node: Node } + interface Node { id: ID! } + type Item implements Node { id: ID! } + type Other { id: ID! } + extend type Other implements Node + """) { + dictionary(Item::class) + } + } - class Baz(val baz: String?) : FooBar + assertEquals(implementationError.message, "Object type 'Other' implements a known interface, but no class could be found for that type name. Please pass a class for type 'Other' in the parser's dictionary.") } @Test - fun `union extensions can add nested unions`() { - // Audio is also returned by a field, since the scanner only expands nested unions it found some other way. That's the - // same for the members of the union itself. - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - media: Media - audio: Audio - } - - union Media = Book - - extend union Media = Audio - - union Audio = Song | Podcast - - type Book { - title: String - } - - type Song { - title: String - } - - type Podcast { - title: String - } - """) - .resolvers(MediaQuery()) - .dictionary(MediaQuery.Book::class, MediaQuery.Song::class, MediaQuery.Podcast::class) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Media") as GraphQLUnionType).types.map { it.name }, listOf("Book", "Song", "Podcast")) - } - - class MediaQuery : GraphQLQueryResolver { - fun media(): Media? = null - fun audio(): Audio? = null - - interface Media - - interface Audio - - class Book(val title: String?) : Media - - class Song(val title: String?) : Audio - - class Podcast(val title: String?) : Audio - } + fun `parser requires the enum values and interface fields of the type and its extensions`() { + listOf("enum Color { BLUE } extend enum Color { RED }", "enum Color { RED } extend enum Color { BLUE }").forEach { color -> + val error = assertThrows(SchemaError::class.java) { + parser("type Query { color: Color } $color").makeExecutableSchema() + } - private val colorSchema = """ - type Query { - color: Color + assertEquals(error.message, "Expected value for name 'BLUE' in enum 'Color' but found none!") } - enum Color { - RED + val fieldError = assertThrows(InvalidSchemaException::class.java) { + parser( + """ + type Query { node: Node } + interface Node { id: ID! } + extend interface Node { name: String } + type Item implements Node { name: String } + """) { + dictionary(Item::class) + }.makeExecutableSchema() } - extend enum Color { - GREEN - } - """ + val message = fieldError.message.orEmpty() + assert(message.contains("object type 'Item' does not implement interface 'Node' because field 'id' is missing")) { message } + } @Test - fun `enum extensions add values to the enum`() { - val schema = SchemaParser.newParser() - .schemaString(colorSchema) - .resolvers(ColorQuery()) - .build() - .makeExecutableSchema() - - val color = schema.getType("Color") as GraphQLEnumType - assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) - assertEquals(definitionClass(color), EnumTypeDefinition::class.java) - assertEquals(color.extensionDefinitions.size, 1) - - val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + fun `directives of extensions follow those of the type and are wired`() { + val tagDirective = TagDirective() + val schema = parser( """ - query { - color + directive @tag(name: String!) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + directive @once(name: String!) on OBJECT + + type Query @once(name: "Query") { + node: Node + fooBar: FooBar + color: Color + search(filter: Filter): String } - """ - } - - assertEquals(data["color"], "GREEN") - } - - @Test - fun `parser requires enum constants for the values of the enum and its extensions`() { - val baseValueError = assertThrows(SchemaError::class.java) { - SchemaParser.newParser() - .schemaString(colorSchema) - .resolvers(object : GraphQLQueryResolver { - fun color(): GreenOnly? = null - }) - .build() - .makeExecutableSchema() - } - - assertEquals(baseValueError.message, "Expected value for name 'RED' in enum 'GreenOnly' but found none!") - - val extensionValueError = assertThrows(SchemaError::class.java) { - SchemaParser.newParser() - .schemaString(colorSchema) - .resolvers(object : GraphQLQueryResolver { - fun color(): RedOnly? = null - }) - .build() - .makeExecutableSchema() - } - assertEquals(extensionValueError.message, "Expected value for name 'GREEN' in enum 'RedOnly' but found none!") - } + # graphql-java rejects the schema for this, here the extension's @once is dropped + extend type Query @once(name: "Query extension") - class ColorQuery : GraphQLQueryResolver { - fun color(): Color = Color.GREEN + interface Node @tag(name: "Node") { id: ID! } + extend interface Node @tag(name: "Node extension") - enum class Color { RED, GREEN } - } + type Item implements Node @tag(name: "Item") { id: ID! } + extend type Item @tag(name: "Item extension") { name: String } - enum class GreenOnly { GREEN } + union FooBar @tag(name: "FooBar") = Foo + extend union FooBar @tag(name: "FooBar extension") - enum class RedOnly { RED } + enum Color @tag(name: "Color") { RED GREEN } + extend enum Color @tag(name: "Color extension") - @Test - fun `interface extensions add fields and interfaces to the interface`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - node: Node - } - - interface Entity { - id: ID! - } - - interface Node { - id: ID! - } - - extend interface Node implements Entity { - name: String - } - - type Item implements Node & Entity { - id: ID! - name: String - } - """) - .resolvers(NodeQuery()) - .dictionary(NodeQuery.Item::class) - .build() - .makeExecutableSchema() + input Filter @tag(name: "Filter") { id: ID } + extend input Filter @tag(name: "Filter extension") - val node = schema.getType("Node") as GraphQLInterfaceType - assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) - assertEquals(node.interfaces.map { it.name }, listOf("Entity")) - assertEquals(definitionClass(node), InterfaceTypeDefinition::class.java) - assertEquals(node.extensionDefinitions.size, 1) + type Foo { foo: String } + """) { + dictionary(Item::class, Foo::class) + directive("tag", tagDirective) + directive("once", tagDirective) + }.makeExecutableSchema() - val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { - """ - query { - node { - id - name - } - } - """ - } - - assertEquals(data["node"], mapOf("id" to "1", "name" to "item")) - } - - @Test - fun `implementations need the fields of the interface and not only those of its extensions`() { - val error = assertThrows(InvalidSchemaException::class.java) { - SchemaParser.newParser() - .schemaString( - """ - type Query { - node: Node - } - - interface Node { - id: ID! - } - - extend interface Node { - name: String - } - - type Item implements Node { - name: String - } - """) - .resolvers(NodeQuery()) - .dictionary(NodeQuery.Item::class) - .build() - .makeExecutableSchema() + val extended = listOf("Node", "Item", "FooBar", "Color", "Filter") + extended.forEach { name -> + assertEquals(nameArguments(schema.getType(name) as GraphQLDirectiveContainer), listOf(name, "$name extension")) } + assertEquals(nameArguments(schema.queryType), listOf("Query")) + assertEquals(tagDirective.tagged.toSet(), extended.flatMap { listOf("$it=$it", "$it=$it extension") }.toSet() + "Query=Query") - assert(error.message!!.contains("object type 'Item' does not implement interface 'Node' because field 'id' is missing")) { error.message!! } - } - - class NodeQuery : GraphQLQueryResolver { - fun node(): Node = Item("1", "item") - - interface Node + val item = schema.getType("Item") as GraphQLObjectType + val filter = schema.getType("Filter") as GraphQLInputObjectType + assertEquals(item.directives.map { it.name }, listOf("tag", "tag")) + assertEquals(filter.directives.map { it.name }, listOf("tag", "tag")) + assertEquals(schema.queryType.directives.map { it.name }, listOf("once")) + assertEquals(item.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(filter.extensionDefinitions.size, 1) - class Item(val id: String, val name: String?) : Node + val printed = SchemaPrinter().print(schema) + assert(printed.contains("""type Item implements Node @tag(name : "Item") @tag(name : "Item extension") {""")) { printed } + assert(printed.contains("""type Query @once(name : "Query") {""")) { printed } } @Test - fun `scanner handles unions used only by fields of interface extensions`() { - val schema = SchemaParser.newParser() - .schemaString( + fun `undeclared directives on extensions need allowUndeclaredDirectives and are all kept`() { + val error = assertThrows(SchemaError::class.java) { + parser( """ - type Query { - item: Item - } - - interface Item { - id: ID! - } - - extend interface Item { - content: Content - } - - type Book implements Item { - id: ID! - content: Text - } - - type Text { - text: String - } - - type Image { - url: String - } - - union Content = Text | Image - """) - .resolvers(InterfaceExtensionUnion()) - .dictionary(InterfaceExtensionUnion.Book::class, InterfaceExtensionUnion.Image::class) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Item") as GraphQLInterfaceType).fieldDefinitions.map { it.name }, listOf("id", "content")) - assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) - } - - class InterfaceExtensionUnion : GraphQLQueryResolver { - fun item(): Item? = null - - interface Item + type Query { today: String } + extend type Query @key(fields: "upc") + """).makeExecutableSchema() + } - class Book(val id: String, val content: Text?) : Item + assertEquals(error.message, "Found applied directive key without corresponding directive definition.") - class Text(val text: String?) + // they may be repeatable, like federation's @key + val schema = parser( + """ + type Query @key(fields: "upc") { today: String } + extend type Query @key(fields: "name") + """) { + options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) + }.makeExecutableSchema() - class Image(val url: String?) + assertEquals(schema.queryType.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) } @Test - fun `scalar extensions keep the description of the scalar`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - directive @format(pattern: String!) on SCALAR - - type Query { - today: Date - } + fun `unused unions are reported by their definition and get the members of their extensions when included`() { + val unusedUnion = """ + type Query { today: String } + union FooBar = Foo | Bar + extend union FooBar = Baz + $fooBarBaz + """ - "A date" - scalar Date + assertEquals(parser(unusedUnion).getUnusedDefinitions().map { it.javaClass.simpleName to it.name }.toSet(), setOf( + "UnionTypeDefinition" to "FooBar", + "ObjectTypeDefinition" to "Foo", + "ObjectTypeDefinition" to "Bar", + "ObjectTypeDefinition" to "Baz" + )) - extend scalar Date @format(pattern: "yyyy-MM-dd") - """) - .resolvers(object : GraphQLQueryResolver { - fun today(): String = "2026-10-12" - }) - .scalars(GraphQLScalarType.newScalar().name("Date").coercing(Scalars.GraphQLString.coercing).build()) - .build() - .makeExecutableSchema() + val schema = parser(unusedUnion) { + dictionary(Foo::class, Bar::class, Baz::class) + options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + }.makeExecutableSchema() - val date = schema.getType("Date") as GraphQLScalarType - assertEquals(date.description, "A date") - assertEquals(definitionClass(date), ScalarTypeDefinition::class.java) - assertEquals(date.extensionDefinitions.size, 1) + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) } @Test - fun `object extensions apply their directives`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - directive @key(fields: String!) repeatable on OBJECT | INTERFACE - - type Query { - product: Product - } - - type Product @key(fields: "upc") { - upc: String - } - - extend type Product @key(fields: "name") { - name: String - } - """) - .resolvers(ProductQuery()) - .build() - .makeExecutableSchema() + fun `repeats and extensions without a type keep building`() { + // how schemas worked around extensions that weren't merged + val schema = parser( + """ + type Query { + color: Color + node: Node + fooBar: FooBar + } - val product = schema.getObjectType("Product")!! - assertEquals(product.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) - assertEquals(product.directives.map { it.name }, listOf("key", "key")) - assertEquals(product.fieldDefinitions.map { it.name }, listOf("upc", "name")) - assertEquals(definitionClass(product), ObjectTypeDefinition::class.java) - assertEquals(product.extensionDefinitions.size, 1) + extend enum Color { + "The first red" + RED + } + extend enum Color { RED GREEN } - val printed = SchemaPrinter().print(schema) - assert(printed.contains("""type Product @key(fields : "upc") @key(fields : "name") {""")) { printed } - } + interface Node { + "The base id" + id: ID! + } + extend interface Node { id: ID! name: String } - @Test - fun `non-repeatable directives that an extension applies again keep the first one`() { - val tagDirective = TagDirective() - val schema = SchemaParser.newParser() - .schemaString( - """ - directive @tag(name: String!) on OBJECT + type Item implements Node { id: ID! name: String } - type Query { - product: Product - } + extend union FooBar = Foo | Bar + extend union FooBar = Bar | Baz - type Product @tag(name: "catalog") { - upc: String - } + $fooBarBaz + """) { + dictionary(Item::class, Foo::class, Bar::class, Baz::class) + }.makeExecutableSchema() - extend type Product @tag(name: "pricing") - """) - .resolvers(ProductQuery()) - .directive("tag", tagDirective) - .build() - .makeExecutableSchema() + val color = schema.getType("Color") as GraphQLEnumType + assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) + assertEquals(color.getValue("RED")?.description, "The first red") - // graphql-java's SchemaGenerator rejects this schema - val product = schema.getObjectType("Product")!! - assertEquals(product.appliedDirectives.map { it.getArgument("name")?.getValue() }, listOf("catalog")) - assertEquals(product.directives.map { it.name }, listOf("tag")) - assertEquals(tagDirective.tagged, listOf("Product=catalog")) + val node = schema.getType("Node") as GraphQLInterfaceType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) + assertEquals(node.getFieldDefinition("id").description, "The base id") - val printed = SchemaPrinter().print(schema) - assert(printed.contains("""type Product @tag(name : "catalog") {""")) { printed } + assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) } @Test - fun `input object extensions apply their directives`() { - val schema = SchemaParser.newParser() - .schemaString( + fun `extensions of a different kind than the type are rejected`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + parser( """ - directive @a on INPUT_OBJECT - directive @b on INPUT_OBJECT - - type Query { - search(filter: Filter): String - } - - input Filter @a { - id: ID - } - - extend input Filter @b + type Query { today: String } + extend union Foo = Bar + $fooBarBaz """) - .resolvers(object : GraphQLQueryResolver { - fun search(filter: TaggedQuery.Filter?): String? = null - }) - .build() - .makeExecutableSchema() + } - val filter = schema.getType("Filter") as GraphQLInputObjectType - assertEquals(filter.appliedDirectives.map { it.name }, listOf("a", "b")) - assertEquals(filter.directives.map { it.name }, listOf("a", "b")) - assertEquals(filter.extensionDefinitions.size, 1) + assertEquals(error.message, "Type 'Foo' is defined as an object type, but extended as a union.") } @Test - fun `undeclared directives on type extensions are rejected unless allowed`() { - val schemaString = """ - type Query { - product: Product - } - - type Product { - upc: String - } - - extend type Product @key(fields: "upc") - """ - + fun `type errors name the fields of interface extensions that use the type`() { val error = assertThrows(SchemaError::class.java) { - SchemaParser.newParser() - .schemaString(schemaString) - .resolvers(ProductQuery()) - .build() - .makeExecutableSchema() + parser( + """ + |type Query { + | today: String + |} + | + |interface Node { + | id: ID! + |} + | + |extend interface Node { + | search(filter: Filter): String + |} + | + |input Filter { + | id: ID + |} + """.trimMargin()) { + options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + }.makeExecutableSchema() } - assertEquals(error.message, "Found applied directive key without corresponding directive definition.") - - val schema = SchemaParser.newParser() - .schemaString(schemaString) - .resolvers(ProductQuery()) - .options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) - .build() - .makeExecutableSchema() - - assertEquals(schema.getObjectType("Product")!!.getAppliedDirective("key")?.getArgument("fields")?.getValue(), "upc") + val message = error.message.orEmpty() + assert(message.startsWith("Type 'Filter' used by argument 'filter' of field 'Node.search' at line 10, column 20 is defined")) { message } } @Test - fun `undeclared directives that an extension applies again are all kept`() { - // e.g. federation's @key, which is repeatable - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - product: Product - } - - type Product @key(fields: "upc") { - upc: String - } - - extend type Product @key(fields: "name") { - name: String - } - """) - .resolvers(ProductQuery()) - .options(SchemaParserOptions.newOptions().allowUndeclaredDirectives(true).build()) - .build() - .makeExecutableSchema() + fun `enums used only by directives get the values of their extensions`() { + val schema = parser( + """ + directive @link(for: Purpose) repeatable on SCHEMA + extend schema @link(for: EXECUTION) - assertEquals(schema.getObjectType("Product")!!.appliedDirectives.map { it.getArgument("fields")?.getValue() }, listOf("upc", "name")) - } + type Query { today: String } - class ProductQuery : GraphQLQueryResolver { - fun product(): Product? = null + enum Purpose { SECURITY } + extend enum Purpose { EXECUTION } + """).makeExecutableSchema() - class Product(val upc: String?, val name: String?) + assertEquals((schema.getType("Purpose") as GraphQLEnumType).values.map { it.value }, listOf("SECURITY", "EXECUTION")) + assertEquals(schema.getSchemaAppliedDirectives("link").map { it.getArgument("for")?.getValue() }, listOf("EXECUTION")) } - @Test - fun `directives on extensions of every kind of type are wired`() { - val tagDirective = TagDirective() - val schema = SchemaParser.newParser() - .schemaString( - """ - directive @tag(name: String!) on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT - - type Query { - node(filter: Filter): Node - result: Result - color: Color - } - - interface Node { - id: ID! - } - - extend interface Node @tag(name: "node") - - type Item implements Node { - id: ID! - } - - extend type Item @tag(name: "item") - - union Result = Item - - extend union Result @tag(name: "result") - - enum Color { - RED - } + private val fooBarBaz = """ + type Foo { foo: String } + type Bar { bar: String } + type Baz { baz: String } + """ - extend enum Color @tag(name: "color") + private fun parser(schema: String, configure: SchemaParserBuilder.() -> Unit = {}) = + SchemaParser.newParser().schemaString(schema).resolvers(Query()).apply(configure).build() - input Filter { - id: ID - } + private fun nameArguments(type: GraphQLDirectiveContainer) = type.appliedDirectives.map { it.getArgument("name")?.getValue() } - extend input Filter @tag(name: "filter") - """) - .resolvers(TaggedQuery()) - .dictionary(TaggedQuery.Item::class) - .directive("tag", tagDirective) - .build() - .makeExecutableSchema() - - listOf("Node", "Item", "Result", "Color", "Filter").forEach { name -> - assertEquals((schema.getType(name) as GraphQLDirectiveContainer).appliedDirectives.map { it.name }, listOf("tag")) - } - assertEquals(tagDirective.tagged.toSet(), setOf("Node=node", "Item=item", "Result=result", "Color=color", "Filter=filter")) + class Query : GraphQLQueryResolver { + fun fooBar(): FooBar = Baz("baz") + fun barBaz(): FooBar? = null + fun color(): Color = Color.GREEN + fun node(): Node = Item("1", "item") + fun search(filter: Filter?): String? = null + fun today(): String = "2026-10-12" } - class TaggedQuery : GraphQLQueryResolver { - fun node(filter: Filter?): Node? = null - fun result(): Result? = null - fun color(): Color? = null + interface FooBar - interface Node + class Foo(val foo: String?) : FooBar - interface Result + class Bar(val bar: String?) : FooBar - enum class Color { RED } + class Baz(val baz: String?) : FooBar - class Filter(val id: String?) + interface Node - class Item(val id: String) : Node, Result - } + class Item(val id: String, val name: String?, val fooBar: Foo? = null) : Node + + class Filter(val id: String?) private class TagDirective : SchemaDirectiveWiring { val tagged = mutableListOf() @@ -674,309 +474,4 @@ class TypeExtensionTest { return environment.element } } - - @Test - fun `object extensions add interfaces to the object`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - item: Item - } - - interface Node { - id: ID! - } - - interface Named { - name: String - } - - type Item implements Node { - id: ID! - name: String - } - - extend type Item implements Named - """) - .resolvers(object : GraphQLQueryResolver { - fun item(): NodeQuery.Item? = null - }) - .build() - .makeExecutableSchema() - - assertEquals(schema.getObjectType("Item")!!.interfaces.map { it.name }, listOf("Node", "Named")) - assertEquals(schema.getImplementations(schema.getType("Named") as GraphQLInterfaceType)!!.map { it.name }, listOf("Item")) - } - - @Test - fun `scanner requires classes for objects that implement an interface through an extension`() { - val error = assertThrows(SchemaClassScannerError::class.java) { - SchemaParser.newParser() - .schemaString( - """ - type Query { - node: Node - } - - interface Node { - id: ID! - } - - type Item implements Node { - id: ID! - } - - type Other { - id: ID! - } - - extend type Other implements Node - """) - .resolvers(NodeQuery()) - .dictionary(NodeQuery.Item::class) - .build() - .makeExecutableSchema() - } - - assertEquals(error.message, "Object type 'Other' implements a known interface, but no class could be found for that type name. Please pass a class for type 'Other' in the parser's dictionary.") - } - - private val unusedUnionSchema = """ - type Query { - name: String - } - - union Unused = A | B - - extend union Unused = C - - type A { - a: String - } - - type B { - b: String - } - - type C { - c: String - } - """ - - @Test - fun `unused unions get the members of their extensions when including unused types`() { - val schema = SchemaParser.newParser() - .schemaString(unusedUnionSchema) - .resolvers(UnusedUnion()) - .dictionary(UnusedUnion.A::class, UnusedUnion.B::class, UnusedUnion.C::class) - .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Unused") as GraphQLUnionType).types.map { it.name }, listOf("A", "B", "C")) - } - - @Test - fun `unused definitions hold the definition of an extended type`() { - val parser = SchemaParser.newParser() - .schemaString(unusedUnionSchema) - .resolvers(UnusedUnion()) - .build() - - assertEquals(parser.getUnusedDefinitions().map { it.javaClass.simpleName to it.name }.toSet(), setOf( - "UnionTypeDefinition" to "Unused", - "ObjectTypeDefinition" to "A", - "ObjectTypeDefinition" to "B", - "ObjectTypeDefinition" to "C" - )) - } - - class UnusedUnion : GraphQLQueryResolver { - fun name(): String? = null - - class A(val a: String?) - - class B(val b: String?) - - class C(val c: String?) - } - - @Test - fun `values, fields and members repeated in an extension keep their first definition`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - color: Color - node: Node - fooBar: FooBar - } - - enum Color { - "The base red" - RED - } - - extend enum Color { - RED - GREEN - } - - interface Node { - "The base id" - id: ID! - } - - extend interface Node { - id: ID! - name: String - } - - type Item implements Node { - id: ID! - name: String - } - - union FooBar = Foo | Bar - - extend union FooBar = Foo | Bar | Baz - - type Foo { - foo: String - } - - type Bar { - bar: String - } - - type Baz { - baz: String - } - """) - .resolvers(ColorQuery(), NodeQuery(), UnionQuery()) - .dictionary(NodeQuery.Item::class, UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class) - .build() - .makeExecutableSchema() - - val color = schema.getType("Color") as GraphQLEnumType - assertEquals(color.values.map { it.name }, listOf("RED", "GREEN")) - assertEquals(color.getValue("RED")?.description, "The base red") - - val node = schema.getType("Node") as GraphQLInterfaceType - assertEquals(node.fieldDefinitions.map { it.name }, listOf("id", "name")) - assertEquals(node.getFieldDefinition("id").description, "The base id") - - assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) - } - - @Test - fun `enums and unions defined only by extensions are built from all of them`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - color: Color - fooBar: FooBar - } - - extend enum Color { - RED - } - - extend enum Color { - GREEN - } - - extend union FooBar = Foo | Bar - - extend union FooBar = Baz - - type Foo { - foo: String - } - - type Bar { - bar: String - } - - type Baz { - baz: String - } - """) - .resolvers(ColorQuery(), UnionQuery()) - .dictionary(UnionQuery.Foo::class, UnionQuery.Bar::class, UnionQuery.Baz::class) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Color") as GraphQLEnumType).values.map { it.name }, listOf("RED", "GREEN")) - assertEquals((schema.getType("FooBar") as GraphQLUnionType).types.map { it.name }, listOf("Foo", "Bar", "Baz")) - } - - @Test - fun `extensions of a different kind than the type are rejected`() { - val error = assertThrows(SchemaClassScannerError::class.java) { - SchemaParser.newParser() - .schemaString( - """ - type Query { - foo: Foo - } - - type Foo { - foo: String - } - - type Bar { - bar: String - } - - extend union Foo = Bar - """) - .resolvers(object : GraphQLQueryResolver { - fun foo(): UnionQuery.Foo? = null - }) - .build() - } - - assertEquals(error.message, "Type 'Foo' is defined as an object type, but extended as a union.") - } - - @Test - fun `type errors name the fields of interface extensions that use the type`() { - val error = assertThrows(SchemaError::class.java) { - SchemaParser.newParser() - .schemaString( - """ - |type Query { - | today: String - |} - | - |interface Searchable { - | id: ID! - |} - | - |extend interface Searchable { - | search(criteria: Criteria): String - |} - | - |input Criteria { - | text: String - |} - """.trimMargin()) - .resolvers(object : GraphQLQueryResolver { - fun today(): String? = null - }) - .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) - .build() - .makeExecutableSchema() - } - - assert(error.message.orEmpty().startsWith("Type 'Criteria' used by argument 'criteria' of field 'Searchable.search' at line 10, column 22 is defined")) { - error.message.orEmpty() - } - } - - // through the interface, since graphql-java 25 and 26 disagree on which types have a nullable definition - private fun definitionClass(type: GraphQLNamedSchemaElement) = type.definition?.javaClass } From a17693b70177852869b667fa130ea117e8b530e7 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 12:25:50 -0400 Subject: [PATCH 06/11] Ignore extensions of a different kind than their type with a warning An undefined type that only has an object or input extension is now reported as not defined. The directives test also covers a non-repeatable directive that only an extension applies, and checks that each directive is wired once. Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/SchemaClassScanner.kt | 19 +++-- .../graphql/kickstart/tools/SchemaParser.kt | 2 - .../kickstart/tools/TypeExtensionTest.kt | 81 +++++++++++-------- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 185abede..878242cf 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -47,7 +47,10 @@ internal class SchemaClassScanner( .distinctBy { it.name } private val definitionsByName = typeDefinitions.filter { it !is SDLExtensionDefinition || it in loneExtensions }.associateBy { it.name } - private val typeExtensions: TypeExtensions = typeDefinitions.filter { it is SDLExtensionDefinition && it !in loneExtensions }.groupBy { it.name } + // without extensions of a different kind than their type, which are ignored with a warning, see init + private val typeExtensions: TypeExtensions = typeDefinitions + .filter { it is SDLExtensionDefinition && it !in loneExtensions && !extendsOtherKind(it) } + .groupBy { it.name } private val objectDefinitions = allDefinitions.filterIsInstance().filter { it !is ObjectTypeExtensionDefinition } private val objectDefinitionsByName = objectDefinitions.associateBy { it.name } private val interfaceDefinitionsByName = definitionsByName.values.filterIsInstance().associateBy { it.name } @@ -68,11 +71,9 @@ internal class SchemaClassScanner( } } - typeExtensions.values.flatten().forEach { extension -> - val definition = definitionsByName[extension.name] - if (definition != null && definition.kind() != extension.kind()) { - throw SchemaClassScannerError("Type '${extension.name}' is defined as ${definition.kind()}, but extended as ${extension.kind()}.") - } + typeDefinitions.filter { extendsOtherKind(it) }.forEach { extension -> + val definition = definitionsByName.getValue(extension.name) + log.warn("Type '${extension.name}' is defined as ${definition.kind()}, but extended as ${extension.kind()}. The extension is ignored.") } if (options.allowUnimplementedResolvers) { @@ -573,6 +574,12 @@ internal class SchemaClassScanner( private fun ObjectTypeDefinition.getExtendedImplements(): List> = implements + typeExtensions.extensionsOf(name).flatMap { it.implements } + // e.g. `extend union Foo` of `type Foo` + private fun extendsOtherKind(definition: TypeDefinition<*>): Boolean { + val type = definitionsByName[definition.name] + return definition is SDLExtensionDefinition && type != null && type.kind() != definition.kind() + } + private fun TypeDefinition<*>.kind(): String = when (this) { is ObjectTypeDefinition -> "an object type" is InterfaceTypeDefinition -> "an interface" diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 7648a467..99ea81ea 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -637,8 +637,6 @@ class SchemaParser internal constructor( val isInput = expectedType == GraphQLInputType::class val definition = unusedDefinitions.find { it.name == typeName.name } ?: definitions.filterIsInstance>().find { it.name == typeName.name } - // an object or input extension without a definition, which isn't built - ?: typeExtensions[typeName.name]?.first() val outputOnlyKind = when (definition) { is ObjectTypeDefinition -> "an object type" is InterfaceTypeDefinition -> "an interface" diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt index 631dfce4..2bc29bc4 100644 --- a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -212,11 +212,11 @@ class TypeExtensionTest { @Test fun `directives of extensions follow those of the type and are wired`() { - val tagDirective = TagDirective() + val wiring = RecordingWiring() val schema = parser( """ directive @tag(name: String!) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT - directive @once(name: String!) on OBJECT + directive @once(name: String!) on OBJECT | INPUT_OBJECT type Query @once(name: "Query") { node: Node @@ -241,26 +241,33 @@ class TypeExtensionTest { extend enum Color @tag(name: "Color extension") input Filter @tag(name: "Filter") { id: ID } - extend input Filter @tag(name: "Filter extension") + extend input Filter @tag(name: "Filter extension") @once(name: "Filter extension") type Foo { foo: String } """) { dictionary(Item::class, Foo::class) - directive("tag", tagDirective) - directive("once", tagDirective) + directive("tag", wiring) + directive("once", wiring) }.makeExecutableSchema() - val extended = listOf("Node", "Item", "FooBar", "Color", "Filter") - extended.forEach { name -> - assertEquals(nameArguments(schema.getType(name) as GraphQLDirectiveContainer), listOf(name, "$name extension")) + val applied = listOf("Query", "Node", "Item", "FooBar", "Color", "Filter").associateWith { name -> + (schema.getType(name) as GraphQLDirectiveContainer).appliedDirectives.map { it.describe() } } - assertEquals(nameArguments(schema.queryType), listOf("Query")) - assertEquals(tagDirective.tagged.toSet(), extended.flatMap { listOf("$it=$it", "$it=$it extension") }.toSet() + "Query=Query") + assertEquals(applied, mapOf( + "Query" to listOf("@once(Query)"), + "Node" to listOf("@tag(Node)", "@tag(Node extension)"), + "Item" to listOf("@tag(Item)", "@tag(Item extension)"), + "FooBar" to listOf("@tag(FooBar)", "@tag(FooBar extension)"), + "Color" to listOf("@tag(Color)", "@tag(Color extension)"), + "Filter" to listOf("@tag(Filter)", "@tag(Filter extension)", "@once(Filter extension)") + )) + // each applied directive is wired once + assertEquals(wiring.wired.sorted(), applied.values.flatten().sorted()) val item = schema.getType("Item") as GraphQLObjectType val filter = schema.getType("Filter") as GraphQLInputObjectType assertEquals(item.directives.map { it.name }, listOf("tag", "tag")) - assertEquals(filter.directives.map { it.name }, listOf("tag", "tag")) + assertEquals(filter.directives.map { it.name }, listOf("tag", "tag", "once")) assertEquals(schema.queryType.directives.map { it.name }, listOf("once")) assertEquals(item.fieldDefinitions.map { it.name }, listOf("id", "name")) assertEquals(filter.extensionDefinitions.size, 1) @@ -363,17 +370,27 @@ class TypeExtensionTest { } @Test - fun `extensions of a different kind than the type are rejected`() { - val error = assertThrows(SchemaClassScannerError::class.java) { - parser( - """ - type Query { today: String } - extend union Foo = Bar - $fooBarBaz - """) - } + fun `extensions of a different kind than the type are ignored`() { + val schema = parser( + """ + type Query { node: Node } + + interface Node { id: ID! } + extend type Node { name: String } - assertEquals(error.message, "Type 'Foo' is defined as an object type, but extended as a union.") + type Item implements Node { id: ID! } + extend union Item = Foo + + type Foo { foo: String } + """) { + dictionary(Item::class) + }.makeExecutableSchema() + + val node = schema.getType("Node") as GraphQLInterfaceType + val item = schema.getType("Item") as GraphQLObjectType + assertEquals(node.fieldDefinitions.map { it.name }, listOf("id")) + assertEquals(item.interfaces.map { it.name }, listOf("Node")) + assertEquals(node.extensionDefinitions.size + item.extensionDefinitions.size, 0) } @Test @@ -431,8 +448,6 @@ class TypeExtensionTest { private fun parser(schema: String, configure: SchemaParserBuilder.() -> Unit = {}) = SchemaParser.newParser().schemaString(schema).resolvers(Query()).apply(configure).build() - private fun nameArguments(type: GraphQLDirectiveContainer) = type.appliedDirectives.map { it.getArgument("name")?.getValue() } - class Query : GraphQLQueryResolver { fun fooBar(): FooBar = Baz("baz") fun barBaz(): FooBar? = null @@ -456,22 +471,24 @@ class TypeExtensionTest { class Filter(val id: String?) - private class TagDirective : SchemaDirectiveWiring { - val tagged = mutableListOf() + private class RecordingWiring : SchemaDirectiveWiring { + val wired = mutableListOf() - override fun onObject(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + override fun onObject(environment: SchemaDirectiveWiringEnvironment) = record(environment) - override fun onInterface(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + override fun onInterface(environment: SchemaDirectiveWiringEnvironment) = record(environment) - override fun onUnion(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + override fun onUnion(environment: SchemaDirectiveWiringEnvironment) = record(environment) - override fun onEnum(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + override fun onEnum(environment: SchemaDirectiveWiringEnvironment) = record(environment) - override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = tag(environment) + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment) = record(environment) - private fun tag(environment: SchemaDirectiveWiringEnvironment): T { - tagged.add("${environment.element.name}=${environment.appliedDirective.getArgument("name")?.getValue()}") + private fun record(environment: SchemaDirectiveWiringEnvironment): T { + wired.add(environment.appliedDirective.describe()) return environment.element } } } + +private fun GraphQLAppliedDirective.describe() = "@$name(${getArgument("name")?.getValue()})" From 11d07d262c556be080e500e6a7f1a035c8dfb637 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 12:26:29 -0400 Subject: [PATCH 07/11] Share the implements merge and give the extension tests their own Color enum Co-Authored-By: Claude Opus 5.5 --- .../graphql/kickstart/tools/SchemaClassScanner.kt | 10 +++++----- .../kotlin/graphql/kickstart/tools/SchemaParser.kt | 5 +++-- src/main/kotlin/graphql/kickstart/tools/util/Utils.kt | 3 +++ .../graphql/kickstart/tools/TypeExtensionTest.kt | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 878242cf..c2b7069b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -330,7 +330,10 @@ internal class SchemaClassScanner( private fun getAllObjectTypesImplementingDiscoveredInterfaces(): List { return dictionary.keys.filterIsInstance().map { iface -> - objectDefinitions.filter { obj -> obj.getExtendedImplements().filterIsInstance().any { it.name == iface.name } } + objectDefinitions.filter { obj -> + obj.getExtendedImplements(typeExtensions.extensionsOf(obj.name)) + .filterIsInstance().any { it.name == iface.name } + } }.flatten().distinctBy { it.name } } @@ -563,7 +566,7 @@ internal class SchemaClassScanner( } private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { - graphQLType.getExtendedImplements().forEach { + graphQLType.getExtendedImplements(typeExtensions.extensionsOf(graphQLType.name)).forEach { if (it is TypeName) { handleFoundType(interfaceDefinitionsByName[it.name] ?: throw SchemaClassScannerError("Object type ${graphQLType.name} declared interface ${it.name}, but no interface with that name was found in the schema!"), null, InterfaceReference(graphQLType)) @@ -571,9 +574,6 @@ internal class SchemaClassScanner( } } - private fun ObjectTypeDefinition.getExtendedImplements(): List> = - implements + typeExtensions.extensionsOf(name).flatMap { it.implements } - // e.g. `extend union Foo` of `type Foo` private fun extendsOtherKind(definition: TypeDefinition<*>): Boolean { val type = definitionsByName[definition.name] diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 99ea81ea..51a67188 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -8,6 +8,7 @@ import graphql.kickstart.tools.directive.DirectiveWiringHelper import graphql.kickstart.tools.util.extensionsOf import graphql.kickstart.tools.util.getDocumentation import graphql.kickstart.tools.util.getExtendedFieldDefinitions +import graphql.kickstart.tools.util.getExtendedImplements import graphql.kickstart.tools.util.unwrap import graphql.language.* import graphql.schema.* @@ -137,7 +138,7 @@ class SchemaParser internal constructor( .withAppliedDirectives(*buildAppliedDirectives(directives)) .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.OBJECT)) .apply { - (objectDefinition.implements + extensions.flatMap { it.implements }).forEach { implementsDefinition -> + objectDefinition.getExtendedImplements(extensions).forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(interfaces.find { it.name == interfaceName } ?: throw SchemaError("Expected interface type with name '$interfaceName' but found none!")) @@ -262,7 +263,7 @@ class SchemaParser internal constructor( } } .apply { - (interfaceDefinition.implements + extensions.flatMap { it.implements }).forEach { implementsDefinition -> + interfaceDefinition.getExtendedImplements(extensions).forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(GraphQLTypeReference(interfaceName)) } diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index 8ce0c340..8b48ffa5 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -39,6 +39,9 @@ internal fun ObjectTypeDefinition.getExtendedFieldDefinitions(extensions: List.getExtendedImplements(extensions: List>): List> = + implements + extensions.flatMap { it.implements } + internal inline fun > TypeExtensions.extensionsOf(name: String): List = this[name].orEmpty().filterIsInstance() diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt index 2bc29bc4..2455f252 100644 --- a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -2,7 +2,6 @@ package graphql.kickstart.tools import graphql.GraphQL import graphql.Scalars -import graphql.kickstart.tools.NestedInputTypesTest.Color import graphql.language.EnumTypeDefinition import graphql.language.InterfaceTypeDefinition import graphql.language.ObjectTypeDefinition @@ -471,6 +470,8 @@ class TypeExtensionTest { class Filter(val id: String?) + enum class Color { RED, GREEN } + private class RecordingWiring : SchemaDirectiveWiring { val wired = mutableListOf() From 941988a9f38566138f2dfc66d259c2496ad69f55 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 15:22:19 -0400 Subject: [PATCH 08/11] Find sealed subclasses for union members and implementors added by extensions Co-Authored-By: Claude Opus 5.5 --- .../kotlin/graphql/kickstart/tools/SchemaClassScanner.kt | 6 ++++-- .../kickstart/tools/SchemaClassScannerSealedTypesTest.kt | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index de65d562..39bf583c 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -452,8 +452,10 @@ internal class SchemaClassScanner( dictionary .filterKeys { definition -> when (definition) { - is UnionTypeDefinition -> definition.memberTypes.filterIsInstance().any { it.name == type.name } - is InterfaceTypeDefinition -> type.implements.filterIsInstance().any { it.name == definition.name } + is UnionTypeDefinition -> (listOf(definition) + typeExtensions.extensionsOf(definition.name)) + .flatMap { it.memberTypes }.filterIsInstance().any { it.name == type.name } + is InterfaceTypeDefinition -> type.getExtendedImplements(typeExtensions.extensionsOf(type.name)) + .filterIsInstance().any { it.name == definition.name } else -> false } } diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt index 20e59f4e..6efac0f5 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerSealedTypesTest.kt @@ -12,7 +12,9 @@ class SchemaClassScannerSealedTypesTest { pets: [Pet] } - union Pet = NormalPet | WeirdPet + union Pet = NormalPet + + extend union Pet = WeirdPet type NormalPet { name: String @@ -81,10 +83,12 @@ class SchemaClassScannerSealedTypesTest { barks: Boolean } - type Cat implements Animal { + type Cat { name: String lives: Int } + + extend type Cat implements Animal """) .resolvers(SealedInterfaceImplementors()) .build() From fe5859ae87442dbc487dcb48020cf8b06521b0b0 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 18:43:05 -0400 Subject: [PATCH 09/11] Warn when a type repeats an enum value, interface field or union member --- .../graphql/kickstart/tools/SchemaParser.kt | 19 +++++++++++++++---- .../kickstart/tools/TypeExtensionTest.kt | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 51a67188..17c8f47b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -211,6 +211,8 @@ class SchemaParser internal constructor( val extensions = typeExtensions.extensionsOf(name) val directives = directivesOf(definition, extensions) + val values = definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions } + warnRepeated(name, "value", values.map { it.name }) val builder = GraphQLEnumType.newEnum() .name(name) .definition(definition) @@ -219,8 +221,7 @@ class SchemaParser internal constructor( .withAppliedDirectives(*buildAppliedDirectives(directives)) .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.ENUM)) .apply { - // graphql-java rejects values that an extension repeats, but that was the workaround for #552, so the first one is kept - (definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions }).distinctBy { it.name }.forEach { valueDefinition -> + values.distinctBy { it.name }.forEach { valueDefinition -> val enumName = valueDefinition.name val enumValue = if (enumClass == null) { enumName @@ -249,6 +250,8 @@ class SchemaParser internal constructor( private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { val extensions = typeExtensions.extensionsOf(interfaceDefinition.name) val directives = directivesOf(interfaceDefinition, extensions) + val fieldDefinitions = interfaceDefinition.fieldDefinitions + extensions.flatMap { it.fieldDefinitions } + warnRepeated(interfaceDefinition.name, "field", fieldDefinitions.map { it.name }) val builder = GraphQLInterfaceType.newInterface() .name(interfaceDefinition.name) .definition(interfaceDefinition) @@ -257,8 +260,7 @@ class SchemaParser internal constructor( .withAppliedDirectives(*buildAppliedDirectives(directives)) .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.INTERFACE)) .apply { - // graphql-java rejects fields that an extension repeats, but that was the workaround for #552, so the first one is kept - (interfaceDefinition.fieldDefinitions + extensions.flatMap { it.fieldDefinitions }).distinctBy { it.name }.forEach { fieldDefinition -> + fieldDefinitions.distinctBy { it.name }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) } } } @@ -275,6 +277,7 @@ class SchemaParser internal constructor( private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { val extensions = typeExtensions.extensionsOf(definition.name) val directives = directivesOf(definition, extensions) + warnRepeated(definition.name, "member", (definition.memberTypes + extensions.flatMap { it.memberTypes }).map { (it as TypeName).name }) val builder = GraphQLUnionType.newUnionType() .name(definition.name) .definition(definition) @@ -309,6 +312,14 @@ class SchemaParser internal constructor( return leafObjects } + // graphql-java rejects a type that declares an enum value, interface field or union member twice, e.g. in the type and again in + // an extension. Those schemas built before #552, so the first one is kept and a warning is logged. + private fun warnRepeated(typeName: String, kind: String, names: List) { + names.groupingBy { it }.eachCount().filterValues { it > 1 }.keys.forEach { + log.warn("Type '$typeName' declares $kind '$it' more than once, only the first one is kept.") + } + } + // The directives of the extensions follow those of the type, like in graphql-java. A non-repeatable directive that an extension // applies again is dropped, where graphql-java rejects the schema, so those schemas keep building and the applied directives // match the legacy ones (see buildDirectives). Undeclared directives may be repeatable, so they're all kept. diff --git a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt index 2455f252..bcd0bcb4 100644 --- a/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/TypeExtensionTest.kt @@ -326,7 +326,7 @@ class TypeExtensionTest { @Test fun `repeats and extensions without a type keep building`() { - // how schemas worked around extensions that weren't merged + // these built before extensions were merged, so the repeats only log a warning val schema = parser( """ type Query { From bc045991704a457e5117d5404c01a04cf6451d54 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 19:03:00 -0400 Subject: [PATCH 10/11] Read a type and its extensions through one withExtensions util --- .../kickstart/tools/SchemaClassScanner.kt | 23 ++++++++----------- .../graphql/kickstart/tools/SchemaParser.kt | 17 +++++++------- .../graphql/kickstart/tools/util/Utils.kt | 12 ++++------ 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 39bf583c..36f099d5 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -234,7 +234,7 @@ internal class SchemaClassScanner( */ private fun typeNamesUsedByFields(definitions: Set>): Set { return definitions - .flatMap { listOf(it) + typeExtensions[it.name].orEmpty() } + .flatMap { it.withExtensions(typeExtensions) } .flatMap { definition -> when (definition) { is ImplementingTypeDefinition<*> -> definition.fieldDefinitions.flatMap { field -> @@ -344,8 +344,7 @@ internal class SchemaClassScanner( private fun getAllObjectTypesImplementingDiscoveredInterfaces(): List { return dictionary.keys.filterIsInstance().map { iface -> objectDefinitions.filter { obj -> - obj.getExtendedImplements(typeExtensions.extensionsOf(obj.name)) - .filterIsInstance().any { it.name == iface.name } + obj.withExtensions(typeExtensions).flatMap { it.implements }.filterIsInstance().any { it.name == iface.name } } }.flatten().distinctBy { it.name } } @@ -353,7 +352,7 @@ internal class SchemaClassScanner( private fun getAllObjectTypeMembersOfDiscoveredUnions(): List { val unionTypeNames = dictionary.keys.filterIsInstance().map { union -> union.name }.toSet() return dictionary.keys.filterIsInstance().map { union -> - (listOf(union) + typeExtensions.extensionsOf(union.name)).flatMap { definition -> + union.withExtensions(typeExtensions).flatMap { definition -> definition.memberTypes.filterIsInstance().filter { !unionTypeNames.contains(it.name) }.map { objectDefinitionsByName[it.name] ?: throw SchemaClassScannerError("No object type found with name '${it.name}' for union: $definition") @@ -368,8 +367,7 @@ internal class SchemaClassScanner( */ private fun handleInterfaceFieldTypes(): Boolean { val newTypes = dictionary.keys.filterIsInstance().flatMap { iface -> - val fieldDefinitions = iface.fieldDefinitions + typeExtensions.extensionsOf(iface.name).flatMap { it.fieldDefinitions } - fieldDefinitions.mapNotNull { field -> + iface.withExtensions(typeExtensions).flatMap { it.fieldDefinitions }.mapNotNull { field -> val type = definitionsByName[(field.type.unwrap() as TypeName).name] if ((type is UnionTypeDefinition || type is InterfaceTypeDefinition) && !dictionary.containsKey(type)) { type to FieldTypeReference("${iface.name}.${field.name}") @@ -452,10 +450,8 @@ internal class SchemaClassScanner( dictionary .filterKeys { definition -> when (definition) { - is UnionTypeDefinition -> (listOf(definition) + typeExtensions.extensionsOf(definition.name)) - .flatMap { it.memberTypes }.filterIsInstance().any { it.name == type.name } - is InterfaceTypeDefinition -> type.getExtendedImplements(typeExtensions.extensionsOf(type.name)) - .filterIsInstance().any { it.name == definition.name } + is UnionTypeDefinition -> definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.filterIsInstance().any { it.name == type.name } + is InterfaceTypeDefinition -> type.withExtensions(typeExtensions).flatMap { it.implements }.filterIsInstance().any { it.name == definition.name } else -> false } } @@ -528,7 +524,7 @@ internal class SchemaClassScanner( resolverInfo.dataClassType != Any::class.java && resolverInfo.dataClassType.isAssignableFrom(dataClass) private fun scanResolverInfoForPotentialMatches(type: ObjectTypeDefinition, resolverInfo: ResolverInfo) { - type.getExtendedFieldDefinitions(typeExtensions.extensionsOf(type.name)).forEach { field -> + type.withExtensions(typeExtensions).flatMap { it.fieldDefinitions }.forEach { field -> val fieldResolver = fieldResolverScanner.findFieldResolver(field, resolverInfo) fieldResolversByType.getOrPut(type) { mutableMapOf() }[fieldResolver.field] = fieldResolver @@ -598,8 +594,7 @@ internal class SchemaClassScanner( */ private fun handleInputObjectFields(graphQLType: InputObjectTypeDefinition, javaType: JavaType?) { val clazz = javaType?.unwrap() - val inputObjectTypes = listOf(graphQLType) + typeExtensions.extensionsOf(graphQLType.name) - inputObjectTypes + graphQLType.withExtensions(typeExtensions) .flatMap { it.inputValueDefinitions } .forEach { inputValueDefinition -> val inputGraphQLType = inputValueDefinition.type.unwrap() @@ -670,7 +665,7 @@ internal class SchemaClassScanner( } private fun scanInterfacesOfType(graphQLType: ObjectTypeDefinition) { - graphQLType.getExtendedImplements(typeExtensions.extensionsOf(graphQLType.name)).forEach { + graphQLType.withExtensions(typeExtensions).flatMap { it.implements }.forEach { if (it is TypeName) { handleFoundType(interfaceDefinitionsByName[it.name] ?: throw SchemaClassScannerError("Object type ${graphQLType.name} declared interface ${it.name}, but no interface with that name was found in the schema!"), null, InterfaceReference(graphQLType)) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 17c8f47b..dcc8af67 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -7,9 +7,8 @@ import graphql.introspection.Introspection.DirectiveLocation.INPUT_FIELD_DEFINIT import graphql.kickstart.tools.directive.DirectiveWiringHelper import graphql.kickstart.tools.util.extensionsOf import graphql.kickstart.tools.util.getDocumentation -import graphql.kickstart.tools.util.getExtendedFieldDefinitions -import graphql.kickstart.tools.util.getExtendedImplements import graphql.kickstart.tools.util.unwrap +import graphql.kickstart.tools.util.withExtensions import graphql.language.* import graphql.schema.* import graphql.schema.idl.RuntimeWiring @@ -138,14 +137,14 @@ class SchemaParser internal constructor( .withAppliedDirectives(*buildAppliedDirectives(directives)) .withDirectives(*buildDirectives(directives, Introspection.DirectiveLocation.OBJECT)) .apply { - objectDefinition.getExtendedImplements(extensions).forEach { implementsDefinition -> + objectDefinition.withExtensions(typeExtensions).flatMap { it.implements }.forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(interfaces.find { it.name == interfaceName } ?: throw SchemaError("Expected interface type with name '$interfaceName' but found none!")) } } .apply { - objectDefinition.getExtendedFieldDefinitions(extensions).forEach { fieldDefinition -> + objectDefinition.withExtensions(typeExtensions).flatMap { it.fieldDefinitions }.forEach { fieldDefinition -> field { field -> createField(field, fieldDefinition, inputObjects) codeRegistryBuilder.dataFetcher( @@ -211,7 +210,7 @@ class SchemaParser internal constructor( val extensions = typeExtensions.extensionsOf(name) val directives = directivesOf(definition, extensions) - val values = definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions } + val values = definition.withExtensions(typeExtensions).flatMap { it.enumValueDefinitions } warnRepeated(name, "value", values.map { it.name }) val builder = GraphQLEnumType.newEnum() .name(name) @@ -250,7 +249,7 @@ class SchemaParser internal constructor( private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { val extensions = typeExtensions.extensionsOf(interfaceDefinition.name) val directives = directivesOf(interfaceDefinition, extensions) - val fieldDefinitions = interfaceDefinition.fieldDefinitions + extensions.flatMap { it.fieldDefinitions } + val fieldDefinitions = interfaceDefinition.withExtensions(typeExtensions).flatMap { it.fieldDefinitions } warnRepeated(interfaceDefinition.name, "field", fieldDefinitions.map { it.name }) val builder = GraphQLInterfaceType.newInterface() .name(interfaceDefinition.name) @@ -265,7 +264,7 @@ class SchemaParser internal constructor( } } .apply { - interfaceDefinition.getExtendedImplements(extensions).forEach { implementsDefinition -> + interfaceDefinition.withExtensions(typeExtensions).flatMap { it.implements }.forEach { implementsDefinition -> val interfaceName = (implementsDefinition as TypeName).name withInterface(GraphQLTypeReference(interfaceName)) } @@ -277,7 +276,7 @@ class SchemaParser internal constructor( private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { val extensions = typeExtensions.extensionsOf(definition.name) val directives = directivesOf(definition, extensions) - warnRepeated(definition.name, "member", (definition.memberTypes + extensions.flatMap { it.memberTypes }).map { (it as TypeName).name }) + warnRepeated(definition.name, "member", definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.map { (it as TypeName).name }) val builder = GraphQLUnionType.newUnionType() .name(definition.name) .definition(definition) @@ -296,7 +295,7 @@ class SchemaParser internal constructor( val name = definition.name val leafObjects = mutableListOf() - (definition.memberTypes + typeExtensions.extensionsOf(name).flatMap { it.memberTypes }).forEach { + definition.withExtensions(typeExtensions).flatMap { it.memberTypes }.forEach { val typeName = (it as TypeName).name // Is this a nested union? If so, expand diff --git a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt index 8b48ffa5..f12608eb 100644 --- a/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt +++ b/src/main/kotlin/graphql/kickstart/tools/util/Utils.kt @@ -35,16 +35,14 @@ internal fun Type<*>.unwrap(): Type<*> = when (this) { else -> this } -internal fun ObjectTypeDefinition.getExtendedFieldDefinitions(extensions: List): List { - return this.fieldDefinitions + extensions.flatMap { it.fieldDefinitions } -} - -internal fun ImplementingTypeDefinition<*>.getExtendedImplements(extensions: List>): List> = - implements + extensions.flatMap { it.implements } - internal inline fun > TypeExtensions.extensionsOf(name: String): List = this[name].orEmpty().filterIsInstance() +// The type followed by its extensions, e.g. `union.withExtensions(typeExtensions).flatMap { it.memberTypes }`. Every extension class +// is a subclass of its type's class, e.g. UnionTypeExtensionDefinition of UnionTypeDefinition. +internal inline fun > T.withExtensions(typeExtensions: TypeExtensions): List = + listOf(this) + typeExtensions.extensionsOf(name) + internal fun JavaType.unwrap(): Class = if (this is ParameterizedType) { this.rawType as Class<*> From 2241889085f7968f87eb397c819681f003104b78 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 17 Oct 2026 19:14:42 -0400 Subject: [PATCH 11/11] Shorten the comments on lone extensions and repeated declarations --- src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt | 4 +--- src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 36f099d5..edf356bb 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -41,9 +41,7 @@ internal class SchemaClassScanner( private val typeDefinitions = allDefinitions.filterIsInstance>() private val baseTypeNames = typeDefinitions.filter { it !is SDLExtensionDefinition }.map { it.name }.toSet() - // Extending a type that isn't defined isn't valid, but union, enum, interface and scalar extensions are subclasses of their - // definitions, so one without a definition was built as the type before #552. To keep those schemas building, the first one - // is the definition and the others extend it. Object and input extensions without a definition are ignored. + // the first extension of an undefined union, enum, interface or scalar stands in for its definition private val loneExtensions = typeDefinitions .filter { it is SDLExtensionDefinition && it !is ObjectTypeDefinition && it !is InputObjectTypeDefinition && it.name !in baseTypeNames } .distinctBy { it.name } diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index dcc8af67..ce1d4d3b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -311,8 +311,7 @@ class SchemaParser internal constructor( return leafObjects } - // graphql-java rejects a type that declares an enum value, interface field or union member twice, e.g. in the type and again in - // an extension. Those schemas built before #552, so the first one is kept and a warning is logged. + // TODO throw a SchemaError instead in 15.0.0 (#863), like graphql-java does private fun warnRepeated(typeName: String, kind: String, names: List) { names.groupingBy { it }.eachCount().filterValues { it > 1 }.keys.forEach { log.warn("Type '$typeName' declares $kind '$it' more than once, only the first one is kept.")