Skip to content

Commit

Permalink
Shared documents validation (#7494)
Browse files Browse the repository at this point in the history
  • Loading branch information
kamilkisiela committed Feb 9, 2022
1 parent 51caa6e commit cb9adeb
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 54 deletions.
7 changes: 7 additions & 0 deletions .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
63 changes: 33 additions & 30 deletions packages/graphql-codegen-cli/src/codegen.ts
Expand Up @@ -38,21 +38,22 @@ const makeDefaultLoader = (from: string) => {
};
};

// TODO: Replace any with types
function createCache<T>(loader: (key: string) => Promise<T>) {
const cache = new Map<string, Promise<T>>();

return {
load(key: string): Promise<T> {
if (cache.has(key)) {
return cache.get(key);
}
function createCache(): <T>(namespace: string, key: string, factory: () => Promise<T>) => Promise<T> {
const cache = new Map<string, Promise<unknown>>();

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

cache.set(key, value);
return value;
},
const cachedValue = cache.get(cacheKey);

if (cachedValue) {
return cachedValue as Promise<T>;
}

const value = factory();
cache.set(cacheKey, value);

return value;
};
}

Expand Down Expand Up @@ -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<void>, source: string, taskName: string) {
return () => {
return context.profiler.run(async () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
});
})
);

Expand Down Expand Up @@ -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,
Expand Down
65 changes: 57 additions & 8 deletions 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;

Expand Down Expand Up @@ -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<Types.DocumentFile[]> {
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<GraphQLSchema>): Promise<GraphQLSchema> {
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<Types.DocumentFile[]>): Promise<Types.DocumentFile[]> {
return documentFilesPromise.then(documentFiles =>
documentFiles.map(doc => {
doc.hash = hashDocument(doc);

return doc;
})
);
}
37 changes: 22 additions & 15 deletions packages/graphql-codegen-core/src/codegen.ts
Expand Up @@ -13,6 +13,7 @@ import { checkValidationErrors, validateGraphQlDocuments, Source, asArray } from

import { mergeSchemas } from '@graphql-tools/schema';
import {
extractHashFromSchema,
getSkipDocumentsValidationOption,
hasFederationSpec,
pickFlag,
Expand Down Expand Up @@ -80,21 +81,27 @@ export async function codegen(options: Types.GenerateOptions): Promise<string> {
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);
}

Expand Down
8 changes: 8 additions & 0 deletions packages/graphql-codegen-core/src/utils.ts
Expand Up @@ -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;
}
6 changes: 5 additions & 1 deletion packages/utils/plugins-helpers/src/types.ts
Expand Up @@ -17,6 +17,7 @@ export namespace Types {
skipDocumentsValidation?: Types.SkipDocumentsValidationOptions;
pluginContext?: { [key: string]: any };
profiler?: Profiler;
cache?<T>(namespace: string, key: string, factory: () => Promise<T>): Promise<T>;
}

export type FileOutput = {
Expand All @@ -28,7 +29,9 @@ export namespace Types {
};
};

export type DocumentFile = Source;
export interface DocumentFile extends Source {
hash?: string;
}

/* Utils */
export type Promisable<T> = T | Promise<T>;
Expand Down Expand Up @@ -322,6 +325,7 @@ export namespace Types {
[name: string]: any;
};
profiler?: Profiler;
cache?<T>(namespace: string, key: string, factory: () => Promise<T>): Promise<T>;
};

export type OutputPreset<TPresetConfig = any> = {
Expand Down

1 comment on commit cb9adeb

@vercel
Copy link

@vercel vercel bot commented on cb9adeb Feb 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.