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..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]. @@ -387,13 +394,17 @@ class CodeGen private constructor( private fun generateJavaClientApi(definitions: Collection>): CodeGenResult { val methodNames = mutableSetOf() 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, federatedDefinitions(definitions)) + operations .asSequence() - .filterIsInstance() - .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 @@ -402,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() @@ -767,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), @@ -804,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 91e2e3c7..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 @@ -74,16 +74,24 @@ class ClientApiGenerator internal constructor( fun generate( definition: ObjectTypeDefinition, methodNames: MutableSet, + ): CodeGenResult = generate(definition, methodNames, rootProjectionTypes(listOf(definition))) + + /** + * Pass [rootProjectionTypes] computed over every operation, or same-named fields returning different types collide. + */ + internal fun generate( + definition: ObjectTypeDefinition, + methodNames: MutableSet, + rootProjectionTypes: Map, ): CodeGenResult = - definition.fieldDefinitions - .filterIncludedInConfig(definition.name, config) - .filterSkipped() + rootFields(definition) .map { val javaFile = createQueryClass(it, definition.name, methodNames) 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 +442,58 @@ class ClientApiGenerator internal constructor( .addCode("""super(null, null, java.util.Optional.of("$typeName"));""") .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`. + * 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. + * [generateEntities] writes `EntitiesProjectionRoot` after every operation, so a Query field named `entities` loses + * it to [federatedTypes]. + */ + 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 + .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: Map, + ): String = + if (rootProjectionTypes["${fieldName}ProjectionRoot"] == type.name) { + fieldName + } else { + "${fieldName}GraphQL${operation.capitalized()}" + } + private fun createRootProjection( type: TypeDefinition<*>, prefix: String, @@ -615,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")) @@ -922,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 new file mode 100644 index 00000000..ab81f8e7 --- /dev/null +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/ClientProjectionCollisionTest.kt @@ -0,0 +1,288 @@ +/* + * + * 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.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 { + @ParameterizedTest(name = "{0}, {1}") + @MethodSource("codegenCases") + fun `generates a root projection per operation when same-named fields return different types`( + language: Language, + schemaOrder: String, + schema: String, + ) { + val codeGenResult = generate(schema, language) + + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLQueryProjectionRoot")) + .contains("queryOnly") + .doesNotContain("mutationOnly", "subscriptionOnly") + assertThat(rootProjectionMethods(codeGenResult, "ResultGraphQLMutationProjectionRoot")) + .contains("mutationOnly") + .doesNotContain("queryOnly", "subscriptionOnly") + 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 = + generate( + """ + type Query { + result: Result + } + + 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", "ResultGraphQLQueryProjectionRoot", "ResultGraphQLSubscriptionProjectionRoot") + .doesNotContain("ResultGraphQLMutationProjectionRoot") + assertThat(rootProjectionMethods(codeGenResult, "ResultProjectionRoot")).contains("other") + 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, + ): 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 = + """ + type Query { + result: QueryResult + } + + type QueryResult { + queryOnly: String + } + + type Mutation { + result: MutationResult + } + + type MutationResult { + mutationOnly: String + } + + type Subscription { + result: SubscriptionResult + } + + type SubscriptionResult { + subscriptionOnly: String + } + """.trimIndent() + + private val subscriptionFirstSchema = + """ + type Subscription { + result: SubscriptionResult + } + + type SubscriptionResult { + subscriptionOnly: String + } + + 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, "subscription first", subscriptionFirstSchema), + Arguments.of(Language.KOTLIN, "query first", queryFirstSchema), + Arguments.of(Language.KOTLIN, "subscription first", subscriptionFirstSchema), + ) + } +} 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