diff --git a/.changeset/spicy-bananas-destroy.md b/.changeset/spicy-bananas-destroy.md new file mode 100644 index 00000000000..ab66966af1a --- /dev/null +++ b/.changeset/spicy-bananas-destroy.md @@ -0,0 +1,7 @@ +--- +'@graphql-codegen/cli': patch +'@graphql-codegen/core': patch +'@graphql-codegen/plugin-helpers': patch +--- + +Cache validation of documents diff --git a/packages/graphql-codegen-cli/src/codegen.ts b/packages/graphql-codegen-cli/src/codegen.ts index d70eb366180..e2653332045 100644 --- a/packages/graphql-codegen-cli/src/codegen.ts +++ b/packages/graphql-codegen-cli/src/codegen.ts @@ -38,21 +38,22 @@ const makeDefaultLoader = (from: string) => { }; }; -// TODO: Replace any with types -function createCache(loader: (key: string) => Promise) { - const cache = new Map>(); - - return { - load(key: string): Promise { - if (cache.has(key)) { - return cache.get(key); - } +function createCache(): (namespace: string, key: string, factory: () => Promise) => Promise { + const cache = new Map>(); - const value = loader(key); + return function ensure(namespace: string, key: string, factory: () => Promise): Promise { + const cacheKey = `${namespace}:${key}`; - cache.set(key, value); - return value; - }, + const cachedValue = cache.get(cacheKey); + + if (cachedValue) { + return cachedValue as Promise; + } + + const value = factory(); + cache.set(cacheKey, value); + + return value; }; } @@ -93,21 +94,8 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom let rootDocuments: Types.OperationDocument[]; const generates: { [filename: string]: Types.ConfiguredOutput } = {}; - const schemaLoadingCache = createCache(async function (hash) { - const outputSchemaAst = await context.loadSchema(JSON.parse(hash)); - const outputSchema = getCachedDocumentNodeFromSchema(outputSchemaAst); - return { - outputSchemaAst: outputSchemaAst, - outputSchema: outputSchema, - }; - }); + const cache = createCache(); - const documentsLoadingCache = createCache(async function (hash) { - const documents = await context.loadDocuments(JSON.parse(hash)); - return { - documents: documents, - }; - }); function wrapTask(task: () => void | Promise, source: string, taskName: string) { return () => { return context.profiler.run(async () => { @@ -253,7 +241,14 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom } const hash = JSON.stringify(schemaPointerMap); - const result = await schemaLoadingCache.load(hash); + const result = await cache('schema', hash, async () => { + const outputSchemaAst = await context.loadSchema(schemaPointerMap); + const outputSchema = getCachedDocumentNodeFromSchema(outputSchemaAst); + return { + outputSchemaAst: outputSchemaAst, + outputSchema: outputSchema, + }; + }); outputSchemaAst = await result.outputSchemaAst; outputSchema = result.outputSchema; @@ -272,7 +267,12 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom const results = await Promise.all( [rootDocuments, outputSpecificDocuments].map(docs => { const hash = JSON.stringify(docs); - return documentsLoadingCache.load(hash); + return cache('documents', hash, async () => { + const documents = await context.loadDocuments(docs); + return { + documents: documents, + }; + }); }) ); @@ -356,7 +356,10 @@ export async function executeCodegen(input: CodegenContext | Types.Config): Prom } const process = async (outputArgs: Types.GenerateOptions) => { - const output = await codegen(outputArgs); + const output = await codegen({ + ...outputArgs, + cache, + }); result.push({ filename: outputArgs.filename, content: output, diff --git a/packages/graphql-codegen-cli/src/config.ts b/packages/graphql-codegen-cli/src/config.ts index ea054400b7f..53091e00b83 100644 --- a/packages/graphql-codegen-cli/src/config.ts +++ b/packages/graphql-codegen-cli/src/config.ts @@ -1,15 +1,23 @@ import { cosmiconfig, defaultLoaders } from 'cosmiconfig'; import { resolve } from 'path'; -import { DetailedError, Types, Profiler, createProfiler, createNoopProfiler } from '@graphql-codegen/plugin-helpers'; +import { + DetailedError, + Types, + Profiler, + createProfiler, + createNoopProfiler, + getCachedDocumentNodeFromSchema, +} from '@graphql-codegen/plugin-helpers'; import { env } from 'string-env-interpolation'; import yargs from 'yargs'; import { GraphQLConfig } from 'graphql-config'; import { findAndLoadGraphQLConfig } from './graphql-config'; import { loadSchema, loadDocuments, defaultSchemaLoadOptions, defaultDocumentsLoadOptions } from './load'; -import { GraphQLSchema } from 'graphql'; +import { GraphQLSchema, print, GraphQLSchemaExtensions } from 'graphql'; import yaml from 'yaml'; import { createRequire } from 'module'; import { promises } from 'fs'; +import { createHash } from 'crypto'; const { lstat } = promises; @@ -363,24 +371,65 @@ export class CodegenContext { const config = this.getConfig(defaultSchemaLoadOptions); if (this._graphqlConfig) { // TODO: SchemaWithLoader won't work here - return this._graphqlConfig.getProject(this._project).loadSchema(pointer, 'GraphQLSchema', config); + return addHashToSchema( + this._graphqlConfig.getProject(this._project).loadSchema(pointer, 'GraphQLSchema', config) + ); } - return loadSchema(pointer, config); + return addHashToSchema(loadSchema(pointer, config)); } async loadDocuments(pointer: Types.OperationDocument[]): Promise { const config = this.getConfig(defaultDocumentsLoadOptions); if (this._graphqlConfig) { // TODO: pointer won't work here - const documents = await this._graphqlConfig.getProject(this._project).loadDocuments(pointer, config); - - return documents; + return addHashToDocumentFiles(this._graphqlConfig.getProject(this._project).loadDocuments(pointer, config)); } - return loadDocuments(pointer, config); + return addHashToDocumentFiles(loadDocuments(pointer, config)); } } export function ensureContext(input: CodegenContext | Types.Config): CodegenContext { return input instanceof CodegenContext ? input : new CodegenContext({ config: input }); } + +function hashContent(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +function hashSchema(schema: GraphQLSchema): string { + return hashContent(print(getCachedDocumentNodeFromSchema(schema))); +} + +function addHashToSchema(schemaPromise: Promise): Promise { + return schemaPromise.then(schema => { + // It's consumed later on. The general purpose is to use it for caching. + if (!schema.extensions) { + (schema.extensions as unknown as GraphQLSchemaExtensions) = {}; + } + (schema.extensions as unknown as GraphQLSchemaExtensions)['hash'] = hashSchema(schema); + return schema; + }); +} + +function hashDocument(doc: Types.DocumentFile) { + if (doc.rawSDL) { + return hashContent(doc.rawSDL); + } + + if (doc.document) { + return hashContent(print(doc.document)); + } + + return null; +} + +function addHashToDocumentFiles(documentFilesPromise: Promise): Promise { + return documentFilesPromise.then(documentFiles => + documentFiles.map(doc => { + doc.hash = hashDocument(doc); + + return doc; + }) + ); +} diff --git a/packages/graphql-codegen-core/src/codegen.ts b/packages/graphql-codegen-core/src/codegen.ts index fdb658afd5d..82db8117b74 100644 --- a/packages/graphql-codegen-core/src/codegen.ts +++ b/packages/graphql-codegen-core/src/codegen.ts @@ -13,6 +13,7 @@ import { checkValidationErrors, validateGraphQlDocuments, Source, asArray } from import { mergeSchemas } from '@graphql-tools/schema'; import { + extractHashFromSchema, getSkipDocumentsValidationOption, hasFederationSpec, pickFlag, @@ -80,21 +81,27 @@ export async function codegen(options: Types.GenerateOptions): Promise { const extraFragments: { importFrom: string; node: DefinitionNode }[] = pickFlag('externalFragments', options.config) || []; - const errors = await profiler.run( - () => - validateGraphQlDocuments( - schemaInstance, - [ - ...documents, - ...extraFragments.map(f => ({ - location: f.importFrom, - document: { kind: Kind.DOCUMENT, definitions: [f.node] } as DocumentNode, - })), - ], - specifiedRules.filter(rule => !ignored.some(ignoredRule => rule.name.startsWith(ignoredRule))) - ), - 'Validate documents against schema' - ); + const errors = await profiler.run(() => { + const fragments = extraFragments.map(f => ({ + location: f.importFrom, + document: { kind: Kind.DOCUMENT, definitions: [f.node] } as DocumentNode, + })); + const rules = specifiedRules.filter(rule => !ignored.some(ignoredRule => rule.name.startsWith(ignoredRule))); + const schemaHash = extractHashFromSchema(schemaInstance); + + if (!schemaHash || !options.cache || documents.some(d => typeof d.hash !== 'string')) { + return validateGraphQlDocuments(schemaInstance, [...documents, ...fragments], rules); + } + + const cacheKey = [schemaHash] + .concat(documents.map(doc => doc.hash)) + .concat(JSON.stringify(fragments)) + .join(','); + + return options.cache('documents-validation', cacheKey, () => + validateGraphQlDocuments(schemaInstance, [...documents, ...fragments], rules) + ); + }, 'Validate documents against schema'); checkValidationErrors(errors); } diff --git a/packages/graphql-codegen-core/src/utils.ts b/packages/graphql-codegen-core/src/utils.ts index 109c6165f79..d710f9fbd1f 100644 --- a/packages/graphql-codegen-core/src/utils.ts +++ b/packages/graphql-codegen-core/src/utils.ts @@ -75,3 +75,11 @@ export function hasFederationSpec(schemaOrAST: GraphQLSchema | DocumentNode) { } return false; } + +export function extractHashFromSchema(schema: GraphQLSchema): string | null { + if (!schema.extensions) { + schema.extensions = {}; + } + + return (schema.extensions['hash'] as string) ?? null; +} diff --git a/packages/utils/plugins-helpers/src/types.ts b/packages/utils/plugins-helpers/src/types.ts index 66a792db69d..ae05d3b02b7 100644 --- a/packages/utils/plugins-helpers/src/types.ts +++ b/packages/utils/plugins-helpers/src/types.ts @@ -17,6 +17,7 @@ export namespace Types { skipDocumentsValidation?: Types.SkipDocumentsValidationOptions; pluginContext?: { [key: string]: any }; profiler?: Profiler; + cache?(namespace: string, key: string, factory: () => Promise): Promise; } export type FileOutput = { @@ -28,7 +29,9 @@ export namespace Types { }; }; - export type DocumentFile = Source; + export interface DocumentFile extends Source { + hash?: string; + } /* Utils */ export type Promisable = T | Promise; @@ -322,6 +325,7 @@ export namespace Types { [name: string]: any; }; profiler?: Profiler; + cache?(namespace: string, key: string, factory: () => Promise): Promise; }; export type OutputPreset = {