diff --git a/src/__tests__/starWarsValidation-test.ts b/src/__tests__/starWarsValidation-test.ts index afd2e7ee3d..0ccf6d17dc 100644 --- a/src/__tests__/starWarsValidation-test.ts +++ b/src/__tests__/starWarsValidation-test.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../language/parser.js'; +import { parseSync as parse } from '../language/parser.js'; import { Source } from '../language/source.js'; -import { validate } from '../validation/validate.js'; +import { validateSync as validate } from '../validation/validate.js'; import { StarWarsSchema } from './starWarsSchema.js'; diff --git a/src/error/__tests__/GraphQLError-test.ts b/src/error/__tests__/GraphQLError-test.ts index 7cfda2a5a3..25a22b8eee 100644 --- a/src/error/__tests__/GraphQLError-test.ts +++ b/src/error/__tests__/GraphQLError-test.ts @@ -4,7 +4,7 @@ import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent.js'; import { Kind } from '../../language/kinds.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { Source } from '../../language/source.js'; import { GraphQLError } from '../GraphQLError.js'; diff --git a/src/execution/__tests__/abort-signal-test.ts b/src/execution/__tests__/abort-signal-test.ts index d12253b517..7274962999 100644 --- a/src/execution/__tests__/abort-signal-test.ts +++ b/src/execution/__tests__/abort-signal-test.ts @@ -5,7 +5,7 @@ import { expectJSON } from '../../__testUtils__/expectJSON.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import type { DocumentNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; diff --git a/src/execution/__tests__/abstract-test.ts b/src/execution/__tests__/abstract-test.ts index 422f99c0c2..e97997cfdd 100644 --- a/src/execution/__tests__/abstract-test.ts +++ b/src/execution/__tests__/abstract-test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { assertInterfaceType, diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 97dcfeceb6..0fa47eb059 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -8,7 +8,7 @@ import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js'; import type { DocumentNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLList, diff --git a/src/execution/__tests__/directives-test.ts b/src/execution/__tests__/directives-test.ts index 2a89f07b6f..44122eec0d 100644 --- a/src/execution/__tests__/directives-test.ts +++ b/src/execution/__tests__/directives-test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLObjectType } from '../../type/definition.js'; import { GraphQLString } from '../../type/scalars.js'; diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 173dcc9483..58dafde68a 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -7,7 +7,7 @@ import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../../language/kinds.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLInterfaceType, diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index 147520baca..306073ca2c 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -5,7 +5,7 @@ import { expectJSON } from '../../__testUtils__/expectJSON.js'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import type { GraphQLFieldResolver } from '../../type/definition.js'; import { diff --git a/src/execution/__tests__/mutations-test.ts b/src/execution/__tests__/mutations-test.ts index 5697bf5250..0d01143c54 100644 --- a/src/execution/__tests__/mutations-test.ts +++ b/src/execution/__tests__/mutations-test.ts @@ -4,7 +4,7 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLObjectType } from '../../type/definition.js'; import { GraphQLInt } from '../../type/scalars.js'; diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 6a321931b9..20f5d18c82 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -6,7 +6,7 @@ import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLNonNull, GraphQLObjectType } from '../../type/definition.js'; import { GraphQLString } from '../../type/scalars.js'; diff --git a/src/execution/__tests__/oneof-test.ts b/src/execution/__tests__/oneof-test.ts index eed65ae580..73ecda452f 100644 --- a/src/execution/__tests__/oneof-test.ts +++ b/src/execution/__tests__/oneof-test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; diff --git a/src/execution/__tests__/resolve-test.ts b/src/execution/__tests__/resolve-test.ts index b13a4266f0..953defacba 100644 --- a/src/execution/__tests__/resolve-test.ts +++ b/src/execution/__tests__/resolve-test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import type { GraphQLFieldConfig } from '../../type/definition.js'; import { GraphQLObjectType } from '../../type/definition.js'; diff --git a/src/execution/__tests__/schema-test.ts b/src/execution/__tests__/schema-test.ts index 3e94ecf59a..375ca1465c 100644 --- a/src/execution/__tests__/schema-test.ts +++ b/src/execution/__tests__/schema-test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLList, diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index d7bd7a6b48..91eea7e540 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -9,7 +9,7 @@ import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js'; import type { DocumentNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLList, diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index ffa1c85276..87cfb6aeaa 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -10,7 +10,7 @@ import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; import { isPromise } from '../../jsutils/isPromise.js'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLList, GraphQLObjectType } from '../../type/definition.js'; import { diff --git a/src/execution/__tests__/sync-test.ts b/src/execution/__tests__/sync-test.ts index f5efa4097c..94e32eebd8 100644 --- a/src/execution/__tests__/sync-test.ts +++ b/src/execution/__tests__/sync-test.ts @@ -3,13 +3,13 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLObjectType } from '../../type/definition.js'; import { GraphQLString } from '../../type/scalars.js'; import { GraphQLSchema } from '../../type/schema.js'; -import { validate } from '../../validation/validate.js'; +import { validateSync as validate } from '../../validation/validate.js'; import { graphqlSync } from '../../graphql.js'; diff --git a/src/execution/__tests__/union-interface-test.ts b/src/execution/__tests__/union-interface-test.ts index 6f8408c487..31ad886aaf 100644 --- a/src/execution/__tests__/union-interface-test.ts +++ b/src/execution/__tests__/union-interface-test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLInterfaceType, diff --git a/src/execution/__tests__/variables-test.ts b/src/execution/__tests__/variables-test.ts index ca729d0248..39a251546b 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -9,7 +9,7 @@ import { GraphQLError } from '../../error/GraphQLError.js'; import { DirectiveLocation } from '../../language/directiveLocation.js'; import { Kind } from '../../language/kinds.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import type { GraphQLArgumentConfig, diff --git a/src/graphql.ts b/src/graphql.ts index ff11025968..5cc5f5e328 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -2,7 +2,7 @@ import { isPromise } from './jsutils/isPromise.js'; import type { Maybe } from './jsutils/Maybe.js'; import type { PromiseOrValue } from './jsutils/PromiseOrValue.js'; -import { parse } from './language/parser.js'; +import { parseSync as parse } from './language/parser.js'; import type { Source } from './language/source.js'; import type { @@ -12,7 +12,7 @@ import type { import type { GraphQLSchema } from './type/schema.js'; import { validateSchema } from './type/validate.js'; -import { validate } from './validation/validate.js'; +import { validateSync as validate } from './validation/validate.js'; import { execute } from './execution/execute.js'; import type { ExecutionResult } from './execution/types.js'; diff --git a/src/index.ts b/src/index.ts index aef9d75b16..606e1c8d38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -220,6 +220,7 @@ export { TokenKind, // Parse parse, + parseSync, parseValue, parseConstValue, parseType, @@ -247,6 +248,7 @@ export { export type { ParseOptions, + ParseCache, SourceLocation, // Visitor utilities ASTVisitor, @@ -356,6 +358,7 @@ export type { // Validate GraphQL documents. export { validate, + validateSync, ValidationContext, // All validation rules in the GraphQL Specification. specifiedRules, @@ -402,7 +405,11 @@ export { NoSchemaIntrospectionCustomRule, } from './validation/index.js'; -export type { ValidationRule } from './validation/index.js'; +export type { + ValidationRule, + ValidateOptions, + ValidateCache, +} from './validation/index.js'; // Create, format, and print GraphQL errors. export { GraphQLError, syntaxError, locatedError } from './error/index.js'; diff --git a/src/jsutils/__tests__/withCache-test.ts b/src/jsutils/__tests__/withCache-test.ts new file mode 100644 index 0000000000..d155e1c422 --- /dev/null +++ b/src/jsutils/__tests__/withCache-test.ts @@ -0,0 +1,225 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { isPromise } from '../isPromise.js'; +import { withCache } from '../withCache.js'; + +describe('withCache', () => { + it('returns asynchronously using asynchronous cache', async () => { + let cached: string | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache = { + set: async (result: string | Error) => { + await resolveOnNextTick(); + cached = result; + }, + get: () => { + getAttempts++; + if (cached !== undefined) { + cacheHits++; + } + return Promise.resolve(cached); + }, + }; + + const fnWithCache = withCache((arg: string) => arg, customCache); + + const firstResultPromise = fnWithCache('arg'); + expect(isPromise(firstResultPromise)).to.equal(true); + const firstResult = await firstResultPromise; + + expect(firstResult).to.equal('arg'); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondResultPromise = fnWithCache('arg'); + + expect(isPromise(secondResultPromise)).to.equal(true); + + const secondResult = await secondResultPromise; + expect(secondResult).to.equal('arg'); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('returns synchronously using cache with sync getter and async setter', async () => { + let cached: string | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache = { + set: async (result: string | Error) => { + await resolveOnNextTick(); + cached = result; + }, + get: () => { + getAttempts++; + if (cached !== undefined) { + cacheHits++; + } + return cached; + }, + }; + + const fnWithCache = withCache((arg: string) => arg, customCache); + + const firstResult = fnWithCache('arg'); + expect(firstResult).to.equal('arg'); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + await resolveOnNextTick(); + + const secondResult = fnWithCache('arg'); + + expect(secondResult).to.equal('arg'); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('returns asynchronously using cache with async getter and sync setter', async () => { + let cached: string | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache = { + set: (result: string | Error) => { + cached = result; + }, + get: () => { + getAttempts++; + if (cached !== undefined) { + cacheHits++; + } + return Promise.resolve(cached); + }, + }; + + const fnWithCache = withCache((arg: string) => arg, customCache); + + const firstResultPromise = fnWithCache('arg'); + expect(isPromise(firstResultPromise)).to.equal(true); + const firstResult = await firstResultPromise; + + expect(firstResult).to.equal('arg'); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondResultPromise = fnWithCache('arg'); + + expect(isPromise(secondResultPromise)).to.equal(true); + + const secondResult = await secondResultPromise; + expect(secondResult).to.equal('arg'); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('ignores async setter errors', async () => { + let cached: string | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache = { + set: () => Promise.reject(new Error('Oops')), + get: () => { + getAttempts++; + /* c8 ignore next 3 */ + if (cached !== undefined) { + cacheHits++; + } + return Promise.resolve(cached); + }, + }; + + const fnWithCache = withCache((arg: string) => arg, customCache); + + const firstResultPromise = fnWithCache('arg'); + expect(isPromise(firstResultPromise)).to.equal(true); + const firstResult = await firstResultPromise; + + expect(firstResult).to.equal('arg'); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondResultPromise = fnWithCache('arg'); + + expect(isPromise(secondResultPromise)).to.equal(true); + + const secondResult = await secondResultPromise; + expect(secondResult).to.equal('arg'); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(0); + }); + + it('caches fn errors with sync cache', () => { + let cached: string | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache = { + set: (result: string | Error) => { + cached = result; + }, + get: () => { + getAttempts++; + if (cached !== undefined) { + cacheHits++; + } + return cached; + }, + }; + + const fnWithCache = withCache((): string => { + throw new Error('Oops'); + }, customCache); + + expect(() => fnWithCache()).to.throw('Oops'); + + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + expect(() => fnWithCache()).to.throw('Oops'); + + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('caches fn errors with async cache', async () => { + let cached: string | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache = { + set: async (result: string | Error) => { + await resolveOnNextTick(); + cached = result; + }, + get: () => { + getAttempts++; + if (cached !== undefined) { + cacheHits++; + } + return Promise.resolve(cached); + }, + }; + + const fnWithCache = withCache((): string => { + throw new Error('Oops'); + }, customCache); + + const firstResultPromise = fnWithCache(); + expect(isPromise(firstResultPromise)).to.equal(true); + + await expectPromise(firstResultPromise).toRejectWith('Oops'); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondResultPromise = fnWithCache(); + + expect(isPromise(secondResultPromise)).to.equal(true); + + await expectPromise(secondResultPromise).toRejectWith('Oops'); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); +}); diff --git a/src/jsutils/withCache.ts b/src/jsutils/withCache.ts new file mode 100644 index 0000000000..ad33de65bf --- /dev/null +++ b/src/jsutils/withCache.ts @@ -0,0 +1,74 @@ +import { isPromise } from './isPromise.js'; +import type { PromiseOrValue } from './PromiseOrValue.js'; + +export interface FnCache< + T extends (...args: Array) => Exclude, +> { + set: ( + result: ReturnType | Error, + ...args: Parameters + ) => PromiseOrValue; + get: ( + ...args: Parameters + ) => PromiseOrValue | Error | undefined>; +} + +export function withCache< + T extends (...args: Array) => Exclude, +>( + fn: T, + cache: FnCache, +): (...args: Parameters) => ReturnType | Promise>> { + return (...args: Parameters) => { + const maybeResult = cache.get(...args); + if (isPromise(maybeResult)) { + return maybeResult.then((resolved) => + handleCacheResult(resolved, fn, cache, args), + ); + } + + return handleCacheResult(maybeResult, fn, cache, args); + }; +} + +function handleCacheResult< + T extends (...args: Array) => Exclude, +>( + cachedResult: Awaited> | Error | undefined, + fn: T, + cache: FnCache, + args: Parameters, +): Awaited> { + if (cachedResult !== undefined) { + if (cachedResult instanceof Error) { + throw cachedResult; + } + return cachedResult; + } + + let result; + try { + result = fn(...args); + } catch (error) { + updateResult(error, cache, args); + throw error; + } + + updateResult(result, cache, args); + return result; +} + +function updateResult< + T extends (...args: Array) => Exclude, +>( + result: Awaited> | Error, + cache: FnCache, + args: Parameters, +): void { + const setResult = cache.set(result, ...args); + if (isPromise(setResult)) { + setResult.catch(() => { + /* c8 ignore next */ + }); + } +} diff --git a/src/language/__tests__/parser-cache-test.ts b/src/language/__tests__/parser-cache-test.ts new file mode 100644 index 0000000000..becebb4069 --- /dev/null +++ b/src/language/__tests__/parser-cache-test.ts @@ -0,0 +1,267 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { isPromise } from '../../jsutils/isPromise.js'; + +import type { DocumentNode } from '../ast.js'; +import type { ParseCache } from '../parser.js'; +import { parse, parseSync } from '../parser.js'; + +describe('Parser Cache', () => { + const fooDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + name: undefined, + operation: 'query', + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + alias: undefined, + arguments: undefined, + directives: undefined, + kind: 'Field', + loc: { start: 2, end: 5 }, + name: { + kind: 'Name', + value: 'foo', + loc: { start: 2, end: 5 }, + }, + selectionSet: undefined, + }, + ], + loc: { start: 0, end: 7 }, + }, + variableDefinitions: undefined, + directives: undefined, + loc: { start: 0, end: 7 }, + }, + ], + loc: { start: 0, end: 7 }, + }; + + it('parses asynchronously using asynchronous cache', async () => { + let cachedDocument: DocumentNode | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ParseCache = { + set: async (resultedDocument) => { + await resolveOnNextTick(); + cachedDocument = resultedDocument; + }, + get: () => { + getAttempts++; + if (cachedDocument) { + cacheHits++; + } + return Promise.resolve(cachedDocument); + }, + }; + + const firstDocumentPromise = parse('{ foo }', { + cache: customCache, + }); + expect(isPromise(firstDocumentPromise)).to.equal(true); + const firstDocument = await firstDocumentPromise; + + expectJSON(firstDocument).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondDocumentPromise = parse('{ foo }', { + cache: customCache, + }); + + expect(isPromise(secondDocumentPromise)).to.equal(true); + + const secondErrors = await secondDocumentPromise; + expectJSON(secondErrors).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('parses synchronously using cache with sync getter and async setter', async () => { + let cachedDocument: DocumentNode | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ParseCache = { + set: async (resultedDocument) => { + await resolveOnNextTick(); + cachedDocument = resultedDocument; + }, + get: () => { + getAttempts++; + if (cachedDocument) { + cacheHits++; + } + return cachedDocument; + }, + }; + + const firstDocument = parse('{ foo }', { + cache: customCache, + }); + + expectJSON(firstDocument).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + await resolveOnNextTick(); + + const secondErrors = parse('{ foo }', { + cache: customCache, + }); + + expectJSON(secondErrors).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('parses asynchronously using cache with async getter and sync setter', async () => { + let cachedDocument: DocumentNode | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ParseCache = { + set: (resultedDocument) => { + cachedDocument = resultedDocument; + }, + get: () => { + getAttempts++; + if (cachedDocument) { + cacheHits++; + } + return Promise.resolve(cachedDocument); + }, + }; + + const firstDocumentPromise = parse('{ foo }', { + cache: customCache, + }); + const firstDocument = await firstDocumentPromise; + + expectJSON(firstDocument).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondDocumentPromise = parse('{ foo }', { + cache: customCache, + }); + + expect(isPromise(secondDocumentPromise)).to.equal(true); + + const secondErrors = await secondDocumentPromise; + expectJSON(secondErrors).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('parseSync parses synchronously using synchronous cache', () => { + let cachedDocument: DocumentNode | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ParseCache = { + set: (resultedDocument) => { + cachedDocument = resultedDocument; + }, + get: () => { + getAttempts++; + if (cachedDocument) { + cacheHits++; + } + return cachedDocument; + }, + }; + + const firstDocument = parseSync('{ foo }', { + cache: customCache, + }); + + expectJSON(firstDocument).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondErrors = parseSync('{ foo }', { + cache: customCache, + }); + + expectJSON(secondErrors).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('parseSync throws using asynchronous cache', () => { + let cachedDocument: DocumentNode | Error | undefined; + const customCache: ParseCache = { + set: async (resultedDocument) => { + await resolveOnNextTick(); + cachedDocument = resultedDocument; + }, + get: () => Promise.resolve(cachedDocument), + }; + + expect(() => + parseSync('{ foo }', { + cache: customCache, + }), + ).to.throw('GraphQL parsing failed to complete synchronously.'); + }); + + it('parseSync parses synchronously using sync getter and async setter', async () => { + let cachedDocument: DocumentNode | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ParseCache = { + set: async (resultedDocument) => { + await resolveOnNextTick(); + cachedDocument = resultedDocument; + }, + get: () => { + getAttempts++; + if (cachedDocument) { + cacheHits++; + } + return cachedDocument; + }, + }; + + const firstDocument = parseSync('{ foo }', { + cache: customCache, + }); + + await resolveOnNextTick(); + + expectJSON(firstDocument).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondErrors = parseSync('{ foo }', { + cache: customCache, + }); + + expectJSON(secondErrors).toDeepEqual(fooDocument); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('parseSync throws using asynchronous cache', () => { + let cachedDocument: DocumentNode | Error | undefined; + const customCache: ParseCache = { + set: async (resultedDocument) => { + await resolveOnNextTick(); + cachedDocument = resultedDocument; + }, + get: () => Promise.resolve(cachedDocument), + }; + + expect(() => + parseSync('{ foo }', { + cache: customCache, + }), + ).to.throw('GraphQL parsing failed to complete synchronously.'); + }); +}); diff --git a/src/language/__tests__/parser-test.ts b/src/language/__tests__/parser-test.ts index d98b6a6f41..7b41696ce5 100644 --- a/src/language/__tests__/parser-test.ts +++ b/src/language/__tests__/parser-test.ts @@ -11,7 +11,12 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../kinds.js'; -import { parse, parseConstValue, parseType, parseValue } from '../parser.js'; +import { + parseConstValue, + parseSync as parse, + parseType, + parseValue, +} from '../parser.js'; import { Source } from '../source.js'; import { TokenKind } from '../tokenKind.js'; diff --git a/src/language/__tests__/printer-test.ts b/src/language/__tests__/printer-test.ts index 624dc75ca2..889ca9e295 100644 --- a/src/language/__tests__/printer-test.ts +++ b/src/language/__tests__/printer-test.ts @@ -5,7 +5,7 @@ import { dedent, dedentString } from '../../__testUtils__/dedent.js'; import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import { Kind } from '../kinds.js'; -import { parse } from '../parser.js'; +import { parseSync as parse } from '../parser.js'; import { print } from '../printer.js'; describe('Printer: Query document', () => { diff --git a/src/language/__tests__/schema-parser-test.ts b/src/language/__tests__/schema-parser-test.ts index 3780c8330f..839ce7f3d1 100644 --- a/src/language/__tests__/schema-parser-test.ts +++ b/src/language/__tests__/schema-parser-test.ts @@ -8,7 +8,7 @@ import { } from '../../__testUtils__/expectJSON.js'; import { kitchenSinkSDL } from '../../__testUtils__/kitchenSinkSDL.js'; -import { parse } from '../parser.js'; +import { parseSync as parse } from '../parser.js'; function expectSyntaxError(text: string) { return expectToThrowJSON(() => parse(text)); diff --git a/src/language/__tests__/schema-printer-test.ts b/src/language/__tests__/schema-printer-test.ts index 4f8166b7bc..debc265715 100644 --- a/src/language/__tests__/schema-printer-test.ts +++ b/src/language/__tests__/schema-printer-test.ts @@ -5,7 +5,7 @@ import { dedent } from '../../__testUtils__/dedent.js'; import { kitchenSinkSDL } from '../../__testUtils__/kitchenSinkSDL.js'; import { Kind } from '../kinds.js'; -import { parse } from '../parser.js'; +import { parseSync as parse } from '../parser.js'; import { print } from '../printer.js'; describe('Printer: SDL document', () => { diff --git a/src/language/__tests__/visitor-test.ts b/src/language/__tests__/visitor-test.ts index 6aceec88b6..f58d6d83e2 100644 --- a/src/language/__tests__/visitor-test.ts +++ b/src/language/__tests__/visitor-test.ts @@ -6,7 +6,7 @@ import { kitchenSinkQuery } from '../../__testUtils__/kitchenSinkQuery.js'; import type { ASTNode, SelectionSetNode } from '../ast.js'; import { isNode } from '../ast.js'; import { Kind } from '../kinds.js'; -import { parse } from '../parser.js'; +import { parseSync as parse } from '../parser.js'; import type { ASTVisitor, ASTVisitorKeyMap } from '../visitor.js'; import { BREAK, visit, visitInParallel } from '../visitor.js'; diff --git a/src/language/index.ts b/src/language/index.ts index 4d96766abc..409a87db4d 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -11,8 +11,14 @@ export { TokenKind } from './tokenKind.js'; export { Lexer } from './lexer.js'; -export { parse, parseValue, parseConstValue, parseType } from './parser.js'; -export type { ParseOptions } from './parser.js'; +export { + parse, + parseSync, + parseValue, + parseConstValue, + parseType, +} from './parser.js'; +export type { ParseOptions, ParseCache } from './parser.js'; export { print } from './printer.js'; diff --git a/src/language/parser.ts b/src/language/parser.ts index 3d2018ba4b..bf191f1d44 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -1,4 +1,7 @@ +import { isPromise } from '../jsutils/isPromise.js'; import type { Maybe } from '../jsutils/Maybe.js'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; +import { withCache } from '../jsutils/withCache.js'; import type { GraphQLError } from '../error/GraphQLError.js'; import { syntaxError } from '../error/syntaxError.js'; @@ -88,6 +91,11 @@ export interface ParseOptions { */ maxTokens?: number | undefined; + /** + * Generic Cache Interface + */ + cache?: ParseCache | undefined; + /** * EXPERIMENTAL: * @@ -110,13 +118,38 @@ export interface ParseOptions { experimentalFragmentArguments?: boolean | undefined; } +export interface ParseCache { + set: ( + document: DocumentNode | Error, + source: string | Source, + options?: ParseOptions | undefined, + ) => PromiseOrValue | void; + get: ( + source: string | Source, + options?: ParseOptions | undefined, + ) => PromiseOrValue; +} + /** * Given a GraphQL source, parses it into a Document. * Throws GraphQLError if a syntax error is encountered. + * Uses a potentially asynchronous cache if provided to improve performance. */ export function parse( source: string | Source, options?: ParseOptions | undefined, +): PromiseOrValue { + const cache = options?.cache; + if (cache) { + return withCache(parseImpl, cache)(source, options); + } + + return parseImpl(source, options); +} + +function parseImpl( + source: string | Source, + options?: ParseOptions | undefined, ): DocumentNode { const parser = new Parser(source, options); const document = parser.parseDocument(); @@ -127,6 +160,25 @@ export function parse( return document; } +/** + * Given a GraphQL source, parses it into a Document. + * Throws GraphQLError if a syntax error is encountered. + * Guarantees to complete synchronously. + */ +export function parseSync( + source: string | Source, + options?: ParseOptions | undefined, +): DocumentNode { + const document = parse(source, options); + + // Assert that the execution was synchronous. + if (isPromise(document)) { + throw new Error('GraphQL parsing failed to complete synchronously.'); + } + + return document; +} + /** * Given a string containing a GraphQL value (ex. `[42]`), parse the AST for * that value. diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index 71bfba3527..34fe8db569 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -7,7 +7,7 @@ import { expectJSON } from '../../__testUtils__/expectJSON.js'; import { inspect } from '../../jsutils/inspect.js'; import { DirectiveLocation } from '../../language/directiveLocation.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; import { extendSchema } from '../../utilities/extendSchema.js'; diff --git a/src/utilities/__tests__/TypeInfo-test.ts b/src/utilities/__tests__/TypeInfo-test.ts index cc129287c3..b29138b6a7 100644 --- a/src/utilities/__tests__/TypeInfo-test.ts +++ b/src/utilities/__tests__/TypeInfo-test.ts @@ -1,7 +1,7 @@ import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse, parseValue } from '../../language/parser.js'; +import { parseSync as parse, parseValue } from '../../language/parser.js'; import { print } from '../../language/printer.js'; import { visit } from '../../language/visitor.js'; diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 5a794a1203..1dd422558c 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -8,7 +8,7 @@ import type { Maybe } from '../../jsutils/Maybe.js'; import type { ASTNode } from '../../language/ast.js'; import { Kind } from '../../language/kinds.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { print } from '../../language/printer.js'; import { diff --git a/src/utilities/__tests__/concatAST-test.ts b/src/utilities/__tests__/concatAST-test.ts index 95d9a59e08..f7672ac963 100644 --- a/src/utilities/__tests__/concatAST-test.ts +++ b/src/utilities/__tests__/concatAST-test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { print } from '../../language/printer.js'; import { Source } from '../../language/source.js'; diff --git a/src/utilities/__tests__/extendSchema-test.ts b/src/utilities/__tests__/extendSchema-test.ts index 5e2d786f22..357c657105 100644 --- a/src/utilities/__tests__/extendSchema-test.ts +++ b/src/utilities/__tests__/extendSchema-test.ts @@ -6,7 +6,7 @@ import { dedent } from '../../__testUtils__/dedent.js'; import type { Maybe } from '../../jsutils/Maybe.js'; import type { ASTNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { print } from '../../language/printer.js'; import { diff --git a/src/utilities/__tests__/getIntrospectionQuery-test.ts b/src/utilities/__tests__/getIntrospectionQuery-test.ts index b57950841b..870585d4bd 100644 --- a/src/utilities/__tests__/getIntrospectionQuery-test.ts +++ b/src/utilities/__tests__/getIntrospectionQuery-test.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; -import { validate } from '../../validation/validate.js'; +import { validateSync as validate } from '../../validation/validate.js'; import { buildSchema } from '../buildASTSchema.js'; import type { IntrospectionOptions } from '../getIntrospectionQuery.js'; diff --git a/src/utilities/__tests__/getOperationAST-test.ts b/src/utilities/__tests__/getOperationAST-test.ts index 69e9df96de..799942fee8 100644 --- a/src/utilities/__tests__/getOperationAST-test.ts +++ b/src/utilities/__tests__/getOperationAST-test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { getOperationAST } from '../getOperationAST.js'; diff --git a/src/utilities/__tests__/separateOperations-test.ts b/src/utilities/__tests__/separateOperations-test.ts index 4604f005ee..756c426948 100644 --- a/src/utilities/__tests__/separateOperations-test.ts +++ b/src/utilities/__tests__/separateOperations-test.ts @@ -5,7 +5,7 @@ import { dedent } from '../../__testUtils__/dedent.js'; import { mapValue } from '../../jsutils/mapValue.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { print } from '../../language/printer.js'; import { separateOperations } from '../separateOperations.js'; diff --git a/src/utilities/__tests__/stripIgnoredCharacters-test.ts b/src/utilities/__tests__/stripIgnoredCharacters-test.ts index f334810582..e0f898c423 100644 --- a/src/utilities/__tests__/stripIgnoredCharacters-test.ts +++ b/src/utilities/__tests__/stripIgnoredCharacters-test.ts @@ -8,7 +8,7 @@ import { kitchenSinkSDL } from '../../__testUtils__/kitchenSinkSDL.js'; import type { Maybe } from '../../jsutils/Maybe.js'; import { Lexer } from '../../language/lexer.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { Source } from '../../language/source.js'; import { stripIgnoredCharacters } from '../stripIgnoredCharacters.js'; diff --git a/src/utilities/buildASTSchema.ts b/src/utilities/buildASTSchema.ts index 5be0b6e421..7ce62be06b 100644 --- a/src/utilities/buildASTSchema.ts +++ b/src/utilities/buildASTSchema.ts @@ -1,6 +1,6 @@ import type { DocumentNode } from '../language/ast.js'; import type { ParseOptions } from '../language/parser.js'; -import { parse } from '../language/parser.js'; +import { parseSync as parse } from '../language/parser.js'; import type { Source } from '../language/source.js'; import { specifiedDirectives } from '../type/directives.js'; diff --git a/src/utilities/introspectionFromSchema.ts b/src/utilities/introspectionFromSchema.ts index 375d53f119..06be373871 100644 --- a/src/utilities/introspectionFromSchema.ts +++ b/src/utilities/introspectionFromSchema.ts @@ -1,6 +1,6 @@ import { invariant } from '../jsutils/invariant.js'; -import { parse } from '../language/parser.js'; +import { parseSync as parse } from '../language/parser.js'; import type { GraphQLSchema } from '../type/schema.js'; diff --git a/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts b/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts index 6e77055b3c..1ef6978745 100644 --- a/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts +++ b/src/validation/__tests__/FieldsOnCorrectTypeRule-test.ts @@ -1,14 +1,14 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import type { GraphQLSchema } from '../../type/schema.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; import { FieldsOnCorrectTypeRule } from '../rules/FieldsOnCorrectTypeRule.js'; -import { validate } from '../validate.js'; +import { validateSync as validate } from '../validate.js'; import { expectValidationErrorsWithSchema } from './harness.js'; diff --git a/src/validation/__tests__/ScalarLeafsRule-test.ts b/src/validation/__tests__/ScalarLeafsRule-test.ts index 33f091e65c..346b3b8897 100644 --- a/src/validation/__tests__/ScalarLeafsRule-test.ts +++ b/src/validation/__tests__/ScalarLeafsRule-test.ts @@ -7,7 +7,7 @@ import { OperationTypeNode } from '../../language/ast.js'; import { Kind } from '../../language/kinds.js'; import { ScalarLeafsRule } from '../rules/ScalarLeafsRule.js'; -import { validate } from '../validate.js'; +import { validateSync as validate } from '../validate.js'; import { expectValidationErrors, testSchema } from './harness.js'; diff --git a/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts b/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts index fd67ff8719..52f7a8369a 100644 --- a/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts +++ b/src/validation/__tests__/UniqueDirectivesPerLocationRule-test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'mocha'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import type { GraphQLSchema } from '../../type/schema.js'; diff --git a/src/validation/__tests__/ValidationContext-test.ts b/src/validation/__tests__/ValidationContext-test.ts index ac1a5442b4..b02cdae0f9 100644 --- a/src/validation/__tests__/ValidationContext-test.ts +++ b/src/validation/__tests__/ValidationContext-test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'mocha'; import { identityFunc } from '../../jsutils/identityFunc.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLSchema } from '../../type/schema.js'; diff --git a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts index 9631c1ae05..97ac26d4d3 100644 --- a/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts +++ b/src/validation/__tests__/ValuesOfCorrectTypeRule-test.ts @@ -3,14 +3,14 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { GraphQLObjectType, GraphQLScalarType } from '../../type/definition.js'; import { GraphQLString } from '../../type/scalars.js'; import { GraphQLSchema } from '../../type/schema.js'; import { ValuesOfCorrectTypeRule } from '../rules/ValuesOfCorrectTypeRule.js'; -import { validate } from '../validate.js'; +import { validateSync as validate } from '../validate.js'; import { expectValidationErrors, diff --git a/src/validation/__tests__/harness.ts b/src/validation/__tests__/harness.ts index cb0c424a0e..6de11141b9 100644 --- a/src/validation/__tests__/harness.ts +++ b/src/validation/__tests__/harness.ts @@ -2,13 +2,13 @@ import { expectJSON } from '../../__testUtils__/expectJSON.js'; import type { Maybe } from '../../jsutils/Maybe.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import type { GraphQLSchema } from '../../type/schema.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; -import { validate, validateSDL } from '../validate.js'; +import { validateSDL, validateSync as validate } from '../validate.js'; import type { SDLValidationRule, ValidationRule, diff --git a/src/validation/__tests__/validation-test.ts b/src/validation/__tests__/validation-test.ts index 13de153c39..7905e7cb8c 100644 --- a/src/validation/__tests__/validation-test.ts +++ b/src/validation/__tests__/validation-test.ts @@ -2,15 +2,19 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { isPromise } from '../../jsutils/isPromise.js'; import { GraphQLError } from '../../error/GraphQLError.js'; import type { DirectiveNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import { parseSync as parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; -import { validate } from '../validate.js'; +import type { ValidateCache } from '../validate.js'; +import { validate, validateSync } from '../validate.js'; import type { ValidationContext } from '../ValidationContext.js'; import { testSchema } from './harness.js'; @@ -52,6 +56,296 @@ describe('Validate: Supports full validation', () => { ]); }); + it('validates queries asynchronously using asynchronous cache', async () => { + const doc = parse(` + { + unknown + } + `); + + let cachedErrors: ReadonlyArray | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ValidateCache = { + set: async (resultedErrors) => { + await resolveOnNextTick(); + cachedErrors = resultedErrors; + }, + get: (_schema, _documentAST, _rules, _options) => { + getAttempts++; + if (cachedErrors) { + cacheHits++; + } + return Promise.resolve(cachedErrors); + }, + }; + + const firstErrorsPromise = validate(testSchema, doc, undefined, { + cache: customCache, + }); + expect(isPromise(firstErrorsPromise)).to.equal(true); + const firstErrors = await firstErrorsPromise; + + expectJSON(firstErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondErrorsPromise = validate(testSchema, doc, undefined, { + cache: customCache, + }); + + expect(isPromise(secondErrorsPromise)).to.equal(true); + + const secondErrors = await secondErrorsPromise; + expectJSON(secondErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('validates queries synchronously using cache with sync getter and async setter', async () => { + const doc = parse(` + { + unknown + } + `); + + let cachedErrors: ReadonlyArray | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ValidateCache = { + set: async (resultedErrors) => { + await resolveOnNextTick(); + cachedErrors = resultedErrors; + }, + get: (_schema, _documentAST, _rules, _options) => { + getAttempts++; + if (cachedErrors) { + cacheHits++; + } + return cachedErrors; + }, + }; + + const firstErrors = validate(testSchema, doc, undefined, { + cache: customCache, + }); + + expectJSON(firstErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + await resolveOnNextTick(); + + const secondErrors = validate(testSchema, doc, undefined, { + cache: customCache, + }); + + expectJSON(secondErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('validates queries asynchronously using cache with async getter and sync setter', async () => { + const doc = parse(` + { + unknown + } + `); + + let cachedErrors: ReadonlyArray | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ValidateCache = { + set: (resultedErrors) => { + cachedErrors = resultedErrors; + }, + get: (_schema, _documentAST, _rules, _options) => { + getAttempts++; + if (cachedErrors) { + cacheHits++; + } + return Promise.resolve(cachedErrors); + }, + }; + + const firstErrorsPromise = validate(testSchema, doc, undefined, { + cache: customCache, + }); + const firstErrors = await firstErrorsPromise; + + expectJSON(firstErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondErrorsPromise = validate(testSchema, doc, undefined, { + cache: customCache, + }); + + expect(isPromise(secondErrorsPromise)).to.equal(true); + + const secondErrors = await secondErrorsPromise; + expectJSON(secondErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('validateSync validates queries synchronously using synchronous cache', () => { + const doc = parse(` + { + unknown + } + `); + + let cachedErrors: ReadonlyArray | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ValidateCache = { + set: (resultedErrors) => { + cachedErrors = resultedErrors; + }, + get: (_schema, _documentAST, _rules, _options) => { + getAttempts++; + if (cachedErrors) { + cacheHits++; + } + return cachedErrors; + }, + }; + + const firstErrors = validateSync(testSchema, doc, undefined, { + cache: customCache, + }); + + expectJSON(firstErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + const secondErrors = validateSync(testSchema, doc, undefined, { + cache: customCache, + }); + + expectJSON(secondErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('validateSync validates queries synchronously using sync getter and async setter', async () => { + const doc = parse(` + { + unknown + } + `); + + let cachedErrors: ReadonlyArray | Error | undefined; + let getAttempts = 0; + let cacheHits = 0; + const customCache: ValidateCache = { + set: async (resultedErrors) => { + await resolveOnNextTick(); + cachedErrors = resultedErrors; + }, + get: (_schema, _documentAST, _rules, _options) => { + getAttempts++; + if (cachedErrors) { + cacheHits++; + } + return cachedErrors; + }, + }; + + const firstErrors = validateSync(testSchema, doc, undefined, { + cache: customCache, + }); + + expectJSON(firstErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(1); + expect(cacheHits).to.equal(0); + + await resolveOnNextTick(); + + const secondErrors = validateSync(testSchema, doc, undefined, { + cache: customCache, + }); + + expectJSON(secondErrors).toDeepEqual([ + { + locations: [{ line: 3, column: 9 }], + message: 'Cannot query field "unknown" on type "QueryRoot".', + }, + ]); + expect(getAttempts).to.equal(2); + expect(cacheHits).to.equal(1); + }); + + it('validateSync throws using asynchronous cache', () => { + const doc = parse(` + { + unknown + } + `); + + let cachedErrors: ReadonlyArray | Error | undefined; + const customCache: ValidateCache = { + set: async (resultedErrors) => { + await resolveOnNextTick(); + cachedErrors = resultedErrors; + }, + get: (_schema, _documentAST, _rules, _options) => + Promise.resolve(cachedErrors), + }; + + expect(() => + validateSync(testSchema, doc, undefined, { + cache: customCache, + }), + ).to.throw('GraphQL validation failed to complete synchronously.'); + }); + it('validates using a custom rule', () => { const schema = buildSchema(` directive @custom(arg: String) on FIELD diff --git a/src/validation/index.ts b/src/validation/index.ts index dbe8e57dc0..59e6775382 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,4 +1,5 @@ -export { validate } from './validate.js'; +export { validate, validateSync } from './validate.js'; +export type { ValidateOptions, ValidateCache } from './validate.js'; export { ValidationContext } from './ValidationContext.js'; export type { ValidationRule } from './ValidationContext.js'; diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 05eeb39dbb..39dbefb26a 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -1,4 +1,7 @@ +import { isPromise } from '../jsutils/isPromise.js'; import type { Maybe } from '../jsutils/Maybe.js'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; +import { withCache } from '../jsutils/withCache.js'; import { GraphQLError } from '../error/GraphQLError.js'; @@ -17,12 +20,36 @@ import { ValidationContext, } from './ValidationContext.js'; +export interface ValidateOptions { + maxErrors?: number; + hideSuggestions?: Maybe; + cache?: ValidateCache | undefined; +} + +export interface ValidateCache { + set: ( + errors: ReadonlyArray | Error, + schema: GraphQLSchema, + documentAST: DocumentNode, + rules?: ReadonlyArray | undefined, + options?: ValidateOptions | undefined, + ) => PromiseOrValue; + get: ( + schema: GraphQLSchema, + documentAST: DocumentNode, + rules?: ReadonlyArray | undefined, + options?: ValidateOptions | undefined, + ) => PromiseOrValue | Error | undefined>; +} + /** * Implements the "Validation" section of the spec. * * Validation runs synchronously, returning an array of encountered errors, or * an empty array if no errors were encountered and the document is valid. * + * However, a potentially asynchronous cache will be used, if provided. + * * A list of specific validation rules may be provided. If not provided, the * default list of rules defined by the GraphQL specification will be used. * @@ -41,7 +68,21 @@ export function validate( schema: GraphQLSchema, documentAST: DocumentNode, rules: ReadonlyArray = specifiedRules, - options?: { maxErrors?: number; hideSuggestions?: Maybe }, + options?: ValidateOptions | undefined, +): PromiseOrValue> { + const cache = options?.cache; + if (cache) { + return withCache(validateImpl, cache)(schema, documentAST, rules, options); + } + + return validateImpl(schema, documentAST, rules, options); +} + +function validateImpl( + schema: GraphQLSchema, + documentAST: DocumentNode, + rules: ReadonlyArray = specifiedRules, + options?: ValidateOptions | undefined, ): ReadonlyArray { const maxErrors = options?.maxErrors ?? 100; const hideSuggestions = options?.hideSuggestions ?? false; @@ -84,6 +125,26 @@ export function validate( return errors; } +/** + * Also implements the "Validation" section of the spec. + * However, it guarantees to complete synchronously. + */ +export function validateSync( + schema: GraphQLSchema, + documentAST: DocumentNode, + rules: ReadonlyArray = specifiedRules, + options?: ValidateOptions | undefined, +): ReadonlyArray { + const errors = validate(schema, documentAST, rules, options); + + // Assert that the execution was synchronous. + if (isPromise(errors)) { + throw new Error('GraphQL validation failed to complete synchronously.'); + } + + return errors; +} + /** * @internal */