From 51d75b638989a183405d511568e46d7414002dd2 Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 19 Oct 2026 20:09:25 -0400 Subject: [PATCH 1/4] Run directive wirings on the assembled schema so they see resolved types --- .../graphql/kickstart/tools/SchemaParser.kt | 29 +++++++++++--- .../tools/directive/DirectiveWiringHelper.kt | 39 ++++++++++++++++--- .../kickstart/tools/SchemaParserTest.kt | 5 +-- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 002eb3cc..89a6dd2e 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -128,7 +128,24 @@ class SchemaParser internal constructor( // Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not // the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail. val schemaAppliedDirectives = buildAppliedDirectives(rootInfo.getDirectives(), SCHEMA).toList() - return SchemaObjects(query, mutation, subscription, types, schemaDirectives.all, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) + val schemaObjects = SchemaObjects(query, mutation, subscription, types, schemaDirectives.all, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) + return if (directiveWiringHelper.isWiringRequired) wire(schemaObjects) else schemaObjects + } + + /** + * Runs the directive wirings on the assembled schema, where type references are resolved, and takes the wired types from it. + */ + private fun wire(schemaObjects: SchemaObjects): SchemaObjects { + val schema = directiveWiringHelper.wireSchema(schemaObjects.toSchema()) + val directives = schema.directives.associateBy { it.name } + return schemaObjects.copy( + query = schema.queryType, + mutation = schema.mutationType, + subscription = schema.subscriptionType, + dictionary = schemaObjects.dictionary.mapTo(linkedSetOf()) { schema.typeMap.getValue((it as GraphQLNamedType).name) }, + directives = schemaObjects.directives.mapTo(linkedSetOf()) { directives.getValue(it.name) }, + schemaAppliedDirectives = schema.schemaAppliedDirectives + ) } /** @@ -178,7 +195,7 @@ class SchemaParser internal constructor( } } - return directiveWiringHelper.wireObject(builder.build()) + return builder.build() } private fun createInputObject(definition: InputObjectTypeDefinition, inputObjects: List, @@ -214,7 +231,7 @@ class SchemaParser internal constructor( } } - return directiveWiringHelper.wireInputObject(builder.build()) + return builder.build() } private fun createEnumObject(definition: EnumTypeDefinition): GraphQLEnumType { @@ -259,7 +276,7 @@ class SchemaParser internal constructor( } } - return directiveWiringHelper.wireEnum(builder.build()) + return builder.build() } private fun createInterfaceObject(interfaceDefinition: InterfaceTypeDefinition, inputObjects: List): GraphQLInterfaceType { @@ -286,7 +303,7 @@ class SchemaParser internal constructor( } } - return directiveWiringHelper.wireInterFace(builder.build()) + return builder.build() } private fun createUnionObject(definition: UnionTypeDefinition, types: List): GraphQLUnionType { @@ -304,7 +321,7 @@ class SchemaParser internal constructor( getLeafUnionObjects(definition, types).forEach { possibleType(it) } } - return directiveWiringHelper.wireUnion(builder.build()) + return builder.build() } private fun getLeafUnionObjects(definition: UnionTypeDefinition, types: List): List { diff --git a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt index 4ccd45d2..137c1208 100644 --- a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt @@ -8,6 +8,8 @@ import graphql.language.NodeParentTree import graphql.schema.* import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaDirectiveWiring +import graphql.util.TraversalControl +import graphql.util.TraverserContext import java.util.* internal class DirectiveWiringHelper( @@ -16,31 +18,58 @@ internal class DirectiveWiringHelper( ) { private val schemaDirectiveParameters = Parameters(runtimeWiring, codeRegistryBuilder) - fun wireObject(objectType: GraphQLObjectType): GraphQLObjectType { + val isWiringRequired = runtimeWiring.registeredDirectiveWiring.isNotEmpty() || runtimeWiring.directiveWiring.isNotEmpty() + + fun wireSchema(schema: GraphQLSchema): GraphQLSchema = SchemaTransformer.transformSchema(schema, object : GraphQLTypeVisitorStub() { + override fun visitGraphQLObjectType(node: GraphQLObjectType, context: TraverserContext) = + wire(node, context, ::wireObject) + + override fun visitGraphQLInterfaceType(node: GraphQLInterfaceType, context: TraverserContext) = + wire(node, context, ::wireInterFace) + + override fun visitGraphQLInputObjectType(node: GraphQLInputObjectType, context: TraverserContext) = + wire(node, context, ::wireInputObject) + + override fun visitGraphQLEnumType(node: GraphQLEnumType, context: TraverserContext) = + wire(node, context, ::wireEnum) + + override fun visitGraphQLUnionType(node: GraphQLUnionType, context: TraverserContext) = + wire(node, context, ::wireUnion) + + private fun wire(node: T, context: TraverserContext, wire: (T) -> T): TraversalControl { + if (!Introspection.isIntrospectionTypes(node)) { + val wired = wire(node) + if (wired !== node) changeNode(context, wired) + } + return TraversalControl.CONTINUE + } + }) + + private fun wireObject(objectType: GraphQLObjectType): GraphQLObjectType { return wireFields(objectType) .let { fields -> if (objectType.fields != fields) objectType.transform { it.clearFields().fields(fields) } else objectType } .let { wireDirectives(WiringWrapper(it, OBJECT, SchemaDirectiveWiring::onObject)) } } - fun wireInterFace(interfaceType: GraphQLInterfaceType): GraphQLInterfaceType { + private fun wireInterFace(interfaceType: GraphQLInterfaceType): GraphQLInterfaceType { return wireFields(interfaceType) .let { fields -> if (interfaceType.fields != fields) interfaceType.transform { it.clearFields().fields(fields) } else interfaceType } .let { wireDirectives(WiringWrapper(it, INTERFACE, SchemaDirectiveWiring::onInterface)) } } - fun wireInputObject(inputObjectType: GraphQLInputObjectType): GraphQLInputObjectType { + private fun wireInputObject(inputObjectType: GraphQLInputObjectType): GraphQLInputObjectType { return wireInputFields(inputObjectType) .let { fields -> if (inputObjectType.fields != fields) inputObjectType.transform { it.clearFields().fields(fields) } else inputObjectType } .let { wireDirectives(WiringWrapper(it, INPUT_OBJECT, SchemaDirectiveWiring::onInputObjectType)) } } - fun wireEnum(enumType: GraphQLEnumType): GraphQLEnumType { + private fun wireEnum(enumType: GraphQLEnumType): GraphQLEnumType { return wireEnumValues(enumType) .let { values -> if (enumType.values != values) enumType.transform { it.clearValues().values(values) } else enumType } .let { wireDirectives(WiringWrapper(it, ENUM, SchemaDirectiveWiring::onEnum)) } } - fun wireUnion(unionType: GraphQLUnionType): GraphQLUnionType { + private fun wireUnion(unionType: GraphQLUnionType): GraphQLUnionType { return wireDirectives(WiringWrapper(unionType, UNION, SchemaDirectiveWiring::onUnion)) } diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt index b023956b..09b10007 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaParserTest.kt @@ -827,7 +827,7 @@ class SchemaParserTest { } @Test - fun `input objects behind list and non-null input fields should resolve to GraphQLInputObjectType during directive wiring`() { + fun `input objects behind list, non-null and recursive input fields should resolve to GraphQLInputObjectType during directive wiring`() { val wiredFieldTypes = mutableMapOf() val schema = SchemaParser.newParser() .schemaString( @@ -865,11 +865,10 @@ class SchemaParserTest { .makeExecutableSchema() .assertEveryFieldResolves() - // parent points back at NestedFilter while it's still being built, so it stays a type reference until the schema is built assertEquals(wiredFieldTypes, mapOf( "subs" to "GraphQLInputObjectType", "other" to "GraphQLInputObjectType", - "parent" to "GraphQLTypeReference" + "parent" to "GraphQLInputObjectType" )) } From 2b94fef6f0c56c589e336eb6fb41b4a620d00b1b Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 19 Oct 2026 20:46:06 -0400 Subject: [PATCH 2/4] Reference input objects from directive definitions so wired types in cycles can be rebuilt Also skip types without a definition when wiring, and check unmapped input fields on the wired input objects. --- .../graphql/kickstart/tools/SchemaParser.kt | 69 +++++++------------ .../tools/directive/DirectiveWiringHelper.kt | 18 ++--- .../graphql/kickstart/tools/DirectiveTest.kt | 64 ++++++++++++++++- .../tools/UnmappedInputFieldsTest.kt | 36 ++++++++++ 4 files changed, 131 insertions(+), 56 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 89a6dd2e..88393a39 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -71,7 +71,8 @@ class SchemaParser internal constructor( private val codeRegistryBuilder = GraphQLCodeRegistry.newCodeRegistry() private val directiveWiringHelper = DirectiveWiringHelper(runtimeWiring, codeRegistryBuilder) - private lateinit var schemaDirectives: SchemaDirectives + // by name, in the order they're declared + private lateinit var schemaDirectives: MutableMap /** * Parses the given schema with respect to the given dictionary and returns GraphQL objects. @@ -88,7 +89,7 @@ class SchemaParser internal constructor( // Create GraphQL objects val inputObjects: MutableList = mutableListOf() - createDirectives(inputObjects) + createDirectives() inputObjectDefinitions.forEach { if (inputObjects.none { io -> io.name == it.name }) { inputObjects.add(createInputObject(it, inputObjects, mutableSetOf())) @@ -96,13 +97,6 @@ class SchemaParser internal constructor( } val interfaces = interfaceDefinitions.map { createInterfaceObject(it, inputObjects) } val objects = objectDefinitions.map { createObject(it, interfaces, inputObjects) } - if (options.unmappedInputFields != UnmappedInputFields.IGNORE) { - val checker = UnmappedInputFieldChecker(inputObjects.associateBy { it.name }, options.unmappedInputFields) - objectDefinitions.forEach { type -> - fieldResolversByType[type].orEmpty().values.filterIsInstance().forEach { checker.check(type.name, it) } - } - checker.report() - } val unions = unionDefinitions.map { createUnionObject(it, objects) } val enums = enumDefinitions.map { createEnumObject(it) } @@ -128,8 +122,17 @@ class SchemaParser internal constructor( // Directives applied to the schema itself, e.g. `extend schema @link(...)`. Only GraphQLAppliedDirectives are built here, not // the deprecated GraphQLDirective ones: graphql-java doesn't resolve type references in those, so enum arguments would fail. val schemaAppliedDirectives = buildAppliedDirectives(rootInfo.getDirectives(), SCHEMA).toList() - val schemaObjects = SchemaObjects(query, mutation, subscription, types, schemaDirectives.all, codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) - return if (directiveWiringHelper.isWiringRequired) wire(schemaObjects) else schemaObjects + val schemaObjects = SchemaObjects(query, mutation, subscription, types, schemaDirectives.values.toSet(), codeRegistryBuilder, rootInfo.getDescription(), schemaAppliedDirectives) + val wiredObjects = if (directiveWiringHelper.isWiringRequired) wire(schemaObjects) else schemaObjects + if (options.unmappedInputFields != UnmappedInputFields.IGNORE) { + val wiredInputObjects = wiredObjects.dictionary.filterIsInstance().associateBy { it.name } + val checker = UnmappedInputFieldChecker(wiredInputObjects, options.unmappedInputFields) + objectDefinitions.forEach { type -> + fieldResolversByType[type].orEmpty().values.filterIsInstance().forEach { checker.check(type.name, it) } + } + checker.report() + } + return wiredObjects } /** @@ -393,11 +396,10 @@ class SchemaParser internal constructor( .build() } - private fun createDirectives(inputObjects: MutableList) { - schemaDirectives = SchemaDirectives(inputObjects) - directiveDefinitions.forEach { schemaDirectives.find(it.name) } + private fun createDirectives() { + schemaDirectives = directiveDefinitions.associateTo(linkedMapOf()) { it.name to createDirective(it) } // because the arguments can have directives too, we attach them only after the directives themselves are created - schemaDirectives.update { d -> + schemaDirectives.replaceAll { _, d -> val arguments = d.arguments.map { a -> a.transform { it.withAppliedDirectives(*buildAppliedDirectives(a.definition!!.directives, ARGUMENT_DEFINITION)) .withDirectives(*buildDirectives(a.definition!!.directives, ARGUMENT_DEFINITION)) @@ -406,33 +408,7 @@ class SchemaParser internal constructor( } } - /** - * The directive definitions. The input objects used by their arguments need the directives applied to them, so each directive - * is first built with type references to input objects, then rebuilt with the input objects the first time it's looked up. - * Only a directive applied within its own argument types gets its first build there. - */ - private inner class SchemaDirectives(private val inputObjects: MutableList) { - // by name, in the order they're declared - private val byName = directiveDefinitions.associateTo(linkedMapOf()) { definition -> - definition.name to createDirective(definition) { determineType(GraphQLInputType::class, it, permittedTypesForInputObject, inputObjects) as GraphQLInputType } - } - private val unfinished = directiveDefinitions.associateByTo(mutableMapOf()) { it.name } - // shared, so that an input object that's still being built isn't built again for another directive - private val referencingInputObjects = mutableSetOf() - - val all: Set get() = byName.values.toSet() - - fun find(name: String): GraphQLDirective? { - unfinished.remove(name)?.let { definition -> - byName[name] = createDirective(definition) { determineInputType(it, inputObjects, referencingInputObjects) } - } - return byName[name] - } - - fun update(transform: (GraphQLDirective) -> GraphQLDirective) = byName.replaceAll { _, directive -> transform(directive) } - } - - private fun createDirective(definition: DirectiveDefinition, determineArgumentType: (Type<*>) -> GraphQLInputType): GraphQLDirective { + private fun createDirective(definition: DirectiveDefinition): GraphQLDirective { val locations = definition.directiveLocations.map { Introspection.DirectiveLocation.valueOf(it.name) }.toTypedArray() return GraphQLDirective.newDirective() @@ -444,18 +420,19 @@ class SchemaParser internal constructor( .repeatable(definition.isRepeatable) .apply { definition.inputValueDefinitions.forEach { argumentDefinition -> - argument(createDirectiveArgument(argumentDefinition, determineArgumentType)) + argument(createDirectiveArgument(argumentDefinition)) } } .build() } - private fun createDirectiveArgument(definition: InputValueDefinition, determineArgumentType: (Type<*>) -> GraphQLInputType): GraphQLArgument { + private fun createDirectiveArgument(definition: InputValueDefinition): GraphQLArgument { return GraphQLArgument.newArgument() .name(definition.name) .definition(definition) .description(getDocumentation(definition, options)) - .type(determineArgumentType(definition.type)) + // input objects are type references here, since they need the directives to be built first + .type(determineType(GraphQLInputType::class, definition.type, permittedTypesForInputObject, listOf()) as GraphQLInputType) .apply { getDeprecated(definition.directives)?.let { deprecate(it) } } .apply { definition.defaultValue?.let { defaultValueLiteral(it) } } .build() @@ -525,7 +502,7 @@ class SchemaParser internal constructor( } } - private fun findDirective(name: String): GraphQLDirective? = schemaDirectives.find(name) ?: BUILT_IN_DIRECTIVES[name] + private fun findDirective(name: String): GraphQLDirective? = schemaDirectives[name] ?: BUILT_IN_DIRECTIVES[name] private fun buildUndeclaredAppliedDirective(directive: Directive): GraphQLAppliedDirective { return GraphQLAppliedDirective.newDirective() diff --git a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt index 137c1208..bb2ae403 100644 --- a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt @@ -4,6 +4,7 @@ import graphql.introspection.Introspection import graphql.introspection.Introspection.DirectiveLocation.* import graphql.kickstart.tools.directive.SchemaDirectiveWiringEnvironmentImpl.Parameters import graphql.language.NamedNode +import graphql.language.Node import graphql.language.NodeParentTree import graphql.schema.* import graphql.schema.idl.RuntimeWiring @@ -22,23 +23,24 @@ internal class DirectiveWiringHelper( fun wireSchema(schema: GraphQLSchema): GraphQLSchema = SchemaTransformer.transformSchema(schema, object : GraphQLTypeVisitorStub() { override fun visitGraphQLObjectType(node: GraphQLObjectType, context: TraverserContext) = - wire(node, context, ::wireObject) + wire(node, node.definition, context, ::wireObject) override fun visitGraphQLInterfaceType(node: GraphQLInterfaceType, context: TraverserContext) = - wire(node, context, ::wireInterFace) + wire(node, node.definition, context, ::wireInterFace) override fun visitGraphQLInputObjectType(node: GraphQLInputObjectType, context: TraverserContext) = - wire(node, context, ::wireInputObject) + wire(node, node.definition, context, ::wireInputObject) override fun visitGraphQLEnumType(node: GraphQLEnumType, context: TraverserContext) = - wire(node, context, ::wireEnum) + wire(node, node.definition, context, ::wireEnum) override fun visitGraphQLUnionType(node: GraphQLUnionType, context: TraverserContext) = - wire(node, context, ::wireUnion) + wire(node, node.definition, context, ::wireUnion) - private fun wire(node: T, context: TraverserContext, wire: (T) -> T): TraversalControl { - if (!Introspection.isIntrospectionTypes(node)) { - val wired = wire(node) + // types without a definition, like the introspection types or types a wiring builds in code, aren't wired + private fun wire(node: T, definition: Node<*>?, context: TraverserContext, wireType: (T) -> T): TraversalControl { + if (definition != null) { + val wired = wireType(node) if (wired !== node) changeNode(context, wired) } return TraversalControl.CONTINUE diff --git a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt index a3367c0d..f74d434d 100644 --- a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt @@ -255,6 +255,66 @@ class DirectiveTest { assertEquals(schema.queryType.getField("name").description, "Name +auth +static") } + @Test + fun `should keep types a directive wiring builds in code`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @wrap on FIELD_DEFINITION + + type Query { + name: String @wrap + } + """) + .resolvers(object : GraphQLQueryResolver { + fun name(): String? = null + }) + .directive("wrap", object : SchemaDirectiveWiring { + override fun onField(environment: SchemaDirectiveWiringEnvironment): GraphQLFieldDefinition { + val wrapper = GraphQLObjectType.newObject() + .name("Wrapper") + .field { it.name("value").type(environment.element.type as GraphQLOutputType) } + .build() + return environment.element.transform { it.type(wrapper) } + } + }) + .build() + .makeExecutableSchema() + .assertEveryFieldResolves() + + assertEquals(GraphQLTypeUtil.simplePrint(schema.queryType.getField("name").type), "Wrapper") + } + + @Test + fun `should wire an input object that is the argument type of a directive applied to it`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @described on INPUT_OBJECT + directive @example(value: BookFilter) on INPUT_OBJECT + + type Query { + search(filter: BookFilter): String + } + + input BookFilter @described @example(value: { name: "a" }) { + name: String + } + """) + .resolvers(object : GraphQLQueryResolver { + fun search(filter: BookFilter?): String? = null + }) + .directive("described", object : SchemaDirectiveWiring { + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment): GraphQLInputObjectType = + environment.element.transform { it.description("described") } + }) + .build() + .makeExecutableSchema() + .assertEveryFieldResolves() + + assertEquals(schema.getTypeAs("BookFilter")?.description, "described") + } + @Test fun `should fail when a directive wiring returns null`() { val error = assertThrows(IllegalStateException::class.java) { @@ -481,10 +541,10 @@ class DirectiveTest { } @Test - fun `should build input objects used by directive arguments before wiring`() { + fun `should resolve input objects used by directive arguments before wiring`() { val meta = "directive @meta(info: ConfigInput!) on FIELD_DEFINITION" val option = "directive @option(value: OptionInput) on INPUT_OBJECT | INPUT_FIELD_DEFINITION" - // ConfigInput is built for @meta and needs @option, whichever is declared first + // ConfigInput is used by @meta and has @option applied, whichever is declared first listOf("$meta\n$option", "$option\n$meta").forEach { directives -> val recorder = ArgumentTypeRecorder() SchemaParser.newParser() diff --git a/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt b/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt index fe713dd8..0a6aad28 100644 --- a/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/UnmappedInputFieldsTest.kt @@ -26,6 +26,9 @@ import com.fasterxml.jackson.databind.module.SimpleModule import graphql.GraphQL import graphql.kickstart.tools.UnmappedInputFieldsFixtures.* import graphql.schema.DataFetchingEnvironment +import graphql.schema.GraphQLInputObjectType +import graphql.schema.idl.SchemaDirectiveWiring +import graphql.schema.idl.SchemaDirectiveWiringEnvironment import org.junit.Assert.assertThrows import org.junit.Test import java.util.* @@ -298,6 +301,39 @@ class UnmappedInputFieldsTest { ).joinToString("\n")) } + @Test + fun `input fields removed by a directive wiring are not checked`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + directive @withoutLimit on INPUT_OBJECT + + type Query { + search(filter: SearchFilter): String + } + + input SearchFilter @withoutLimit { + query: String + limit: Int + } + """) + .resolvers(object : GraphQLQueryResolver { + fun search(filter: SearchFilter?): String? = filter?.query + }) + .directive("withoutLimit", object : SchemaDirectiveWiring { + override fun onInputObjectType(environment: SchemaDirectiveWiringEnvironment): GraphQLInputObjectType { + val fields = environment.element.fields.filter { it.name != "limit" } + return environment.element.transform { it.clearFields().fields(fields) } + } + }) + .options(failOptions().build()) + .build() + .makeExecutableSchema() + .assertEveryFieldResolves() + + assertEquals(schema.getTypeAs("SearchFilter")?.fields?.map { it.name }, listOf("query")) + } + @Test fun `misspelled property of a field with a default value is reported as failing every time`() { val (schema, warnings) = withWarnings { From 4171fb4b6b748bd160dbb6873429fcfebfeacaad Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 19 Oct 2026 21:22:24 -0400 Subject: [PATCH 3/4] Reference schema types from the fields of wired types so wirings can change self-referencing field types Also drop the input objects parameter of determineType, which can no longer match. --- .../graphql/kickstart/tools/SchemaParser.kt | 24 ++++------ .../tools/directive/DirectiveWiringHelper.kt | 38 ++++++++++++++- .../graphql/kickstart/tools/DirectiveTest.kt | 48 +++++++++++++++++-- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt index 88393a39..35903434 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaParser.kt @@ -373,7 +373,7 @@ class SchemaParser internal constructor( .description(getDocumentation(fieldDefinition, options)) .definition(fieldDefinition) .apply { getDeprecated(fieldDefinition.directives)?.let { deprecate(it) } } - .type(determineOutputType(fieldDefinition.type, inputObjects)) + .type(determineOutputType(fieldDefinition.type)) .withAppliedDirectives(*buildAppliedDirectives(fieldDefinition.directives, FIELD_DEFINITION)) .withDirectives(*buildDirectives(fieldDefinition.directives, FIELD_DEFINITION)) .apply { @@ -431,8 +431,8 @@ class SchemaParser internal constructor( .name(definition.name) .definition(definition) .description(getDocumentation(definition, options)) - // input objects are type references here, since they need the directives to be built first - .type(determineType(GraphQLInputType::class, definition.type, permittedTypesForInputObject, listOf()) as GraphQLInputType) + // type references, so that an input object used by a directive applied to it doesn't contain itself + .type(determineType(GraphQLInputType::class, definition.type, permittedTypesForInputObject) as GraphQLInputType) .apply { getDeprecated(definition.directives)?.let { deprecate(it) } } .apply { definition.defaultValue?.let { defaultValueLiteral(it) } } .build() @@ -591,22 +591,18 @@ class SchemaParser internal constructor( private fun missingArgumentsWithDefault(directive: Directive, graphQLDirective: GraphQLDirective): List = graphQLDirective.arguments.filter { it.hasSetDefaultValue() && directive.getArgument(it.name) == null } - private fun determineOutputType(typeDefinition: Type<*>, inputObjects: List) = - determineType(GraphQLOutputType::class, typeDefinition, permittedTypesForObject, inputObjects) as GraphQLOutputType + private fun determineOutputType(typeDefinition: Type<*>) = + determineType(GraphQLOutputType::class, typeDefinition, permittedTypesForObject) as GraphQLOutputType + // named types other than scalars are type references private fun determineType( expectedType: KClass, typeDefinition: Type<*>, - allowedTypeReferences: Set, - inputObjects: List + allowedTypeReferences: Set ): GraphQLType = when (typeDefinition) { - is ListType -> GraphQLList(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects)) - is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences, inputObjects)) - is InputObjectTypeDefinition -> { - log.info("Create input object") - createInputObject(typeDefinition, inputObjects, mutableSetOf()) - } + is ListType -> GraphQLList(determineType(expectedType, typeDefinition.type, allowedTypeReferences)) + is NonNullType -> GraphQLNonNull(determineType(expectedType, typeDefinition.type, allowedTypeReferences)) is TypeName -> { val scalarType = customScalars[typeDefinition.name] ?: GRAPHQL_SCALARS[typeDefinition.name] @@ -616,7 +612,7 @@ class SchemaParser internal constructor( if (!allowedTypeReferences.contains(typeDefinition.name)) { throw unexpectedTypeError(typeDefinition, expectedType) } - inputObjects.find { it.name == typeDefinition.name } ?: GraphQLTypeReference(typeDefinition.name) + GraphQLTypeReference(typeDefinition.name) } } else -> throw SchemaError("Unknown type: $typeDefinition") diff --git a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt index bb2ae403..20f54ad2 100644 --- a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt @@ -22,6 +22,8 @@ internal class DirectiveWiringHelper( val isWiringRequired = runtimeWiring.registeredDirectiveWiring.isNotEmpty() || runtimeWiring.directiveWiring.isNotEmpty() fun wireSchema(schema: GraphQLSchema): GraphQLSchema = SchemaTransformer.transformSchema(schema, object : GraphQLTypeVisitorStub() { + private val references = TypeReferences(schema) + override fun visitGraphQLObjectType(node: GraphQLObjectType, context: TraverserContext) = wire(node, node.definition, context, ::wireObject) @@ -41,12 +43,46 @@ internal class DirectiveWiringHelper( private fun wire(node: T, definition: Node<*>?, context: TraverserContext, wireType: (T) -> T): TraversalControl { if (definition != null) { val wired = wireType(node) - if (wired !== node) changeNode(context, wired) + if (wired !== node) changeNode(context, references.inFields(wired)) } return TraversalControl.CONTINUE } }) + /** + * Swaps the schema's own types in the fields of a wired type for type references, like they were before the schema was built. + * Otherwise a type whose fields refer to itself contains itself, which the schema transformer can't rebuild. + */ + private class TypeReferences(private val schema: GraphQLSchema) { + // the types wirings build in code, rebuilt once each + private val built = IdentityHashMap() + + @Suppress("UNCHECKED_CAST") + fun inFields(type: T): T = when (type) { + is GraphQLObjectType -> type.transform { it.clearFields().fields(type.fields.map(::inField)) } + is GraphQLInterfaceType -> type.transform { it.clearFields().fields(type.fields.map(::inField)) } + is GraphQLInputObjectType -> type.transform { it.clearFields().fields(type.fields.map(::inInputField)) } + else -> type + } as T + + private fun inField(field: GraphQLFieldDefinition) = field.transform { + it.type(of(field.type) as GraphQLOutputType) + .clearArguments() + .arguments(field.arguments.map { argument -> argument.transform { a -> a.type(of(argument.type) as GraphQLInputType) } }) + } + + private fun inInputField(field: GraphQLInputObjectField) = field.transform { it.type(of(field.type) as GraphQLInputType) } + + private fun of(type: GraphQLType): GraphQLType = when (type) { + is GraphQLNonNull -> GraphQLNonNull.nonNull(of(type.wrappedType)) + is GraphQLList -> GraphQLList.list(of(type.wrappedType)) + // only these can lead back to the wired type. Scalars stay, since a reference only finds a scalar another field uses + is GraphQLFieldsContainer, is GraphQLInputObjectType -> + if (schema.typeMap[type.name] === type) GraphQLTypeReference.typeRef(type.name) else built.getOrPut(type) { inFields(type) } + else -> type + } + } + private fun wireObject(objectType: GraphQLObjectType): GraphQLObjectType { return wireFields(objectType) .let { fields -> if (objectType.fields != fields) objectType.transform { it.clearFields().fields(fields) } else objectType } diff --git a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt index f74d434d..12fdc9bd 100644 --- a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt @@ -256,18 +256,40 @@ class DirectiveTest { } @Test - fun `should keep types a directive wiring builds in code`() { + fun `should let directive wirings change the types of self-referencing fields`() { val schema = SchemaParser.newParser() .schemaString( """ + directive @required on FIELD_DEFINITION + directive @many on INPUT_FIELD_DEFINITION directive @wrap on FIELD_DEFINITION type Query { - name: String @wrap + person(filter: PersonFilter): Person + } + + type Person { + name: String + age: Int + friend: Person @required + best: Person @wrap + } + + input PersonFilter { + name: String + parent: PersonFilter @many } """) .resolvers(object : GraphQLQueryResolver { - fun name(): String? = null + fun person(filter: PersonFilter?): Person = Person("a") + }) + .directive("required", object : SchemaDirectiveWiring { + override fun onField(environment: SchemaDirectiveWiringEnvironment): GraphQLFieldDefinition = + environment.element.transform { it.type(GraphQLNonNull.nonNull(environment.element.type)) } + }) + .directive("many", object : SchemaDirectiveWiring { + override fun onInputObjectField(environment: SchemaDirectiveWiringEnvironment): GraphQLInputObjectField = + environment.element.transform { it.type(GraphQLList.list(environment.element.type)) } }) .directive("wrap", object : SchemaDirectiveWiring { override fun onField(environment: SchemaDirectiveWiringEnvironment): GraphQLFieldDefinition { @@ -280,9 +302,15 @@ class DirectiveTest { }) .build() .makeExecutableSchema() - .assertEveryFieldResolves() - assertEquals(GraphQLTypeUtil.simplePrint(schema.queryType.getField("name").type), "Wrapper") + val person = schema.getObjectType("Person")!! + val filter = schema.getTypeAs("PersonFilter")!! + val wrapper = schema.getObjectType("Wrapper")!! + assertEquals(GraphQLTypeUtil.simplePrint(person.getField("friend").type), "Person!") + assertEquals(GraphQLTypeUtil.simplePrint(person.getField("best").type), "Wrapper") + assertEquals(GraphQLTypeUtil.simplePrint(filter.getField("parent").type), "[PersonFilter]") + assert(GraphQLTypeUtil.unwrapAll(person.getField("friend").type) === person) + assert(wrapper.getField("value").type === person) } @Test @@ -849,6 +877,16 @@ class DirectiveTest { val label: String? ) + class Person(val name: String, val age: Int = 1) { + val friend: Person get() = this + val best: Person get() = this + } + + data class PersonFilter( + val name: String?, + val parent: PersonFilter? + ) + private data class BoundInput( val max: Int? ) From 3a1a5f6218b94b00c0a3f2ceaa1eefef4142f55d Mon Sep 17 00:00:00 2001 From: Oryan Date: Mon, 19 Oct 2026 21:43:27 -0400 Subject: [PATCH 4/4] Reference unions from the fields of wired types too, since a union can lead back to the wired type --- .../kickstart/tools/directive/DirectiveWiringHelper.kt | 2 +- src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt index 20f54ad2..a5f50228 100644 --- a/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt +++ b/src/main/kotlin/graphql/kickstart/tools/directive/DirectiveWiringHelper.kt @@ -77,7 +77,7 @@ internal class DirectiveWiringHelper( is GraphQLNonNull -> GraphQLNonNull.nonNull(of(type.wrappedType)) is GraphQLList -> GraphQLList.list(of(type.wrappedType)) // only these can lead back to the wired type. Scalars stay, since a reference only finds a scalar another field uses - is GraphQLFieldsContainer, is GraphQLInputObjectType -> + is GraphQLFieldsContainer, is GraphQLUnionType, is GraphQLInputObjectType -> if (schema.typeMap[type.name] === type) GraphQLTypeReference.typeRef(type.name) else built.getOrPut(type) { inFields(type) } else -> type } diff --git a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt index 12fdc9bd..7502c7b2 100644 --- a/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/DirectiveTest.kt @@ -273,8 +273,11 @@ class DirectiveTest { age: Int friend: Person @required best: Person @wrap + someone: Someone } + union Someone = Person + input PersonFilter { name: String parent: PersonFilter @many @@ -880,6 +883,7 @@ class DirectiveTest { class Person(val name: String, val age: Int = 1) { val friend: Person get() = this val best: Person get() = this + val someone: Person get() = this } data class PersonFilter(