From f328ffdf19ba135bfef5f19f466db8307c005990 Mon Sep 17 00:00:00 2001 From: Iuliia Sobolevska Date: Tue, 29 Sep 2026 21:14:40 -0700 Subject: [PATCH] Make schema ordering cache-safe and relocatable Co-Authored-By: Claude Sonnet 5.5 --- .../netflix/graphql/dgs/codegen/CodeGen.kt | 10 +- .../graphql/dgs/codegen/SchemaMergingTest.kt | 137 ++++++ .../dgs/codegen/gradle/GenerateJavaTask.kt | 31 ++ .../dgs/GenerateJavaTaskBuildCacheTest.kt | 405 ++++++++++++++++++ 4 files changed, 579 insertions(+), 4 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 193c89b88..97158e833 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 @@ -99,7 +99,7 @@ private class InMemorySchemaSource( private data class DependencySchemaSource( val file: File, - val entryIndex: Int, + val entryName: String, val schema: String, ) @@ -274,18 +274,20 @@ class CodeGen private constructor( } } - jarFile.entries().asSequence().forEachIndexed { index, entry -> + jarFile.entries().asSequence().forEach { entry -> if (!entry.isDirectory && entry.name.hasGraphQLSchemaExtension()) { logger.info("Generating schema from {}: {}", file.name, entry.name) val schema = jarFile.getInputStream(entry).bufferedReader().use { it.readText() } - dependencySchemaSources += DependencySchemaSource(file, index, schema) + dependencySchemaSources += DependencySchemaSource(file, entry.name, schema) } } } } + // Jars are ordered by path, as in 8.7.0. Entries within a jar are ordered by name, not by position in the + // archive: Gradle's classpath fingerprint ignores entry order, so the zip order must not affect the output. dependencySchemaSources - .sortedWith(compareBy { it.file }.thenBy { it.entryIndex }) + .sortedWith(compareBy { it.file }.thenBy { it.entryName }) .mapTo(schemaSources) { InMemorySchemaSource(it.schema, "codegen") } return schemaSources } diff --git a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/SchemaMergingTest.kt b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/SchemaMergingTest.kt index 2a139808b..bd2de0f04 100644 --- a/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/SchemaMergingTest.kt +++ b/graphql-dgs-codegen-core/src/test/kotlin/com/netflix/graphql/dgs/codegen/SchemaMergingTest.kt @@ -21,9 +21,146 @@ package com.netflix.graphql.dgs.codegen import com.squareup.kotlinpoet.TypeSpec import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Files +import java.nio.file.Path import java.nio.file.Paths +import java.util.jar.JarOutputStream +import java.util.zip.ZipEntry class SchemaMergingTest { + private fun fooFields(config: CodeGenConfig): List = + CodeGen(config) + .generate() + .javaDataTypes + .single { it.typeSpec().name() == "Foo" } + .typeSpec() + .fieldSpecs() + .map { it.name() } + + private fun createJar( + file: File, + entries: List>, + ): File { + file.parentFile.mkdirs() + JarOutputStream(file.outputStream()).use { jar -> + entries.forEach { (name, content) -> + jar.putNextEntry(ZipEntry(name)) + jar.write(content.toByteArray()) + jar.closeEntry() + } + } + return file + } + + @Test + fun `schema files are ordered by absolute path across roots`( + @TempDir tempDir: Path, + ) { + val baseRoot = Files.createDirectories(tempDir.resolve("base")).toFile() + val zRoot = Files.createDirectories(tempDir.resolve("z-root")).toFile() + val aRoot = Files.createDirectories(tempDir.resolve("a-root")).toFile() + File(baseRoot, "base.graphqls").writeText("type Query { foo: Foo } type Foo { base: String }") + File(zRoot, "same.graphqls").writeText("extend type Foo { fromZ: String }") + File(aRoot, "same.graphqls").writeText("extend type Foo { fromA: String }") + + // Every permutation of the configured roots yields the 8.7.0 order of the extensions: a-root, then z-root. + listOf(baseRoot, zRoot, aRoot).let { roots -> + listOf(roots, roots.reversed(), roots.drop(1) + roots.first()).forEach { + Assertions + .assertThat(fooFields(CodeGenConfig(schemaFiles = it.toSet(), packageName = "com.example"))) + .containsExactly("base", "fromA", "fromZ") + } + } + } + + @Test + fun `dependency jar schemas are ordered by entry name instead of zip order`( + @TempDir tempDir: Path, + ) { + val schemaJar = + createJar( + tempDir.resolve("schemas.jar").toFile(), + listOf( + "c-extension.graphqls" to "extend type Foo { third: String }", + "a-base.graphqls" to "type Query { foo: Foo } type Foo { first: String }", + "b-extension.graphqls" to "extend type Foo { second: String }", + ), + ) + val reorderedJar = + createJar( + tempDir.resolve("reordered/schemas.jar").toFile(), + listOf( + "a-base.graphqls" to "type Query { foo: Foo } type Foo { first: String }", + "b-extension.graphqls" to "extend type Foo { second: String }", + "c-extension.graphqls" to "extend type Foo { third: String }", + ), + ) + + listOf(schemaJar, reorderedJar).forEach { + Assertions + .assertThat(fooFields(CodeGenConfig(schemaJarFilesFromDependencies = listOf(it), packageName = "com.example"))) + .containsExactly("first", "second", "third") + } + } + + @Test + fun `dependency jars are ordered by path, not by input order`( + @TempDir tempDir: Path, + ) { + val aJar = + createJar( + tempDir.resolve("a/lib.jar").toFile(), + listOf("schema.graphqls" to "type Query { foo: Foo } type Foo { base: String } extend type Foo { fromA: String }"), + ) + val zJar = createJar(tempDir.resolve("z/lib.jar").toFile(), listOf("schema.graphqls" to "extend type Foo { fromZ: String }")) + + listOf(listOf(aJar, zJar), listOf(zJar, aJar)).forEach { + Assertions + .assertThat(fooFields(CodeGenConfig(schemaJarFilesFromDependencies = it, packageName = "com.example"))) + .containsExactly("base", "fromA", "fromZ") + } + } + + @Test + fun `dependency type mapping precedence keeps the input jar order`( + @TempDir tempDir: Path, + ) { + val schemaJar = + createJar( + tempDir.resolve("schema.jar").toFile(), + listOf( + "schema.graphqls" to "scalar BigDecimal type Query { foo: Foo } type Foo { value: BigDecimal }", + "META-INF/dgs.codegen.typemappings" to "BigDecimal=java.math.BigDecimal", + ), + ) + val mappingJar = + createJar( + tempDir.resolve("mapping.jar").toFile(), + listOf("META-INF/dgs.codegen.typemappings" to "BigDecimal=java.math.BigInteger"), + ) + + val result = + CodeGen( + CodeGenConfig( + schemaJarFilesFromDependencies = listOf(schemaJar, mappingJar), + packageName = "com.example", + ), + ).generate() + + Assertions + .assertThat( + result.javaDataTypes + .single { it.typeSpec().name() == "Foo" } + .typeSpec() + .fieldSpecs() + .single() + .type() + .toString(), + ).isEqualTo("java.math.BigDecimal") + } + @Test fun mergeSchemasJava() { val schemaDir = Paths.get("src/test/resources/schemas").toAbsolutePath().toFile() diff --git a/graphql-dgs-codegen-gradle/src/main/kotlin/com/netflix/graphql/dgs/codegen/gradle/GenerateJavaTask.kt b/graphql-dgs-codegen-gradle/src/main/kotlin/com/netflix/graphql/dgs/codegen/gradle/GenerateJavaTask.kt index 1d87a7f7f..b37c25a16 100644 --- a/graphql-dgs-codegen-gradle/src/main/kotlin/com/netflix/graphql/dgs/codegen/gradle/GenerateJavaTask.kt +++ b/graphql-dgs-codegen-gradle/src/main/kotlin/com/netflix/graphql/dgs/codegen/gradle/GenerateJavaTask.kt @@ -73,6 +73,28 @@ open class GenerateJavaTask val schemaFiles: ConfigurableFileCollection = objectFactory.fileCollection().from(providerFactory.provider { schemaPaths }) + /** + * The schema roots in the order `CodeGen` reads them. `CodeGen` sorts the files under all roots by absolute + * path, so the roots come out in path order, with `/` appended to a directory because its files continue + * with that separator. A root is taken for a file by its name, so that the order does not probe the file + * system, which would make the configuration cache entry depend on the roots' existence. `schemaFiles` fingerprints each root separately and with relative paths, so it cannot + * see this order. Two layouts with the same files but a different absolute sort order (swapped roots, a + * workspace moved to a path that sorts differently) would share a cache key and produce different output. + * `@Classpath` keeps the order and ignores the paths. Nested roots are not modeled: their files are read + * twice, interleaved by path. + * + * Only the root paths are listed, never the files under them. The files are fingerprinted when the task + * executes. A list of files would be evaluated when the configuration cache entry is stored, before an + * earlier task has produced the schema, and frozen from then on. + */ + @get:Classpath + val orderedSchemaRoots: ConfigurableFileCollection = + objectFactory.fileCollection().from( + providerFactory.provider { schemaFiles.files.sortedBy { if (it.isSchemaFileName()) it.path else it.path + "/" } }, + ) + + private fun File.isSchemaFileName(): Boolean = name.endsWith(".graphql") || name.endsWith(".graphqls") + init { outputs.doNotCacheIf("Generated sources contain timestamps") { addGeneratedAnnotation && @@ -221,6 +243,15 @@ open class GenerateJavaTask project.configurations.findByName("dgsCodegen"), ) + /** + * `CodeGen` orders dependency jars by absolute path, which `dgsCodegenClasspath` (classpath order, no paths) + * cannot see. Same reasoning as [orderedSchemaRoots]. Type mapping precedence follows classpath order and is + * already covered by `dgsCodegenClasspath`. + */ + @get:Classpath + val sortedDgsCodegenJars: ConfigurableFileCollection = + objectFactory.fileCollection().from(providerFactory.provider { dgsCodegenClasspath.files.sorted() }) + @Input val jacksonVersionOverride: ListProperty = objectFactory.listProperty(String::class.java).convention(emptyList()) diff --git a/graphql-dgs-codegen-gradle/src/test/kotlin/com/netflix/graphql/dgs/GenerateJavaTaskBuildCacheTest.kt b/graphql-dgs-codegen-gradle/src/test/kotlin/com/netflix/graphql/dgs/GenerateJavaTaskBuildCacheTest.kt index 866d8205f..728983049 100644 --- a/graphql-dgs-codegen-gradle/src/test/kotlin/com/netflix/graphql/dgs/GenerateJavaTaskBuildCacheTest.kt +++ b/graphql-dgs-codegen-gradle/src/test/kotlin/com/netflix/graphql/dgs/GenerateJavaTaskBuildCacheTest.kt @@ -27,8 +27,217 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File import java.security.MessageDigest +import java.util.jar.JarOutputStream +import java.util.zip.ZipEntry class GenerateJavaTaskBuildCacheTest { + @Test + fun schemaContentSwapsAcrossSameNamedRootsDoNotReuseStaleOutput( + @TempDir tempDir: File, + ) { + val cacheDir = File(tempDir, "shared-build-cache") + val projectDir = createRootsProject(tempDir, "swapped-roots", cacheDir) + writeRoots(projectDir, FIRST_EXTENSION, SECOND_EXTENSION) + + assertThat(run(projectDir, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb") + + writeRoots(projectDir, SECOND_EXTENSION, FIRST_EXTENSION) + assertThat(run(projectDir, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fb", "fa") + + val freshProject = createRootsProject(tempDir, "swapped-roots-fresh", File(tempDir, "unused-cache")) + writeRoots(freshProject, SECOND_EXTENSION, FIRST_EXTENSION) + assertThat(run(freshProject, "--no-build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(outputFingerprints(generatedSources(projectDir))).isEqualTo(outputFingerprints(generatedSources(freshProject))) + } + + @Test + fun workspacesWithADifferentAbsolutePathOrderDoNotShareCachedOutput( + @TempDir tempDir: File, + ) { + val cacheDir = File(tempDir, "shared-build-cache") + // The same relative layout: the project's own schema plus a sibling `m-ext` root. `a-proj` sorts before + // `m-ext` and `z-proj` sorts after it, so CodeGen concatenates the two roots in opposite order. + val aProject = createWorkspace(File(tempDir, "ws-a"), "a-proj", cacheDir) + val zProject = createWorkspace(File(tempDir, "ws-z"), "z-proj", cacheDir) + val zFresh = createWorkspace(File(tempDir, "ws-z-fresh"), "z-proj", File(tempDir, "unused-cache")) + + assertThat(run(aProject, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(aProject)).containsExactly("id", "fp", "fe") + + assertThat(run(zProject, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(zProject)).containsExactly("id", "fe", "fp") + + assertThat(run(zFresh, "--no-build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(outputFingerprints(generatedSources(zProject))).isEqualTo(outputFingerprints(generatedSources(zFresh))) + } + + @Test + fun reversingConfiguredSchemaRootsDoesNotChangeTheOutput( + @TempDir tempDir: File, + ) { + val cacheDir = File(tempDir, "shared-build-cache") + val projectDir = createRootsProject(tempDir, "reversed-roots", cacheDir) + writeRoots(projectDir, FIRST_EXTENSION, SECOND_EXTENSION) + + assertThat(run(projectDir, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + val original = outputFingerprints(generatedSources(projectDir)) + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb") + + run(projectDir, "--build-cache", "-PreverseRoots=true", "generateJava") + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb") + assertThat(outputFingerprints(generatedSources(projectDir))).isEqualTo(original) + + val freshProject = createRootsProject(tempDir, "reversed-roots-fresh", File(tempDir, "unused-cache")) + writeRoots(freshProject, FIRST_EXTENSION, SECOND_EXTENSION) + run(freshProject, "--no-build-cache", "-PreverseRoots=true", "generateJava") + assertThat(outputFingerprints(generatedSources(freshProject))).isEqualTo(original) + } + + @Test + fun dependencyJarEntryOrderDoesNotChangeTheOutput( + @TempDir tempDir: File, + ) { + val cacheDir = File(tempDir, "shared-build-cache") + val projectDir = createJarProject(tempDir, "jar-entry-order", cacheDir) + writeSchemaJar(File(projectDir, "schemas.jar"), listOf("c-extension.graphqls", "a-base.graphqls", "b-extension.graphqls")) + + assertThat(run(projectDir, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fb", "fc") + + writeSchemaJar(File(projectDir, "schemas.jar"), listOf("a-base.graphqls", "b-extension.graphqls", "c-extension.graphqls")) + run(projectDir, "--build-cache", "generateJava") + assertThat(fooFields(projectDir)).containsExactly("base", "fb", "fc") + + val freshProject = createJarProject(tempDir, "jar-entry-order-fresh", File(tempDir, "unused-cache")) + writeSchemaJar(File(freshProject, "schemas.jar"), listOf("a-base.graphqls", "b-extension.graphqls", "c-extension.graphqls")) + assertThat(run(freshProject, "--no-build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(outputFingerprints(generatedSources(projectDir))).isEqualTo(outputFingerprints(generatedSources(freshProject))) + } + + @Test + fun dependencyJarsWithADifferentAbsolutePathOrderDoNotShareCachedOutput( + @TempDir tempDir: File, + ) { + val cacheDir = File(tempDir, "shared-build-cache") + + // The classpath order is always base.jar, then ext.jar. `a-proj/base.jar` sorts before `m-lib/ext.jar`, + // and `m-lib/ext.jar` sorts before `z-proj/base.jar`, so CodeGen reads the jars in opposite order. + fun workspace( + name: String, + projectName: String, + cache: File, + ): File { + val projectDir = createJarProject(File(tempDir, name), projectName, cache, "base.jar", "../m-lib/ext.jar") + writeJar(File(projectDir, "base.jar"), "a-base.graphqls" to BASE_SCHEMA + " extend type Foo { fp: String }") + writeJar(File(projectDir, "../m-lib/ext.jar"), "ext.graphqls" to "extend type Foo { fe: String }") + return projectDir + } + + val aProject = workspace("ws-a", "a-proj", cacheDir) + val zProject = workspace("ws-z", "z-proj", cacheDir) + val zFresh = workspace("ws-z-fresh", "z-proj", File(tempDir, "unused-cache")) + + assertThat(run(aProject, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(aProject)).containsExactly("base", "fp", "fe") + + assertThat(run(zProject, "--build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(zProject)).containsExactly("base", "fe", "fp") + + assertThat(run(zFresh, "--no-build-cache", "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(outputFingerprints(generatedSources(zProject))).isEqualTo(outputFingerprints(generatedSources(zFresh))) + } + + @Test + fun schemaProducedByAnEarlierTaskIsGeneratedUnderTheConfigurationCache( + @TempDir tempDir: File, + ) { + val projectDir = createProducerProject(tempDir, "producer") + File(projectDir, "swap.txt").writeText("plain") + + val first = runWithConfigurationCache(projectDir, "generateJava") + assertThat(first.output).contains("Configuration cache entry stored.") + assertThat(first.task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb") + + val second = runWithConfigurationCache(projectDir, "generateJava") + assertThat(second.output).contains("Reusing configuration cache.") + assertThat(second.task(":generateJava")?.outcome).isEqualTo(UP_TO_DATE) + } + + @Test + fun schemaFileAddedBetweenConfigurationCacheRunsIsPickedUp( + @TempDir tempDir: File, + ) { + val projectDir = createRootsProject(tempDir, "added-file", File(tempDir, "unused-cache")) + writeRoots(projectDir, FIRST_EXTENSION, SECOND_EXTENSION) + + assertThat(runWithConfigurationCache(projectDir, "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb") + + File(projectDir, "schemas/base/added.graphqls").writeText("extend type Foo { fz: String }") + val second = runWithConfigurationCache(projectDir, "generateJava") + assertThat(second.output).contains("Reusing configuration cache.") + assertThat(second.task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb", "fz") + } + + @Test + fun swapOfSameNamedFilesFromAProducerTaskReexecutesUnderTheConfigurationCache( + @TempDir tempDir: File, + ) { + val projectDir = createProducerProject(tempDir, "producer-swap") + File(projectDir, "swap.txt").writeText("plain") + + assertThat(runWithConfigurationCache(projectDir, "generateJava").task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fa", "fb") + + File(projectDir, "swap.txt").writeText("swap") + val second = runWithConfigurationCache(projectDir, "generateJava") + assertThat(second.output).contains("Reusing configuration cache.") + assertThat(second.task(":produce")?.outcome).isEqualTo(SUCCESS) + assertThat(second.task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base", "fb", "fa") + } + + @Test + fun unbuiltSchemaProjectDependencyWorksUnderTheConfigurationCache( + @TempDir tempDir: File, + ) { + val projectDir = File(tempDir, "project-dependency").also { it.mkdirs() } + File(projectDir, "settings.gradle").writeText("include 'schemas'") + File(projectDir, "build.gradle").writeText( + """ + plugins { + id 'java' + id 'com.netflix.dgs.codegen' + } + + codegen.clientCoreConventionsEnabled = false + + dependencies { + dgsCodegen project(':schemas') + } + + generateJava { + schemaPaths = [] + packageName = 'com.netflix.testproject.graphql' + generatedSourcesDir = file('build/graphql').absolutePath + } + """.trimIndent(), + ) + File(projectDir, "schemas/src/main/resources").mkdirs() + File(projectDir, "schemas/build.gradle").writeText("plugins { id 'java' }") + File(projectDir, "schemas/src/main/resources/schema.graphqls").writeText(BASE_SCHEMA) + + val first = runWithConfigurationCache(projectDir, "generateJava") + assertThat(first.output).contains("Configuration cache entry stored.") + assertThat(first.task(":schemas:jar")?.outcome).isEqualTo(SUCCESS) + assertThat(first.task(":generateJava")?.outcome).isEqualTo(SUCCESS) + assertThat(fooFields(projectDir)).containsExactly("base") + } + @Test fun derivesOutputProvidersFromGeneratedSourcesDir( @TempDir tempDir: File, @@ -180,6 +389,195 @@ class GenerateJavaTaskBuildCacheTest { return projectDir } + private fun settingsFile(cacheDir: File): String = + """ + buildCache { + local { + directory = file('${cacheDir.invariantSeparatorsPath}') + } + } + """.trimIndent() + + private fun createRootsProject( + parent: File, + name: String, + cacheDir: File, + ): File { + val projectDir = File(parent, name).also { it.mkdirs() } + File(projectDir, "settings.gradle").writeText(settingsFile(cacheDir)) + File(projectDir, "build.gradle").writeText( + """ + plugins { + id 'com.netflix.dgs.codegen' + } + + codegen.clientCoreConventionsEnabled = false + + generateJava { + schemaPaths = project.hasProperty('reverseRoots') ? + [file('schemas/b'), file('schemas/a'), file('schemas/base')] : + [file('schemas/base'), file('schemas/a'), file('schemas/b')] + packageName = 'com.netflix.testproject.graphql' + generatedSourcesDir = file('build/graphql').absolutePath + } + """.trimIndent(), + ) + return projectDir + } + + private fun writeRoots( + projectDir: File, + aSchema: String, + bSchema: String, + ) { + listOf("a" to aSchema, "b" to bSchema, "base" to BASE_SCHEMA).forEach { (root, schema) -> + File(projectDir, "schemas/$root").mkdirs() + File(projectDir, "schemas/$root/${if (root == "base") "base" else "same"}.graphqls").writeText(schema) + } + } + + private fun createWorkspace( + workspace: File, + projectName: String, + cacheDir: File, + ): File { + val projectDir = File(workspace, projectName).also { it.mkdirs() } + File(projectDir, "settings.gradle").writeText(settingsFile(cacheDir)) + File(projectDir, "build.gradle").writeText( + """ + plugins { + id 'com.netflix.dgs.codegen' + } + + codegen.clientCoreConventionsEnabled = false + + generateJava { + schemaPaths = [file('schema'), file('../m-ext')] + packageName = 'com.netflix.testproject.graphql' + generatedSourcesDir = file('build/graphql').absolutePath + } + """.trimIndent(), + ) + File(projectDir, "schema").mkdirs() + File(projectDir, "schema/base.graphqls").writeText("type Query { foo: Foo } type Foo { id: ID } extend type Foo { fp: String }") + File(workspace, "m-ext").mkdirs() + File(workspace, "m-ext/ext.graphqls").writeText("extend type Foo { fe: String }") + return projectDir + } + + private fun createJarProject( + parent: File, + name: String, + cacheDir: File, + vararg jars: String = arrayOf("schemas.jar"), + ): File { + val projectDir = File(parent, name).also { it.mkdirs() } + File(projectDir, "settings.gradle").writeText(settingsFile(cacheDir)) + File(projectDir, "build.gradle").writeText( + """ + plugins { + id 'com.netflix.dgs.codegen' + } + + codegen.clientCoreConventionsEnabled = false + + dependencies { + dgsCodegen files(${jars.joinToString { "'$it'" }}) + } + + generateJava { + schemaPaths = [] + packageName = 'com.netflix.testproject.graphql' + generatedSourcesDir = file('build/graphql').absolutePath + } + """.trimIndent(), + ) + return projectDir + } + + /** The schema roots are written by `produce`, so they do not exist until generateJava's first execution. */ + private fun createProducerProject( + parent: File, + name: String, + ): File { + val projectDir = File(parent, name).also { it.mkdirs() } + File(projectDir, "settings.gradle").writeText("") + File(projectDir, "build.gradle").writeText( + """ + plugins { + id 'com.netflix.dgs.codegen' + } + + codegen.clientCoreConventionsEnabled = false + + def swapFile = layout.projectDirectory.file('swap.txt') + def gen = layout.buildDirectory.dir('gen') + tasks.register('produce') { + inputs.file(swapFile) + outputs.dir(gen) + doLast { + def swap = swapFile.asFile.text.trim() == 'swap' + def dir = gen.get().asFile + ['a', 'b', 'base'].each { new File(dir, it).mkdirs() } + new File(dir, 'base/base.graphqls').text = '$BASE_SCHEMA' + new File(dir, 'a/same.graphqls').text = swap ? '$SECOND_EXTENSION' : '$FIRST_EXTENSION' + new File(dir, 'b/same.graphqls').text = swap ? '$FIRST_EXTENSION' : '$SECOND_EXTENSION' + } + } + + generateJava { + dependsOn 'produce' + schemaPaths = ['base', 'a', 'b'].collect { gen.get().dir(it).asFile } + packageName = 'com.netflix.testproject.graphql' + generatedSourcesDir = file('build/graphql').absolutePath + } + """.trimIndent(), + ) + return projectDir + } + + private fun writeSchemaJar( + jar: File, + entryOrder: List, + ) { + val schemas = + mapOf( + "a-base.graphqls" to BASE_SCHEMA, + "b-extension.graphqls" to SECOND_EXTENSION, + "c-extension.graphqls" to THIRD_EXTENSION, + ) + writeJar(jar, *entryOrder.map { it to schemas.getValue(it) }.toTypedArray()) + } + + private fun writeJar( + jar: File, + vararg entries: Pair, + ) { + jar.parentFile.mkdirs() + JarOutputStream(jar.outputStream()).use { out -> + entries.forEach { (name, content) -> + out.putNextEntry(ZipEntry(name)) + out.write(content.toByteArray()) + out.closeEntry() + } + } + } + + private fun generatedSources(projectDir: File): File = File(projectDir, "build/graphql/generated/sources/dgs-codegen") + + /** The field names of the generated `Foo`, in declaration order (the nested builder repeats them). */ + private fun fooFields(projectDir: File): List = + Regex("""private \w+ (\w+);""") + .findAll(File(generatedSources(projectDir), "com/netflix/testproject/graphql/types/Foo.java").readText()) + .map { it.groupValues[1] } + .distinct() + .toList() + + private fun runWithConfigurationCache( + projectDir: File, + vararg arguments: String, + ): BuildResult = run(projectDir, "--configuration-cache", "--configuration-cache-problems=fail", *arguments) + private fun run( projectDir: File, vararg arguments: String, @@ -211,4 +609,11 @@ class GenerateJavaTaskBuildCacheTest { .digest(file.readBytes()) .joinToString("") { "%02x".format(it) } } + + private companion object { + const val BASE_SCHEMA = "type Query { foo: Foo } type Foo { base: String }" + const val FIRST_EXTENSION = "extend type Foo { fa: String }" + const val SECOND_EXTENSION = "extend type Foo { fb: String }" + const val THIRD_EXTENSION = "extend type Foo { fc: String }" + } }