diff --git a/.changeset/tall-moments-count.md b/.changeset/tall-moments-count.md new file mode 100644 index 00000000000..9fc031aed6f --- /dev/null +++ b/.changeset/tall-moments-count.md @@ -0,0 +1,13 @@ +--- +'@graphql-codegen/plugin-helpers': minor +'@graphql-codegen/cli': minor +--- + +Extend `overwrite` with `overwrite.removeStaleFiles` and `overwrite.updateExistingFiles` + +`overwrite` was being used to both remove stale files in watch mode and update existing files. Some plugins such as Server Preset may dynamically return files to write between watch runs (for performance purposes). + +The `overwrite` can now take an object with `overwrite.removeStaleFiles` and `overwrite.updateExistingFiles` fields to allow granular control over actions. + +This is not a breaking change because `overwrite=true|false` still works. + diff --git a/packages/graphql-codegen-cli/src/generate-and-save.ts b/packages/graphql-codegen-cli/src/generate-and-save.ts index dfa339d3274..b8c41d28aa6 100644 --- a/packages/graphql-codegen-cli/src/generate-and-save.ts +++ b/packages/graphql-codegen-cli/src/generate-and-save.ts @@ -37,7 +37,7 @@ export async function generate( // find stale files from previous build which are not present in current build const staleFilenames = previouslyGeneratedFilenames.filter(f => !filenames.includes(f)); for (const filename of staleFilenames) { - if (shouldOverwrite(config, filename)) { + if (normalizeOverwriteConfig(config, filename).removeStaleFiles) { unlinkFile(filename, err => { const prettyFilename = filename.replace(`${input.cwd || process.cwd()}/`, ''); if (err) { @@ -79,7 +79,7 @@ export async function generate( recentOutputHash.set(result.filename, previousHash); } - if (!shouldOverwrite(config, result.filename) && exists) { + if (!normalizeOverwriteConfig(config, result.filename).updateExistingFiles && exists) { return; } @@ -189,20 +189,42 @@ export async function generate( return outputFiles; } -function shouldOverwrite(config: Types.Config, outputPath: string): boolean { - const globalValue = config.overwrite === undefined ? true : !!config.overwrite; - const outputConfig = config.generates[outputPath]; +function normalizeOverwriteConfig( + config: Types.Config, + outputPath: string, +): Types.NormalizedOverwriteOption { + const overwrite = (function getOverwriteOption(): Types.Config['overwrite'] { + const { overwrite: result = true } = config; + const outputConfig = config.generates[outputPath]; + + if (!outputConfig) { + debugLog(`Couldn't find a config of ${outputPath}`); + return result; + } + if (isConfiguredOutput(outputConfig) && outputConfig.overwrite !== undefined) { + return outputConfig.overwrite; + } - if (!outputConfig) { - debugLog(`Couldn't find a config of ${outputPath}`); - return globalValue; + return result; + })(); + + if (overwrite === true) { + return { + removeStaleFiles: true, + updateExistingFiles: true, + }; } - if (isConfiguredOutput(outputConfig) && typeof outputConfig.overwrite === 'boolean') { - return outputConfig.overwrite; + if (overwrite === false) { + return { + removeStaleFiles: false, + updateExistingFiles: false, + }; } - return globalValue; + const { removeStaleFiles = true, updateExistingFiles = true } = overwrite; + + return { removeStaleFiles, updateExistingFiles }; } function isConfiguredOutput(output: any): output is Types.ConfiguredOutput { diff --git a/packages/graphql-codegen-cli/tests/generate-and-save.spec.ts b/packages/graphql-codegen-cli/tests/generate-and-save.spec.ts index dc5b7074d71..98210ad3788 100644 --- a/packages/graphql-codegen-cli/tests/generate-and-save.spec.ts +++ b/packages/graphql-codegen-cli/tests/generate-and-save.spec.ts @@ -165,6 +165,132 @@ describe('generate-and-save', () => { expect(writeSpy).toHaveBeenCalled(); }); + test('should write to an existing file when global overwrite.updateExistingFiles=true', async () => { + const filename = 'overwrite.ts'; + writeSpy.mockImplementation(() => Promise.resolve()); + readSpy.mockImplementation(async () => ''); // forces file to exist + + const output = await generate( + { + schema: SIMPLE_TEST_SCHEMA, + overwrite: { + updateExistingFiles: true, + removeStaleFiles: false, + }, + generates: { + [filename]: { + schema: ` + type OtherType { a: String } + `, + plugins: ['typescript'], + }, + }, + }, + true, + ); + + expect(output.length).toBe(1); + // makes sure it checks if file is there + expect(readSpy).toHaveBeenCalledWith(filename); + // makes sure it writes a new file + expect(writeSpy).toHaveBeenCalledTimes(1); + }); + + test('should NOT write to an existing file when global overwrite.updateExistingFiles=false', async () => { + const filename = 'overwrite.ts'; + writeSpy.mockImplementation(() => Promise.resolve()); + readSpy.mockImplementation(async () => ''); // forces file to exist + + const output = await generate( + { + schema: SIMPLE_TEST_SCHEMA, + overwrite: { + updateExistingFiles: false, + removeStaleFiles: true, + }, + generates: { + [filename]: { + schema: ` + type OtherType { a: String } + `, + plugins: ['typescript'], + }, + }, + }, + true, + ); + + expect(output.length).toBe(1); + // makes sure it checks if file is there + expect(readSpy).toHaveBeenCalledWith(filename); + // makes sure it doesn't write a new file + expect(writeSpy).not.toHaveBeenCalled(); + }); + + test("should write to an existing file when specific output's overwrite.updateExistingFiles=true", async () => { + const filename = 'overwrite.ts'; + writeSpy.mockImplementation(() => Promise.resolve()); + readSpy.mockImplementation(async () => ''); // forces file to exist + + const output = await generate( + { + schema: SIMPLE_TEST_SCHEMA, + overwrite: false, + generates: { + [filename]: { + overwrite: { + updateExistingFiles: true, + }, + schema: ` + type OtherType { a: String } + `, + plugins: ['typescript'], + }, + }, + }, + true, + ); + + expect(output.length).toBe(1); + // makes sure it checks if file is there + expect(readSpy).toHaveBeenCalledWith(filename); + // makes sure it writes a new file + expect(writeSpy).toHaveBeenCalledTimes(1); + }); + + test("should NOT write to an existing file when specific output's overwrite.updateExistingFiles=false", async () => { + const filename = 'overwrite.ts'; + writeSpy.mockImplementation(() => Promise.resolve()); + readSpy.mockImplementation(async () => ''); // forces file to exist + + const output = await generate( + { + schema: SIMPLE_TEST_SCHEMA, + overwrite: { + updateExistingFiles: true, + }, + generates: { + [filename]: { + overwrite: { + updateExistingFiles: false, + }, + schema: ` + type OtherType { a: String } + `, + plugins: ['typescript'], + }, + }, + }, + true, + ); + + expect(output.length).toBe(1); + // makes sure it checks if file is there + expect(readSpy).toHaveBeenCalledWith(filename); + // makes sure it doesn't write a new file + expect(writeSpy).not.toHaveBeenCalled(); + }); + test('should override generated files', async () => { vi.unmock('fs'); const fs = await import('fs'); diff --git a/packages/graphql-codegen-cli/tests/watcher.run.spec.ts b/packages/graphql-codegen-cli/tests/watcher.run.spec.ts index 8146fb27f41..3f3df677338 100644 --- a/packages/graphql-codegen-cli/tests/watcher.run.spec.ts +++ b/packages/graphql-codegen-cli/tests/watcher.run.spec.ts @@ -1,8 +1,11 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs'; import * as path from 'path'; import type { Mock } from 'vitest'; import { CodegenContext } from '../src/config.js'; -import { createWatcher } from '../src/utils/watcher.js'; +import { generate } from '../src/generate-and-save.js'; +import * as watcherModule from '../src/utils/watcher.js'; + +const createWatcherSpy = vi.spyOn(watcherModule, 'createWatcher'); /** * waitForNextEvent @@ -49,7 +52,7 @@ const setupMockWatcher = async ( codegenContext: ConstructorParameters[0], onNext: Mock = vi.fn().mockResolvedValue([]), ) => { - const { stopWatching } = createWatcher(new CodegenContext(codegenContext), onNext); + const { stopWatching } = watcherModule.createWatcher(new CodegenContext(codegenContext), onNext); // After creating watcher, wait for a tick for subscription to be completely set up await waitForNextEvent(); return { stopWatching }; @@ -137,3 +140,176 @@ describe('Watch runs', () => { await waitForNextEvent(); }); }); + +describe('Watch runs - overwrite.removeStaleFiles', () => { + const runWatchAndGetStopWatching = async ( + codegenContext: ConstructorParameters[0], + ) => { + const context = new CodegenContext(codegenContext); + const runningWatcher = generate(context); + await waitForNextEvent(); + + const { stopWatching } = createWatcherSpy.mock.results.at(-1)!.value as ReturnType< + typeof watcherModule.createWatcher + >; + + return { context, runningWatcher, stopWatching }; + }; + + test('removes a stale generated file on rebuild when overwrite.removeStaleFiles=true', async () => { + const { testDir, schemaFile, documentFile } = setupTestFiles(); + writeFileSync( + schemaFile.absolute, + /* GraphQL */ ` + type Query { + me: User + } + + type User { + id: ID! + name: String! + } + `, + ); + writeFileSync( + documentFile.absolute, + /* GraphQL */ ` + query { + me { + id + } + } + `, + ); + await waitForNextEvent(); + + const keptOutputFile = path.join(testDir, 'kept.ts'); + const staleOutputFile = path.join(testDir, 'stale.ts'); + + const { context, runningWatcher, stopWatching } = await runWatchAndGetStopWatching({ + filepath: path.join(testDir, 'codegen.ts'), + config: { + schema: schemaFile.relative, + documents: documentFile.relative, + watch: true, + overwrite: { + removeStaleFiles: true, + updateExistingFiles: true, + }, + generates: { + [keptOutputFile]: { plugins: ['typescript'] }, + [staleOutputFile]: { plugins: ['typescript'] }, + }, + }, + }); + + // Initial run: both outputs are generated + expect(existsSync(keptOutputFile)).toBe(true); + expect(existsSync(staleOutputFile)).toBe(true); + + // Simulate the config no longer producing `staleOutputFile` (e.g. removed from codegen config) + context.updateConfig({ + generates: { + [keptOutputFile]: { plugins: ['typescript'] }, + }, + }); + writeFileSync( + documentFile.absolute, + /* GraphQL */ ` + query { + me { + id + name + } + } + `, + ); + await waitForNextEvent(); + + expect(existsSync(keptOutputFile)).toBe(true); + expect(existsSync(staleOutputFile)).toBe(false); + + await stopWatching(); + await runningWatcher; + await waitForNextEvent(); + }); + + test('keeps a stale generated file on rebuild when overwrite.removeStaleFiles=false', async () => { + const { testDir, schemaFile, documentFile } = setupTestFiles(); + writeFileSync( + schemaFile.absolute, + /* GraphQL */ ` + type Query { + me: User + } + + type User { + id: ID! + name: String! + } + `, + ); + writeFileSync( + documentFile.absolute, + /* GraphQL */ ` + query { + me { + id + } + } + `, + ); + await waitForNextEvent(); + + const keptOutputFile = path.join(testDir, 'kept.ts'); + const staleOutputFile = path.join(testDir, 'stale.ts'); + + const { context, runningWatcher, stopWatching } = await runWatchAndGetStopWatching({ + filepath: path.join(testDir, 'codegen.ts'), + config: { + schema: schemaFile.relative, + documents: documentFile.relative, + watch: true, + overwrite: { + removeStaleFiles: false, + updateExistingFiles: true, + }, + generates: { + [keptOutputFile]: { plugins: ['typescript'] }, + [staleOutputFile]: { plugins: ['typescript'] }, + }, + }, + }); + + // Initial run: both outputs are generated + expect(existsSync(keptOutputFile)).toBe(true); + expect(existsSync(staleOutputFile)).toBe(true); + + // Simulate the config no longer producing `staleOutputFile` (e.g. removed from codegen config) + context.updateConfig({ + generates: { + [keptOutputFile]: { plugins: ['typescript'] }, + }, + }); + writeFileSync( + documentFile.absolute, + /* GraphQL */ ` + query { + me { + id + name + } + } + `, + ); + await waitForNextEvent(); + + expect(existsSync(keptOutputFile)).toBe(true); + // removeStaleFiles=false means the stale file is left on disk + expect(existsSync(staleOutputFile)).toBe(true); + + await stopWatching(); + await runningWatcher; + await waitForNextEvent(); + }); +}); diff --git a/packages/utils/plugins-helpers/src/types.ts b/packages/utils/plugins-helpers/src/types.ts index f323b6a038f..a4144dc86c2 100644 --- a/packages/utils/plugins-helpers/src/types.ts +++ b/packages/utils/plugins-helpers/src/types.ts @@ -288,7 +288,7 @@ export namespace Types { * * For more details: https://graphql-code-generator.com/docs/config-reference/codegen-config */ - overwrite?: boolean; + overwrite?: boolean | Partial; /** * @description A pointer(s) to your GraphQL documents: query, mutation, subscription and fragment. These documents will be loaded into for all your output files. * You can use one of the following: @@ -471,7 +471,7 @@ export namespace Types { * * For more details: https://graphql-code-generator.com/docs/config-reference/codegen-config */ - overwrite?: boolean; + overwrite?: boolean | Partial; /** * @description A flag to trigger codegen when there are changes in the specified GraphQL schemas. * @@ -571,6 +571,11 @@ export namespace Types { cwd?: string; } + export type NormalizedOverwriteOption = { + removeStaleFiles: boolean; + updateExistingFiles: boolean; + }; + export type ComplexPluginOutput> = { content: string; prepend?: string[]; diff --git a/website/src/pages/docs/config-reference/codegen-config.mdx b/website/src/pages/docs/config-reference/codegen-config.mdx index 8de7dea1ad5..7880e7c9db4 100644 --- a/website/src/pages/docs/config-reference/codegen-config.mdx +++ b/website/src/pages/docs/config-reference/codegen-config.mdx @@ -81,7 +81,7 @@ Here are the supported options that you can define in the config file (see for the specific output file - **`generates.overwrite`** - Same as root `overwrite`, but applies only for the specific output - file + file. When set, it takes precedence over the root `overwrite` option for this output file - [**`require`**](./require-field) - A path to a file which defines custom Node.JS `require()` handlers for custom file extensions. This option is essential if the code generator has to go @@ -98,7 +98,24 @@ Here are the supported options that you can define in the config file (see more information. You can read more about passing configuration to plugins [here](./config-field) - **`overwrite`** - A flag to overwrite files if they already exist when generating code (`true` by - default) + default). Instead of a boolean, you can pass an object to control the two overwrite behaviors + independently: + + - **`overwrite.updateExistingFiles`** - Whether to overwrite the content of a generated file that + already exists on disk (`true` by default) + - **`overwrite.removeStaleFiles`** - Whether to delete previously generated files that are no + longer produced by the current run. This only takes effect in + [watch mode](/docs/getting-started/development-workflow#watch-mode) (`true` by default) + + ```ts + const config: CodegenConfig = { + // ... + overwrite: { + updateExistingFiles: true, + removeStaleFiles: false + } + } + ``` - **`watch`** - A flag to trigger codegen when there are changes in the specified GraphQL schemas. You can either specify a boolean to turn it on/off or specify an array of glob patterns to add