Skip to content

Commit cb9adeb

Browse files
authored
Shared documents validation (#7494)
1 parent 51caa6e commit cb9adeb

File tree

6 files changed

+132
-54
lines changed

6 files changed

+132
-54
lines changed

‎.changeset/spicy-bananas-destroy.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@graphql-codegen/cli': patch
3+
'@graphql-codegen/core': patch
4+
'@graphql-codegen/plugin-helpers': patch
5+
---
6+
7+
Cache validation of documents

‎packages/graphql-codegen-cli/src/codegen.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,22 @@ const makeDefaultLoader = (from: string) => {
3838
};
3939
};
4040

41-
// TODO: Replace any with types
42-
function createCache<T>(loader: (key: string) => Promise<T>) {
43-
const cache = new Map<string, Promise<T>>();
44-
45-
return {
46-
load(key: string): Promise<T> {
47-
if (cache.has(key)) {
48-
return cache.get(key);
49-
}
41+
function createCache(): <T>(namespace: string, key: string, factory: () => Promise<T>) => Promise<T> {
42+
const cache = new Map<string, Promise<unknown>>();
5043

51-
const value = loader(key);
44+
return function ensure<T>(namespace: string, key: string, factory: () => Promise<T>): Promise<T> {
45+
const cacheKey = `${namespace}:${key}`;
5246

53-
cache.set(key, value);
54-
return value;
55-
},
47+
const cachedValue = cache.get(cacheKey);
48+
49+
if (cachedValue) {
50+
return cachedValue as Promise<T>;
51+
}
52+
53+
const value = factory();
54+
cache.set(cacheKey, value);
55+
56+
return value;
5657
};
5758
}
5859

@@ -93,21 +94,8 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom
9394
let rootDocuments: Types.OperationDocument[];
9495
const generates: { [filename: string]: Types.ConfiguredOutput } = {};
9596

96-
const schemaLoadingCache = createCache(async function (hash) {
97-
const outputSchemaAst = await context.loadSchema(JSON.parse(hash));
98-
const outputSchema = getCachedDocumentNodeFromSchema(outputSchemaAst);
99-
return {
100-
outputSchemaAst: outputSchemaAst,
101-
outputSchema: outputSchema,
102-
};
103-
});
97+
const cache = createCache();
10498

105-
const documentsLoadingCache = createCache(async function (hash) {
106-
const documents = await context.loadDocuments(JSON.parse(hash));
107-
return {
108-
documents: documents,
109-
};
110-
});
11199
function wrapTask(task: () => void | Promise<void>, source: string, taskName: string) {
112100
return () => {
113101
return context.profiler.run(async () => {
@@ -253,7 +241,14 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom
253241
}
254242

255243
const hash = JSON.stringify(schemaPointerMap);
256-
const result = await schemaLoadingCache.load(hash);
244+
const result = await cache('schema', hash, async () => {
245+
const outputSchemaAst = await context.loadSchema(schemaPointerMap);
246+
const outputSchema = getCachedDocumentNodeFromSchema(outputSchemaAst);
247+
return {
248+
outputSchemaAst: outputSchemaAst,
249+
outputSchema: outputSchema,
250+
};
251+
});
257252

258253
outputSchemaAst = await result.outputSchemaAst;
259254
outputSchema = result.outputSchema;
@@ -272,7 +267,12 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom
272267
const results = await Promise.all(
273268
[rootDocuments, outputSpecificDocuments].map(docs => {
274269
const hash = JSON.stringify(docs);
275-
return documentsLoadingCache.load(hash);
270+
return cache('documents', hash, async () => {
271+
const documents = await context.loadDocuments(docs);
272+
return {
273+
documents: documents,
274+
};
275+
});
276276
})
277277
);
278278

@@ -356,7 +356,10 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom
356356
}
357357

358358
const process = async (outputArgs: Types.GenerateOptions) => {
359-
const output = await codegen(outputArgs);
359+
const output = await codegen({
360+
...outputArgs,
361+
cache,
362+
});
360363
result.push({
361364
filename: outputArgs.filename,
362365
content: output,

‎packages/graphql-codegen-cli/src/config.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import { cosmiconfig, defaultLoaders } from 'cosmiconfig';
22
import { resolve } from 'path';
3-
import { DetailedError, Types, Profiler, createProfiler, createNoopProfiler } from '@graphql-codegen/plugin-helpers';
3+
import {
4+
DetailedError,
5+
Types,
6+
Profiler,
7+
createProfiler,
8+
createNoopProfiler,
9+
getCachedDocumentNodeFromSchema,
10+
} from '@graphql-codegen/plugin-helpers';
411
import { env } from 'string-env-interpolation';
512
import yargs from 'yargs';
613
import { GraphQLConfig } from 'graphql-config';
714
import { findAndLoadGraphQLConfig } from './graphql-config';
815
import { loadSchema, loadDocuments, defaultSchemaLoadOptions, defaultDocumentsLoadOptions } from './load';
9-
import { GraphQLSchema } from 'graphql';
16+
import { GraphQLSchema, print, GraphQLSchemaExtensions } from 'graphql';
1017
import yaml from 'yaml';
1118
import { createRequire } from 'module';
1219
import { promises } from 'fs';
20+
import { createHash } from 'crypto';
1321

1422
const { lstat } = promises;
1523

@@ -363,24 +371,65 @@ export class CodegenContext {
363371
const config = this.getConfig(defaultSchemaLoadOptions);
364372
if (this._graphqlConfig) {
365373
// TODO: SchemaWithLoader won't work here
366-
return this._graphqlConfig.getProject(this._project).loadSchema(pointer, 'GraphQLSchema', config);
374+
return addHashToSchema(
375+
this._graphqlConfig.getProject(this._project).loadSchema(pointer, 'GraphQLSchema', config)
376+
);
367377
}
368-
return loadSchema(pointer, config);
378+
return addHashToSchema(loadSchema(pointer, config));
369379
}
370380

371381
async loadDocuments(pointer: Types.OperationDocument[]): Promise<Types.DocumentFile[]> {
372382
const config = this.getConfig(defaultDocumentsLoadOptions);
373383
if (this._graphqlConfig) {
374384
// TODO: pointer won't work here
375-
const documents = await this._graphqlConfig.getProject(this._project).loadDocuments(pointer, config);
376-
377-
return documents;
385+
return addHashToDocumentFiles(this._graphqlConfig.getProject(this._project).loadDocuments(pointer, config));
378386
}
379387

380-
return loadDocuments(pointer, config);
388+
return addHashToDocumentFiles(loadDocuments(pointer, config));
381389
}
382390
}
383391

384392
export function ensureContext(input: CodegenContext | Types.Config): CodegenContext {
385393
return input instanceof CodegenContext ? input : new CodegenContext({ config: input });
386394
}
395+
396+
function hashContent(content: string): string {
397+
return createHash('sha256').update(content).digest('hex');
398+
}
399+
400+
function hashSchema(schema: GraphQLSchema): string {
401+
return hashContent(print(getCachedDocumentNodeFromSchema(schema)));
402+
}
403+
404+
function addHashToSchema(schemaPromise: Promise<GraphQLSchema>): Promise<GraphQLSchema> {
405+
return schemaPromise.then(schema => {
406+
// It's consumed later on. The general purpose is to use it for caching.
407+
if (!schema.extensions) {
408+
(schema.extensions as unknown as GraphQLSchemaExtensions) = {};
409+
}
410+
(schema.extensions as unknown as GraphQLSchemaExtensions)['hash'] = hashSchema(schema);
411+
return schema;
412+
});
413+
}
414+
415+
function hashDocument(doc: Types.DocumentFile) {
416+
if (doc.rawSDL) {
417+
return hashContent(doc.rawSDL);
418+
}
419+
420+
if (doc.document) {
421+
return hashContent(print(doc.document));
422+
}
423+
424+
return null;
425+
}
426+
427+
function addHashToDocumentFiles(documentFilesPromise: Promise<Types.DocumentFile[]>): Promise<Types.DocumentFile[]> {
428+
return documentFilesPromise.then(documentFiles =>
429+
documentFiles.map(doc => {
430+
doc.hash = hashDocument(doc);
431+
432+
return doc;
433+
})
434+
);
435+
}

‎packages/graphql-codegen-core/src/codegen.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { checkValidationErrors, validateGraphQlDocuments, Source, asArray } from
1313

1414
import { mergeSchemas } from '@graphql-tools/schema';
1515
import {
16+
extractHashFromSchema,
1617
getSkipDocumentsValidationOption,
1718
hasFederationSpec,
1819
pickFlag,
@@ -80,21 +81,27 @@ export async function codegen(options: Types.GenerateOptions): Promise<string> {
8081
const extraFragments: { importFrom: string; node: DefinitionNode }[] =
8182
pickFlag('externalFragments', options.config) || [];
8283

83-
const errors = await profiler.run(
84-
() =>
85-
validateGraphQlDocuments(
86-
schemaInstance,
87-
[
88-
...documents,
89-
...extraFragments.map(f => ({
90-
location: f.importFrom,
91-
document: { kind: Kind.DOCUMENT, definitions: [f.node] } as DocumentNode,
92-
})),
93-
],
94-
specifiedRules.filter(rule => !ignored.some(ignoredRule => rule.name.startsWith(ignoredRule)))
95-
),
96-
'Validate documents against schema'
97-
);
84+
const errors = await profiler.run(() => {
85+
const fragments = extraFragments.map(f => ({
86+
location: f.importFrom,
87+
document: { kind: Kind.DOCUMENT, definitions: [f.node] } as DocumentNode,
88+
}));
89+
const rules = specifiedRules.filter(rule => !ignored.some(ignoredRule => rule.name.startsWith(ignoredRule)));
90+
const schemaHash = extractHashFromSchema(schemaInstance);
91+
92+
if (!schemaHash || !options.cache || documents.some(d => typeof d.hash !== 'string')) {
93+
return validateGraphQlDocuments(schemaInstance, [...documents, ...fragments], rules);
94+
}
95+
96+
const cacheKey = [schemaHash]
97+
.concat(documents.map(doc => doc.hash))
98+
.concat(JSON.stringify(fragments))
99+
.join(',');
100+
101+
return options.cache('documents-validation', cacheKey, () =>
102+
validateGraphQlDocuments(schemaInstance, [...documents, ...fragments], rules)
103+
);
104+
}, 'Validate documents against schema');
98105
checkValidationErrors(errors);
99106
}
100107

‎packages/graphql-codegen-core/src/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,11 @@ export function hasFederationSpec(schemaOrAST: GraphQLSchema | DocumentNode) {
7575
}
7676
return false;
7777
}
78+
79+
export function extractHashFromSchema(schema: GraphQLSchema): string | null {
80+
if (!schema.extensions) {
81+
schema.extensions = {};
82+
}
83+
84+
return (schema.extensions['hash'] as string) ?? null;
85+
}

‎packages/utils/plugins-helpers/src/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export namespace Types {
1717
skipDocumentsValidation?: Types.SkipDocumentsValidationOptions;
1818
pluginContext?: { [key: string]: any };
1919
profiler?: Profiler;
20+
cache?<T>(namespace: string, key: string, factory: () => Promise<T>): Promise<T>;
2021
}
2122

2223
export type FileOutput = {
@@ -28,7 +29,9 @@ export namespace Types {
2829
};
2930
};
3031

31-
export type DocumentFile = Source;
32+
export interface DocumentFile extends Source {
33+
hash?: string;
34+
}
3235

3336
/* Utils */
3437
export type Promisable<T> = T | Promise<T>;
@@ -322,6 +325,7 @@ export namespace Types {
322325
[name: string]: any;
323326
};
324327
profiler?: Profiler;
328+
cache?<T>(namespace: string, key: string, factory: () => Promise<T>): Promise<T>;
325329
};
326330

327331
export type OutputPreset<TPresetConfig = any> = {

0 commit comments

Comments
 (0)