From 0432a86db67d5a6e2183703c89a76158307a3f04 Mon Sep 17 00:00:00 2001 From: Iuliia Sobolevska Date: Fri, 25 Sep 2026 20:41:58 -0700 Subject: [PATCH 1/4] Test client projection collision behavior --- .../codegen/ClientProjectionCollisionTest.kt | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt new file mode 100644 index 00000000..5ee99812 --- /dev/null +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt @@ -0,0 +1,103 @@ +/* + * + * Copyright 2026 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.netflix.graphql.dgs.codegen + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class ClientProjectionCollisionTest { + @ParameterizedTest(name = "{0}, {1}") + @MethodSource("codegenCases") + fun `preserves query projection when operation fields generate the same class name`( + language: Language, + schemaOrder: String, + schema: String, + ) { + val codeGenResult = + CodeGen( + CodeGenConfig( + schemas = setOf(schema), + packageName = "com.netflix.test", + language = language, + generateClientApi = true, + addGeneratedAnnotation = false, + ), + ).generate() + + val resultProjection = + codeGenResult.clientProjections.single { it.typeSpec().name() == "ResultProjectionRoot" }.typeSpec() + assertThat(resultProjection.methodSpecs()) + .extracting("name") + .contains("queryOnly") + .doesNotContain("mutationOnly") + + assertCompilesJava(codeGenResult) + } + + companion object { + private val queryFirstSchema = + """ + type Query { + result: QueryResult + } + + type QueryResult { + queryOnly: String + } + + type Mutation { + result: MutationResult + } + + type MutationResult { + mutationOnly: String + } + """.trimIndent() + + private val mutationFirstSchema = + """ + type Mutation { + result: MutationResult + } + + type MutationResult { + mutationOnly: String + } + + type Query { + result: QueryResult + } + + type QueryResult { + queryOnly: String + } + """.trimIndent() + + @JvmStatic + fun codegenCases(): Stream = + Stream.of( + Arguments.of(Language.JAVA, "query first", queryFirstSchema), + Arguments.of(Language.JAVA, "mutation first", mutationFirstSchema), + Arguments.of(Language.KOTLIN, "query first", queryFirstSchema), + Arguments.of(Language.KOTLIN, "mutation first", mutationFirstSchema), + ) + } +} From 8b33d967e7fdc43cbd1e73b3e514809670de1da8 Mon Sep 17 00:00:00 2001 From: Iuliia Sobolevska Date: Fri, 25 Sep 2026 21:18:28 -0700 Subject: [PATCH 2/4] generate a root projection per operation on field name collisions Co-Authored-By: Claude Opus 5.5 (1M context) --- .../netflix/graphql/dgs/codegen/CodeGen.kt | 3 +- .../generators/java/ClientApiGenerator.kt | 31 +++++- .../codegen/ClientProjectionCollisionTest.kt | 101 ++++++++++++++---- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt index d00cbe3e..9e371adf 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt @@ -386,6 +386,7 @@ class CodeGen private constructor( private fun generateJavaClientApi(definitions: Collection>): CodeGenResult { val methodNames = mutableSetOf() + val rootProjectionTypes = mutableMapOf() return if (config.generateClientApi) { definitions .asSequence() @@ -393,7 +394,7 @@ class CodeGen private constructor( .filter { it.name == "Query" || it.name == "Mutation" || it.name == "Subscription" } .sortedBy { it.name.length } .map { - ClientApiGenerator(config, schemaIndex).generate(it, methodNames) + ClientApiGenerator(config, schemaIndex).generate(it, methodNames, rootProjectionTypes) }.fold(CodeGenResult.EMPTY) { result, next -> result.merge(next) } } else { CodeGenResult.EMPTY diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt index 91e2e3c7..2d03e2ee 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt @@ -74,6 +74,16 @@ class ClientApiGenerator internal constructor( fun generate( definition: ObjectTypeDefinition, methodNames: MutableSet, + ): CodeGenResult = generate(definition, methodNames, mutableMapOf()) + + /** + * [rootProjectionTypes] maps each root projection class name to the type it projects. + * Share it across operations, like [methodNames], or same-named fields returning different types collide. + */ + internal fun generate( + definition: ObjectTypeDefinition, + methodNames: MutableSet, + rootProjectionTypes: MutableMap, ): CodeGenResult = definition.fieldDefinitions .filterIncludedInConfig(definition.name, config) @@ -83,7 +93,8 @@ class ClientApiGenerator internal constructor( val rootProjection = it.type.findTypeDefinition(schemaIndex, true)?.let { typeDefinition -> - createRootProjection(typeDefinition, it.name.capitalized()) + val prefix = rootProjectionPrefix(it.name.capitalized(), definition.name, typeDefinition, rootProjectionTypes) + createRootProjection(typeDefinition, prefix) } ?: CodeGenResult.EMPTY CodeGenResult(javaQueryTypes = listOf(javaFile)).merge(rootProjection) @@ -434,6 +445,24 @@ class ClientApiGenerator internal constructor( .addCode("""super(null, null, java.util.Optional.of("$typeName"));""") .build() + /** + * Root projections are named after the operation field, so `Query.result: QueryResult` and + * `Mutation.result: MutationResult` both want `ResultProjectionRoot`, and one would silently replace the other. + * Same-named fields returning the same type keep sharing one root. + * A field returning a different type gets an operation-qualified root such as `ResultGraphQLMutationProjectionRoot`, + * mirroring the query class name from [generateMethodName]. + */ + private fun rootProjectionPrefix( + fieldName: String, + operation: String, + type: TypeDefinition<*>, + rootProjectionTypes: MutableMap, + ): String { + val claimedType = rootProjectionTypes.putIfAbsent("${fieldName}ProjectionRoot", type.name) + if (claimedType == null || claimedType == type.name) return fieldName + return "${fieldName}GraphQL${operation.capitalized()}" + } + private fun createRootProjection( type: TypeDefinition<*>, prefix: String, diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt index 5ee99812..c5bceba6 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt @@ -18,6 +18,7 @@ package com.netflix.graphql.dgs.codegen import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -26,32 +27,76 @@ import java.util.stream.Stream class ClientProjectionCollisionTest { @ParameterizedTest(name = "{0}, {1}") @MethodSource("codegenCases") - fun `preserves query projection when operation fields generate the same class name`( + fun `generates a root projection per operation when same-named fields return different types`( language: Language, schemaOrder: String, schema: String, ) { - val codeGenResult = - CodeGen( - CodeGenConfig( - schemas = setOf(schema), - packageName = "com.netflix.test", - language = language, - generateClientApi = true, - addGeneratedAnnotation = false, - ), - ).generate() - - val resultProjection = - codeGenResult.clientProjections.single { it.typeSpec().name() == "ResultProjectionRoot" }.typeSpec() - assertThat(resultProjection.methodSpecs()) - .extracting("name") + val codeGenResult = generate(schema, language) + + assertThat(rootProjectionMethods(codeGenResult, "ResultProjectionRoot")) .contains("queryOnly") - .doesNotContain("mutationOnly") + .doesNotContain("mutationOnly", "subscriptionOnly") + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLMutationProjectionRoot")) + .contains("mutationOnly") + .doesNotContain("queryOnly", "subscriptionOnly") + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLSubscriptionProjectionRoot")) + .contains("subscriptionOnly") + .doesNotContain("queryOnly", "mutationOnly") assertCompilesJava(codeGenResult) } + @Test + fun `shares one root projection when same-named fields return the same type`() { + val codeGenResult = + generate( + """ + type Query { + result: Result + } + + type Mutation { + result: Result + } + + type Result { + value: String + } + """.trimIndent(), + Language.JAVA, + ) + + assertThat(codeGenResult.clientProjections.map { it.typeSpec().name() }) + .containsOnlyOnce("ResultProjectionRoot") + .noneMatch { it.startsWith("ResultGraphQL") } + } + + private fun generate( + schema: String, + language: Language, + ): CodeGenResult = + CodeGen( + CodeGenConfig( + schemas = setOf(schema), + packageName = "com.netflix.test", + language = language, + generateClientApi = true, + addGeneratedAnnotation = false, + ), + ).generate() + + // single() also fails if two operations emit the same class name, which is how the collision used to surface + private fun rootProjectionMethods( + codeGenResult: CodeGenResult, + className: String, + ): List = + codeGenResult.clientProjections + .single { it.typeSpec().name() == className } + .typeSpec() + .methodSpecs() + .map { it.name() } + companion object { private val queryFirstSchema = """ @@ -70,10 +115,26 @@ class ClientProjectionCollisionTest { type MutationResult { mutationOnly: String } + + type Subscription { + result: SubscriptionResult + } + + type SubscriptionResult { + subscriptionOnly: String + } """.trimIndent() - private val mutationFirstSchema = + private val subscriptionFirstSchema = """ + type Subscription { + result: SubscriptionResult + } + + type SubscriptionResult { + subscriptionOnly: String + } + type Mutation { result: MutationResult } @@ -95,9 +156,9 @@ class ClientProjectionCollisionTest { fun codegenCases(): Stream = Stream.of( Arguments.of(Language.JAVA, "query first", queryFirstSchema), - Arguments.of(Language.JAVA, "mutation first", mutationFirstSchema), + Arguments.of(Language.JAVA, "subscription first", subscriptionFirstSchema), Arguments.of(Language.KOTLIN, "query first", queryFirstSchema), - Arguments.of(Language.KOTLIN, "mutation first", mutationFirstSchema), + Arguments.of(Language.KOTLIN, "subscription first", subscriptionFirstSchema), ) } } From 9f1eba26d5a698685071883b7f9ce9c40ab77661 Mon Sep 17 00:00:00 2001 From: Iuliia Sobolevska Date: Sat, 26 Sep 2026 08:46:32 -0700 Subject: [PATCH 3/4] keep the 8.7.0 root projection on collisions Co-Authored-By: Claude Opus 5.5 (1M context) --- .../netflix/graphql/dgs/codegen/CodeGen.kt | 12 +++-- .../generators/java/ClientApiGenerator.kt | 53 +++++++++++++------ .../codegen/ClientProjectionCollisionTest.kt | 52 ++++++++++++++++-- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt index 9e371adf..89378e10 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt @@ -386,13 +386,15 @@ class CodeGen private constructor( private fun generateJavaClientApi(definitions: Collection>): CodeGenResult { val methodNames = mutableSetOf() - val rootProjectionTypes = mutableMapOf() return if (config.generateClientApi) { - definitions + val operations = + definitions + .filterIsInstance() + .filter { it.name == "Query" || it.name == "Mutation" || it.name == "Subscription" } + .sortedBy { it.name.length } + val rootProjectionTypes = ClientApiGenerator(config, schemaIndex).rootProjectionTypes(operations) + operations .asSequence() - .filterIsInstance() - .filter { it.name == "Query" || it.name == "Mutation" || it.name == "Subscription" } - .sortedBy { it.name.length } .map { ClientApiGenerator(config, schemaIndex).generate(it, methodNames, rootProjectionTypes) }.fold(CodeGenResult.EMPTY) { result, next -> result.merge(next) } diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt index 2d03e2ee..86d23552 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt @@ -74,20 +74,17 @@ class ClientApiGenerator internal constructor( fun generate( definition: ObjectTypeDefinition, methodNames: MutableSet, - ): CodeGenResult = generate(definition, methodNames, mutableMapOf()) + ): CodeGenResult = generate(definition, methodNames, rootProjectionTypes(listOf(definition))) /** - * [rootProjectionTypes] maps each root projection class name to the type it projects. - * Share it across operations, like [methodNames], or same-named fields returning different types collide. + * Pass [rootProjectionTypes] computed over every operation, or same-named fields returning different types collide. */ internal fun generate( definition: ObjectTypeDefinition, methodNames: MutableSet, - rootProjectionTypes: MutableMap, + rootProjectionTypes: Map, ): CodeGenResult = - definition.fieldDefinitions - .filterIncludedInConfig(definition.name, config) - .filterSkipped() + rootFields(definition) .map { val javaFile = createQueryClass(it, definition.name, methodNames) @@ -446,22 +443,44 @@ class ClientApiGenerator internal constructor( .build() /** + * Maps each unqualified root projection class name to the type it projects, given [operations] in generation order. + * * Root projections are named after the operation field, so `Query.result: QueryResult` and - * `Mutation.result: MutationResult` both want `ResultProjectionRoot`, and one would silently replace the other. - * Same-named fields returning the same type keep sharing one root. - * A field returning a different type gets an operation-qualified root such as `ResultGraphQLMutationProjectionRoot`, - * mirroring the query class name from [generateMethodName]. + * `Mutation.result: MutationResult` both want `ResultProjectionRoot`. + * Up to 8.7.0 each operation generated its own, identical ones were deduplicated keeping the first and the last + * one written replaced the rest on disk. + * The type that won there keeps the unqualified name, so clients compiled against it still compile: + * `distinct()` reproduces the deduplication and `toMap()` the last write. + */ + internal fun rootProjectionTypes(operations: List): Map = + operations + .flatMap { operation -> + rootFields(operation).mapNotNull { field -> + field.type.findTypeDefinition(schemaIndex, true)?.let { "${field.name.capitalized()}ProjectionRoot" to it.name } + } + }.distinct() + .toMap() + + private fun rootFields(definition: ObjectTypeDefinition): List = + definition.fieldDefinitions + .filterIncludedInConfig(definition.name, config) + .filterSkipped() + + /** + * A field returning a different type than the unqualified root gets an operation-qualified one such as + * `ResultGraphQLQueryProjectionRoot`, mirroring the query class name from [generateMethodName]. */ private fun rootProjectionPrefix( fieldName: String, operation: String, type: TypeDefinition<*>, - rootProjectionTypes: MutableMap, - ): String { - val claimedType = rootProjectionTypes.putIfAbsent("${fieldName}ProjectionRoot", type.name) - if (claimedType == null || claimedType == type.name) return fieldName - return "${fieldName}GraphQL${operation.capitalized()}" - } + rootProjectionTypes: Map, + ): String = + if (rootProjectionTypes["${fieldName}ProjectionRoot"] == type.name) { + fieldName + } else { + "${fieldName}GraphQL${operation.capitalized()}" + } private fun createRootProjection( type: TypeDefinition<*>, diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt index c5bceba6..dc9d1d22 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt @@ -34,19 +34,53 @@ class ClientProjectionCollisionTest { ) { val codeGenResult = generate(schema, language) - assertThat(rootProjectionMethods(codeGenResult, "ResultProjectionRoot")) + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLQueryProjectionRoot")) .contains("queryOnly") .doesNotContain("mutationOnly", "subscriptionOnly") assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLMutationProjectionRoot")) .contains("mutationOnly") .doesNotContain("queryOnly", "subscriptionOnly") - assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLSubscriptionProjectionRoot")) + assertThat(rootProjectionMethods(codeGenResult, "ResultProjectionRoot")) .contains("subscriptionOnly") .doesNotContain("queryOnly", "mutationOnly") assertCompilesJava(codeGenResult) } + // Up to 8.7.0 the last operation's ResultProjectionRoot overwrote the others on disk; clients compiled against it + @Test + fun `keeps the unqualified root projection for the last operation`() { + val codeGenResult = + generate( + """ + type Query { + result: QueryResult + } + + type Mutation { + result: MutationResult + } + + type QueryResult { + queryOnly: String + } + + type MutationResult { + mutationOnly: String + } + """.trimIndent(), + Language.JAVA, + ) + + assertThat(rootProjectionMethods(codeGenResult, "ResultProjectionRoot")) + .contains("mutationOnly") + .doesNotContain("queryOnly") + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLQueryProjectionRoot")) + .contains("queryOnly") + .doesNotContain("mutationOnly") + } + + // Up to 8.7.0 the Subscription copy of the Query projection was dropped as a duplicate, so the Mutation one won @Test fun `shares one root projection when same-named fields return the same type`() { val codeGenResult = @@ -57,19 +91,29 @@ class ClientProjectionCollisionTest { } type Mutation { + result: OtherResult + } + + type Subscription { result: Result } type Result { value: String } + + type OtherResult { + other: String + } """.trimIndent(), Language.JAVA, ) assertThat(codeGenResult.clientProjections.map { it.typeSpec().name() }) - .containsOnlyOnce("ResultProjectionRoot") - .noneMatch { it.startsWith("ResultGraphQL") } + .containsOnlyOnce("ResultProjectionRoot", "ResultGraphQLQueryProjectionRoot", "ResultGraphQLSubscriptionProjectionRoot") + .doesNotContain("ResultGraphQLMutationProjectionRoot") + assertThat(rootProjectionMethods(codeGenResult, "ResultProjectionRoot")).contains("other") + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLQueryProjectionRoot")).contains("value") } private fun generate( From 462f84501044e9725bfd1c4bfcfe4366c92f44b8 Mon Sep 17 00:00:00 2001 From: Iuliia Sobolevska Date: Sat, 26 Sep 2026 09:16:36 -0700 Subject: [PATCH 4/4] restore 8.7.0 projection dedupe without quadratic rendering Co-Authored-By: Claude Opus 5.5 (1M context) --- .../netflix/graphql/dgs/codegen/CodeGen.kt | 52 +++++++++--- .../generators/java/ClientApiGenerator.kt | 34 +++++--- .../codegen/ClientProjectionCollisionTest.kt | 80 +++++++++++++++++++ .../graphql/dgs/codegen/CodeGenResultTest.kt | 10 ++- 4 files changed, 150 insertions(+), 26 deletions(-) diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt index 89378e10..784f1176 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt @@ -177,7 +177,7 @@ class CodeGen private constructor( codeGenResult.javaInterfaces + codeGenResult.javaEnumTypes + codeGenResult.javaQueryTypes + - codeGenResult.clientProjections + + lastProjectionPerPath(codeGenResult.clientProjections) + codeGenResult.javaConstants, kotlinFiles = codeGenResult.kotlinDataTypes + @@ -196,6 +196,13 @@ class CodeGen private constructor( return codeGenResult } + /** + * Up to 8.7.0 files were written one after another, so of two same-named projections the last one won on disk. + * The parallel writer rejects duplicate destinations instead, so resolve them up front. + */ + private fun lastProjectionPerPath(projections: List): List = + projections.associateBy { it.packageName() to it.typeSpec().name() }.values.toList() + /** * Build a [Document] containing the combined schemas from * [config]. @@ -392,7 +399,8 @@ class CodeGen private constructor( .filterIsInstance() .filter { it.name == "Query" || it.name == "Mutation" || it.name == "Subscription" } .sortedBy { it.name.length } - val rootProjectionTypes = ClientApiGenerator(config, schemaIndex).rootProjectionTypes(operations) + val rootProjectionTypes = + ClientApiGenerator(config, schemaIndex).rootProjectionTypes(operations, federatedDefinitions(definitions)) operations .asSequence() .map { @@ -405,17 +413,16 @@ class CodeGen private constructor( private fun generateJavaClientEntitiesApi(definitions: Collection>): CodeGenResult = if (config.generateClientApi) { - val federatedDefinitions = - definitions - .asSequence() - .filterIsInstance() - .filter { it.hasDirective("key") } - .toList() - ClientApiGenerator(config, schemaIndex).generateEntities(federatedDefinitions) + ClientApiGenerator(config, schemaIndex).generateEntities(federatedDefinitions(definitions)) } else { CodeGenResult.EMPTY } + private fun federatedDefinitions(definitions: Collection>): List = + definitions + .filterIsInstance() + .filter { it.hasDirective("key") } + private fun generateJavaClientEntitiesRepresentations(definitions: Collection>): CodeGenResult = if (config.generateClientApi) { val generatedRepresentations = mutableMapOf() @@ -770,10 +777,7 @@ data class CodeGenResult( javaEnumTypes = javaEnumTypes.concat(current.javaEnumTypes), javaDataFetchers = javaDataFetchers.concat(current.javaDataFetchers), javaQueryTypes = javaQueryTypes.concat(current.javaQueryTypes), - clientProjections = - clientProjections - .concat(current.clientProjections) - .distinctBy { it.packageName() to it.typeSpec().name() }, + clientProjections = clientProjections.concat(current.clientProjections).distinctProjections(), javaConstants = javaConstants.concat(current.javaConstants), kotlinDataTypes = kotlinDataTypes.concat(current.kotlinDataTypes), kotlinInputTypes = kotlinInputTypes.concat(current.kotlinInputTypes), @@ -807,6 +811,28 @@ data class CodeGenResult( .plus(kotlinClientTypes) .toList() + /** + * Drops projections identical to an earlier one, like the `distinct()` used up to 8.7.0, but only renders files + * whose names collide: equality renders the source, and rendering every accumulated file on every merge made + * merging quadratic. + * The package is part of the key, so comparing the rendered type skips `JavaFile`'s import pass. + * Same-named projections that differ are kept; `CodeGen` writes the last one, as 8.7.0 did. + */ + private fun List.distinctProjections(): List { + val byName = HashMap, MutableList>() + val rendered = IdentityHashMap() + val source = { file: JavaFile -> rendered.getOrPut(file) { file.typeSpec().toString() } } + return filter { file -> + val sameName = byName.getOrPut(file.packageName() to file.typeSpec().name()) { mutableListOf() } + if (sameName.any { it === file || source(it) == source(file) }) { + false + } else { + sameName.add(file) + true + } + } + } + private fun List.concat(other: List): List { if (other.isEmpty()) { return this diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt index 86d23552..34a8947d 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt @@ -451,15 +451,27 @@ class ClientApiGenerator internal constructor( * one written replaced the rest on disk. * The type that won there keeps the unqualified name, so clients compiled against it still compile: * `distinct()` reproduces the deduplication and `toMap()` the last write. + * [generateEntities] writes `EntitiesProjectionRoot` after every operation, so a Query field named `entities` loses + * it to [federatedTypes]. */ - internal fun rootProjectionTypes(operations: List): Map = - operations - .flatMap { operation -> - rootFields(operation).mapNotNull { field -> - field.type.findTypeDefinition(schemaIndex, true)?.let { "${field.name.capitalized()}ProjectionRoot" to it.name } - } - }.distinct() - .toMap() + internal fun rootProjectionTypes( + operations: List, + federatedTypes: List = emptyList(), + ): Map { + val rootProjectionTypes = + operations + .flatMap { operation -> + rootFields(operation).mapNotNull { field -> + field.type.findTypeDefinition(schemaIndex, true)?.let { "${field.name.capitalized()}ProjectionRoot" to it.name } + } + }.distinct() + .toMap() + return if (!config.skipEntityQueries && federatedTypes.isNotEmpty()) { + rootProjectionTypes + (ENTITIES_PROJECTION_ROOT to "_entities") + } else { + rootProjectionTypes + } + } private fun rootFields(definition: ObjectTypeDefinition): List = definition.fieldDefinitions @@ -663,7 +675,7 @@ class ClientApiGenerator internal constructor( } private fun createEntitiesRootProjection(federatedTypes: List): CodeGenResult { - val clazzName = "EntitiesProjectionRoot" + val clazzName = ENTITIES_PROJECTION_ROOT val javaType = createProjectionClass(clazzName) .addMethod(createRootProjectionConstructor("_entities")) @@ -970,4 +982,8 @@ class ClientApiGenerator internal constructor( private fun getPackageName(): String = config.packageNameClient private fun getDatatypesPackageName(): String = config.packageNameTypes + + private companion object { + const val ENTITIES_PROJECTION_ROOT = "EntitiesProjectionRoot" + } } diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt index dc9d1d22..ab81f8e7 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt @@ -19,9 +19,11 @@ package com.netflix.graphql.dgs.codegen import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.nio.file.Path import java.util.stream.Stream class ClientProjectionCollisionTest { @@ -116,6 +118,84 @@ class ClientProjectionCollisionTest { assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLQueryProjectionRoot")).contains("value") } + // Up to 8.7.0 the federation root was written after the client one and won on disk + @Test + fun `keeps EntitiesProjectionRoot for federated entities when a query field is named entities`() { + val codeGenResult = + generate( + """ + type Query { + entities: [Movie] + } + + type Movie @key(fields: "id") { + id: ID + title: String + } + """.trimIndent(), + Language.JAVA, + ) + + assertThat(rootProjectionMethods(codeGenResult, "EntitiesProjectionRoot")).contains("onMovie") + assertThat(rootProjectionMethods(codeGenResult, "EntitiesGraphQLQueryProjectionRoot")) + .contains("title") + .doesNotContain("onMovie") + } + + // Query generates MovieFragmentProjection for the union fragment, Mutation for the MovieFragment type; + // 8.7.0 wrote both and the Mutation one won + @Test + fun `writes the last of same-named projections from different operations`( + @TempDir outputDir: Path, + ) { + CodeGen( + CodeGenConfig( + schemas = + setOf( + """ + type Query { + search: SearchResult + } + + type Mutation { + wrap: Wrapper + } + + union SearchResult = Movie | Show + + type Movie { + title: String + } + + type Show { + name: String + } + + type Wrapper { + movieFragment: MovieFragment + } + + type MovieFragment { + fragmentOnly: String + } + """.trimIndent(), + ), + packageName = "com.netflix.test", + generateClientApi = true, + addGeneratedAnnotation = false, + writeToFiles = true, + outputDir = outputDir, + examplesOutputDir = outputDir.resolve("examples"), + generatedDocsFolder = outputDir.resolve("docs"), + ), + ).generate() + + assertThat(outputDir.resolve("com/netflix/test/client/MovieFragmentProjection.java")) + .content() + .contains("fragmentOnly()") + .doesNotContain("title()") + } + private fun generate( schema: String, language: Language, diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodeGenResultTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodeGenResultTest.kt index 99d888bf..8c156ba9 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodeGenResultTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/CodeGenResultTest.kt @@ -27,17 +27,19 @@ import java.time.Duration import javax.lang.model.element.Modifier class CodeGenResultTest { + // Same-named projections that differ must survive the merge: 8.7.0 wrote both and the last one won on disk @Test - fun `client projections are distinct by their generated type`() { + fun `client projections drop only identical duplicates`() { val original = projection("example.client", "MovieProjection", methodCount = 1) - val duplicateType = projection("example.client", "MovieProjection", methodCount = 2) + val identical = projection("example.client", "MovieProjection", methodCount = 1) + val differentContent = projection("example.client", "MovieProjection", methodCount = 2) val sameNameInAnotherPackage = projection("example.admin", "MovieProjection", methodCount = 1) val merged = CodeGenResult(clientProjections = listOf(original)) - .merge(CodeGenResult(clientProjections = listOf(duplicateType, sameNameInAnotherPackage))) + .merge(CodeGenResult(clientProjections = listOf(identical, differentContent, sameNameInAnotherPackage))) - assertThat(merged.clientProjections).containsExactly(original, sameNameInAnotherPackage) + assertThat(merged.clientProjections).containsExactly(original, differentContent, sameNameInAnotherPackage) } @Test