Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spotty-sheep-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-codegen/cli': minor
---

Update watcher to only re-runs generation for blocks with affected watched files
15 changes: 13 additions & 2 deletions packages/graphql-codegen-cli/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ function createCache(): <T>(namespace: string, key: string, factory: () => Promi
}

export async function executeCodegen(
input: CodegenContext | Types.Config
input: CodegenContext | Types.Config,
options: { onlyGeneratesKeys: Record<string, true> } = { onlyGeneratesKeys: {} }
): Promise<{ result: Types.FileOutput[]; error: Error | null }> {
const context = ensureContext(input);
const config = context.getConfig();
Expand Down Expand Up @@ -197,7 +198,17 @@ export async function executeCodegen(
{
title: 'Generate outputs',
task: (ctx, task) => {
const generateTasks: ListrTask<Ctx>[] = Object.keys(generates).map(filename => {
const originalGeneratesKeys = Object.keys(generates);
const foundGeneratesKeys = originalGeneratesKeys.filter(
generatesKey => options.onlyGeneratesKeys[generatesKey]
);
const effectiveGeneratesKeys = foundGeneratesKeys.length === 0 ? originalGeneratesKeys : foundGeneratesKeys;
const hasFilteredDownGeneratesKeys = originalGeneratesKeys.length > effectiveGeneratesKeys.length;
if (hasFilteredDownGeneratesKeys) {
debugLog(`[CLI] Generating partial config:\n${effectiveGeneratesKeys.map(key => `- ${key}`).join('\n')}`);
}

const generateTasks: ListrTask<Ctx>[] = effectiveGeneratesKeys.map(filename => {
const outputConfig = generates[filename];
const hasPreset = !!outputConfig.preset;

Expand Down
36 changes: 22 additions & 14 deletions packages/graphql-codegen-cli/src/utils/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,18 @@ export const allAffirmativePatternsFromPatternSets = (patternSets: PatternSet[])
* a match even if it would be negated by some pattern in documents or schemas
* * The trigger returns true if any output target's local patterns result in
* a match, after considering the precedence of any global and local negations
*
* The result is a function that when given an absolute path,
* it will tell which generates blocks' keys are affected so we can re-run for those keys
*/
export const makeShouldRebuild = ({
globalPatternSet,
localPatternSets,
}: {
globalPatternSet: PatternSet;
localPatternSets: PatternSet[];
}) => {
const localMatchers = localPatternSets.map(localPatternSet => {
localPatternSets: Record<string, PatternSet>;
}): ((params: { path: string }) => Record<string, true>) => {
const localMatchers = Object.entries(localPatternSets).map(([generatesPath, localPatternSet]) => {
return (path: string) => {
// Is path negated by any negating watch pattern?
if (matchesAnyNegatedPattern(path, [...globalPatternSet.watch.negated, ...localPatternSet.watch.negated])) {
Expand All @@ -63,7 +66,7 @@ export const makeShouldRebuild = ({
])
) {
// Immediately return true: Watch pattern takes priority, even if documents or schema would negate it
return true;
return generatesPath;
}

// Does path match documents patterns (without being negated)?
Expand All @@ -74,7 +77,7 @@ export const makeShouldRebuild = ({
]) &&
!matchesAnyNegatedPattern(path, [...globalPatternSet.documents.negated, ...localPatternSet.documents.negated])
) {
return true;
return generatesPath;
}

// Does path match schemas patterns (without being negated)?
Expand All @@ -85,25 +88,30 @@ export const makeShouldRebuild = ({
]) &&
!matchesAnyNegatedPattern(path, [...globalPatternSet.schemas.negated, ...localPatternSet.schemas.negated])
) {
return true;
return generatesPath;
}

// Otherwise, there is no match
return false;
};
});

/**
* Return `true` if `path` should trigger a rebuild
*/
return ({ path: absolutePath }: { path: string }) => {
return ({ path: absolutePath }) => {
if (!isAbsolute(absolutePath)) {
throw new Error('shouldRebuild trigger should be called with absolute path');
}

const path = relative(process.cwd(), absolutePath);
const shouldRebuild = localMatchers.some(matcher => matcher(path));
return shouldRebuild;

const generatesKeysToRebuild: Record<string, true> = {};
for (const matcher of localMatchers) {
const result = matcher(path);
if (result) {
generatesKeysToRebuild[result] = true;
}
}

return generatesKeysToRebuild;
};
};

Expand Down Expand Up @@ -137,7 +145,7 @@ export const makeGlobalPatternSet = (initialContext: CodegenContext) => {
* patterns will be mixed into the pattern set of their respective gobal pattern
* set equivalents.
*/
export const makeLocalPatternSet = (conf: Types.ConfiguredOutput) => {
export const makeLocalPatternSet = (conf: Types.ConfiguredOutput): PatternSet => {
return {
watch: sortPatterns(normalizeInstanceOrArray(conf.watchPattern)),
documents: sortPatterns(
Expand Down Expand Up @@ -216,7 +224,7 @@ type SortedPatterns<PP extends string | NegatedPattern = string | NegatedPattern
* patterns which are separable into "watch" (always takes precedence), "documents",
* and "schemas". This type can hold sorted versions of these patterns.
*/
type PatternSet = {
export type PatternSet = {
watch: SortedPatterns;
documents: SortedPatterns;
schemas: SortedPatterns;
Expand Down
29 changes: 20 additions & 9 deletions packages/graphql-codegen-cli/src/utils/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { debugLog } from './debugging.js';
import { getLogger } from './logger.js';
import {
allAffirmativePatternsFromPatternSets,
type PatternSet,
makeGlobalPatternSet,
makeLocalPatternSet,
makeShouldRebuild,
Expand Down Expand Up @@ -48,10 +49,19 @@ export const createWatcher = (
let config: Types.Config & { configFilePath?: string } = initialContext.getConfig();

const globalPatternSet = makeGlobalPatternSet(initialContext);
const localPatternSets = Object.keys(config.generates)
.map(filename => normalizeOutputParam(config.generates[filename]))
.map(conf => makeLocalPatternSet(conf));
const allAffirmativePatterns = allAffirmativePatternsFromPatternSets([globalPatternSet, ...localPatternSets]);

const localPatternSetArray: PatternSet[] = [];
const localPatternSets = Object.entries(config.generates).reduce<Record<string, PatternSet>>(
(res, [filename, conf]) => {
const patternSet = makeLocalPatternSet(normalizeOutputParam(conf));
res[filename] = patternSet;
localPatternSetArray.push(patternSet);
return res;
},
{}
);

const allAffirmativePatterns = allAffirmativePatternsFromPatternSets([globalPatternSet, ...localPatternSetArray]);

const shouldRebuild = makeShouldRebuild({ globalPatternSet, localPatternSets });

Expand All @@ -75,9 +85,9 @@ export const createWatcher = (

let isShutdown = false;

const debouncedExec = debounce(() => {
const debouncedExec = debounce((generatesKeysToRebuild: Record<string, true>) => {
if (!isShutdown) {
executeCodegen(initialContext)
executeCodegen(initialContext, { onlyGeneratesKeys: generatesKeysToRebuild })
.then(
({ result, error }) => {
// FIXME: this is a quick fix to stop `onNext` (writeOutput) from
Expand Down Expand Up @@ -123,11 +133,12 @@ export const createWatcher = (
watcherSubscription = await parcelWatcher.subscribe(
watchDirectory,
async (_, events) => {
// it doesn't matter what has changed, need to run whole process anyway
await Promise.all(
// NOTE: @parcel/watcher always provides path as an absolute path
events.map(async ({ type: eventName, path }) => {
if (!shouldRebuild({ path })) {
const generatesKeysToRebuild = shouldRebuild({ path });

if (Object.keys(generatesKeysToRebuild).length === 0) {
return;
}

Expand All @@ -151,7 +162,7 @@ export const createWatcher = (
initialContext.updateConfig(config);
}

debouncedExec();
debouncedExec(generatesKeysToRebuild);
})
);
},
Expand Down
126 changes: 104 additions & 22 deletions packages/graphql-codegen-cli/tests/watcher.run.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Mock } from 'vitest';
import * as path from 'path';
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
import { createWatcher } from '../src/utils/watcher.js';
Expand Down Expand Up @@ -39,16 +38,12 @@ const setupTestFiles = (): { testDir: string; schemaFile: TestFilePaths; documen
};
};

const onNextMock = vi.fn();

const setupMockWatcher = async (
codegenContext: ConstructorParameters<typeof CodegenContext>[0],
onNext: Mock = vi.fn().mockResolvedValue([])
) => {
const { stopWatching } = createWatcher(new CodegenContext(codegenContext), onNext);
const setupMockWatcher = async (codegenContext: ConstructorParameters<typeof CodegenContext>[0]) => {
const onNextMock = vi.fn().mockResolvedValue([]);
const { stopWatching } = createWatcher(new CodegenContext(codegenContext), onNextMock);
// After creating watcher, wait for a tick for subscription to be completely set up
await waitForNextEvent();
return { stopWatching };
return { stopWatching, onNextMock };
};

describe('Watch runs', () => {
Expand Down Expand Up @@ -77,22 +72,19 @@ describe('Watch runs', () => {
}
`
);
await waitForNextEvent();
const { stopWatching } = await setupMockWatcher(
{
filepath: path.join(testDir, 'codegen.ts'),
config: {
schema: schemaFile.relative,
documents: documentFile.relative,
generates: {
[path.join(testDir, 'types.ts')]: {
plugins: ['typescript'],
},

const { stopWatching, onNextMock } = await setupMockWatcher({
filepath: path.join(testDir, 'codegen.ts'),
config: {
schema: schemaFile.relative,
documents: documentFile.relative,
generates: {
[path.join(testDir, 'types.ts')]: {
plugins: ['typescript'],
},
},
},
onNextMock
);
});

// 1. Initial setup: onNext in initial run should be called because no errors
expect(onNextMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -129,7 +121,97 @@ describe('Watch runs', () => {
expect(onNextMock).toHaveBeenCalledTimes(2);

await stopWatching();
});

test('only re-runs generates processes based on watched path', 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
}
}
`
);

const generatesKey1 = path.join(testDir, 'types-1.ts');
const generatesKey2 = path.join(testDir, 'types-2.ts');

const { stopWatching, onNextMock } = await setupMockWatcher({
filepath: path.join(testDir, 'codegen.ts'),
config: {
schema: schemaFile.relative,
generates: {
[generatesKey1]: {
plugins: ['typescript'],
},
[generatesKey2]: {
documents: documentFile.relative, // When this file is changed, only this block will be re-generated
plugins: ['typescript'],
},
},
},
});

// 1. Initial setup: onNext in initial run should be called successfully with 2 files,
// because there are no errors
expect(onNextMock.mock.calls[0][0].length).toBe(2);
expect(onNextMock.mock.calls[0][0][0].filename).toBe(generatesKey1);
expect(onNextMock.mock.calls[0][0][1].filename).toBe(generatesKey2);

// 2. Subsequent run 1: update document file for generatesKey2,
// so only the second generates block gets triggered
writeFileSync(
documentFile.absolute,
/* GraphQL */ `
query {
me {
id
name
}
}
`
);
await waitForNextEvent();
expect(onNextMock.mock.calls[1][0].length).toBe(1);
expect(onNextMock.mock.calls[1][0][0].filename).toBe(generatesKey2);

// 2. Subsequent run 2: update schema file, so both generates block are triggered
writeFileSync(
schemaFile.absolute,
/* GraphQL */ `
type Query {
me: User
}

type User {
id: ID!
name: String!
}

scalar DateTime
`
);
await waitForNextEvent();
expect(onNextMock.mock.calls[2][0].length).toBe(2);
expect(onNextMock.mock.calls[2][0][0].filename).toBe(generatesKey1);
expect(onNextMock.mock.calls[2][0][1].filename).toBe(generatesKey2);

await stopWatching();
});
});
Loading