Skip to content

Commit

Permalink
Faster validation (#4801)
Browse files Browse the repository at this point in the history
  • Loading branch information
ardatan committed Oct 31, 2022
1 parent 3262a05 commit 8f6d3ef
Show file tree
Hide file tree
Showing 3 changed files with 49 additions and 220 deletions.
5 changes: 5 additions & 0 deletions .changeset/green-seas-repair.md
@@ -0,0 +1,5 @@
---
'@graphql-tools/utils': major
---

_BREAKING_: `checkValidationErrors` has been dropped and `validateGraphQlDocuments` now accepts `DocumentNode[]` instead and it throws the original `GraphQLError`s with the correct stack trace
105 changes: 21 additions & 84 deletions packages/utils/src/validate-documents.ts
Expand Up @@ -2,108 +2,45 @@ import {
Kind,
validate,
GraphQLSchema,
GraphQLError,
specifiedRules,
FragmentDefinitionNode,
ValidationContext,
ASTVisitor,
DefinitionNode,
concatAST,
DocumentNode,
versionInfo,
DefinitionNode,
} from 'graphql';
import { Source } from './loaders.js';
import { AggregateError } from './AggregateError.js';

export type ValidationRule = (context: ValidationContext) => ASTVisitor;

export interface LoadDocumentError {
readonly filePath?: string;
readonly errors: ReadonlyArray<GraphQLError>;
}

export async function validateGraphQlDocuments(
export function validateGraphQlDocuments(
schema: GraphQLSchema,
documentFiles: Source[],
effectiveRules: ValidationRule[] = createDefaultRules()
): Promise<ReadonlyArray<LoadDocumentError>> {
const allFragmentMap = new Map<string, FragmentDefinitionNode>();
const documentFileObjectsToValidate: {
location?: string;
document: DocumentNode;
}[] = [];

for (const documentFile of documentFiles) {
if (documentFile.document) {
const definitionsToValidate: DefinitionNode[] = [];
for (const definitionNode of documentFile.document.definitions) {
if (definitionNode.kind === Kind.FRAGMENT_DEFINITION) {
allFragmentMap.set(definitionNode.name.value, definitionNode);
} else {
definitionsToValidate.push(definitionNode);
}
documents: DocumentNode[],
rules: ValidationRule[] = createDefaultRules()
) {
const definitionMap = new Map<string, DefinitionNode>();
for (const document of documents) {
for (const docDefinition of document.definitions) {
if ('name' in docDefinition && docDefinition.name) {
definitionMap.set(docDefinition.name.value, docDefinition);
} else {
definitionMap.set(Date.now().toString(), docDefinition);
}
documentFileObjectsToValidate.push({
location: documentFile.location,
document: {
kind: Kind.DOCUMENT,
definitions: definitionsToValidate,
},
});
}
}

const allErrors: LoadDocumentError[] = [];

const allFragmentsDocument: DocumentNode = {
const fullAST: DocumentNode = {
kind: Kind.DOCUMENT,
definitions: [...allFragmentMap.values()],
definitions: Array.from(definitionMap.values()),
};

await Promise.all(
documentFileObjectsToValidate.map(async documentFile => {
const documentToValidate = concatAST([allFragmentsDocument, documentFile.document]);

const errors = validate(schema, documentToValidate, effectiveRules);

if (errors.length > 0) {
allErrors.push({
filePath: documentFile.location,
errors,
});
}
})
);

return allErrors;
}

export function checkValidationErrors(loadDocumentErrors: ReadonlyArray<LoadDocumentError>): void | never {
if (loadDocumentErrors.length > 0) {
const errors: Error[] = [];

for (const loadDocumentError of loadDocumentErrors) {
for (const graphQLError of loadDocumentError.errors) {
const error = new Error();
error.name = 'GraphQLDocumentError';
error.message = `${error.name}: ${graphQLError.message}`;
error.stack = error.message;
if (graphQLError.locations) {
for (const location of graphQLError.locations) {
error.stack += `\n at ${loadDocumentError.filePath}:${location.line}:${location.column}`;
}
}

errors.push(error);
const errors = validate(schema, fullAST, rules);
for (const error of errors) {
error.stack = error.message;
if (error.locations) {
for (const location of error.locations) {
error.stack += `\n at ${error.source?.name}:${location.line}:${location.column}`;
}
}

throw new AggregateError(
errors,
`GraphQL Document Validation failed with ${errors.length} errors;
${errors.map((error, index) => `Error ${index}: ${error.stack}`).join('\n\n')}`
);
}
return errors;
}

export function createDefaultRules() {
Expand Down
159 changes: 23 additions & 136 deletions packages/utils/tests/validate-documents.spec.ts
@@ -1,5 +1,5 @@
import { checkValidationErrors, validateGraphQlDocuments } from '../src/index.js';
import { buildSchema, parse, GraphQLError } from 'graphql';
import { validateGraphQlDocuments } from '../src/index.js';
import { buildSchema, parse, GraphQLError, Source } from 'graphql';

describe('validateGraphQlDocuments', () => {
it('Should throw an informative error when validation errors happens, also check for fragments validation even why they are duplicated', async () => {
Expand All @@ -25,146 +25,33 @@ describe('validateGraphQlDocuments', () => {
}
`;

const result = await validateGraphQlDocuments(schema, [
{
location: 'fragment.graphql',
document: parse(fragment),
},
{
location: 'query.graphql',
document: parse(/* GraphQL */ `
query searchPage {
otherStuff {
foo
const result = validateGraphQlDocuments(schema, [
parse(new Source(fragment, 'packages/client/src/fragments/pizzeriaFragment.fragment.graphql')),
parse(
new Source(
/* GraphQL */ `
query searchPage {
otherStuff {
foo
}
...pizzeriaFragment
}
...pizzeriaFragment
}
${fragment}
`),
},
${fragment}
`,
'packages/client/src/pages/search/searchPage.query.graphql'
)
),
]);

expect(result).toHaveLength(1);
expect(result[0].filePath).toBe('query.graphql');
expect(result[0].errors[0] instanceof GraphQLError).toBeTruthy();
expect(result[0].errors[0].message).toBe(
expect(result[0].source?.name).toBe('packages/client/src/pages/search/searchPage.query.graphql');
expect(result[0] instanceof GraphQLError).toBeTruthy();
expect(result[0].message).toBe(
'Fragment "pizzeriaFragment" cannot be spread here as objects of type "Query" can never be of type "Pizzeria".'
);

try {
checkValidationErrors(result);
expect(true).toBeFalsy();
} catch (aggregateError: any) {
const { errors } = aggregateError;
expect(Symbol.iterator in errors).toBeTruthy();
const generator = errors[Symbol.iterator]();

const error = generator.next().value;

expect(error).toBeInstanceOf(Error);
expect(error.name).toEqual('GraphQLDocumentError');
expect(error.message).toEqual(
'GraphQLDocumentError: Fragment "pizzeriaFragment" cannot be spread here as objects of type "Query" can never be of type "Pizzeria".'
);
expect(error.stack).toEqual(
[
'GraphQLDocumentError: Fragment "pizzeriaFragment" cannot be spread here as objects of type "Query" can never be of type "Pizzeria".',
' at query.graphql:6:13',
].join('\n')
);
}
});
});

describe('checkValidationErrors', () => {
it('Should throw errors source files and locations', async () => {
const loadDocumentErrors = [
{
filePath: 'packages/server/src/modules/github-check-run/providers/documents/create-check-run.mutation.graphql',
errors: [
{
message: 'Cannot query field "randomField" on type "CheckRun".',
locations: [
{
line: 7,
column: 13,
},
],
},
{
message: 'Cannot query field "randomField2" on type "CheckRun".',
locations: [
{
line: 8,
column: 13,
},
],
},
],
},
{
filePath: 'packages/server/src/modules/github-check-run/providers/documents/check-run.query.graphql',
errors: [
{
message: 'Cannot query field "randomField" on type "CheckRun".',
locations: [
{
line: 7,
column: 13,
},
],
},
],
},
];

let errors;
try {
checkValidationErrors(loadDocumentErrors as any);
} catch (aggregateError: any) {
errors = aggregateError.errors;
}

expect(Symbol.iterator in errors).toBeTruthy();

let error;
const generator = errors[Symbol.iterator]();

error = generator.next().value;

expect(error).toBeInstanceOf(Error);
expect(error.name).toEqual('GraphQLDocumentError');
expect(error.message).toEqual('GraphQLDocumentError: Cannot query field "randomField" on type "CheckRun".');
expect(error.stack).toEqual(
[
'GraphQLDocumentError: Cannot query field "randomField" on type "CheckRun".',
' at packages/server/src/modules/github-check-run/providers/documents/create-check-run.mutation.graphql:7:13',
].join('\n')
);

error = generator.next().value;

expect(error).toBeInstanceOf(Error);
expect(error.name).toEqual('GraphQLDocumentError');
expect(error.message).toEqual('GraphQLDocumentError: Cannot query field "randomField2" on type "CheckRun".');
expect(error.stack).toEqual(
[
'GraphQLDocumentError: Cannot query field "randomField2" on type "CheckRun".',
' at packages/server/src/modules/github-check-run/providers/documents/create-check-run.mutation.graphql:8:13',
].join('\n')
);

error = generator.next().value;

expect(error).toBeInstanceOf(Error);
expect(error.name).toEqual('GraphQLDocumentError');
expect(error.message).toEqual('GraphQLDocumentError: Cannot query field "randomField" on type "CheckRun".');
expect(error.stack).toEqual(
[
'GraphQLDocumentError: Cannot query field "randomField" on type "CheckRun".',
' at packages/server/src/modules/github-check-run/providers/documents/check-run.query.graphql:7:13',
].join('\n')
);
expect(result[0].stack)
.toBe(`Fragment "pizzeriaFragment" cannot be spread here as objects of type "Query" can never be of type "Pizzeria".
at packages/client/src/pages/search/searchPage.query.graphql:6:15`);
});
});

0 comments on commit 8f6d3ef

Please sign in to comment.