From 876f73bdd13aa95a31f960ad82173bfc46d9d2c8 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 29 May 2026 00:30:45 +0300 Subject: [PATCH] feat: compile operations Rebase on 17.x.x and carry the static document plus raw runtime variable values through compiled validated execution args so root-selection-set tracing has the execution context it needs. Simplify compiled execution entrypoints by sharing validated-argument error handling across execute modes. --- .../compiled-async-root-fields-benchmark.js | 53 + ...ompiled-field-argument-values-benchmark.js | 96 + ...piled-introspectionFromSchema-benchmark.js | 30 + .../compile/compiled-list-async-benchmark.js | 48 + ...led-list-async-non-null-items-benchmark.js | 48 + .../compile/compiled-list-sync-benchmark.js | 48 + ...led-variable-field-collection-benchmark.js | 89 + cspell.yml | 3 + resources/benchmark/args.ts | 24 +- resources/benchmark/output.ts | 12 +- resources/benchmark/run.ts | 13 +- resources/benchmark/statistics.ts | 5 +- resources/benchmark/types.ts | 2 +- .../expectEqualPromisesOrValues-test.ts | 126 +- ...alPromisesOrValuesOrAsyncIterables-test.ts | 133 + .../__tests__/expectJSON-test.ts | 34 + .../expectMatchingAsyncIterables-test.ts | 488 +++ .../__tests__/expectMatchingValues-test.ts | 56 +- .../__tests__/replayableIterables-test.ts | 262 ++ .../expectEqualPromisesOrValues.ts | 63 +- ...ctEqualPromisesOrValuesOrAsyncIterables.ts | 106 + src/__testUtils__/expectJSON.ts | 3 +- .../expectMatchingAsyncIterables.ts | 316 ++ src/__testUtils__/expectMatchingValues.ts | 76 +- src/__testUtils__/replayableIterables.ts | 190 ++ src/execution/ExecutionArgs.ts | 67 +- src/execution/Executor.ts | 113 +- .../__tests__/AsyncWorkTracker-test.ts | 4 +- src/execution/__tests__/abstract-test.ts | 2 +- src/execution/__tests__/cancellation-test.ts | 31 +- src/execution/__tests__/directives-test.ts | 2 +- .../__tests__/errorPropagation-test.ts | 3 +- src/execution/__tests__/executeTestUtils.ts | 686 ++++ src/execution/__tests__/executor-test.ts | 189 +- src/execution/__tests__/hooks-test.ts | 27 +- src/execution/__tests__/incremental-test.ts | 8 +- src/execution/__tests__/lists-test.ts | 356 +- src/execution/__tests__/mutations-test.ts | 23 +- src/execution/__tests__/nonnull-test.ts | 3 +- src/execution/__tests__/oneof-test.ts | 3 +- src/execution/__tests__/resolve-test.ts | 2 +- src/execution/__tests__/schema-test.ts | 2 +- src/execution/__tests__/subscribe-test.ts | 251 +- src/execution/__tests__/sync-test.ts | 8 +- .../__tests__/union-interface-test.ts | 2 +- src/execution/__tests__/variables-test.ts | 22 +- src/execution/collectFields.ts | 37 +- src/execution/compile/CompiledExecutor.ts | 2979 +++++++++++++++++ src/execution/compile/README.md | 182 + .../__tests__/CompiledExecutor-test.ts | 1576 +++++++++ .../__tests__/compileCollectFields-test.ts | 520 +++ .../compileFieldExecutionPlan-test.ts | 129 + .../compileFragmentVariables-test.ts | 40 + .../compileInclusionDirectives-test.ts | 131 + .../getCompiledArgumentValues-test.ts | 715 ++++ .../__tests__/getCompiledDeferUsage-test.ts | 145 + .../getCompiledDirectiveIfValue-test.ts | 299 ++ .../getCompiledDirectiveValues-test.ts | 325 ++ .../getCompiledVariableValues-test.ts | 209 ++ .../getStaticFragmentVariableValues-test.ts | 198 ++ .../compile/buildValidatedExecutionArgs.ts | 56 + .../compile/compileArgumentValues.ts | 207 ++ .../compile/compileBooleanDirective.ts | 48 + src/execution/compile/compileCollectFields.ts | 763 +++++ .../compile/compileDeferDirective.ts | 50 + .../compile/compileExecutionState.ts | 165 + .../compile/compileFieldExecutionPlan.ts | 377 +++ .../compile/compileFragmentVariables.ts | 111 + .../compile/compileInclusionDirectives.ts | 82 + src/execution/compile/compileInputValue.ts | 572 ++++ .../compile/compileStreamDirective.ts | 167 + .../compile/compileVariableValues.ts | 79 + .../compile/getCompiledArgumentValues.ts | 351 ++ .../compile/getCompiledDeferUsage.ts | 37 + .../compile/getCompiledDirectiveIfValue.ts | 132 + .../compile/getCompiledDirectiveValues.ts | 161 + .../compile/getCompiledVariableValues.ts | 180 + .../getStaticFragmentVariableValues.ts | 96 + src/execution/compile/index.ts | 619 ++++ src/execution/createSharedExecutionContext.ts | 19 +- src/execution/execute.ts | 45 +- src/execution/getStreamUsage.ts | 8 +- .../incremental/IncrementalExecutor.ts | 4 +- .../incremental/__tests__/defer-test.ts | 280 +- .../incremental/__tests__/stream-test.ts | 503 +-- src/execution/index.ts | 9 +- .../legacyIncremental/__tests__/execute.ts | 51 + .../__tests__/legacy-defer-test.ts | 172 +- .../__tests__/legacy-stream-test.ts | 212 +- src/execution/values.ts | 21 +- src/index.ts | 6 + src/type/__tests__/scalars-test.ts | 16 + src/type/scalars.ts | 151 +- .../__tests__/coerceInputValue-test.ts | 155 +- 94 files changed, 16472 insertions(+), 1046 deletions(-) create mode 100644 benchmark/compile/compiled-async-root-fields-benchmark.js create mode 100644 benchmark/compile/compiled-field-argument-values-benchmark.js create mode 100644 benchmark/compile/compiled-introspectionFromSchema-benchmark.js create mode 100644 benchmark/compile/compiled-list-async-benchmark.js create mode 100644 benchmark/compile/compiled-list-async-non-null-items-benchmark.js create mode 100644 benchmark/compile/compiled-list-sync-benchmark.js create mode 100644 benchmark/compile/compiled-variable-field-collection-benchmark.js create mode 100644 src/__testUtils__/__tests__/expectEqualPromisesOrValuesOrAsyncIterables-test.ts create mode 100644 src/__testUtils__/__tests__/expectJSON-test.ts create mode 100644 src/__testUtils__/__tests__/expectMatchingAsyncIterables-test.ts create mode 100644 src/__testUtils__/__tests__/replayableIterables-test.ts create mode 100644 src/__testUtils__/expectEqualPromisesOrValuesOrAsyncIterables.ts create mode 100644 src/__testUtils__/expectMatchingAsyncIterables.ts create mode 100644 src/__testUtils__/replayableIterables.ts create mode 100644 src/execution/__tests__/executeTestUtils.ts create mode 100644 src/execution/compile/CompiledExecutor.ts create mode 100644 src/execution/compile/README.md create mode 100644 src/execution/compile/__tests__/CompiledExecutor-test.ts create mode 100644 src/execution/compile/__tests__/compileCollectFields-test.ts create mode 100644 src/execution/compile/__tests__/compileFieldExecutionPlan-test.ts create mode 100644 src/execution/compile/__tests__/compileFragmentVariables-test.ts create mode 100644 src/execution/compile/__tests__/compileInclusionDirectives-test.ts create mode 100644 src/execution/compile/__tests__/getCompiledArgumentValues-test.ts create mode 100644 src/execution/compile/__tests__/getCompiledDeferUsage-test.ts create mode 100644 src/execution/compile/__tests__/getCompiledDirectiveIfValue-test.ts create mode 100644 src/execution/compile/__tests__/getCompiledDirectiveValues-test.ts create mode 100644 src/execution/compile/__tests__/getCompiledVariableValues-test.ts create mode 100644 src/execution/compile/__tests__/getStaticFragmentVariableValues-test.ts create mode 100644 src/execution/compile/buildValidatedExecutionArgs.ts create mode 100644 src/execution/compile/compileArgumentValues.ts create mode 100644 src/execution/compile/compileBooleanDirective.ts create mode 100644 src/execution/compile/compileCollectFields.ts create mode 100644 src/execution/compile/compileDeferDirective.ts create mode 100644 src/execution/compile/compileExecutionState.ts create mode 100644 src/execution/compile/compileFieldExecutionPlan.ts create mode 100644 src/execution/compile/compileFragmentVariables.ts create mode 100644 src/execution/compile/compileInclusionDirectives.ts create mode 100644 src/execution/compile/compileInputValue.ts create mode 100644 src/execution/compile/compileStreamDirective.ts create mode 100644 src/execution/compile/compileVariableValues.ts create mode 100644 src/execution/compile/getCompiledArgumentValues.ts create mode 100644 src/execution/compile/getCompiledDeferUsage.ts create mode 100644 src/execution/compile/getCompiledDirectiveIfValue.ts create mode 100644 src/execution/compile/getCompiledDirectiveValues.ts create mode 100644 src/execution/compile/getCompiledVariableValues.ts create mode 100644 src/execution/compile/getStaticFragmentVariableValues.ts create mode 100644 src/execution/compile/index.ts create mode 100644 src/execution/legacyIncremental/__tests__/execute.ts diff --git a/benchmark/compile/compiled-async-root-fields-benchmark.js b/benchmark/compile/compiled-async-root-fields-benchmark.js new file mode 100644 index 0000000000..e29684d55e --- /dev/null +++ b/benchmark/compile/compiled-async-root-fields-benchmark.js @@ -0,0 +1,53 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +const fieldCount = 1000; +const fieldNames = Array.from( + { length: fieldCount }, + (_, index) => `f${index}`, +); + +const schema = buildSchema( + `type Query { ${fieldNames.map((fieldName) => `${fieldName}: Int`).join(' ')} }`, + { assumeValid: true }, +); + +const document = parse(`{ ${fieldNames.join(' ')} }`); + +const rootValue = Object.fromEntries( + fieldNames.map((fieldName, index) => [ + fieldName, + () => Promise.resolve(index), + ]), +); + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Compiled Asynchronous Root Fields', + measure: () => { + const runtimeArgs = { rootValue }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + const validatedArgs = execution.validateExecutionArgs({ + schema, + document, + ...runtimeArgs, + }); + if (!('schema' in validatedArgs)) { + throw validatedArgs[0]; + } + return execution.executeRootSelectionSet(validatedArgs); + }, +}; diff --git a/benchmark/compile/compiled-field-argument-values-benchmark.js b/benchmark/compile/compiled-field-argument-values-benchmark.js new file mode 100644 index 0000000000..516492dac5 --- /dev/null +++ b/benchmark/compile/compiled-field-argument-values-benchmark.js @@ -0,0 +1,96 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +const fieldCount = 100; +const fieldNames = Array.from( + { length: fieldCount }, + (_, index) => `f${index}`, +); + +const schema = buildSchema( + ` + input FieldInput { + enabled: Boolean + value: Int + } + + type Query { + ${fieldNames + .map( + (fieldName) => ` + ${fieldName}( + value: Int! + enabled: Boolean + input: FieldInput + list: [Int] + ): Int + `, + ) + .join('\n')} + } + `, + { assumeValid: true }, +); + +const document = parse(` + query CompiledArgumentValues($value: Int!, $enabled: Boolean!) { + ${fieldNames + .map( + (fieldName, index) => ` + ${fieldName}( + value: $value + enabled: $enabled + input: { enabled: $enabled, value: ${index} } + list: [${index}, $value] + ) + `, + ) + .join('\n')} + } +`); + +const rootValue = Object.fromEntries( + fieldNames.map((fieldName, index) => [ + fieldName, + (args) => args.value + args.input.value + args.list[0] + index, + ]), +); + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +let value = 0; +let enabled = false; + +export const benchmark = { + name: 'Compiled Field Argument Values', + measure: () => { + value = (value + 1) % 10; + enabled = !enabled; + const runtimeArgs = { + rootValue, + variableValues: { value, enabled }, + }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + const validatedArgs = execution.validateExecutionArgs({ + schema, + document, + ...runtimeArgs, + }); + if (!('schema' in validatedArgs)) { + throw validatedArgs[0]; + } + return execution.executeRootSelectionSet(validatedArgs); + }, +}; diff --git a/benchmark/compile/compiled-introspectionFromSchema-benchmark.js b/benchmark/compile/compiled-introspectionFromSchema-benchmark.js new file mode 100644 index 0000000000..973d8dd32e --- /dev/null +++ b/benchmark/compile/compiled-introspectionFromSchema-benchmark.js @@ -0,0 +1,30 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; +import { getIntrospectionQuery } from 'graphql/utilities/getIntrospectionQuery.js'; + +import { bigSchemaSDL } from '../fixtures.js'; + +const schema = buildSchema(bigSchemaSDL, { assumeValid: true }); +const document = parse(getIntrospectionQuery()); + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Compiled Execute Introspection Query', + measure: () => { + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute() + : compiled.executeRootSelectionSet(); + } + + return execution.executeSync({ schema, document }); + }, +}; diff --git a/benchmark/compile/compiled-list-async-benchmark.js b/benchmark/compile/compiled-list-async-benchmark.js new file mode 100644 index 0000000000..2dad8e64e4 --- /dev/null +++ b/benchmark/compile/compiled-list-async-benchmark.js @@ -0,0 +1,48 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +const schema = buildSchema('type Query { listField: [String] }', { + assumeValid: true, +}); +const document = parse('{ listField }'); + +function listField() { + const results = []; + for (let index = 0; index < 1000; index++) { + results.push(Promise.resolve(index)); + } + return results; +} + +const rootValue = { listField }; + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Compiled Asynchronous List Field', + measure: () => { + const runtimeArgs = { rootValue }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + const validatedArgs = execution.validateExecutionArgs({ + schema, + document, + ...runtimeArgs, + }); + if (!('schema' in validatedArgs)) { + throw validatedArgs[0]; + } + return execution.executeRootSelectionSet(validatedArgs); + }, +}; diff --git a/benchmark/compile/compiled-list-async-non-null-items-benchmark.js b/benchmark/compile/compiled-list-async-non-null-items-benchmark.js new file mode 100644 index 0000000000..8be06cc41c --- /dev/null +++ b/benchmark/compile/compiled-list-async-non-null-items-benchmark.js @@ -0,0 +1,48 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +const schema = buildSchema('type Query { listField: [String!] }', { + assumeValid: true, +}); +const document = parse('{ listField }'); + +function listField() { + const results = []; + for (let index = 0; index < 1000; index++) { + results.push(Promise.resolve(index)); + } + return results; +} + +const rootValue = { listField }; + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Compiled Asynchronous Non-Null List Items', + measure: () => { + const runtimeArgs = { rootValue }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + const validatedArgs = execution.validateExecutionArgs({ + schema, + document, + ...runtimeArgs, + }); + if (!('schema' in validatedArgs)) { + throw validatedArgs[0]; + } + return execution.executeRootSelectionSet(validatedArgs); + }, +}; diff --git a/benchmark/compile/compiled-list-sync-benchmark.js b/benchmark/compile/compiled-list-sync-benchmark.js new file mode 100644 index 0000000000..7d8727568e --- /dev/null +++ b/benchmark/compile/compiled-list-sync-benchmark.js @@ -0,0 +1,48 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +const schema = buildSchema('type Query { listField: [String] }', { + assumeValid: true, +}); +const document = parse('{ listField }'); + +function listField() { + const results = []; + for (let index = 0; index < 1000; index++) { + results.push(index); + } + return results; +} + +const rootValue = { listField }; + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Compiled Synchronous List Field', + measure: () => { + const runtimeArgs = { rootValue }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + const validatedArgs = execution.validateExecutionArgs({ + schema, + document, + ...runtimeArgs, + }); + if (!('schema' in validatedArgs)) { + throw validatedArgs[0]; + } + return execution.executeRootSelectionSet(validatedArgs); + }, +}; diff --git a/benchmark/compile/compiled-variable-field-collection-benchmark.js b/benchmark/compile/compiled-variable-field-collection-benchmark.js new file mode 100644 index 0000000000..96db7e0ce1 --- /dev/null +++ b/benchmark/compile/compiled-variable-field-collection-benchmark.js @@ -0,0 +1,89 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +const fieldCount = 100; +const fieldNames = Array.from( + { length: fieldCount }, + (_, index) => `f${index}`, +); + +const schema = buildSchema( + ` + type Query { + item: Item! + } + + type Item { + ${fieldNames.map((fieldName) => `${fieldName}: Int`).join('\n')} + nested: Item + } + `, + { assumeValid: true }, +); + +const repeatedFields = fieldNames + .map( + (fieldName, index) => + `${fieldName} @${index % 2 === 0 ? 'include' : 'skip'}(if: $flag)`, + ) + .join('\n'); + +const document = parse(` + query CompiledExecute($flag: Boolean!) { + item { + ...ItemFields + ... on Item { + ${repeatedFields} + } + nested { + ...ItemFields + } + } + } + + fragment ItemFields on Item { + ${repeatedFields} + } +`); + +const item = Object.fromEntries( + fieldNames.map((fieldName, index) => [fieldName, index]), +); +item.nested = item; + +const compiled = + typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined; +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +let flag = false; + +export const benchmark = { + name: 'Compiled Variable Field Collection', + measure: () => { + flag = !flag; + const runtimeArgs = { + rootValue: { item }, + variableValues: { flag }, + }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + const validatedArgs = execution.validateExecutionArgs({ + schema, + document, + ...runtimeArgs, + }); + if (!('schema' in validatedArgs)) { + throw validatedArgs[0]; + } + return execution.executeRootSelectionSet(validatedArgs); + }, +}; diff --git a/cspell.yml b/cspell.yml index edc9af76c5..e1651664a8 100644 --- a/cspell.yml +++ b/cspell.yml @@ -41,7 +41,10 @@ ignoreRegExpList: words: - graphiql - Jsdocs + - backpressure - metafield + - microtask + - replayable - thunked - uncoerce - uncoerced diff --git a/resources/benchmark/args.ts b/resources/benchmark/args.ts index 01914581d1..7d0acc3eba 100644 --- a/resources/benchmark/args.ts +++ b/resources/benchmark/args.ts @@ -74,10 +74,22 @@ function inferRuntimeFromExecPath(execPath: string): Runtime { } function findAllBenchmarks(): Array { - return fs - .readdirSync(localRepoPath('benchmark'), { withFileTypes: true }) - .filter((dirent) => dirent.isFile()) - .map((dirent) => dirent.name) - .filter((name) => name.endsWith('-benchmark.js')) - .map((name) => path.join('benchmark', name)); + const benchmarkDir = localRepoPath('benchmark'); + const benchmarks: Array = []; + collectBenchmarks(benchmarkDir, benchmarks); + return benchmarks.sort(); +} + +function collectBenchmarks( + directoryPath: string, + benchmarks: Array, +): void { + for (const dirent of fs.readdirSync(directoryPath, { withFileTypes: true })) { + const absolutePath = path.join(directoryPath, dirent.name); + if (dirent.isDirectory()) { + collectBenchmarks(absolutePath, benchmarks); + } else if (dirent.isFile() && dirent.name.endsWith('-benchmark.js')) { + benchmarks.push(path.relative(localRepoPath(), absolutePath)); + } + } } diff --git a/resources/benchmark/output.ts b/resources/benchmark/output.ts index 21d40a6fa2..cee62f4568 100644 --- a/resources/benchmark/output.ts +++ b/resources/benchmark/output.ts @@ -12,7 +12,7 @@ export function printBenchmarkResults( const opsMaxLen = maxBy(results, ({ ops }) => beautifyNumber(ops).length); const memPerOpMaxLen = maxBy( results, - ({ memPerOp }) => beautifyBytes(memPerOp).length, + ({ memPerOp }) => formatMemory(memPerOp).length, ); for (const result of results) { @@ -51,7 +51,7 @@ export function printBenchmarkResults( } function memPerOpStr(): string { - return beautifyBytes(memPerOp).padStart(memPerOpMaxLen); + return formatMemory(memPerOp).padStart(memPerOpMaxLen); } } } @@ -104,7 +104,15 @@ export function printPairedComparisons( ); } } +function formatMemory(bytes: number | undefined): string { + return bytes === undefined ? 'n/a' : beautifyBytes(bytes); +} + function beautifyBytes(bytes: number): string { + if (bytes < 1) { + return beautifyNumber(bytes) + ' Bytes'; + } + const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log2(bytes) / 10); return beautifyNumber(bytes / 2 ** (i * 10)) + ' ' + sizes[i]; diff --git a/resources/benchmark/run.ts b/resources/benchmark/run.ts index 089455e18e..427edbdde1 100644 --- a/resources/benchmark/run.ts +++ b/resources/benchmark/run.ts @@ -199,14 +199,17 @@ export function collectMemorySamples( runtime: Runtime, ): Array { const samples: Array = []; + const maxSampleAttempts = memorySamplesPerBenchmark * 3; for ( - let sampleIndex = 0; - sampleIndex < memorySamplesPerBenchmark; - ++sampleIndex + let sampleAttempt = 0; + sampleAttempt < maxSampleAttempts && + samples.length < memorySamplesPerBenchmark; + ++sampleAttempt ) { const sample = sampleMemoryModule(modulePath, runtime); - assert(sample > 0); - samples.push(sample); + if (Number.isFinite(sample) && sample > 0) { + samples.push(sample); + } } return samples; } diff --git a/resources/benchmark/statistics.ts b/resources/benchmark/statistics.ts index d804cefd03..17cbd0ad22 100644 --- a/resources/benchmark/statistics.ts +++ b/resources/benchmark/statistics.ts @@ -35,7 +35,10 @@ export function computeStats( return { name, - memPerOp: Math.floor(computeMean(memorySamples)), + memPerOp: + memorySamples.length === 0 + ? undefined + : Math.floor(computeMean(memorySamples)), ops: NS_PER_SEC / mean, deviation: computeRelativeMarginOfError(timingSamples), numSamples: timingSamples.length, diff --git a/resources/benchmark/types.ts b/resources/benchmark/types.ts index e57a6f3aef..9c7b83e6b8 100644 --- a/resources/benchmark/types.ts +++ b/resources/benchmark/types.ts @@ -5,7 +5,7 @@ export interface BenchmarkProject { export interface BenchmarkResult { name: string; - memPerOp: number; + memPerOp: number | undefined; ops: number; deviation: number; numSamples: number; diff --git a/src/__testUtils__/__tests__/expectEqualPromisesOrValues-test.ts b/src/__testUtils__/__tests__/expectEqualPromisesOrValues-test.ts index 6056d82286..5414964565 100644 --- a/src/__testUtils__/__tests__/expectEqualPromisesOrValues-test.ts +++ b/src/__testUtils__/__tests__/expectEqualPromisesOrValues-test.ts @@ -7,40 +7,144 @@ import { expectPromise } from '../expectPromise.ts'; describe('expectEqualPromisesOrValues', () => { it('throws when given unequal values', () => { - expect(() => expectEqualPromisesOrValues([{}, {}, { test: 'test' }])).throw( - "expected { test: 'test' } to deeply equal {}", - ); + expect(() => + expectEqualPromisesOrValues([ + () => ({}), + () => ({}), + () => ({ test: 'test' }), + ]), + ).throw("expected { test: 'test' } to deeply equal {}"); }); it('does not throw when given equal values', () => { const testValue = { test: 'test' }; expect(() => - expectEqualPromisesOrValues([testValue, testValue, testValue]), + expectEqualPromisesOrValues([ + () => testValue, + () => testValue, + () => testValue, + ]), ).not.to.throw(); }); it('does not throw when given equal promises', async (): Promise => { - const testValue = Promise.resolve({ test: 'test' }); - await expectPromise( - expectEqualPromisesOrValues([testValue, testValue, testValue]), + expectEqualPromisesOrValues([ + () => Promise.resolve({ test: 'test' }), + () => Promise.resolve({ test: 'test' }), + () => Promise.resolve({ test: 'test' }), + ]), ).toResolve(); }); it('throws when given unequal promises', async () => { await expectPromise( expectEqualPromisesOrValues([ - Promise.resolve({}), - Promise.resolve({}), - Promise.resolve({ test: 'test' }), + () => Promise.resolve({}), + () => Promise.resolve({}), + () => Promise.resolve({ test: 'test' }), ]), ).toRejectWith("expected { test: 'test' } to deeply equal {}"); }); + it('rejects when given matching rejected promises', async () => { + await expectPromise( + expectEqualPromisesOrValues([ + () => Promise.reject(new Error('test error')), + () => Promise.reject(new Error('test error')), + ]), + ).toRejectWith('test error'); + }); + + it('rejects when given different rejected promises', async () => { + const error = await expectPromise( + expectEqualPromisesOrValues([ + () => Promise.reject(new Error('test error')), + () => Promise.reject(new Error('different error')), + ]), + ).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('rejects when matching errors mix throws and promises', async () => { + await expectPromise( + expectEqualPromisesOrValues([ + () => Promise.reject(new Error('test error')), + () => { + throw new Error('test error'); + }, + ]), + ).toRejectWith('test error'); + }); + + it('rejects when mixed throws and promises produce different errors', async () => { + const error = await expectPromise( + expectEqualPromisesOrValues([ + () => Promise.reject(new Error('test error')), + () => { + throw new Error('different error'); + }, + ]), + ).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('rejects when thrown errors are mixed with resolved promises', async () => { + await expectPromise( + expectEqualPromisesOrValues([ + () => Promise.resolve({ test: 'test' }), + () => { + throw new Error('test error'); + }, + ]), + ).toRejectWith('Received an invalid mixture of values and thrown errors.'); + }); + + it('throws when given matching throwing functions', () => { + expect(() => + expectEqualPromisesOrValues([ + () => { + throw new Error('test error'); + }, + () => { + throw new Error('test error'); + }, + ]), + ).to.throw('test error'); + }); + + it('throws when given a mixture of thrown errors and values', () => { + expect(() => + expectEqualPromisesOrValues([ + () => { + throw new Error('test error'); + }, + () => ({ test: 'test' }), + ]), + ).to.throw('Received an invalid mixture of thrown errors and values.'); + }); + it('throws when given equal values that are mixtures of values and promises', () => { const testValue = { test: 'test' }; expect(() => - expectEqualPromisesOrValues([testValue, Promise.resolve(testValue)]), + expectEqualPromisesOrValues([ + () => testValue, + () => Promise.resolve(testValue), + ]), + ).to.throw('Received an invalid mixture of promises and values.'); + }); + + it('throws when the first item is a promise and later items are values', () => { + const testValue = { test: 'test' }; + expect(() => + expectEqualPromisesOrValues([ + () => Promise.resolve(testValue), + () => testValue, + ]), ).to.throw('Received an invalid mixture of promises and values.'); }); }); diff --git a/src/__testUtils__/__tests__/expectEqualPromisesOrValuesOrAsyncIterables-test.ts b/src/__testUtils__/__tests__/expectEqualPromisesOrValuesOrAsyncIterables-test.ts new file mode 100644 index 0000000000..442402535c --- /dev/null +++ b/src/__testUtils__/__tests__/expectEqualPromisesOrValuesOrAsyncIterables-test.ts @@ -0,0 +1,133 @@ +import { describe, it } from 'node:test'; + +import { assert, expect } from 'chai'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.ts'; + +import { expectEqualPromisesOrValuesOrAsyncIterables } from '../expectEqualPromisesOrValuesOrAsyncIterables.ts'; +import { expectPromise } from '../expectPromise.ts'; + +async function* source( + values: ReadonlyArray, +): AsyncGenerator { + await Promise.resolve(); + for (const value of values) { + yield value; + } +} + +async function collectAsyncIterable( + iterable: AsyncIterable, +): Promise> { + const values = []; + for await (const value of iterable) { + values.push(value); + } + return values; +} + +describe('expectEqualPromisesOrValuesOrAsyncIterables', () => { + it('returns matching values', () => { + const testValue = { test: 'test' }; + + expect( + expectEqualPromisesOrValuesOrAsyncIterables([ + () => testValue, + () => ({ test: 'test' }), + () => ({ test: 'test' }), + ]), + ).to.equal(testValue); + }); + + it('throws when values do not match', () => { + expect(() => + expectEqualPromisesOrValuesOrAsyncIterables([ + () => ({}), + () => ({}), + () => ({ test: 'test' }), + ]), + ).to.throw("expected { test: 'test' } to deeply equal {}"); + }); + + it('resolves matching promises', async () => { + const testValue = { test: 'test' }; + + await expectPromise( + expectEqualPromisesOrValuesOrAsyncIterables([ + () => Promise.resolve(testValue), + () => Promise.resolve({ test: 'test' }), + ]), + ).toResolve(); + }); + + it('rejects when promises do not match', async () => { + await expectPromise( + expectEqualPromisesOrValuesOrAsyncIterables([ + () => Promise.resolve({}), + () => Promise.resolve({ test: 'test' }), + ]), + ).toRejectWith("expected { test: 'test' } to deeply equal {}"); + }); + + it('rejects when promises reject with matching errors', async () => { + await expectPromise( + expectEqualPromisesOrValuesOrAsyncIterables([ + () => Promise.reject(new Error('test error')), + () => Promise.reject(new Error('test error')), + ]), + ).toRejectWith('test error'); + }); + + it('yields matching async iterable values', async () => { + const result = expectEqualPromisesOrValuesOrAsyncIterables([ + () => source([1, 2]), + () => source([1, 2]), + ]); + assert(isAsyncIterable(result)); + + expect(await collectAsyncIterable(result)).to.deep.equal([1, 2]); + }); + + it('resolves matching async iterable values', async () => { + const result = await expectEqualPromisesOrValuesOrAsyncIterables([ + () => Promise.resolve(source([1, 2])), + () => Promise.resolve(source([1, 2])), + ]); + assert(isAsyncIterable(result)); + + expect(await collectAsyncIterable(result)).to.deep.equal([1, 2]); + }); + + it('rejects when async iterable values do not match', async () => { + const result = expectEqualPromisesOrValuesOrAsyncIterables([ + () => source([1]), + () => source([2]), + ]); + assert(isAsyncIterable(result)); + + const error = await expectPromise(collectAsyncIterable(result)).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('throws when given a mixture of promises and values', () => { + expect(() => + expectEqualPromisesOrValuesOrAsyncIterables([ + () => ({ test: 'test' }), + () => Promise.resolve({ test: 'test' }), + ]), + ).to.throw('Received an invalid mixture of promises and values.'); + }); + + it('rejects when promises resolve to a mixture of values and async iterables', async () => { + await expectPromise( + expectEqualPromisesOrValuesOrAsyncIterables([ + () => Promise.resolve(1), + () => Promise.resolve(source([1])), + ]), + ).toRejectWith( + 'Received an invalid mixture of async iterables and values.', + ); + }); +}); diff --git a/src/__testUtils__/__tests__/expectJSON-test.ts b/src/__testUtils__/__tests__/expectJSON-test.ts new file mode 100644 index 0000000000..fabe7ba262 --- /dev/null +++ b/src/__testUtils__/__tests__/expectJSON-test.ts @@ -0,0 +1,34 @@ +import { describe, it } from 'node:test'; + +import { expectJSON } from '../expectJSON.ts'; + +describe('expectJSON', () => { + it('normalizes values returned from toJSON', () => { + const actual = { + error: { + toJSON() { + return { + extensions: { code: 'CUSTOM' }, + }; + }, + }, + }; + + expectJSON(actual).toDeepEqual({ + error: { + extensions: { code: 'CUSTOM' }, + }, + }); + }); + + it('allows toJSON to return the source object', () => { + const actual = { + message: 'same object', + toJSON() { + return actual; + }, + }; + + expectJSON(actual).toDeepEqual(actual); + }); +}); diff --git a/src/__testUtils__/__tests__/expectMatchingAsyncIterables-test.ts b/src/__testUtils__/__tests__/expectMatchingAsyncIterables-test.ts new file mode 100644 index 0000000000..e126340d26 --- /dev/null +++ b/src/__testUtils__/__tests__/expectMatchingAsyncIterables-test.ts @@ -0,0 +1,488 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { + expectMatchingAsyncIterables, + expectMatchingAsyncIterablesConcurrently, +} from '../expectMatchingAsyncIterables.ts'; +import { expectPromise } from '../expectPromise.ts'; + +async function* source( + values: ReadonlyArray, +): AsyncGenerator { + await Promise.resolve(); + for (const value of values) { + yield value; + } +} + +async function collectAsyncIterable( + iterable: AsyncIterable, +): Promise> { + const values = []; + for await (const value of iterable) { + values.push(value); + } + return values; +} + +describe('expectMatchingAsyncIterables', () => { + it('yields matching async iterable values', async () => { + const values = await collectAsyncIterable( + expectMatchingAsyncIterables([ + source([{ value: 1 }, { value: 2 }]), + source([{ value: 1 }, { value: 2 }]), + ]), + ); + + expect(values).to.deep.equal([{ value: 1 }, { value: 2 }]); + }); + + it('rejects when async iterable values do not match', async () => { + const error = await expectPromise( + collectAsyncIterable( + expectMatchingAsyncIterables([ + source([{ value: 1 }]), + source([{ value: 2 }]), + ]), + ), + ).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('rejects when async iterable lengths do not match', async () => { + const error = await expectPromise( + collectAsyncIterable( + expectMatchingAsyncIterables([source([1]), source([1, 2])]), + ), + ).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('rejects with matching async iterable errors', async () => { + async function* throwingSource(): AsyncGenerator { + await Promise.resolve(); + yield 1; + throw new Error('bad iterator'); + } + + const iterator = expectMatchingAsyncIterables([ + throwingSource(), + throwingSource(), + ]); + + expect(await iterator.next()).to.deep.equal({ value: 1, done: false }); + await expectPromise(iterator.next()).toRejectWith('bad iterator'); + }); + + it('closes source iterators when the comparison is closed', async () => { + let firstClosed = false; + let secondClosed = false; + async function* closeableSource(): AsyncGenerator { + try { + await Promise.resolve(); + yield 1; + yield 2; + } finally { + firstClosed = true; + } + } + const secondSource = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + return() { + secondClosed = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + + const iterator = expectMatchingAsyncIterables([ + closeableSource(), + secondSource, + ]); + expect(await iterator.next()).to.deep.equal({ value: 1, done: false }); + await iterator.return(); + + expect(firstClosed).to.equal(true); + expect(secondClosed).to.equal(true); + }); + + it('closes comparisons when source iterators do not implement return', async () => { + const sourceWithoutReturn = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + }; + }, + }; + + const iterator = expectMatchingAsyncIterables([ + sourceWithoutReturn, + sourceWithoutReturn, + ]); + expect(await iterator.next()).to.deep.equal({ value: 1, done: false }); + + expect(await iterator.return()).to.deep.equal({ + value: undefined, + done: true, + }); + expect(await iterator.next()).to.deep.equal({ + value: undefined, + done: true, + }); + }); + + it('closes source iterators while a next call is pending', async () => { + let resolveNext: ((result: IteratorResult) => void) | undefined; + let firstClosed = false; + let secondClosed = false; + const firstSource = { + [Symbol.asyncIterator]() { + return { + next() { + return new Promise>((resolve) => { + resolveNext = resolve; + }); + }, + return() { + firstClosed = true; + resolveNext?.({ done: true, value: undefined }); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + const secondSource = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + return() { + secondClosed = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + + const iterator = expectMatchingAsyncIterables([firstSource, secondSource]); + const next = iterator.next(); + await iterator.return(); + + expect(await next).to.deep.equal({ done: true, value: undefined }); + expect(firstClosed).to.equal(true); + expect(secondClosed).to.equal(true); + }); + + it('returns done after comparison has completed', async () => { + const iterator = expectMatchingAsyncIterables([source([1]), source([1])]); + + expect(await iterator.next()).to.deep.equal({ value: 1, done: false }); + expect(await iterator.next()).to.deep.equal({ + value: undefined, + done: true, + }); + expect(await iterator.next()).to.deep.equal({ + value: undefined, + done: true, + }); + }); + + it('closes remaining iterators when comparison throws', async () => { + let closed = false; + const throwingSource = { + [Symbol.asyncIterator]() { + let count = 0; + return { + next() { + count += 1; + if (count === 1) { + return Promise.resolve({ value: 1, done: false }); + } + return Promise.reject(new Error('bad iterator')); + }, + return() { + closed = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + + const iterator = expectMatchingAsyncIterables([ + source([1]), + throwingSource, + ]); + + expect(await iterator.next()).to.deep.equal({ value: 1, done: false }); + const error = await expectPromise(iterator.next()).toReject(); + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + expect(closed).to.equal(true); + }); + + it('closes the source iterator when thrown', async () => { + let closed = false; + const closeableSource = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + return() { + closed = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + const iterator = expectMatchingAsyncIterables([ + closeableSource, + source([1]), + ]); + + await expectPromise(iterator.throw(new Error('thrown'))).toRejectWith( + 'thrown', + ); + expect(closed).to.equal(true); + }); + + it('throws when source iterator does not implement throw', async () => { + const sourceWithoutThrow = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + }; + }, + }; + const iterator = expectMatchingAsyncIterables([ + sourceWithoutThrow, + source([1]), + ]); + + await expectPromise(iterator.throw(new Error('thrown'))).toRejectWith( + 'thrown', + ); + }); + + it('can be disposed', async () => { + let closed = false; + const closeableSource = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + return() { + closed = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + const iterator = expectMatchingAsyncIterables([ + closeableSource, + source([1]), + ]); + + await iterator[Symbol.asyncDispose](); + + expect(closed).to.equal(true); + }); +}); + +describe('expectMatchingAsyncIterablesConcurrently', () => { + it('yields first iterable values and compares collected values', async () => { + const values = await collectAsyncIterable( + expectMatchingAsyncIterablesConcurrently([ + source([{ value: 1 }, { value: 2 }]), + source([{ value: 1 }, { value: 2 }]), + ]), + ); + + expect(values).to.deep.equal([{ value: 1 }, { value: 2 }]); + }); + + it('rejects when collected values do not match', async () => { + const error = await expectPromise( + collectAsyncIterable( + expectMatchingAsyncIterablesConcurrently([ + source([{ value: 1 }]), + source([{ value: 2 }]), + ]), + ), + ).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('rejects when a comparison iterable has extra values', async () => { + const error = await expectPromise( + collectAsyncIterable( + expectMatchingAsyncIterablesConcurrently([ + source([1]), + source([1, 2, 3]), + ]), + ), + ).toReject(); + + expect(error).to.be.an.instanceOf(Error); + expect(error).to.have.property('message').that.contains('deeply equal'); + }); + + it('can compare transformed value batches', async () => { + const iterator = expectMatchingAsyncIterablesConcurrently( + [source([1, 2]), source([2, 1])], + (valueBatches) => { + expect( + valueBatches.map((values) => [...values].sort((a, b) => a - b)), + ).to.deep.equal([ + [1, 2], + [1, 2], + ]); + }, + ); + + expect(await collectAsyncIterable(iterator)).to.deep.equal([1, 2]); + }); + + it('starts comparison iterators without delaying yielded values', async () => { + let comparisonNextCallCount = 0; + let resolveComparisonNext: + | ((result: IteratorResult) => void) + | undefined; + const comparison = { + [Symbol.asyncIterator]() { + return { + next() { + comparisonNextCallCount += 1; + return new Promise>((resolve) => { + resolveComparisonNext = resolve; + }); + }, + return() { + resolveComparisonNext?.({ done: true, value: undefined }); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + + const iterator = expectMatchingAsyncIterablesConcurrently([ + source([1]), + comparison, + ]); + + expect(await iterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(comparisonNextCallCount).to.equal(1); + await iterator.return(); + }); + + it('closes comparison iterators when the result is closed', async () => { + let firstClosed = false; + let secondClosed = false; + const first = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ done: false, value: 1 }); + }, + return() { + firstClosed = true; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + const second = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ done: false, value: 1 }); + }, + return() { + secondClosed = true; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + + const iterator = expectMatchingAsyncIterablesConcurrently([first, second]); + + expect(await iterator.next()).to.deep.equal({ done: false, value: 1 }); + await iterator.return(); + + expect(firstClosed).to.equal(true); + expect(secondClosed).to.equal(true); + }); + + it('returns done after concurrent comparison has completed', async () => { + const iterator = expectMatchingAsyncIterablesConcurrently([ + source([1]), + source([1]), + ]); + + expect(await iterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(await iterator.next()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(await iterator.next()).to.deep.equal({ + done: true, + value: undefined, + }); + }); + + it('throws when concurrent source iterators do not implement throw', async () => { + let comparisonClosed = false; + const sourceWithoutThrow = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + }; + }, + }; + const comparisonWithoutThrow = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ value: 1, done: false }); + }, + return() { + comparisonClosed = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }; + }, + }; + const iterator = expectMatchingAsyncIterablesConcurrently([ + sourceWithoutThrow, + comparisonWithoutThrow, + ]); + + await expectPromise(iterator.throw(new Error('thrown'))).toRejectWith( + 'thrown', + ); + + expect(comparisonClosed).to.equal(true); + }); +}); diff --git a/src/__testUtils__/__tests__/expectMatchingValues-test.ts b/src/__testUtils__/__tests__/expectMatchingValues-test.ts index 641a8a3975..7113296b97 100644 --- a/src/__testUtils__/__tests__/expectMatchingValues-test.ts +++ b/src/__testUtils__/__tests__/expectMatchingValues-test.ts @@ -2,19 +2,65 @@ import { describe, it } from 'node:test'; import { expect } from 'chai'; -import { expectMatchingValues } from '../expectMatchingValues.ts'; +import { + expectMatchingErrors, + expectMatchingValues, +} from '../expectMatchingValues.ts'; describe('expectMatchingValues', () => { it('throws when given unequal values', () => { - expect(() => expectMatchingValues([{}, {}, { test: 'test' }])).throw( - "expected { test: 'test' } to deeply equal {}", - ); + expect(() => + expectMatchingValues([() => ({}), () => ({}), () => ({ test: 'test' })]), + ).throw("expected { test: 'test' } to deeply equal {}"); }); it('does not throw when given equal values', () => { const testValue = { test: 'test' }; expect(() => - expectMatchingValues([testValue, testValue, testValue]), + expectMatchingValues([() => testValue, () => testValue, () => testValue]), + ).not.to.throw(); + }); + + it('rethrows when given matching thrown errors', () => { + expect(() => + expectMatchingValues([ + () => { + throw new Error('test error'); + }, + () => { + throw new Error('test error'); + }, + ]), + ).to.throw('test error'); + }); + + it('throws when given different thrown errors', () => { + expect(() => + expectMatchingValues([ + () => { + throw new Error('test error'); + }, + () => { + throw new Error('different error'); + }, + ]), + ).to.throw(/deeply equal/); + }); + + it('does not throw when given matching non-error values as errors', () => { + expect(() => + expectMatchingErrors(['test error', 'test error']), ).not.to.throw(); }); + + it('throws when given a mixture of values and thrown errors', () => { + expect(() => + expectMatchingValues([ + () => ({ test: 'test' }), + () => { + throw new Error('test error'); + }, + ]), + ).to.throw('Received an invalid mixture of values and thrown errors.'); + }); }); diff --git a/src/__testUtils__/__tests__/replayableIterables-test.ts b/src/__testUtils__/__tests__/replayableIterables-test.ts new file mode 100644 index 0000000000..d750afe38f --- /dev/null +++ b/src/__testUtils__/__tests__/replayableIterables-test.ts @@ -0,0 +1,262 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { expectPromise } from '../expectPromise.ts'; +import { + createReplayableAsyncIterablePair, + createReplayableIterablePair, +} from '../replayableIterables.ts'; + +async function collectAsyncIterable( + iterable: AsyncIterable, +): Promise> { + const values = []; + for await (const value of iterable) { + values.push(value); + } + return values; +} + +describe('createReplayableIterablePair', () => { + it('replays recorded iterable values', () => { + function* source() { + yield { value: 1 }; + yield { value: 2 }; + } + + const [recordingIterable, replayIterable] = + createReplayableIterablePair(source()); + + expect(Array.from(recordingIterable)).to.deep.equal([ + { value: 1 }, + { value: 2 }, + ]); + expect(Array.from(replayIterable)).to.deep.equal([ + { value: 1 }, + { value: 2 }, + ]); + expect(Array.from(replayIterable)).to.deep.equal([ + { value: 1 }, + { value: 2 }, + ]); + }); + + it('throws when replayed before recording completes', () => { + const [recordingIterable, replayIterable] = createReplayableIterablePair([ + 1, 2, + ]); + const recordingIterator = recordingIterable[Symbol.iterator](); + + expect(recordingIterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(() => Array.from(replayIterable)).to.throw( + 'Expected iterable input to be recorded before replaying it.', + ); + }); + + it('replays recorded iterable errors', () => { + function* source() { + yield 1; + throw new Error('bad iterator'); + } + + const [recordingIterable, replayIterable] = + createReplayableIterablePair(source()); + const recordingIterator = recordingIterable[Symbol.iterator](); + const replayIterator = replayIterable[Symbol.iterator](); + + expect(recordingIterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(() => recordingIterator.next()).to.throw('bad iterator'); + + expect(replayIterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(() => replayIterator.next()).to.throw('bad iterator'); + }); + + it('replays iterator completion after early return', () => { + let closed = false; + function* source() { + try { + yield 1; + yield 2; + } finally { + closed = true; + } + } + + const [recordingIterable, replayIterable] = + createReplayableIterablePair(source()); + const recordingIterator = recordingIterable[Symbol.iterator](); + + expect(recordingIterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(recordingIterator.return?.()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(recordingIterator.next()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(recordingIterator.return?.()).to.deep.equal({ + done: true, + value: undefined, + }); + + expect(closed).to.equal(true); + expect(Array.from(replayIterable)).to.deep.equal([1]); + }); + + it('replays iterator completion when return is not implemented', () => { + const recordingSource = { + [Symbol.iterator]() { + return { + next() { + return { done: false, value: 1 }; + }, + }; + }, + }; + const [recordingIterable, replayIterable] = + createReplayableIterablePair(recordingSource); + const recordingIterator = recordingIterable[Symbol.iterator](); + + expect(recordingIterator.next()).to.deep.equal({ done: false, value: 1 }); + expect(recordingIterator.return?.()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(Array.from(replayIterable)).to.deep.equal([1]); + }); +}); + +describe('createReplayableAsyncIterablePair', () => { + it('replays recorded async iterable values', async () => { + async function* source() { + await Promise.resolve(); + yield { value: 1 }; + yield { value: 2 }; + } + + const [recordingIterable, replayIterable] = + createReplayableAsyncIterablePair(source()); + + expect(await collectAsyncIterable(recordingIterable)).to.deep.equal([ + { value: 1 }, + { value: 2 }, + ]); + expect(await collectAsyncIterable(replayIterable)).to.deep.equal([ + { value: 1 }, + { value: 2 }, + ]); + expect(await collectAsyncIterable(replayIterable)).to.deep.equal([ + { value: 1 }, + { value: 2 }, + ]); + }); + + it('rejects when replayed before recording completes', async () => { + async function* source() { + await Promise.resolve(); + yield 1; + yield 2; + } + + const [recordingIterable, replayIterable] = + createReplayableAsyncIterablePair(source()); + const recordingIterator = recordingIterable[Symbol.asyncIterator](); + + expect(await recordingIterator.next()).to.deep.equal({ + done: false, + value: 1, + }); + await expectPromise(collectAsyncIterable(replayIterable)).toRejectWith( + 'Expected async iterable input to be recorded before replaying it.', + ); + }); + + it('replays recorded async iterable errors', async () => { + async function* source() { + await Promise.resolve(); + yield 1; + throw new Error('bad iterator'); + } + + const [recordingIterable, replayIterable] = + createReplayableAsyncIterablePair(source()); + const recordingIterator = recordingIterable[Symbol.asyncIterator](); + const replayIterator = replayIterable[Symbol.asyncIterator](); + + expect(await recordingIterator.next()).to.deep.equal({ + done: false, + value: 1, + }); + await expectPromise(recordingIterator.next()).toRejectWith('bad iterator'); + + expect(await replayIterator.next()).to.deep.equal({ + done: false, + value: 1, + }); + await expectPromise(replayIterator.next()).toRejectWith('bad iterator'); + }); + + it('replays async iterator completion after early return', async () => { + let closed = false; + async function* source() { + await Promise.resolve(); + try { + yield 1; + yield 2; + } finally { + closed = true; + } + } + + const [recordingIterable, replayIterable] = + createReplayableAsyncIterablePair(source()); + const recordingIterator = recordingIterable[Symbol.asyncIterator](); + + expect(await recordingIterator.next()).to.deep.equal({ + done: false, + value: 1, + }); + expect(await recordingIterator.return?.()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(await recordingIterator.next()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(await recordingIterator.return?.()).to.deep.equal({ + done: true, + value: undefined, + }); + + expect(closed).to.equal(true); + expect(await collectAsyncIterable(replayIterable)).to.deep.equal([1]); + }); + + it('replays async iterator completion when return is not implemented', async () => { + const recordingSource = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ done: false, value: 1 }); + }, + }; + }, + }; + const [recordingIterable, replayIterable] = + createReplayableAsyncIterablePair(recordingSource); + const recordingIterator = recordingIterable[Symbol.asyncIterator](); + + expect(await recordingIterator.next()).to.deep.equal({ + done: false, + value: 1, + }); + expect(await recordingIterator.return?.()).to.deep.equal({ + done: true, + value: undefined, + }); + expect(await collectAsyncIterable(replayIterable)).to.deep.equal([1]); + }); +}); diff --git a/src/__testUtils__/expectEqualPromisesOrValues.ts b/src/__testUtils__/expectEqualPromisesOrValues.ts index ceaa96dc17..9c56ce3b70 100644 --- a/src/__testUtils__/expectEqualPromisesOrValues.ts +++ b/src/__testUtils__/expectEqualPromisesOrValues.ts @@ -3,19 +3,72 @@ import { assert } from 'chai'; import { isPromise } from '../jsutils/isPromise.ts'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue.ts'; -import { expectMatchingValues } from './expectMatchingValues.ts'; +import type { MatchingOutcome, MatchingValue } from './expectMatchingValues.ts'; +import { + captureMatchingValue, + expectMatchingOutcomes, + expectMatchingValues, +} from './expectMatchingValues.ts'; + +type PromiseOrValueOrThunk = MatchingValue>; export function expectEqualPromisesOrValues( - items: ReadonlyArray>, + items: ReadonlyArray>, ): PromiseOrValue { - const [firstItem, ...remainingItems] = items; + const outcomes = items.map(captureMatchingValue); + const [firstOutcome] = outcomes; + assert(firstOutcome !== undefined, 'Expected at least one item.'); + + if (outcomes.some((outcome) => outcome.kind === 'error')) { + if ( + outcomes.some((outcome) => outcome.kind === 'value') && + outcomes.every( + (outcome) => outcome.kind === 'error' || isPromise(outcome.value), + ) + ) { + return Promise.all( + outcomes.map(async (outcome) => { + if (outcome.kind === 'error') { + return outcome; + } + + assert(isPromise(outcome.value)); + try { + return { kind: 'value', value: await outcome.value } as const; + } catch (error) { + return { kind: 'error', error } as const; + } + }), + ).then(expectMatchingOutcomes); + } + return expectMatchingOutcomes(outcomes); + } + + const values = outcomes.map((outcome) => { + assert(outcome.kind === 'value'); + return outcome.value; + }); + const [firstItem, ...remainingItems] = values; if (isPromise(firstItem)) { if (remainingItems.every(isPromise)) { - return Promise.all(items).then(expectMatchingValues); + return Promise.allSettled(values).then((settledItems) => + expectMatchingOutcomes(settledItems.map(outcomeFromSettledItem)), + ); } } else if (remainingItems.every((item) => !isPromise(item))) { - return expectMatchingValues(items); + return expectMatchingValues( + (values as ReadonlyArray).map((value) => () => value), + ); } assert(false, 'Received an invalid mixture of promises and values.'); } + +function outcomeFromSettledItem( + settledItem: PromiseSettledResult, +): MatchingOutcome { + if (settledItem.status === 'fulfilled') { + return { kind: 'value', value: settledItem.value }; + } + return { kind: 'error', error: settledItem.reason }; +} diff --git a/src/__testUtils__/expectEqualPromisesOrValuesOrAsyncIterables.ts b/src/__testUtils__/expectEqualPromisesOrValuesOrAsyncIterables.ts new file mode 100644 index 0000000000..7baa456f1a --- /dev/null +++ b/src/__testUtils__/expectEqualPromisesOrValuesOrAsyncIterables.ts @@ -0,0 +1,106 @@ +import { assert } from 'chai'; + +import { isAsyncIterable } from '../jsutils/isAsyncIterable.ts'; +import { isPromise } from '../jsutils/isPromise.ts'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.ts'; + +import { expectMatchingAsyncIterablesConcurrently } from './expectMatchingAsyncIterables.ts'; +import type { MatchingOutcome, MatchingValue } from './expectMatchingValues.ts'; +import { + captureMatchingValue, + expectMatchingOutcomes, + expectMatchingValues, +} from './expectMatchingValues.ts'; + +type PromiseOrValueOrAsyncIterableOrThunk = MatchingValue< + PromiseOrValue> +>; + +export function expectEqualPromisesOrValuesOrAsyncIterables( + items: ReadonlyArray>, +): PromiseOrValue> { + const outcomes = items.map(captureMatchingValue); + const [firstOutcome] = outcomes; + assert(firstOutcome !== undefined, 'Expected at least one item.'); + + if (outcomes.some((outcome) => outcome.kind === 'error')) { + expectMatchingOutcomes(outcomes); + assert(false, 'Expected matching errors to throw.'); + } + + const values = outcomes.map((outcome) => { + assert(outcome.kind === 'value'); + return outcome.value; + }); + const [firstItem, ...remainingItems] = values; + if (isPromise(firstItem)) { + if (remainingItems.every(isPromise)) { + return Promise.allSettled( + values as ReadonlyArray>>, + ).then((settledItems) => + expectMatchingResolvedOutcomes( + settledItems.map(outcomeFromSettledItem), + ), + ); + } + } else if (remainingItems.every((item) => !isPromise(item))) { + return expectMatchingResolvedItems( + values as ReadonlyArray>, + ); + } + + assert(false, 'Received an invalid mixture of promises and values.'); +} + +function expectMatchingResolvedOutcomes( + outcomes: ReadonlyArray>>, +): T | AsyncGenerator { + if (outcomes.some((outcome) => outcome.kind === 'error')) { + expectMatchingOutcomes(outcomes); + assert(false, 'Expected matching errors to throw.'); + } + + return expectMatchingResolvedItems( + outcomes.map((outcome) => { + assert(outcome.kind === 'value'); + return outcome.value; + }), + ); +} + +function expectMatchingResolvedItems( + items: ReadonlyArray>, +): T | AsyncGenerator { + const [firstItem] = items; + const firstIsAsyncIterable = isAsyncIterable(firstItem); + + if (!firstIsAsyncIterable) { + for (const item of items) { + assert( + !isAsyncIterable(item), + 'Received an invalid mixture of async iterables and values.', + ); + } + return expectMatchingValues( + (items as ReadonlyArray).map((item) => () => item), + ); + } + + const iterables = items.map((item) => { + assert( + isAsyncIterable(item), + 'Received an invalid mixture of async iterables and values.', + ); + return item; + }); + return expectMatchingAsyncIterablesConcurrently(iterables); +} + +function outcomeFromSettledItem( + settledItem: PromiseSettledResult, +): MatchingOutcome { + if (settledItem.status === 'fulfilled') { + return { kind: 'value', value: settledItem.value }; + } + return { kind: 'error', error: settledItem.reason }; +} diff --git a/src/__testUtils__/expectJSON.ts b/src/__testUtils__/expectJSON.ts index 4a62de2df4..3ead55c273 100644 --- a/src/__testUtils__/expectJSON.ts +++ b/src/__testUtils__/expectJSON.ts @@ -13,7 +13,8 @@ function toJSONDeep(value: unknown): unknown { } if (typeof value.toJSON === 'function') { - return value.toJSON(); + const jsonValue = value.toJSON(); + return jsonValue === value ? jsonValue : toJSONDeep(jsonValue); } if (Array.isArray(value)) { diff --git a/src/__testUtils__/expectMatchingAsyncIterables.ts b/src/__testUtils__/expectMatchingAsyncIterables.ts new file mode 100644 index 0000000000..0f466eb531 --- /dev/null +++ b/src/__testUtils__/expectMatchingAsyncIterables.ts @@ -0,0 +1,316 @@ +import { assert } from 'chai'; + +import { + expectMatchingErrors, + expectMatchingValues, +} from './expectMatchingValues.ts'; +import { createReplayableAsyncIterablePair } from './replayableIterables.ts'; + +interface AsyncIteratorOutcome { + readonly results: ReadonlyArray>; + readonly hasError: boolean; + readonly error: unknown; +} + +export type AsyncIterableValuesComparator = ( + valuesByIterable: ReadonlyArray>, +) => void; + +export function expectMatchingAsyncIterables( + iterables: ReadonlyArray>, +): AsyncGenerator { + const [firstIterable, ...remainingIterables] = iterables; + assert(firstIterable !== undefined, 'Expected at least one async iterable.'); + + const [recordingIterable, replayIterable] = + createReplayableAsyncIterablePair(firstIterable); + const firstIterator = recordingIterable[Symbol.asyncIterator](); + const remainingIterators = remainingIterables.map((iterable) => + iterable[Symbol.asyncIterator](), + ); + let hasComparedResults = false; + let isClosed = false; + + return { + async next() { + if (isClosed) { + return { done: true, value: undefined }; + } + + let result: IteratorResult; + try { + result = normalizeIteratorResult(await firstIterator.next()); + } catch (error) { + closeComparison(); + await compareRemainingIterators(); + throw error; + } + + if (isClosed) { + return { done: true, value: undefined }; + } + + if (result.done === true) { + await compareRemainingIterators(); + } + + return result; + }, + async return() { + isClosed = true; + await firstIterator.return?.(); + await closeRemainingIterators(); + return { done: true, value: undefined }; + }, + async throw(error) { + isClosed = true; + try { + await firstIterator.return?.(); + throw error; + } finally { + await closeRemainingIterators(); + } + }, + async [Symbol.asyncDispose]() { + await this.return(); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + + async function compareRemainingIterators(): Promise { + if (hasComparedResults) { + return; + } + hasComparedResults = true; + + const expectedOutcome = await collectAsyncIteratorOutcome( + replayIterable[Symbol.asyncIterator](), + ); + for (const iterator of remainingIterators) { + // eslint-disable-next-line no-await-in-loop + const actualOutcome = await collectAsyncIteratorOutcome(iterator); + expectMatchingIteratorOutcome(expectedOutcome, actualOutcome); + } + } + + async function closeRemainingIterators(): Promise { + for (const iterator of remainingIterators) { + // eslint-disable-next-line no-await-in-loop + await iterator.return?.(); + } + } + + function closeComparison(): void { + isClosed = true; + } +} + +export function expectMatchingAsyncIterablesConcurrently( + iterables: ReadonlyArray>, + compareValues: AsyncIterableValuesComparator = expectMatchingValueBatches, +): AsyncGenerator { + const [firstIterable, ...remainingIterables] = iterables; + assert(firstIterable !== undefined, 'Expected at least one async iterable.'); + + const firstIterator = firstIterable[Symbol.asyncIterator](); + const comparisons = remainingIterables.map((iterable) => ({ + iterator: iterable[Symbol.asyncIterator](), + pendingNextResults: [] as Array>>, + nextResultIndex: 0, + values: [] as Array, + })); + const firstValues: Array = []; + let hasComparedResults = false; + let isClosed = false; + + return { + async next() { + if (isClosed) { + return { done: true, value: undefined }; + } + + for (const comparison of comparisons) { + const nextResult = comparison.iterator.next(); + nextResult.catch(() => undefined); + comparison.pendingNextResults.push(nextResult); + } + + let result: IteratorResult; + try { + result = normalizeIteratorResult(await firstIterator.next()); + } catch (error) { + // eslint-disable-next-line require-atomic-updates + isClosed = true; + await closeRemainingIterators(); + throw error; + } + + if (isClosed) { + return { done: true, value: undefined }; + } + + if (result.done === true) { + await collectRemainingValues(); + compareCollectedValues(); + } else { + firstValues.push(result.value); + } + + return result; + }, + async return() { + isClosed = true; + const firstReturn = firstIterator.return?.(); + const remainingReturns = comparisons.map((comparison) => + Promise.resolve(comparison.iterator.return?.()), + ); + await firstReturn; + await Promise.all(remainingReturns); + return { done: true, value: undefined }; + }, + async throw(error) { + isClosed = true; + try { + if (firstIterator.throw !== undefined) { + return await firstIterator.throw(error); + } + throw error; + } finally { + await throwIntoRemainingIterators(error); + } + }, + async [Symbol.asyncDispose]() { + await this.return(); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + + async function collectRemainingValues(): Promise { + for (const comparison of comparisons) { + // eslint-disable-next-line no-await-in-loop + await collectComparisonValues(comparison); + } + } + + function compareCollectedValues(): void { + if (hasComparedResults) { + return; + } + hasComparedResults = true; + compareValues([ + firstValues, + ...comparisons.map((comparison) => comparison.values), + ]); + } + + async function closeRemainingIterators(): Promise { + for (const comparison of comparisons) { + // eslint-disable-next-line no-await-in-loop + await comparison.iterator.return?.(); + } + } + + async function throwIntoRemainingIterators(error: unknown): Promise { + for (const comparison of comparisons) { + if (comparison.iterator.throw !== undefined) { + // eslint-disable-next-line no-await-in-loop + await comparison.iterator.throw(error).catch(() => undefined); + } else { + // eslint-disable-next-line no-await-in-loop + await comparison.iterator.return?.(); + } + } + } +} + +async function collectAsyncIteratorOutcome( + iterator: AsyncIterator, +): Promise> { + const results: Array> = []; + + while (true) { + let result: IteratorResult; + try { + // eslint-disable-next-line no-await-in-loop + result = normalizeIteratorResult(await iterator.next()); + } catch (error) { + // eslint-disable-next-line no-await-in-loop + await iterator.return?.(); + return { + results, + hasError: true, + error, + }; + } + + results.push(result); + if (result.done === true) { + return { + results, + hasError: false, + error: undefined, + }; + } + } +} + +async function collectComparisonValues(comparison: { + iterator: AsyncIterator; + pendingNextResults: Array>>; + nextResultIndex: number; + values: Array; +}): Promise { + while (comparison.nextResultIndex < comparison.pendingNextResults.length) { + const pendingResult = + comparison.pendingNextResults[comparison.nextResultIndex++]; + assert(pendingResult !== undefined); + // eslint-disable-next-line no-await-in-loop + const result = await pendingResult; + if (result.done === true) { + return; + } + comparison.values.push(result.value); + } + + while (true) { + // eslint-disable-next-line no-await-in-loop + const result = await comparison.iterator.next(); + if (result.done === true) { + return; + } + comparison.values.push(result.value); + } +} + +function normalizeIteratorResult( + result: IteratorResult, +): IteratorResult { + return result.done === true ? { done: true, value: undefined } : result; +} + +function expectMatchingIteratorOutcome( + expectedOutcome: AsyncIteratorOutcome, + actualOutcome: AsyncIteratorOutcome, +): void { + expectMatchingValues([ + () => expectedOutcome.results, + () => actualOutcome.results, + ]); + expectMatchingValues([ + () => expectedOutcome.hasError, + () => actualOutcome.hasError, + ]); + if (expectedOutcome.hasError) { + expectMatchingErrors([expectedOutcome.error, actualOutcome.error]); + } +} + +function expectMatchingValueBatches( + valuesByIterable: ReadonlyArray>, +): void { + expectMatchingValues(valuesByIterable.map((values) => () => values)); +} diff --git a/src/__testUtils__/expectMatchingValues.ts b/src/__testUtils__/expectMatchingValues.ts index a4969ecec4..d2ee728591 100644 --- a/src/__testUtils__/expectMatchingValues.ts +++ b/src/__testUtils__/expectMatchingValues.ts @@ -1,9 +1,77 @@ +import { assert } from 'chai'; + import { expectJSON } from './expectJSON.ts'; -export function expectMatchingValues(values: ReadonlyArray): T { - const [firstValue, ...remainingValues] = values; - for (const value of remainingValues) { - expectJSON(value).toDeepEqual(firstValue); +export type MatchingValue = () => T; + +export type MatchingOutcome = + | { readonly kind: 'value'; readonly value: T } + | { readonly kind: 'error'; readonly error: unknown }; + +export function expectMatchingValues( + values: ReadonlyArray>, +): T { + return expectMatchingOutcomes(values.map(captureMatchingValue)); +} + +export function captureMatchingValue( + value: MatchingValue, +): MatchingOutcome { + try { + return { kind: 'value', value: value() }; + } catch (error) { + return { kind: 'error', error }; + } +} + +export function expectMatchingOutcomes( + outcomes: ReadonlyArray>, +): T { + const [firstOutcome, ...remainingOutcomes] = outcomes; + assert(firstOutcome !== undefined, 'Expected at least one matching value.'); + + if (firstOutcome.kind === 'error') { + expectMatchingErrors([ + firstOutcome.error, + ...remainingOutcomes.map((outcome) => { + assert( + outcome.kind === 'error', + 'Received an invalid mixture of thrown errors and values.', + ); + return outcome.error; + }), + ]); + throw firstOutcome.error; + } + + const firstValue = firstOutcome.value; + for (const outcome of remainingOutcomes) { + assert( + outcome.kind === 'value', + 'Received an invalid mixture of values and thrown errors.', + ); + expectJSON(outcome.value).toDeepEqual(firstValue); } return firstValue; } + +export function expectMatchingErrors(errors: ReadonlyArray): void { + assert(errors.length > 0, 'Expected at least one matching error.'); + const [firstError, ...remainingErrors] = errors; + + for (const error of remainingErrors) { + expectJSON(errorToComparableValue(error)).toDeepEqual( + errorToComparableValue(firstError), + ); + } +} + +function errorToComparableValue(error: unknown): unknown { + if (!(error instanceof Error)) { + return error; + } + + return { + message: error.message, + }; +} diff --git a/src/__testUtils__/replayableIterables.ts b/src/__testUtils__/replayableIterables.ts new file mode 100644 index 0000000000..819c69ed15 --- /dev/null +++ b/src/__testUtils__/replayableIterables.ts @@ -0,0 +1,190 @@ +type TerminalRecord = + | { readonly kind: 'done'; readonly value: unknown } + | { readonly kind: 'error'; readonly error: unknown }; + +/** Creates one iterable that records a source and another that replays it. */ +export function createReplayableIterablePair( + iterable: Iterable, +): readonly [Iterable, Iterable] { + const iterator = iterable[Symbol.iterator](); + const values: Array = []; + let terminalRecord: TerminalRecord | undefined; + + const recordingIterator: Iterator = { + next() { + if (terminalRecord !== undefined) { + return replayTerminalRecord(terminalRecord); + } + + try { + return recordIteratorResult(iterator.next()); + } catch (error) { + recordError(error); + throw error; + } + }, + return(value?: unknown) { + if (terminalRecord !== undefined) { + return replayTerminalRecord(terminalRecord); + } + + if (iterator.return === undefined) { + return recordIteratorResult({ done: true, value }); + } + return recordIteratorResult(iterator.return(value)); + }, + }; + + return [ + { + [Symbol.iterator]() { + return recordingIterator; + }, + }, + createReplayIterable(values, () => terminalRecord), + ]; + + function recordIteratorResult( + result: IteratorResult, + ): IteratorResult { + if (result.done === true) { + terminalRecord = { kind: 'done', value: result.value }; + } else { + values.push(result.value); + } + return result; + } + + function recordError(error: unknown): void { + terminalRecord = { kind: 'error', error }; + } +} + +/** Creates one async iterable that records a source and another that replays it. */ +export function createReplayableAsyncIterablePair( + iterable: AsyncIterable, +): readonly [AsyncIterable, AsyncIterable] { + const iterator = iterable[Symbol.asyncIterator](); + const values: Array = []; + let terminalRecord: TerminalRecord | undefined; + + const recordingIterator: AsyncIterator = { + async next() { + if (terminalRecord !== undefined) { + return replayTerminalRecord(terminalRecord); + } + + try { + return recordIteratorResult(await iterator.next()); + } catch (error) { + recordError(error); + throw error; + } + }, + async return(value?: unknown) { + if (terminalRecord !== undefined) { + return replayTerminalRecord(terminalRecord); + } + + if (iterator.return === undefined) { + return recordIteratorResult({ done: true, value }); + } + return recordIteratorResult(await iterator.return(value)); + }, + }; + + return [ + { + [Symbol.asyncIterator]() { + return recordingIterator; + }, + }, + createReplayAsyncIterable(values, () => terminalRecord), + ]; + + function recordIteratorResult( + result: IteratorResult, + ): IteratorResult { + if (result.done === true) { + terminalRecord = { kind: 'done', value: result.value }; + } else { + values.push(result.value); + } + return result; + } + + function recordError(error: unknown): void { + terminalRecord = { kind: 'error', error }; + } +} + +function createReplayIterable( + values: ReadonlyArray, + getTerminalRecord: () => TerminalRecord | undefined, +): Iterable { + return { + [Symbol.iterator]() { + let index = 0; + return { + next(): IteratorResult { + if (index < values.length) { + return { done: false, value: values[index++] }; + } + + const terminalRecord = getTerminalRecord(); + if (terminalRecord !== undefined) { + return replayTerminalRecord(terminalRecord); + } + + throw new Error( + 'Expected iterable input to be recorded before replaying it.', + ); + }, + }; + }, + }; +} + +function createReplayAsyncIterable( + values: ReadonlyArray, + getTerminalRecord: () => TerminalRecord | undefined, +): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0; + return { + next(): Promise> { + if (index < values.length) { + return Promise.resolve({ done: false, value: values[index++] }); + } + + const terminalRecord = getTerminalRecord(); + if (terminalRecord !== undefined) { + return replayTerminalRecordAsync(terminalRecord); + } + + return Promise.reject( + new Error( + 'Expected async iterable input to be recorded before replaying it.', + ), + ); + }, + }; + }, + }; +} + +function replayTerminalRecord( + terminalRecord: TerminalRecord, +): IteratorResult { + if (terminalRecord.kind === 'done') { + return { done: true, value: terminalRecord.value }; + } + throw terminalRecord.error; +} + +function replayTerminalRecordAsync( + terminalRecord: TerminalRecord, +): Promise> { + return Promise.resolve().then(() => replayTerminalRecord(terminalRecord)); +} diff --git a/src/execution/ExecutionArgs.ts b/src/execution/ExecutionArgs.ts index 8428b24e80..3118ea7996 100644 --- a/src/execution/ExecutionArgs.ts +++ b/src/execution/ExecutionArgs.ts @@ -2,6 +2,7 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { ObjMap } from '../jsutils/ObjMap.ts'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.ts'; import type { DocumentNode, @@ -12,25 +13,36 @@ import type { import type { GraphQLFieldResolver, + GraphQLObjectType, GraphQLTypeResolver, } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; -import type { FragmentDetails } from './collectFields.ts'; +import type { + FieldDetailsList, + FragmentDetails, + RootFieldCollection, + SubfieldCollection, +} from './collectFields.ts'; +import type { ExecutionResult } from './Executor.ts'; import type { VariableValues } from './values.ts'; -/** Arguments accepted by execute and executeSync. */ -export interface ExecutionArgs { +/** @internal */ +export const EMPTY_VARIABLE_VALUES: { + readonly [variable: string]: unknown; +} = Object.freeze(Object.create(null)); + +/** Function used to execute a validated root selection set for a subscription event. */ +export type RootSelectionSetExecutor = ( + validatedExecutionArgs: ValidatedSubscriptionArgs, +) => PromiseOrValue; + +/** Arguments accepted by compileExecution and compileSubscription. */ +export interface CompileExecutionArgs { /** The schema used for validation or execution. */ schema: GraphQLSchema; /** The parsed GraphQL document to execute. */ document: DocumentNode; - /** Initial root value passed to the operation. */ - rootValue?: unknown; - /** Application context value passed to every resolver. */ - contextValue?: unknown; - /** Runtime variable values keyed by variable name. */ - variableValues?: Maybe<{ readonly [variable: string]: unknown }>; /** Name of the operation to execute when the document contains multiple operations. */ operationName?: Maybe; /** Resolver used when a field does not define its own resolver. */ @@ -41,12 +53,24 @@ export interface ExecutionArgs { subscribeFieldResolver?: Maybe>; /** Whether suggestion text should be omitted from request errors. */ hideSuggestions?: Maybe; - /** AbortSignal used to cancel execution. */ - abortSignal?: Maybe; /** Whether incremental execution may begin eligible work early. */ enableEarlyExecution?: Maybe; + /** Whether experimental field batch resolvers should be used. */ + enableBatchResolvers?: Maybe; /** Execution hooks invoked during this operation. */ hooks?: Maybe; +} + +/** Runtime arguments accepted by compiled execution methods. */ +export interface CompiledExecutionArgs { + /** Initial root value passed to the operation. */ + rootValue?: unknown; + /** Application context value passed to every resolver. */ + contextValue?: unknown; + /** Runtime variable values keyed by variable name. */ + variableValues?: Maybe<{ readonly [variable: string]: unknown }>; + /** AbortSignal used to cancel execution. */ + abortSignal?: Maybe; /** Additional execution options. */ options?: { /** @@ -58,6 +82,10 @@ export interface ExecutionArgs { }; } +/** Arguments accepted by execute and executeSync. */ +export interface ExecutionArgs + extends CompileExecutionArgs, CompiledExecutionArgs {} + /** * Data that must be available at all points during query execution. * @@ -100,8 +128,12 @@ export interface ValidatedExecutionArgs { externalAbortSignal: AbortSignal | undefined; /** Whether incremental execution may begin eligible work early. */ enableEarlyExecution: boolean; + /** Whether experimental field batch resolvers should be used. */ + enableBatchResolvers: boolean; /** Execution hooks supplied by the caller. */ hooks: ExecutionHooks | undefined; + /** Memoized field collectors for this execution. @internal */ + fieldCollectors: FieldCollectors; } /** Validated execution arguments for a subscription operation. */ @@ -110,6 +142,19 @@ export interface ValidatedSubscriptionArgs extends ValidatedExecutionArgs { operation: SubscriptionOperationDefinitionNode; } +/** @internal */ +export interface FieldCollectors { + collectRootFields: ( + variableValues: VariableValues, + rootType: GraphQLObjectType, + ) => RootFieldCollection; + collectSubfields: ( + variableValues: VariableValues, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + ) => SubfieldCollection; +} + /** Information passed to hooks after asynchronous execution work has finished. */ export interface AsyncWorkFinishedInfo { /** Validated execution arguments for the operation that finished async work. */ diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 88163c712f..23e789d41b 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -59,6 +59,8 @@ import type { DeferUsage, FieldDetailsList, GroupedFieldSet, + RootFieldCollection, + SubfieldCollection, } from './collectFields.ts'; import { collectFields, @@ -67,11 +69,15 @@ import { import { collectIteratorPromises } from './collectIteratorPromises.ts'; import type { SharedExecutionContext } from './createSharedExecutionContext.ts'; import { createSharedExecutionContext } from './createSharedExecutionContext.ts'; -import type { ValidatedExecutionArgs } from './ExecutionArgs.ts'; +import type { + FieldCollectors, + ValidatedExecutionArgs, +} from './ExecutionArgs.ts'; import type { StreamUsage } from './getStreamUsage.ts'; import { getStreamUsage as _getStreamUsage } from './getStreamUsage.ts'; import { runAsyncWorkFinishedHook } from './hooks.ts'; import { returnIteratorCatchingErrors } from './returnIteratorCatchingErrors.ts'; +import type { VariableValues } from './values.ts'; import { getArgumentValues } from './values.ts'; /* eslint-disable max-params */ @@ -100,35 +106,96 @@ import { getArgumentValues } from './values.ts'; * @internal */ -/** - * A memoized collection of relevant subfields with regard to the return - * type. Memoizing ensures the subfields are not repeatedly calculated, which - * saves overhead when resolving lists of values. - * - * @internal - */ -export const collectSubfields: ( +type FieldCollectorArgs = Pick< + ValidatedExecutionArgs, + 'schema' | 'fragments' | 'operation' | 'hideSuggestions' +>; + +/** @internal */ +export function collectRootFields( validatedExecutionArgs: ValidatedExecutionArgs, - returnType: GraphQLObjectType, - fieldDetailsList: FieldDetailsList, -) => ReturnType = memoize3( - ( - validatedExecutionArgs: ValidatedExecutionArgs, + rootType: GraphQLObjectType, +): RootFieldCollection { + return validatedExecutionArgs.fieldCollectors.collectRootFields( + validatedExecutionArgs.variableValues, + rootType, + ); +} + +/** @internal */ +export function createFieldCollectors( + validatedExecutionArgs: FieldCollectorArgs, +): FieldCollectors { + return new NonCompiledFieldCollectors(validatedExecutionArgs); +} + +class NonCompiledFieldCollectors implements FieldCollectors { + private _validatedExecutionArgs: FieldCollectorArgs; + private _collectSubfieldsImpl: + | FieldCollectors['collectSubfields'] + | undefined; + + constructor(validatedExecutionArgs: FieldCollectorArgs) { + this._validatedExecutionArgs = validatedExecutionArgs; + } + + collectRootFields( + variableValues: VariableValues, + rootType: GraphQLObjectType, + ): RootFieldCollection { + const validatedExecutionArgs = this._validatedExecutionArgs; + return collectFields( + validatedExecutionArgs.schema, + validatedExecutionArgs.fragments, + variableValues, + rootType, + validatedExecutionArgs.operation.selectionSet, + validatedExecutionArgs.hideSuggestions, + ); + } + + collectSubfields( + variableValues: VariableValues, returnType: GraphQLObjectType, fieldDetailsList: FieldDetailsList, - ) => { - const { schema, fragments, variableValues, hideSuggestions } = - validatedExecutionArgs; - return _collectSubfields( - schema, - fragments, + ): SubfieldCollection { + this._collectSubfieldsImpl ??= memoize3( + ( + memoizedVariableValues: VariableValues, + memoizedReturnType: GraphQLObjectType, + memoizedFieldDetailsList: FieldDetailsList, + ): SubfieldCollection => { + const validatedExecutionArgs = this._validatedExecutionArgs; + return _collectSubfields( + validatedExecutionArgs.schema, + validatedExecutionArgs.fragments, + memoizedVariableValues, + memoizedReturnType, + memoizedFieldDetailsList, + validatedExecutionArgs.hideSuggestions, + ); + }, + ); + return this._collectSubfieldsImpl( variableValues, returnType, fieldDetailsList, - hideSuggestions, ); - }, -); + } +} + +/** @internal */ +export function collectSubfields( + validatedExecutionArgs: ValidatedExecutionArgs, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, +): SubfieldCollection { + return validatedExecutionArgs.fieldCollectors.collectSubfields( + validatedExecutionArgs.variableValues, + returnType, + fieldDetailsList, + ); +} /** @internal */ export const getStreamUsage: typeof _getStreamUsage = memoize2( diff --git a/src/execution/__tests__/AsyncWorkTracker-test.ts b/src/execution/__tests__/AsyncWorkTracker-test.ts index ba55fee9e9..64251851cb 100644 --- a/src/execution/__tests__/AsyncWorkTracker-test.ts +++ b/src/execution/__tests__/AsyncWorkTracker-test.ts @@ -29,8 +29,8 @@ describe('promiseAllTrackOnReject', () => { const values = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]; await expectEqualPromisesOrValues([ - tracker.promiseAllTrackOnReject(values), - Promise.all(values), + () => tracker.promiseAllTrackOnReject(values), + () => Promise.all(values), ]); }); diff --git a/src/execution/__tests__/abstract-test.ts b/src/execution/__tests__/abstract-test.ts index f6c1bb586a..0fbd53debb 100644 --- a/src/execution/__tests__/abstract-test.ts +++ b/src/execution/__tests__/abstract-test.ts @@ -18,7 +18,7 @@ import { GraphQLSchema } from '../../type/schema.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { execute, executeSync } from '../execute.ts'; +import { execute, executeSync } from './executeTestUtils.ts'; interface Context { async: boolean; diff --git a/src/execution/__tests__/cancellation-test.ts b/src/execution/__tests__/cancellation-test.ts index 2f77b0a089..1a12ea1904 100644 --- a/src/execution/__tests__/cancellation-test.ts +++ b/src/execution/__tests__/cancellation-test.ts @@ -26,12 +26,14 @@ import { GraphQLSchema } from '../../type/schema.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; import { AbortedGraphQLExecutionError } from '../AbortedGraphQLExecutionError.ts'; +import { execute as originalExecute } from '../execute.ts'; +import { legacyExecuteIncrementally } from '../legacyIncremental/legacyExecuteIncrementally.ts'; + import { execute, experimentalExecuteIncrementally, subscribe, -} from '../execute.ts'; -import { legacyExecuteIncrementally } from '../legacyIncremental/legacyExecuteIncrementally.ts'; +} from './executeTestUtils.ts'; const schema = buildSchema(` type Todo { @@ -272,7 +274,7 @@ describe('Execute: Cancellation', () => { it('throws the aborted execution error with an external abort while incremental initial result is still pending', async () => { await expectEqualPromisesOrValues( [experimentalExecuteIncrementally, legacyExecuteIncrementally].map( - async (executeIncrementally) => { + (executeIncrementally) => async () => { const abortController = new AbortController(); const abortReason = new Error('Custom abort error'); @@ -787,7 +789,9 @@ describe('Execute: Cancellation', () => { }); const document = parse('{ parent { boom side { value } } other }'); - const resultPromise = execute({ schema: bubbleSchema, document }); + // Compiled execution finishes already-started sibling work after null + // bubbling instead of returning early. + const resultPromise = originalExecute({ schema: bubbleSchema, document }); rejectBoom(new Error('boom')); // wait for boom to bubble up @@ -901,7 +905,6 @@ describe('Execute: Cancellation', () => { await expectPromise(resultPromise).toRejectWith('Custom abort error'); }); - it('should stop the execution when aborted prior to return of a subscription resolver', async () => { const abortController = new AbortController(); const document = parse(` @@ -949,14 +952,14 @@ describe('Execute: Cancellation', () => { yield await Promise.resolve({ foo: 'foo' }); } - const subscription = await subscribe({ + const subscription = await subscribe(() => ({ document, schema, abortSignal: abortController.signal, rootValue: { foo: Promise.resolve(foo()), }, - }); + })); assert(isAsyncIterable(subscription)); @@ -987,14 +990,14 @@ describe('Execute: Cancellation', () => { yield await Promise.resolve({ foo: 'foo' }); } - const subscription = subscribe({ + const subscription = subscribe(() => ({ document, schema, abortSignal: abortController.signal, rootValue: { foo: foo(), }, - }); + })); assert(isAsyncIterable(subscription)); @@ -1026,14 +1029,14 @@ describe('Execute: Cancellation', () => { yield await Promise.resolve({ foo: 'foo' }); } - const subscription = await subscribe({ + const subscription = await subscribe(() => ({ document, schema, abortSignal: abortController.signal, rootValue: { foo: Promise.resolve(foo()), }, - }); + })); assert(isAsyncIterable(subscription)); @@ -1098,7 +1101,8 @@ describe('Execute: Cancellation', () => { await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); - expect(returnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(returnSpy.callCount).to.equal(2); }); it('ignores async iterator return promise rejections after aborting list completion', async () => { @@ -1146,6 +1150,7 @@ describe('Execute: Cancellation', () => { await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); - expect(returnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(returnSpy.callCount).to.equal(2); }); }); diff --git a/src/execution/__tests__/directives-test.ts b/src/execution/__tests__/directives-test.ts index 4a43d33538..4626fd2d67 100644 --- a/src/execution/__tests__/directives-test.ts +++ b/src/execution/__tests__/directives-test.ts @@ -8,7 +8,7 @@ import { GraphQLObjectType } from '../../type/definition.ts'; import { GraphQLString } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; -import { executeSync } from '../execute.ts'; +import { executeSync } from './executeTestUtils.ts'; const schema = new GraphQLSchema({ query: new GraphQLObjectType({ diff --git a/src/execution/__tests__/errorPropagation-test.ts b/src/execution/__tests__/errorPropagation-test.ts index def2b9d45c..98bef510bc 100644 --- a/src/execution/__tests__/errorPropagation-test.ts +++ b/src/execution/__tests__/errorPropagation-test.ts @@ -8,9 +8,10 @@ import { parse } from '../../language/parser.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { execute } from '../execute.ts'; import type { ExecutionResult } from '../Executor.ts'; +import { execute } from './executeTestUtils.ts'; + const syncError = new Error('bar'); const throwingData = { diff --git a/src/execution/__tests__/executeTestUtils.ts b/src/execution/__tests__/executeTestUtils.ts new file mode 100644 index 0000000000..7c4736e245 --- /dev/null +++ b/src/execution/__tests__/executeTestUtils.ts @@ -0,0 +1,686 @@ +import { assert } from 'chai'; + +import { expectEqualPromisesOrValuesOrAsyncIterables } from '../../__testUtils__/expectEqualPromisesOrValuesOrAsyncIterables.ts'; +import { + expectMatchingAsyncIterables, + expectMatchingAsyncIterablesConcurrently, +} from '../../__testUtils__/expectMatchingAsyncIterables.ts'; +import type { MatchingOutcome } from '../../__testUtils__/expectMatchingValues.ts'; +import { + captureMatchingValue, + expectMatchingOutcomes, + expectMatchingValues, +} from '../../__testUtils__/expectMatchingValues.ts'; +import { createReplayableAsyncIterablePair } from '../../__testUtils__/replayableIterables.ts'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.ts'; +import { isPromise } from '../../jsutils/isPromise.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import type { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { VariableDefinitionNode } from '../../language/ast.ts'; + +import type { GraphQLSchema } from '../../type/schema.ts'; + +import { compileVariableValues } from '../compile/compileVariableValues.ts'; +import { getCompiledVariableValues } from '../compile/getCompiledVariableValues.ts'; +import { compileExecution, compileSubscription } from '../compile/index.ts'; +import type { ExecutionArgs } from '../execute.ts'; +import { + createSourceEventStream as originalCreateSourceEventStream, + execute as originalExecute, + executeIgnoringIncremental as originalExecuteIgnoringIncremental, + executeSubscriptionEvent as originalExecuteSubscriptionEvent, + executeSync as originalExecuteSync, + experimentalExecuteIncrementally as originalExperimentalExecuteIncrementally, + mapSourceToResponseEvent as originalMapSourceToResponseEvent, + subscribe as originalSubscribe, + validateSubscriptionArgs as originalValidateSubscriptionArgs, +} from '../execute.ts'; +import type { ValidatedSubscriptionArgs } from '../ExecutionArgs.ts'; +import type { ExecutionResult } from '../Executor.ts'; +import type { + ExperimentalIncrementalExecutionResults, + InitialIncrementalExecutionResult, + SubsequentIncrementalExecutionResult, +} from '../incremental/IncrementalExecutor.ts'; +import { legacyExecuteIncrementally } from '../legacyIncremental/legacyExecuteIncrementally.ts'; +import type { VariableValuesOrErrors } from '../values.ts'; +import { getVariableValues as originalGetVariableValues } from '../values.ts'; + +type CompiledExecutionMethod = + | 'execute' + | 'executeIgnoringIncremental' + | 'experimentalExecuteIncrementally'; + +type IncrementalExecutionResult = + | ExecutionResult + | ExperimentalIncrementalExecutionResults; + +type SubscriptionResult = + | AsyncGenerator + | ExecutionResult; + +type SourceEventStreamResult = + | AsyncGenerator + | ExecutionResult; + +type RootSelectionSetExecutor = NonNullable< + Parameters[2] +>; + +export type IncrementalExecutionPayload = + | InitialIncrementalExecutionResult + | SubsequentIncrementalExecutionResult; + +export type ExecutionArgsInput = ExecutionArgs | (() => ExecutionArgs); + +const subscriptionExecutionArgs = new WeakMap< + ValidatedSubscriptionArgs, + ExecutionArgs +>(); + +export function execute( + args: ExecutionArgsInput, +): PromiseOrValue { + return expectMatchingExecutionResults([ + () => originalExecute(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + ]); +} + +export function executeSync(args: ExecutionArgsInput): ExecutionResult { + return expectMatchingExecutionResults([ + () => originalExecuteSync(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + ]) as ExecutionResult; +} + +export function executeWithAllMethods( + args: ExecutionArgsInput, +): PromiseOrValue { + return expectMatchingExecutionResults([ + () => originalExecute(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + () => originalExecuteIgnoringIncremental(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'executeIgnoringIncremental'), + () => originalExperimentalExecuteIncrementally(getExecutionArgs(args)), + () => + executeCompiled( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ), + () => legacyExecuteIncrementally(getExecutionArgs(args)), + ]); +} + +export function executeSyncWithAllMethods( + args: ExecutionArgsInput, +): ExecutionResult { + return expectMatchingExecutionResults([ + () => originalExecuteSync(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + () => originalExecuteIgnoringIncremental(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'executeIgnoringIncremental'), + () => originalExperimentalExecuteIncrementally(getExecutionArgs(args)), + () => + executeCompiled( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ), + () => legacyExecuteIncrementally(getExecutionArgs(args)), + ]) as ExecutionResult; +} + +export function subscribe( + args: ExecutionArgsInput, +): PromiseOrValue { + return expectEqualPromisesOrValuesOrAsyncIterables([ + () => originalSubscribe(getExecutionArgs(args)), + () => { + const executionArgs = getExecutionArgs(args); + try { + const compiledSubscription = compileSubscription(executionArgs); + return 'subscribe' in compiledSubscription + ? compiledSubscription.subscribe(executionArgs) + : { errors: compiledSubscription }; + } catch (error) { + if ( + error instanceof Error && + error.message === 'Expected subscription operation.' + ) { + throw error; + } + return { errors: [error] } as ExecutionResult; + } + }, + ]); +} + +export function validateSubscriptionArgs( + args: ExecutionArgs, +): ReadonlyArray | ValidatedSubscriptionArgs { + const result = originalValidateSubscriptionArgs(args); + if ('schema' in result) { + setSubscriptionExecutionArgs(result, args); + } + return result; +} + +export function createSourceEventStream( + validatedSubscriptionArgs: ValidatedSubscriptionArgs, +): PromiseOrValue { + return expectEqualPromisesOrValuesOrAsyncIterables([ + () => originalCreateSourceEventStream(validatedSubscriptionArgs), + () => { + const subscriptionArgs = getSubscriptionExecutionArgs( + validatedSubscriptionArgs, + ); + const compiledSubscription = compileSubscription(subscriptionArgs); + return 'createSourceEventStream' in compiledSubscription + ? compiledSubscription.createSourceEventStream( + validatedSubscriptionArgs, + ) + : { errors: compiledSubscription }; + }, + ]) as PromiseOrValue; +} + +export function executeSubscriptionEvent( + args: ValidatedSubscriptionArgs, +): PromiseOrValue { + const subscriptionArgs = getSubscriptionExecutionArgs(args); + + return expectMatchingExecutionResults([ + () => originalExecuteSubscriptionEvent(args), + () => { + const compiledSubscription = compileSubscription(subscriptionArgs); + return 'executeSubscriptionEvent' in compiledSubscription + ? compiledSubscription.executeSubscriptionEvent(args) + : { errors: compiledSubscription }; + }, + ]); +} + +export function mapSourceToResponseEvent( + validatedSubscriptionArgs: ValidatedSubscriptionArgs, + sourceEventStream: AsyncIterable, + rootSelectionSetExecutor?: RootSelectionSetExecutor, +): AsyncGenerator | ExecutionResult { + const [recordingSourceEventStream, replaySourceEventStream] = + createReplayableAsyncIterablePair(sourceEventStream); + const subscriptionArgs = getSubscriptionExecutionArgs( + validatedSubscriptionArgs, + ); + const wrappedRootSelectionSetExecutor = + rootSelectionSetExecutor === undefined + ? undefined + : (eventArgs: ValidatedSubscriptionArgs) => { + setSubscriptionExecutionArgs(eventArgs, { + ...subscriptionArgs, + rootValue: eventArgs.rootValue, + }); + return rootSelectionSetExecutor(eventArgs); + }; + + const result = originalMapSourceToResponseEvent( + validatedSubscriptionArgs, + recordingSourceEventStream, + wrappedRootSelectionSetExecutor, + ); + const compiledSubscription = compileSubscription(subscriptionArgs); + const compiledResult = + 'mapSourceToResponseEvent' in compiledSubscription + ? compiledSubscription.mapSourceToResponseEvent( + validatedSubscriptionArgs, + replaySourceEventStream, + wrappedRootSelectionSetExecutor, + ) + : { errors: compiledSubscription }; + + if (!isAsyncIterable(result)) { + assert(!isAsyncIterable(compiledResult)); + const comparedResult = expectMatchingExecutionResults([ + () => result, + () => compiledResult, + ]); + assert(!isPromise(comparedResult)); + return comparedResult; + } + + assert(isAsyncIterable(compiledResult)); + return expectMatchingAsyncIterables([result, compiledResult]); +} + +function setSubscriptionExecutionArgs( + validatedSubscriptionArgs: ValidatedSubscriptionArgs, + args: ExecutionArgs, +): void { + subscriptionExecutionArgs.set(validatedSubscriptionArgs, args); +} + +function getSubscriptionExecutionArgs( + validatedSubscriptionArgs: ValidatedSubscriptionArgs, +): ExecutionArgs { + const args = subscriptionExecutionArgs.get(validatedSubscriptionArgs); + assert( + args !== undefined, + 'Expected subscription args validated by the execution test helper.', + ); + return args; +} + +export function experimentalExecuteIncrementally( + args: ExecutionArgsInput, +): PromiseOrValue { + const result = originalExperimentalExecuteIncrementally( + getExecutionArgs(args), + ); + const compiledResult = executeCompiled( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ); + + if (isPromise(result) || isPromise(compiledResult)) { + return Promise.allSettled([ + Promise.resolve(result), + Promise.resolve(compiledResult), + ]).then(([settledResult, settledCompiledResult]) => { + const resultOutcome: MatchingOutcome = + settledResult.status === 'fulfilled' + ? { kind: 'value', value: settledResult.value } + : { kind: 'error', error: settledResult.reason }; + const compiledResultOutcome: MatchingOutcome = + settledCompiledResult.status === 'fulfilled' + ? { kind: 'value', value: settledCompiledResult.value } + : { kind: 'error', error: settledCompiledResult.reason }; + if ( + resultOutcome.kind === 'error' || + compiledResultOutcome.kind === 'error' + ) { + return expectMatchingOutcomes([resultOutcome, compiledResultOutcome]); + } + return compareIncrementalExecutionResult( + resultOutcome.value, + compiledResultOutcome.value, + ); + }); + } + + return compareIncrementalExecutionResult(result, compiledResult); +} + +export function executeIncrementally( + args: ExecutionArgsInput, +): PromiseOrValue { + return experimentalExecuteIncrementally(args); +} + +export async function completeExecution( + args: ExecutionArgsInput, +): Promise> { + return collectIncrementalResults( + await experimentalExecuteIncrementally(args), + ); +} + +export async function completeDirectly( + args: ExecutionArgs, +): Promise> { + return collectIncrementalResults( + await originalExperimentalExecuteIncrementally(args), + ); +} + +export function getVariableValues( + schema: GraphQLSchema, + varDefNodes: ReadonlyArray, + inputs: { readonly [variable: string]: unknown }, + options?: { + maxErrors?: number; + hideSuggestions?: boolean; + }, +): VariableValuesOrErrors { + return expectMatchingValues([ + () => originalGetVariableValues(schema, varDefNodes, inputs, options), + () => { + const compiled = compileVariableValues( + schema, + varDefNodes, + options?.hideSuggestions ?? false, + ); + return getCompiledVariableValues( + compiled, + inputs, + options?.maxErrors ?? 50, + ); + }, + ]); +} + +function expectMatchingExecutionResults( + items: ReadonlyArray<() => PromiseOrValue>, +): PromiseOrValue { + const outcomes = items.map(captureMatchingValue); + + if ( + outcomes.some( + (outcome) => outcome.kind === 'value' && isPromise(outcome.value), + ) + ) { + return Promise.all( + outcomes.map((outcome): Promise> => { + if (outcome.kind === 'error') { + return Promise.resolve(outcome); + } + + const value = outcome.value; + if (!isPromise(value)) { + return Promise.resolve({ kind: 'value', value }); + } + + return Promise.resolve(value).then( + (resolved) => ({ kind: 'value', value: resolved }) as const, + (error: unknown) => ({ kind: 'error', error }) as const, + ); + }), + ).then(compareExecutionResultOutcomes); + } + + return compareExecutionResultOutcomes( + outcomes.map((outcome) => { + if (outcome.kind === 'error') { + return outcome; + } + assert(!isPromise(outcome.value)); + return { kind: 'value', value: outcome.value }; + }), + ); +} + +function compareExecutionResultOutcomes( + outcomes: ReadonlyArray>, +): ExecutionResult { + if (outcomes.some((outcome) => outcome.kind === 'error')) { + expectMatchingOutcomes(outcomes); + assert(false, 'Expected matching errors to throw.'); + } + + const results = outcomes.map((outcome) => { + assert(outcome.kind === 'value'); + assert( + !isIncrementalExecutionResult(outcome.value), + 'Received an incremental execution result.', + ); + assert( + typeof outcome.value === 'object' && outcome.value !== null, + 'Received an invalid result.', + ); + return outcome.value; + }); + const [firstResult] = results; + assert(firstResult !== undefined, 'Expected at least one execution result.'); + + expectMatchingValues( + results.map((result) => () => { + const normalized: { + data?: unknown; + errors?: true; + extensions?: unknown; + } = {}; + if ('data' in result) { + normalized.data = result.data; + } + if ('errors' in result && result.errors !== undefined) { + normalized.errors = true; + } + if ('extensions' in result && result.extensions !== undefined) { + normalized.extensions = result.extensions; + } + return normalized; + }), + ); + return firstResult; +} + +function executeCompiled( + args: ExecutionArgs, + method: CompiledExecutionMethod, +): PromiseOrValue { + const compiledExecution = compileExecution(args); + if ('execute' in compiledExecution) { + return compiledExecution[method](args); + } + return { errors: compiledExecution }; +} + +function getExecutionArgs(args: ExecutionArgsInput): ExecutionArgs { + return typeof args === 'function' ? args() : args; +} + +async function collectIncrementalResults( + result: IncrementalExecutionResult, +): Promise> { + if (!isIncrementalExecutionResult(result)) { + return result; + } + + const results: Array = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; +} + +function compareIncrementalExecutionResult( + result: IncrementalExecutionResult, + compiledResult: IncrementalExecutionResult, +): IncrementalExecutionResult { + if (!isIncrementalExecutionResult(result)) { + assert( + !isIncrementalExecutionResult(compiledResult), + 'Received an invalid mixture of execution results and incremental execution results.', + ); + return expectMatchingValues([() => result, () => compiledResult]); + } + + assert( + isIncrementalExecutionResult(compiledResult), + 'Received an invalid mixture of execution results and incremental execution results.', + ); + expectMatchingValues([ + () => normalizeIncrementalPayloads([result.initialResult]), + () => normalizeIncrementalPayloads([compiledResult.initialResult]), + ]); + + const expectedPayloads: Array = [ + result.initialResult, + ]; + const actualPayloads: Array = [ + compiledResult.initialResult, + ]; + return { + initialResult: result.initialResult, + subsequentResults: expectMatchingAsyncIterablesConcurrently( + [result.subsequentResults, compiledResult.subsequentResults], + (payloadBatches) => { + const [expectedSubsequentPayloads, actualSubsequentPayloads] = + payloadBatches; + assert(expectedSubsequentPayloads !== undefined); + assert(actualSubsequentPayloads !== undefined); + expectMatchingValues([ + () => + normalizeIncrementalPayloads([ + ...expectedPayloads, + ...expectedSubsequentPayloads, + ]), + () => + normalizeIncrementalPayloads([ + ...actualPayloads, + ...actualSubsequentPayloads, + ]), + ]); + }, + ), + }; +} + +function isIncrementalExecutionResult( + result: unknown, +): result is ExperimentalIncrementalExecutionResults { + return ( + typeof result === 'object' && result !== null && 'initialResult' in result + ); +} + +function normalizeIncrementalPayloads( + payloads: ReadonlyArray, +): unknown { + const pendingIds = new Map(); + const pending: Array = []; + const incremental: Array = []; + const streamIncremental = new Map< + string, + { + id: string; + subPath?: ReadonlyArray; + errors?: Array; + items: Array; + extensions?: unknown; + } + >(); + const completed: Array = []; + let initial: unknown; + let finalHasNext = false; + + for (const payload of payloads) { + if ('data' in payload) { + initial = + payload.errors === undefined + ? { data: payload.data } + : { data: payload.data, errors: sortErrors(payload.errors) }; + } + + if (payload.pending !== undefined) { + for (const pendingResult of payload.pending) { + const id = normalizePendingId(pendingResult); + const normalizedPending = + pendingResult.label === undefined + ? { id, path: pendingResult.path } + : { id, path: pendingResult.path, label: pendingResult.label }; + pending.push(normalizedPending); + } + } + + if ('incremental' in payload && payload.incremental !== undefined) { + for (const incrementalResult of payload.incremental) { + if ('items' in incrementalResult) { + const id = normalizeKnownId(incrementalResult.id); + const key = comparableKey({ + id, + subPath: incrementalResult.subPath, + extensions: incrementalResult.extensions, + }); + let normalizedStreamIncremental = streamIncremental.get(key); + if (normalizedStreamIncremental === undefined) { + normalizedStreamIncremental = + incrementalResult.subPath === undefined + ? { id, items: [] } + : { id, subPath: incrementalResult.subPath, items: [] }; + if (incrementalResult.extensions !== undefined) { + normalizedStreamIncremental.extensions = + incrementalResult.extensions; + } + streamIncremental.set(key, normalizedStreamIncremental); + } + normalizedStreamIncremental.items.push(...incrementalResult.items); + if (incrementalResult.errors !== undefined) { + (normalizedStreamIncremental.errors ??= []).push( + ...incrementalResult.errors, + ); + } + continue; + } + + const normalizedIncremental: { [key: string]: unknown } = { + ...incrementalResult, + id: normalizeKnownId(incrementalResult.id), + }; + if (incrementalResult.errors !== undefined) { + normalizedIncremental.errors = sortErrors(incrementalResult.errors); + } + incremental.push(normalizedIncremental); + } + } + + if ('completed' in payload && payload.completed !== undefined) { + for (const completedResult of payload.completed) { + const normalizedCompleted = + completedResult.errors === undefined + ? { id: normalizeKnownId(completedResult.id) } + : { + id: normalizeKnownId(completedResult.id), + errors: sortErrors(completedResult.errors), + }; + completed.push(normalizedCompleted); + } + } + + finalHasNext = !payload.hasNext; + } + + for (const incrementalResult of streamIncremental.values()) { + const normalizedIncremental: { [key: string]: unknown } = { + ...incrementalResult, + }; + if (incrementalResult.errors !== undefined) { + normalizedIncremental.errors = sortErrors(incrementalResult.errors); + } + incremental.push(normalizedIncremental); + } + + assert(initial !== undefined, 'Expected an initial incremental payload.'); + return { + initial, + pending: sortComparableValues(pending), + incremental: sortComparableValues(incremental), + completed: sortComparableValues(completed), + finalHasNext, + }; + + function normalizePendingId(pendingResult: { + id: string; + path: ReadonlyArray; + label?: string | undefined; + }): string { + const normalizedId = comparableKey({ + path: pendingResult.path, + label: pendingResult.label, + }); + pendingIds.set(pendingResult.id, normalizedId); + return normalizedId; + } + + function normalizeKnownId(id: string): string { + return pendingIds.get(id) ?? id; + } +} + +function sortErrors( + errors: ReadonlyArray, +): ReadonlyArray<{ message: string }> { + return Array.from(new Set(errors.map((error) => error.message))) + .sort() + .map((message) => ({ message })); +} + +function sortComparableValues(values: ReadonlyArray): ReadonlyArray { + return [...values].sort((a, b) => + comparableKey(a).localeCompare(comparableKey(b)), + ); +} + +function comparableKey(value: unknown): string { + return JSON.stringify(value); +} diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index ba8368af43..555d2a2bd9 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -2,12 +2,10 @@ import { describe, it } from 'node:test'; import { assert, expect } from 'chai'; -import { expectEqualPromisesOrValues } from '../../__testUtils__/expectEqualPromisesOrValues.ts'; import { expectJSON } from '../../__testUtils__/expectJSON.ts'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.ts'; import { inspect } from '../../jsutils/inspect.ts'; -import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.ts'; import type { FieldNode } from '../../language/ast.ts'; @@ -32,38 +30,59 @@ import { } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; +import { valueFromASTUntyped } from '../../utilities/valueFromASTUntyped.ts'; + import type { FieldDetailsList } from '../collectFields.ts'; import { - execute as executeThrowingOnIncremental, - executeIgnoringIncremental, - executeSync as executeSyncWrappingThrowingOnIncremental, - experimentalExecuteIncrementally, + execute as originalExecute, validateExecutionArgs, } from '../execute.ts'; -import type { ExecutionArgs } from '../ExecutionArgs.ts'; -import type { ExecutionResult } from '../Executor.ts'; import { collectSubfields, getStreamUsage } from '../Executor.ts'; -import { legacyExecuteIncrementally } from '../legacyIncremental/legacyExecuteIncrementally.ts'; - -function execute(args: ExecutionArgs): PromiseOrValue { - return expectEqualPromisesOrValues([ - executeThrowingOnIncremental(args), - executeIgnoringIncremental(args), - experimentalExecuteIncrementally(args), - legacyExecuteIncrementally(args), - ]) as PromiseOrValue; -} -function executeSync(args: ExecutionArgs): ExecutionResult { - return expectEqualPromisesOrValues([ - executeSyncWrappingThrowingOnIncremental(args), - executeIgnoringIncremental(args), - experimentalExecuteIncrementally(args), - legacyExecuteIncrementally(args), - ]) as ExecutionResult; +import { + executeSyncWithAllMethods as executeSync, + executeWithAllMethods as execute, +} from './executeTestUtils.ts'; + +function fieldDetailsListFromNode(node: FieldNode): FieldDetailsList { + return [ + { + node, + deferUsage: undefined, + fragmentVariableValues: undefined, + staticFragmentVariableValues: undefined, + compiledFieldPlan: undefined, + }, + ]; } describe('Execute: Handles basic execution tasks', () => { + it('removes external abort listener when execution setup throws', () => { + const abortController = new AbortController(); + const result = originalExecute({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + value: { type: GraphQLString }, + }, + }), + }), + document: parse('mutation { value }'), + abortSignal: abortController.signal, + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Schema is not configured to execute mutation operation.', + locations: [{ line: 1, column: 1 }], + }, + ], + }); + }); + it('executes arbitrary code', async () => { const data = { a: () => 'Apple', @@ -431,6 +450,54 @@ describe('Execute: Handles basic execution tasks', () => { expect(resolvedArgs).to.deep.equal({ numArg: 123, stringArg: 'foo' }); }); + it('executes custom scalars with embedded nested fragment variables', () => { + const jsonScalar = new GraphQLScalarType({ + name: 'JSONScalar', + coerceInputValue(value) { + return value; + }, + coerceInputLiteral(value) { + return valueFromASTUntyped(value); + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + fieldWithJSONScalarInput: { + type: GraphQLString, + args: { input: { type: jsonScalar } }, + resolve(_source, args) { + return inspect(args.input); + }, + }, + }, + }), + }); + const document = parse( + ` + { + ...JSONFragment(input1: "foo") + } + fragment JSONFragment($input1: String) on Query { + ...JSONNestedFragment(input2: $input1) + } + fragment JSONNestedFragment($input2: String) on Query { + fieldWithJSONScalarInput(input: { a: $input2, b: ["bar"], c: "baz" }) + } + `, + { experimentalFragmentArguments: true }, + ); + + const result = executeSync({ schema, document }); + + expectJSON(result).toDeepEqual({ + data: { + fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }', + }, + }); + }); + it('nulls out error subtrees', async () => { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -693,7 +760,9 @@ describe('Execute: Handles basic execution tasks', () => { } `); - const result = execute({ schema, document }); + // Compiled execution finishes already-started sibling work after null + // bubbling instead of returning early. + const result = originalExecute({ schema, document }); expectJSON(await result).toDeepEqual({ data: null, @@ -1344,6 +1413,28 @@ describe('Execute: Handles basic execution tasks', () => { expect(result).to.deep.equal({ data: { foo: null } }); }); + it('uses the default field resolver with a non-object source', () => { + const fooType = new GraphQLObjectType({ + name: 'Foo', + fields: { + bar: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: fooType }, + }, + }), + }); + const document = parse('{ foo { bar } }'); + + const result = executeSync({ schema, document }); + + expect(result).to.deep.equal({ data: { foo: null } }); + }); + it('uses a custom field resolver', () => { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -1367,6 +1458,35 @@ describe('Execute: Handles basic execution tasks', () => { expect(result).to.deep.equal({ data: { foo: 'foo' } }); }); + it('uses a custom field resolver for object fields', () => { + const fooType = new GraphQLObjectType({ + name: 'Foo', + fields: { + bar: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: fooType }, + }, + }), + }); + const document = parse('{ foo { bar } }'); + + const result = executeSync({ + schema, + document, + rootValue: { foo: { bar: 'BAR' } }, + fieldResolver(source, _args, _context, info) { + return source[info.fieldName]; + }, + }); + + expect(result).to.deep.equal({ data: { foo: { bar: 'BAR' } } }); + }); + it('uses a custom type resolver', () => { const document = parse('{ foo { bar } }'); @@ -1510,7 +1630,7 @@ describe('Execute: Handles basic execution tasks', () => { const operation = validatedExecutionArgs.operation; const node = operation.selectionSet.selections[0] as FieldNode; - const fieldDetailsList: FieldDetailsList = [{ node }]; + const fieldDetailsList = fieldDetailsListFromNode(node); const first = collectSubfields( validatedExecutionArgs, @@ -1526,9 +1646,11 @@ describe('Execute: Handles basic execution tasks', () => { expect(second).to.equal(first); - const third = collectSubfields(validatedExecutionArgs, deepType, [ - { node }, - ]); + const third = collectSubfields( + validatedExecutionArgs, + deepType, + fieldDetailsListFromNode(node), + ); expect(third).to.not.equal(first); }); @@ -1560,7 +1682,7 @@ describe('Execute: Handles basic execution tasks', () => { const operation = validatedExecutionArgs.operation; const node = operation.selectionSet.selections[0] as FieldNode; - const fieldDetailsList = [{ node }]; + const fieldDetailsList = fieldDetailsListFromNode(node); const first = getStreamUsage(validatedExecutionArgs, fieldDetailsList); expect(first).to.not.equal(undefined); @@ -1569,7 +1691,10 @@ describe('Execute: Handles basic execution tasks', () => { expect(second).to.equal(first); - const third = getStreamUsage(validatedExecutionArgs, [{ node }]); + const third = getStreamUsage( + validatedExecutionArgs, + fieldDetailsListFromNode(node), + ); expect(third).to.not.equal(first); }); diff --git a/src/execution/__tests__/hooks-test.ts b/src/execution/__tests__/hooks-test.ts index f3494cabcc..7b406c8d23 100644 --- a/src/execution/__tests__/hooks-test.ts +++ b/src/execution/__tests__/hooks-test.ts @@ -20,7 +20,6 @@ import { GraphQLSchema } from '../../type/schema.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; import type { SharedExecutionContext } from '../createSharedExecutionContext.ts'; -import { execute, experimentalExecuteIncrementally } from '../execute.ts'; import type { ExecutionArgs, ValidatedExecutionArgs, @@ -28,6 +27,11 @@ import type { import type { ExecutionResult } from '../Executor.ts'; import { runAsyncWorkFinishedHook } from '../hooks.ts'; +import { + execute, + experimentalExecuteIncrementally, +} from './executeTestUtils.ts'; + const executeHookSchema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', @@ -102,7 +106,8 @@ describe('Execute: Hooks', () => { }, }); await hooksFinished; - expect(calls).to.deep.equal(['asyncWork']); + // Doubled counts reflect original and compiled execution. + expect(calls).to.deep.equal(['asyncWork', 'asyncWork']); }); it('runs post execution hooks synchronously when no async work is tracked', () => { @@ -123,7 +128,8 @@ describe('Execute: Hooks', () => { test: 'ok', }, }); - expect(calls).to.deep.equal(['asyncWork']); + // Doubled counts reflect original and compiled execution. + expect(calls).to.deep.equal(['asyncWork', 'asyncWork']); }); it('runs post execution hooks for asynchronous execution', async () => { @@ -164,7 +170,8 @@ describe('Execute: Hooks', () => { }, }); await hooksFinished; - expect(calls).to.deep.equal(['asyncWork']); + // Doubled counts reflect original and compiled execution. + expect(calls).to.deep.equal(['asyncWork', 'asyncWork']); }); it('ignores async-work tracker wait rejection', async () => { @@ -313,7 +320,8 @@ describe('Execute: Hooks', () => { test: 'ok', }, }); - expect(calls).to.deep.equal(['asyncWork']); + // Doubled counts reflect original and compiled execution. + expect(calls).to.deep.equal(['asyncWork', 'asyncWork']); }); it('wrapper returns a promise and resolves after asyncWorkFinished for track(...) side effects', async () => { @@ -367,7 +375,8 @@ describe('Execute: Hooks', () => { test: 'ok', }, }); - expect(calls).to.deep.equal(['asyncWork']); + // Doubled counts reflect original and compiled execution. + expect(calls).to.deep.equal(['asyncWork', 'asyncWork']); }); it('runs post execution hooks for aborted execution', async () => { @@ -420,7 +429,8 @@ describe('Execute: Hooks', () => { resolveCleanup('done'); await asyncWorkFinished; - expect(calls).to.deep.equal(['asyncWork']); + // Doubled counts reflect original and compiled execution. + expect(calls).to.deep.equal(['asyncWork', 'asyncWork']); }); it('fires asyncWorkFinished after async iterator return cleanup', async () => { @@ -561,6 +571,7 @@ describe('Execute: Hooks', () => { expect(nextResult.value.hasNext).to.equal(false); await asyncWorkFinished; await asyncWorkObserved; - expect(asyncWorkFinishedSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(asyncWorkFinishedSpy.callCount).to.equal(2); }); }); diff --git a/src/execution/__tests__/incremental-test.ts b/src/execution/__tests__/incremental-test.ts index 5e45a97097..c650e04dff 100644 --- a/src/execution/__tests__/incremental-test.ts +++ b/src/execution/__tests__/incremental-test.ts @@ -16,7 +16,9 @@ import { } from '../../type/index.ts'; import { GraphQLSchema } from '../../type/schema.ts'; -import { execute } from '../execute.ts'; +import { execute as originalExecute } from '../execute.ts'; + +import { execute } from './executeTestUtils.ts'; describe('Original execute errors on experimental @defer and @stream directives', () => { it('errors when using original execute with schemas including experimental @defer directive', () => { @@ -31,7 +33,7 @@ describe('Original execute errors on experimental @defer and @stream directives' }); const document = parse('query Q { a }'); - expect(() => execute({ schema, document })).to.throw( + expect(() => originalExecute({ schema, document })).to.throw( 'The provided schema unexpectedly contains experimental directives (@defer or @stream). These directives may only be utilized if experimental execution features are explicitly enabled.', ); }); @@ -48,7 +50,7 @@ describe('Original execute errors on experimental @defer and @stream directives' }); const document = parse('query Q { a }'); - expect(() => execute({ schema, document })).to.throw( + expect(() => originalExecute({ schema, document })).to.throw( 'The provided schema unexpectedly contains experimental directives (@defer or @stream). These directives may only be utilized if experimental execution features are explicitly enabled.', ); }); diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index 4db47eba9c..f8ce5bbfff 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -4,7 +4,6 @@ import { expect } from 'chai'; import { expectJSON } from '../../__testUtils__/expectJSON.ts'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.ts'; -import { spyOnMethod } from '../../__testUtils__/spyOn.ts'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; @@ -21,9 +20,10 @@ import { GraphQLSchema } from '../../type/schema.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { execute, executeSync } from '../execute.ts'; import type { ExecutionResult } from '../Executor.ts'; +import { execute, executeSync } from './executeTestUtils.ts'; + function delayedReject(message: string): Promise { return (async () => { await resolveOnNextTick(); @@ -34,11 +34,15 @@ function delayedReject(message: string): Promise { describe('Execute: Accepts any iterable as list value', () => { function complete(rootValue: unknown) { - return executeSync({ + return completeWithRootValue(() => rootValue); + } + + function completeWithRootValue(rootValue: () => unknown) { + return executeSync(() => ({ schema: buildSchema('type Query { listField: [String] }'), document: parse('{ listField }'), - rootValue, - }); + rootValue: rootValue(), + })); } it('Accepts a Set as a List value', () => { @@ -90,24 +94,31 @@ describe('Execute: Accepts any iterable as list value', () => { it('Does not call iterator `return` when iteration throws', () => { let nextCalls = 0; - const listField = { - [Symbol.iterator]() { - return this; - }, - next() { - nextCalls++; - if (nextCalls === 1) { - throw new Error('bad'); - } - return { done: true, value: undefined }; - }, - return() { - throw new Error('return bad'); - }, + let returnCallCount = 0; + const createListField = () => { + let index = 0; + return { + [Symbol.iterator]() { + return this; + }, + next() { + nextCalls++; + index++; + if (index === 1) { + throw new Error('bad'); + } + return { done: true, value: undefined }; + }, + return() { + returnCallCount++; + throw new Error('return bad'); + }, + }; }; - const returnSpy = spyOnMethod(listField, 'return'); - expectJSON(complete({ listField })).toDeepEqual({ + expectJSON( + completeWithRootValue(() => ({ listField: createListField() })), + ).toDeepEqual({ data: { listField: null }, errors: [ { @@ -117,44 +128,55 @@ describe('Execute: Accepts any iterable as list value', () => { }, ], }); - expect(nextCalls).to.equal(2); - expect(returnSpy.callCount).to.equal(0); + // Doubled counts reflect original and compiled execution. + expect(nextCalls).to.equal(4); + expect(returnCallCount).to.equal(0); }); }); describe('Execute: Handles abrupt completion in synchronous iterables', () => { - function complete(rootValue: unknown, as: string = '[String]') { - return execute({ + function completeWithRootValue( + rootValue: () => unknown, + as: string = '[String]', + ) { + return execute(() => ({ schema: buildSchema(`type Query { listField: ${as} }`), document: parse('{ listField }'), - rootValue, - }); + rootValue: rootValue(), + })); } it('drains the iterator when `next` throws', async () => { let nextCalls = 0; - const listField: IterableIterator = { - [Symbol.iterator](): IterableIterator { - return this; - }, - next(): IteratorResult { - nextCalls++; - if (nextCalls === 1) { - return { done: false, value: 'ok' }; - } - if (nextCalls === 2) { - throw new Error('bad'); - } - return { done: true, value: undefined }; - }, - return(): IteratorResult { - return { done: true, value: undefined }; - }, + let returnCallCount = 0; + const createListField = (): IterableIterator => { + let index = 0; + return { + [Symbol.iterator](): IterableIterator { + return this; + }, + next(): IteratorResult { + nextCalls++; + index++; + if (index === 1) { + return { done: false, value: 'ok' }; + } + if (index === 2) { + throw new Error('bad'); + } + return { done: true, value: undefined }; + }, + return(): IteratorResult { + returnCallCount++; + return { done: true, value: undefined }; + }, + }; }; - const returnSpy = spyOnMethod(listField, 'return'); - expectJSON(await complete({ listField })).toDeepEqual({ + expectJSON( + await completeWithRootValue(() => ({ listField: createListField() })), + ).toDeepEqual({ data: { listField: null }, errors: [ { @@ -164,32 +186,42 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { }, ], }); - expect(nextCalls).to.equal(3); - expect(returnSpy.callCount).to.equal(0); + // Doubled counts reflect original and compiled execution. + expect(nextCalls).to.equal(6); + expect(returnCallCount).to.equal(0); }); it('drains the iterator when a null bubbles up from a non-null item', async () => { const values = [1, null, 2]; - let index = 0; - - const listField: IterableIterator = { - [Symbol.iterator](): IterableIterator { - return this; - }, - next(): IteratorResult { - const value = values[index++]; - if (value === undefined) { + let nextCalls = 0; + let returnCallCount = 0; + const createListField = (): IterableIterator => { + let index = 0; + return { + [Symbol.iterator](): IterableIterator { + return this; + }, + next(): IteratorResult { + nextCalls++; + const value = values[index++]; + if (value === undefined) { + return { done: true, value: undefined }; + } + return { done: false, value }; + }, + return(): IteratorResult { + returnCallCount++; return { done: true, value: undefined }; - } - return { done: false, value }; - }, - return(): IteratorResult { - return { done: true, value: undefined }; - }, + }, + }; }; - const returnSpy = spyOnMethod(listField, 'return'); - expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ + expectJSON( + await completeWithRootValue( + () => ({ listField: createListField() }), + '[Int!]', + ), + ).toDeepEqual({ data: { listField: null }, errors: [ { @@ -199,8 +231,9 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { }, ], }); - expect(index).to.equal(4); - expect(returnSpy.callCount).to.equal(0); + // Doubled counts reflect original and compiled execution. + expect(nextCalls).to.equal(8); + expect(returnCallCount).to.equal(0); }); it('handles iterator errors with later pending promises without calling `return`', async () => { @@ -211,32 +244,38 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { // eslint-disable-next-line no-undef process.on('unhandledRejection', unhandledRejectionListener); let nextCalls = 0; - const laterPromise = delayedReject('later bad'); - - const listField: IterableIterator> = { - [Symbol.iterator](): IterableIterator> { - return this; - }, - next(): IteratorResult> { - nextCalls++; - if (nextCalls === 1) { - return { done: false, value: 1 }; - } - if (nextCalls === 2) { - throw new Error('bad'); - } - if (nextCalls === 3) { - return { done: false, value: laterPromise }; - } - return { done: true, value: undefined }; - }, - return(): IteratorResult> { - throw new Error('ignored return error'); - }, + let returnCallCount = 0; + const createListField = (): IterableIterator> => { + const laterPromise = delayedReject('later bad'); + let index = 0; + return { + [Symbol.iterator](): IterableIterator> { + return this; + }, + next(): IteratorResult> { + nextCalls++; + index++; + if (index === 1) { + return { done: false, value: 1 }; + } + if (index === 2) { + throw new Error('bad'); + } + if (index === 3) { + return { done: false, value: laterPromise }; + } + return { done: true, value: undefined }; + }, + return(): IteratorResult> { + returnCallCount++; + throw new Error('ignored return error'); + }, + }; }; - const returnSpy = spyOnMethod(listField, 'return'); - expectJSON(await complete({ listField })).toDeepEqual({ + expectJSON( + await completeWithRootValue(() => ({ listField: createListField() })), + ).toDeepEqual({ data: { listField: null }, errors: [ { @@ -254,8 +293,9 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { // eslint-disable-next-line no-undef process.removeListener('unhandledRejection', unhandledRejectionListener); - expect(nextCalls).to.equal(4); - expect(returnSpy.callCount).to.equal(0); + // Doubled counts reflect original and compiled execution. + expect(nextCalls).to.equal(8); + expect(returnCallCount).to.equal(0); expect(unhandledRejection).to.equal(null); }); @@ -266,30 +306,40 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { }; // eslint-disable-next-line no-undef process.on('unhandledRejection', unhandledRejectionListener); - let index = 0; - const values = [ - delayedReject('first bad'), - null, - delayedReject('third bad'), - ]; - const listField: IterableIterator | null> = { - [Symbol.iterator](): IterableIterator | null> { - return this; - }, - next(): IteratorResult | null> { - const value = values[index++]; - if (value === undefined) { - return { done: true, value: undefined }; - } - return { done: false, value }; - }, - return(): IteratorResult | null> { - throw new Error('ignored return error'); - }, + let nextCalls = 0; + let returnCallCount = 0; + const createListField = (): IterableIterator | null> => { + const values = [ + delayedReject('first bad'), + null, + delayedReject('third bad'), + ]; + let index = 0; + return { + [Symbol.iterator](): IterableIterator | null> { + return this; + }, + next(): IteratorResult | null> { + nextCalls++; + const value = values[index++]; + if (value === undefined) { + return { done: true, value: undefined }; + } + return { done: false, value }; + }, + return(): IteratorResult | null> { + returnCallCount++; + throw new Error('ignored return error'); + }, + }; }; - const returnSpy = spyOnMethod(listField, 'return'); - expectJSON(await complete({ listField }, '[String!]!')).toDeepEqual({ + expectJSON( + await completeWithRootValue( + () => ({ listField: createListField() }), + '[String!]!', + ), + ).toDeepEqual({ data: null, errors: [ { @@ -307,19 +357,27 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { // eslint-disable-next-line no-undef process.removeListener('unhandledRejection', unhandledRejectionListener); - expect(returnSpy.callCount).to.equal(0); - expect(index).to.equal(4); + // Doubled counts reflect original and compiled execution. + expect(nextCalls).to.equal(8); + expect(returnCallCount).to.equal(0); expect(unhandledRejection).to.equal(null); }); }); describe('Execute: Accepts async iterables as list value', () => { function complete(rootValue: unknown, as: string = '[String]') { - return execute({ + return completeWithRootValue(() => rootValue, as); + } + + function completeWithRootValue( + rootValue: () => unknown, + as: string = '[String]', + ) { + return execute(() => ({ schema: buildSchema(`type Query { listField: ${as} }`), document: parse('{ listField }'), - rootValue, - }); + rootValue: rootValue(), + })); } function completeObjectList( @@ -524,19 +582,22 @@ describe('Execute: Accepts async iterables as list value', () => { it('Returns async iterable when list nulls', async () => { const values = [1, null, 2]; - let i = 0; - const listField = { - [Symbol.asyncIterator]() { - return this; - }, - next() { - return Promise.resolve({ value: values[i++], done: false }); - }, - return() { - return Promise.resolve({ value: undefined, done: true }); - }, + let returnCallCount = 0; + const createListField = () => { + let i = 0; + return { + [Symbol.asyncIterator]() { + return this; + }, + next() { + return Promise.resolve({ value: values[i++], done: false }); + }, + return() { + returnCallCount++; + return Promise.resolve({ value: undefined, done: true }); + }, + }; }; - const returnSpy = spyOnMethod(listField, 'return'); const errors = [ { message: 'Cannot return null for non-nullable field Query.listField.', @@ -545,21 +606,29 @@ describe('Execute: Accepts async iterables as list value', () => { }, ]; - expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ + expectJSON( + await completeWithRootValue( + () => ({ listField: createListField() }), + '[Int!]', + ), + ).toDeepEqual({ data: { listField: null }, errors, }); - expect(returnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(returnCallCount).to.equal(2); }); it('Ignores error on return method when async iterator nulls', async () => { const values = [1, null, 2]; - let i = 0; - const listField = { - [Symbol.asyncIterator]: () => ({ - next: () => Promise.resolve({ value: values[i++], done: false }), - return: () => Promise.reject(new Error('ignored return error')), - }), + const createListField = () => { + let i = 0; + return { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({ value: values[i++], done: false }), + return: () => Promise.reject(new Error('ignored return error')), + }), + }; }; const errors = [ { @@ -569,7 +638,12 @@ describe('Execute: Accepts async iterables as list value', () => { }, ]; - expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ + expectJSON( + await completeWithRootValue( + () => ({ listField: createListField() }), + '[Int!]', + ), + ).toDeepEqual({ data: { listField: null }, errors, }); diff --git a/src/execution/__tests__/mutations-test.ts b/src/execution/__tests__/mutations-test.ts index fc0e7bd331..5749c6437b 100644 --- a/src/execution/__tests__/mutations-test.ts +++ b/src/execution/__tests__/mutations-test.ts @@ -15,7 +15,7 @@ import { execute, executeSync, experimentalExecuteIncrementally, -} from '../execute.ts'; +} from './executeTestUtils.ts'; class NumberHolder { theNumber: number; @@ -132,8 +132,11 @@ describe('Execute: Handles mutation execution ordering', () => { } `); - const rootValue = new Root(6); - const mutationResult = await execute({ schema, document, rootValue }); + const mutationResult = await execute(() => ({ + schema, + document, + rootValue: new Root(6), + })); expect(mutationResult).to.deep.equal({ data: { @@ -179,8 +182,11 @@ describe('Execute: Handles mutation execution ordering', () => { } `); - const rootValue = new Root(6); - const result = await execute({ schema, document, rootValue }); + const result = await execute(() => ({ + schema, + document, + rootValue: new Root(6), + })); expectJSON(result).toDeepEqual({ data: { @@ -272,8 +278,11 @@ describe('Execute: Handles mutation execution ordering', () => { } `); - const rootValue = new Root(6); - const mutationResult = await execute({ schema, document, rootValue }); + const mutationResult = await execute(() => ({ + schema, + document, + rootValue: new Root(6), + })); expect(mutationResult).to.deep.equal({ data: { diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 088b365dda..c226722213 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -17,9 +17,10 @@ import { GraphQLSchema } from '../../type/schema.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { execute, executeSync } from '../execute.ts'; import type { ExecutionResult } from '../Executor.ts'; +import { execute, executeSync } from './executeTestUtils.ts'; + const syncError = new Error('sync'); const syncNonNullError = new Error('syncNonNull'); const promiseError = new Error('promise'); diff --git a/src/execution/__tests__/oneof-test.ts b/src/execution/__tests__/oneof-test.ts index 892c599b19..a5480609d8 100644 --- a/src/execution/__tests__/oneof-test.ts +++ b/src/execution/__tests__/oneof-test.ts @@ -6,9 +6,10 @@ import { parse } from '../../language/parser.ts'; import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { execute } from '../execute.ts'; import type { ExecutionResult } from '../Executor.ts'; +import { execute } from './executeTestUtils.ts'; + const schema = buildSchema(` type Query { test(input: TestInputObject!): TestObject diff --git a/src/execution/__tests__/resolve-test.ts b/src/execution/__tests__/resolve-test.ts index b8e7455a72..9a1d51357a 100644 --- a/src/execution/__tests__/resolve-test.ts +++ b/src/execution/__tests__/resolve-test.ts @@ -9,7 +9,7 @@ import { GraphQLObjectType } from '../../type/definition.ts'; import { GraphQLInt, GraphQLString } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; -import { executeSync } from '../execute.ts'; +import { executeSync } from './executeTestUtils.ts'; describe('Execute: resolve function', () => { function testSchema(testField: GraphQLFieldConfig) { diff --git a/src/execution/__tests__/schema-test.ts b/src/execution/__tests__/schema-test.ts index 28b09a2a94..50fc984904 100644 --- a/src/execution/__tests__/schema-test.ts +++ b/src/execution/__tests__/schema-test.ts @@ -17,7 +17,7 @@ import { } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; -import { executeSync } from '../execute.ts'; +import { executeSync } from './executeTestUtils.ts'; describe('Execute: Handles execution with a complex schema', () => { it('executes using a schema', () => { diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index 25751d1136..797247bf3c 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -22,16 +22,18 @@ import { } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; +import { compileSubscription } from '../compile/index.ts'; +import { createSourceEventStream as originalCreateSourceEventStream } from '../execute.ts'; +import type { ExecutionArgs } from '../ExecutionArgs.ts'; +import type { ExecutionResult } from '../Executor.ts'; + import { createSourceEventStream, executeSubscriptionEvent, mapSourceToResponseEvent, subscribe, validateSubscriptionArgs, -} from '../execute.ts'; -import type { ExecutionArgs } from '../ExecutionArgs.ts'; -import type { ExecutionResult } from '../Executor.ts'; - +} from './executeTestUtils.ts'; import { SimplePubSub } from './simplePubSub.ts'; interface Email { @@ -139,28 +141,32 @@ function createSubscription( unread: false, }, ]; + const seenEmails = new Set(); const data: any = { inbox: { emails }, - // FIXME: we shouldn't use mapAsyncIterator here since it makes tests way more complex - importantEmail: pubsub.getSubscriber((newEmail) => { - emails.push(newEmail); - - return { - importantEmail: { - email: newEmail, - inbox: data.inbox, - }, - }; - }), + importantEmail: () => + pubsub.getSubscriber((newEmail) => { + if (!seenEmails.has(newEmail)) { + seenEmails.add(newEmail); + emails.push(newEmail); + } + + return { + importantEmail: { + email: newEmail, + inbox: data.inbox, + }, + }; + }), }; - return subscribe({ + return subscribe(() => ({ schema: emailSchema, document, rootValue: data, variableValues, - }); + })); } const DummyQueryType = new GraphQLObjectType({ @@ -190,15 +196,14 @@ function subscribeWithBadFn( function subscribeWithBadArgs( args: ExecutionArgs, ): PromiseOrValue> { - const validatedExecutionArgs = validateSubscriptionArgs(args); - const sourceEventStreamResult = - 'schema' in validatedExecutionArgs - ? createSourceEventStream(validatedExecutionArgs) - : { errors: validatedExecutionArgs }; - return expectEqualPromisesOrValues([ - subscribe(args), - sourceEventStreamResult, + () => subscribe(args), + () => { + const validatedSubscriptionArgs = validateSubscriptionArgs(args); + return 'schema' in validatedSubscriptionArgs + ? createSourceEventStream(validatedSubscriptionArgs) + : { errors: validatedSubscriptionArgs }; + }, ]); } @@ -217,7 +222,7 @@ describe('Subscription Initialization Phase', () => { }); expect(() => - createSourceEventStream({ + originalCreateSourceEventStream({ schema, document: parse('subscription { foo }'), } as never), @@ -226,6 +231,32 @@ describe('Subscription Initialization Phase', () => { ); }); + it('throws for legacy ExecutionArgs passed to compiled createSourceEventStream', () => { + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + const legacyExecutionArgs = { + schema, + document: parse('subscription { foo }'), + }; + const compiledSubscription = compileSubscription(legacyExecutionArgs); + assert('createSourceEventStream' in compiledSubscription); + + expect(() => + compiledSubscription.createSourceEventStream( + legacyExecutionArgs as never, + ), + ).to.throw( + 'Passing ExecutionArgs to createSourceEventStream() was removed in graphql-js@17.0.0; call validateSubscriptionArgs() first and pass the result instead, or use subscribe() for the full subscription pipeline.', + ); + }); + it('throws when validateSubscriptionArgs is called with a non-subscription operation', () => { const schema = new GraphQLSchema({ query: DummyQueryType, @@ -252,6 +283,32 @@ describe('Subscription Initialization Phase', () => { ).to.throw('Expected subscription operation.'); }); + it('resolves to an error if no operation name is provided with multiple subscription operations', () => { + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + subscription First { foo } + subscription Second { foo } + `); + + const result = subscribeWithBadArgs({ schema, document }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Must provide operation name if query contains multiple operations.', + }, + ], + }); + }); + it('accepts multiple subscription fields defined in schema', async () => { const schema = new GraphQLSchema({ query: DummyQueryType, @@ -396,6 +453,43 @@ describe('Subscription Initialization Phase', () => { }); }); + it('uses the default subscribeFieldResolver with function sources', async () => { + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + + async function* fooGenerator() { + yield { foo: 'FooValue' }; + } + function rootValue() { + return undefined; + } + Object.assign(rootValue, { foo: fooGenerator }); + + const subscription = subscribe({ + schema, + document: parse('subscription { foo }'), + rootValue, + }); + assert(isAsyncIterable(subscription)); + + expect(await subscription.next()).to.deep.equal({ + done: false, + value: { data: { foo: 'FooValue' } }, + }); + + expect(await subscription.next()).to.deep.equal({ + done: true, + value: undefined, + }); + }); + it('maps a source stream to response events with a custom rootSelectionSetExecutor', async () => { const schema = new GraphQLSchema({ query: DummyQueryType, @@ -443,7 +537,29 @@ describe('Subscription Initialization Phase', () => { done: true, value: undefined, }); - expect(count).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(count).to.equal(2); + }); + + it('executes subscription events directly', () => { + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + + const validatedSubscriptionArgs = validateSubscriptionArgs({ + schema, + document: parse('subscription { foo }'), + rootValue: { foo: 'FooValue' }, + }); + assert('schema' in validatedSubscriptionArgs); + const result = executeSubscriptionEvent(validatedSubscriptionArgs); + expectJSON(result).toDeepEqual({ data: { foo: 'FooValue' } }); }); it('should only resolve the first field of invalid multi-field', async () => { @@ -481,7 +597,8 @@ describe('Subscription Initialization Phase', () => { }); assert(isAsyncIterable(subscription)); - expect(fooSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(fooSpy.callCount).to.equal(2); expect(barSpy.callCount).to.equal(0); expect(await subscription.next()).to.have.property('done', false); @@ -531,6 +648,41 @@ describe('Subscription Initialization Phase', () => { }); }); + it('resolves to an error if subscription root fields are skipped', async () => { + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + subscription ($shouldInclude: Boolean!) { + ...Foo @include(if: $shouldInclude) + } + + fragment Foo on Subscription { + foo + } + `); + + const result = subscribeWithBadArgs({ + schema, + document, + variableValues: { shouldInclude: false }, + }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Subscription operation must select a field.', + }, + ], + }); + }); + it('should pass through unexpected errors thrown in subscribe', async () => { const schema = new GraphQLSchema({ query: DummyQueryType, @@ -605,7 +757,7 @@ describe('Subscription Initialization Phase', () => { ).toDeepEqual(expectedResult); }); - it('resolves to an error if variables were wrong type', async () => { + it('resolves to an error if variables were wrong type', () => { const schema = new GraphQLSchema({ query: DummyQueryType, subscription: new GraphQLObjectType({ @@ -628,7 +780,7 @@ describe('Subscription Initialization Phase', () => { // If we receive variables that cannot be coerced correctly, subscribe() will // resolve to an ExecutionResult that contains an informative error description. - const result = subscribeWithBadArgs({ schema, document, variableValues }); + const result = subscribe({ schema, document, variableValues }); expectJSON(result).toDeepEqual({ errors: [ { @@ -639,6 +791,45 @@ describe('Subscription Initialization Phase', () => { ], }); }); + + it('maps source events to response events directly', async () => { + const schema = new GraphQLSchema({ + query: DummyQueryType, + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + const validatedSubscriptionArgs = validateSubscriptionArgs({ + schema, + document: parse('subscription { foo }'), + }); + assert('schema' in validatedSubscriptionArgs); + + async function* sourceEventStream() { + yield { foo: 'FooValue' }; + } + + const responseStream = mapSourceToResponseEvent( + validatedSubscriptionArgs, + sourceEventStream(), + ); + assert(isAsyncIterable(responseStream)); + expect(await responseStream.next()).to.deep.equal({ + done: false, + value: { + data: { + foo: 'FooValue', + }, + }, + }); + expect(await responseStream.next()).to.deep.equal({ + done: true, + value: undefined, + }); + }); }); // Once a subscription returns a valid AsyncIterator, it can still yield errors. diff --git a/src/execution/__tests__/sync-test.ts b/src/execution/__tests__/sync-test.ts index 6515549676..08ceee2864 100644 --- a/src/execution/__tests__/sync-test.ts +++ b/src/execution/__tests__/sync-test.ts @@ -14,7 +14,9 @@ import { validate } from '../../validation/validate.ts'; import { graphqlSync } from '../../graphql.ts'; -import { execute, executeSync } from '../execute.ts'; +import { executeSync as originalExecuteSync } from '../execute.ts'; + +import { execute, executeSync } from './executeTestUtils.ts'; describe('Execute: synchronously when possible', () => { const schema = new GraphQLSchema({ @@ -107,7 +109,7 @@ describe('Execute: synchronously when possible', () => { it('throws if encountering async execution', () => { const doc = 'query Example { syncField, asyncField }'; expect(() => { - executeSync({ + originalExecuteSync({ schema, document: parse(doc), rootValue: 'rootValue', @@ -125,7 +127,7 @@ describe('Execute: synchronously when possible', () => { } `; expect(() => { - executeSync({ + originalExecuteSync({ schema, document: parse(doc), rootValue: 'rootValue', diff --git a/src/execution/__tests__/union-interface-test.ts b/src/execution/__tests__/union-interface-test.ts index 398ab7fc46..fa2e6b7167 100644 --- a/src/execution/__tests__/union-interface-test.ts +++ b/src/execution/__tests__/union-interface-test.ts @@ -15,7 +15,7 @@ import { import { GraphQLBoolean, GraphQLString } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; -import { execute, executeSync } from '../execute.ts'; +import { execute, executeSync } from './executeTestUtils.ts'; class Dog { name: string; diff --git a/src/execution/__tests__/variables-test.ts b/src/execution/__tests__/variables-test.ts index bc2a2660e9..6f582199a1 100644 --- a/src/execution/__tests__/variables-test.ts +++ b/src/execution/__tests__/variables-test.ts @@ -33,25 +33,27 @@ import { GraphQLSchema } from '../../type/schema.ts'; import { valueFromASTUntyped } from '../../utilities/valueFromASTUntyped.ts'; -import { executeSync, experimentalExecuteIncrementally } from '../execute.ts'; -import { getVariableValues } from '../values.ts'; +import { + executeSync, + experimentalExecuteIncrementally, + getVariableValues, +} from './executeTestUtils.ts'; -const TestFaultyScalarGraphQLError = new GraphQLError( - 'FaultyScalarErrorMessage', - { +function createTestFaultyScalarGraphQLError(): GraphQLError { + return new GraphQLError('FaultyScalarErrorMessage', { extensions: { code: 'FaultyScalarErrorExtensionCode', }, - }, -); + }); +} const TestFaultyScalar = new GraphQLScalarType({ name: 'FaultyScalar', coerceInputValue() { - throw TestFaultyScalarGraphQLError; + throw createTestFaultyScalarGraphQLError(); }, coerceInputLiteral() { - throw TestFaultyScalarGraphQLError; + throw createTestFaultyScalarGraphQLError(); }, }); @@ -631,7 +633,7 @@ describe('Execute: Handles inputs', () => { errors: [ { message: - 'Variable "$input" has invalid value at .e: Argument "TestType.fieldWithObjectInput(input:)" has invalid value at .e: FaultyScalarErrorMessage', + 'Variable "$input" has invalid value at .e: FaultyScalarErrorMessage', locations: [{ line: 2, column: 16 }], extensions: { code: 'FaultyScalarErrorExtensionCode' }, }, diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index 98546f032e..6ba78d4ddb 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -23,6 +23,7 @@ import type { GraphQLSchema } from '../type/schema.ts'; import { typeFromAST } from '../utilities/typeFromAST.ts'; +import type { CompiledFieldExecutionPlan } from './compile/compileFieldExecutionPlan.ts'; import type { GraphQLVariableSignature } from './getVariableSignature.ts'; import type { VariableValues } from './values.ts'; import { @@ -46,15 +47,17 @@ export interface FragmentVariableValues { /** @internal */ export interface FragmentVariableValueSource { readonly signature: GraphQLVariableSignature; - readonly value?: ValueNode; - readonly fragmentVariableValues?: FragmentVariableValues; + readonly value: ValueNode | undefined; + readonly fragmentVariableValues: FragmentVariableValues | undefined; } /** @internal */ export interface FieldDetails { node: FieldNode; - deferUsage?: DeferUsage | undefined; - fragmentVariableValues?: FragmentVariableValues | undefined; + deferUsage: DeferUsage | undefined; + fragmentVariableValues: FragmentVariableValues | undefined; + staticFragmentVariableValues: FragmentVariableValues | undefined; + compiledFieldPlan: CompiledFieldExecutionPlan | undefined; } /** @internal */ @@ -63,6 +66,19 @@ export type FieldDetailsList = ReadonlyArray; /** @internal */ export type GroupedFieldSet = ReadonlyMap; +/** @internal */ +export interface RootFieldCollection { + groupedFieldSet: GroupedFieldSet; + newDeferUsages: ReadonlyArray; + forbiddenDirectiveInstances: ReadonlyArray; +} + +/** @internal */ +export interface SubfieldCollection { + groupedFieldSet: GroupedFieldSet; + newDeferUsages: ReadonlyArray; +} + /** @internal */ export interface FragmentDetails { definition: FragmentDefinitionNode; @@ -98,11 +114,7 @@ export function collectFields( selectionSet: SelectionSetNode, hideSuggestions: boolean, forbidSkipAndInclude = false, -): { - groupedFieldSet: GroupedFieldSet; - newDeferUsages: ReadonlyArray; - forbiddenDirectiveInstances: ReadonlyArray; -} { +): RootFieldCollection { const groupedFieldSet = new AccumulatorMap(); const newDeferUsages: Array = []; const context: CollectFieldsContext = { @@ -142,10 +154,7 @@ export function collectSubfields( returnType: GraphQLObjectType, fieldDetailsList: FieldDetailsList, hideSuggestions: boolean, -): { - groupedFieldSet: GroupedFieldSet; - newDeferUsages: ReadonlyArray; -} { +): SubfieldCollection { const context: CollectFieldsContext = { schema, fragments, @@ -216,6 +225,8 @@ function collectFieldsImpl( node: selection, deferUsage, fragmentVariableValues, + staticFragmentVariableValues: undefined, + compiledFieldPlan: undefined, }); break; } diff --git a/src/execution/compile/CompiledExecutor.ts b/src/execution/compile/CompiledExecutor.ts new file mode 100644 index 0000000000..bfd65a034f --- /dev/null +++ b/src/execution/compile/CompiledExecutor.ts @@ -0,0 +1,2979 @@ +/** @category Execution */ + +/* eslint-disable max-params */ + +import { inspect } from '../../jsutils/inspect.ts'; +import { invariant } from '../../jsutils/invariant.ts'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.ts'; +import { isIterableObject } from '../../jsutils/isIterableObject.ts'; +import { isPromise, isPromiseLike } from '../../jsutils/isPromise.ts'; +import { memoize1 } from '../../jsutils/memoize1.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import type { Path } from '../../jsutils/Path.ts'; +import { addPath, pathToArray } from '../../jsutils/Path.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import type { GraphQLError } from '../../error/GraphQLError.ts'; +import { GraphQLError as GraphQLErrorClass } from '../../error/GraphQLError.ts'; +import { locatedError } from '../../error/locatedError.ts'; + +import type { FieldNode } from '../../language/ast.ts'; +import { OperationTypeNode } from '../../language/ast.ts'; + +import type { + GraphQLAbstractType, + GraphQLLeafType, + GraphQLList, + GraphQLObjectType, + GraphQLOutputType, + GraphQLResolveInfo, + GraphQLResolveInfoHelpers, +} from '../../type/definition.ts'; +import { + isAbstractType, + isLeafType, + isListType, + isNonNullType, + isObjectType, +} from '../../type/definition.ts'; + +import { AbortedGraphQLExecutionError } from '../AbortedGraphQLExecutionError.ts'; +import { withCancellation } from '../cancellablePromise.ts'; +import type { + DeferUsage, + FieldDetailsList, + GroupedFieldSet, +} from '../collectFields.ts'; +import { collectIteratorPromises } from '../collectIteratorPromises.ts'; +import type { SharedExecutionContext } from '../createSharedExecutionContext.ts'; +import { createSharedExecutionContext } from '../createSharedExecutionContext.ts'; +import type { ValidatedExecutionArgs } from '../ExecutionArgs.ts'; +import type { StreamUsage } from '../getStreamUsage.ts'; +import { runAsyncWorkFinishedHook } from '../hooks.ts'; +import type { DeferUsageSet } from '../incremental/buildExecutionPlan.ts'; +import { buildExecutionPlan } from '../incremental/buildExecutionPlan.ts'; +import { Computation } from '../incremental/Computation.ts'; +import type { + DeliveryGroup, + ExecutionGroupResult, + ExecutionGroupValue, + ExperimentalIncrementalExecutionResults, + IncrementalWork, + ItemStream, + StreamItemResult, + StreamItemValue, +} from '../incremental/IncrementalExecutor.ts'; +import { IncrementalPublisher } from '../incremental/IncrementalPublisher.ts'; +import { Queue } from '../incremental/Queue.ts'; +import type { Task } from '../incremental/WorkQueue.ts'; +import { returnIteratorCatchingErrors } from '../returnIteratorCatchingErrors.ts'; + +import type { + CompiledExecutionRuntime, + CompiledFieldExecutionPlan, +} from './compileFieldExecutionPlan.ts'; +import { getCompiledDirectiveValues } from './getCompiledDirectiveValues.ts'; + +type ExecutionMode = 'throw' | 'incremental' | 'ignore'; + +type ExecutionGroup = Task< + ExecutionGroupValue, + StreamItemValue, + DeliveryGroup, + ItemStream +> & { + path: Path | undefined; +}; + +type StreamItemCompleter = ( + executor: CompiledExecutor, + itemPath: Path, + item: unknown, + index: number, +) => PromiseOrValue; + +type PreplannedExecutionGroupExecutor = ( + executor: CompiledExecutor, + runner: CompiledExecutionRunner, + source: unknown, + target: ObjMap, + parentNullTarget: CompletionTarget, + deliveryGroupMap: IncrementalPositionContext, +) => void; + +interface ExecutionResult< + TData = ObjMap, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data?: TData | null; + extensions?: TExtensions; +} + +type RootBox = ObjMap & { data: T }; + +interface ObjectCompletionTarget { + container: ObjMap; + key: string; + path: Path | undefined; +} + +interface ArrayCompletionTarget { + container: Array; + key: number; + path: Path | undefined; +} + +type CompletionTarget = ObjectCompletionTarget | ArrayCompletionTarget; + +interface StreamIterator { + handle: Iterator; + isAsync?: never; +} + +interface AsyncStreamIterator { + handle: AsyncIterator; + isAsync: true; +} + +type StreamIteratorHandle = StreamIterator | AsyncStreamIterator; + +type IncrementalPositionContext = + | ReadonlyMap + | undefined; + +interface FieldSetJob { + kind: 'FIELD_SET'; + parentType: GraphQLObjectType; + source: unknown; + path: Path | undefined; + groupedFieldSet: GroupedFieldSet; + target: ObjMap; + parentNullTarget: CompletionTarget; + serially: boolean; + newDeferUsages: ReadonlyArray; + deliveryGroupMap: IncrementalPositionContext; +} + +interface FieldJob { + kind: 'FIELD'; + parentType: GraphQLObjectType; + source: unknown; + responseName: string; + fieldDetailsList: FieldDetailsList; + plan: CompiledFieldExecutionPlan; + path: Path; + target: ObjMap; + parentNullTarget: CompletionTarget; + deliveryGroupMap: IncrementalPositionContext; +} + +interface CompleteJob { + kind: 'COMPLETE'; + returnType: GraphQLOutputType; + fieldDetailsList: FieldDetailsList; + info: GraphQLResolveInfo; + path: Path; + result: unknown; + target: CompletionTarget; + nullTarget: CompletionTarget; + deliveryGroupMap: IncrementalPositionContext; +} + +type Job = FieldSetJob | FieldJob | CompleteJob; + +interface AsyncListRead { + values: ReadonlyArray; + iterator: AsyncIterator; + nextIndex: number; + done: boolean; +} + +const UNEXPECTED_MULTIPLE_PAYLOADS = + 'Executing this GraphQL operation would unexpectedly produce multiple payloads (due to @defer or @stream directive)'; + +const defaultAbortReason = new Error('This operation was aborted'); +const resolverAbortWithoutReason = Symbol('resolverAbortWithoutReason'); + +/** @internal */ +export class CompiledExecutor< + TMode extends ExecutionMode = ExecutionMode, +> implements CompiledExecutionRuntime { + validatedExecutionArgs: ValidatedExecutionArgs; + mode: TMode; + deferUsageSet: DeferUsageSet | undefined; + aborted: boolean; + abortReason: unknown; + abortResultPromise: (() => void) | undefined; + resolverAbortController: AbortController | undefined; + private _getAbortSignal: (() => AbortSignal | undefined) | undefined; + private _getAsyncHelpers: (() => GraphQLResolveInfoHelpers) | undefined; + private _collectedErrors: CollectedErrors | undefined; + private _sharedExecutionContext: SharedExecutionContext | undefined; + private _groups: Array | undefined; + private _tasks: Array | undefined; + private _streams: Array | undefined; + private _resolverAbortReason: unknown; + private _resolverAbortFinished: boolean; + + constructor( + validatedExecutionArgs: ValidatedExecutionArgs, + mode: TMode, + sharedExecutionContext?: SharedExecutionContext, + deferUsageSet?: DeferUsageSet, + ) { + this.validatedExecutionArgs = validatedExecutionArgs; + this.mode = mode; + this.deferUsageSet = deferUsageSet; + this.aborted = false; + this.abortReason = defaultAbortReason; + this._resolverAbortReason = resolverAbortWithoutReason; + this._resolverAbortFinished = false; + this._sharedExecutionContext = sharedExecutionContext; + } + + get getAbortSignal(): () => AbortSignal | undefined { + return (this._getAbortSignal ??= () => + this.sharedExecutionContext.getAbortSignal()); + } + + get getAsyncHelpers(): () => GraphQLResolveInfoHelpers { + return (this._getAsyncHelpers ??= () => + this.sharedExecutionContext.getAsyncHelpers()); + } + + get collectedErrors(): CollectedErrors { + return (this._collectedErrors ??= new CollectedErrors()); + } + + get sharedExecutionContext(): SharedExecutionContext { + return (this._sharedExecutionContext ??= createSharedExecutionContext(() => + this.getResolverAbortSignal(), + )); + } + + get groups(): Array { + return (this._groups ??= []); + } + + get tasks(): Array { + return (this._tasks ??= []); + } + + get streams(): Array { + return (this._streams ??= []); + } + + applyNulledTargets(): void { + this._collectedErrors?.applyNulledTargets(); + } + + finishAsyncRootExecution( + completed: Promise, + rootBox: RootBox | null>, + maybeRemoveExternalAbortListener?: () => void, + ): Promise { + const promise = completed.then( + () => { + maybeRemoveExternalAbortListener?.(); + return this.finish(this.buildResponse(rootBox.data)); + }, + (error: unknown) => { + maybeRemoveExternalAbortListener?.(); + this.collectedErrors.add(ensureGraphQLError(error), undefined); + return this.finish(this.buildResponse(null)); + }, + ); + if (this.validatedExecutionArgs.hooks?.asyncWorkFinished !== undefined) { + this.sharedExecutionContext.asyncWorkTracker.add(promise); + } + if (this.validatedExecutionArgs.externalAbortSignal === undefined) { + return promise; + } + const { promise: cancellablePromise, abort } = withCancellation(promise); + this.abortResultPromise = () => { + abort(this.createAbortedExecutionError(promise)); + }; + if (this.aborted) { + this.abortResultPromise(); + } + return cancellablePromise; + } + + abort(reason?: unknown): void { + this.aborted = true; + if (reason !== undefined) { + this.abortReason = reason; + } + this.abortResultPromise?.(); + this.abortResolverSignal(this.abortReason); + const tasks = this._tasks; + if (tasks !== undefined) { + for (const task of tasks) { + const aborted = task.computation.abort(reason); + invariant(!isPromise(aborted)); + } + } + const streams = this._streams; + if (streams !== undefined) { + for (const stream of streams) { + const aborted = stream.queue.abort(reason); + invariant(!isPromise(aborted)); + } + } + } + + finish(result: T): T { + if (this.aborted) { + throw this.createAbortedExecutionError(result); + } + this.aborted = true; + return result; + } + + createAbortedExecutionError( + result: PromiseOrValue, + ): AbortedGraphQLExecutionError { + return new AbortedGraphQLExecutionError(this.abortReason, result); + } + + buildResponse( + data: ObjMap | null, + ): ExecutionResult | ExperimentalIncrementalExecutionResults { + if (this.mode === 'incremental' && data !== null) { + const work = this.getIncrementalWork(); + const hasIncrementalWork = + (work.tasks?.length ?? 0) > 0 || (work.streams?.length ?? 0) > 0; + if (!hasIncrementalWork) { + this.finishSharedExecution(); + const errors = this._collectedErrors?.errors ?? emptyCollectedErrors; + return errors.length ? { errors, data } : { data }; + } + const errors = this._collectedErrors?.errors ?? emptyCollectedErrors; + return new IncrementalPublisher().buildResponse( + data, + errors, + work, + this.validatedExecutionArgs.externalAbortSignal, + this.getFinishSharedExecution(), + ); + } + + this.finishSharedExecution(); + const errors = this._collectedErrors?.errors ?? emptyCollectedErrors; + return errors.length ? { errors, data } : { data }; + } + + getFinishSharedExecution(): () => void { + const asyncWorkFinishedHook = + this.validatedExecutionArgs.hooks?.asyncWorkFinished; + if (asyncWorkFinishedHook === undefined) { + return () => { + this.abortResolverSignal(); + }; + } + + const sharedExecutionContext = this.sharedExecutionContext; + return () => { + this.abortResolverSignal(); + runAsyncWorkFinishedHook( + this.validatedExecutionArgs, + sharedExecutionContext, + asyncWorkFinishedHook, + ); + }; + } + + finishSharedExecution(): void { + const asyncWorkFinishedHook = + this.validatedExecutionArgs.hooks?.asyncWorkFinished; + this.abortResolverSignal(); + if (asyncWorkFinishedHook !== undefined) { + runAsyncWorkFinishedHook( + this.validatedExecutionArgs, + this.sharedExecutionContext, + asyncWorkFinishedHook, + ); + } + } + + handleLeafFieldError( + rawError: unknown, + returnType: GraphQLOutputType, + fieldDetailsList: FieldDetailsList, + fieldPath: Path, + target: ObjMap, + responseName: string, + parentNullTarget: CompletionTarget, + ): void { + const fieldTarget: CompletionTarget = { + container: target, + key: responseName, + path: fieldPath, + }; + this.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + this.getNullableTarget(returnType, fieldTarget, parentNullTarget), + ); + } + + getNullableTarget( + returnType: GraphQLOutputType, + ownTarget: CompletionTarget, + parentNullTarget: CompletionTarget, + ): CompletionTarget { + return isNonNullType(returnType) ? parentNullTarget : ownTarget; + } + + handleCompletionError( + rawError: unknown, + returnType: GraphQLOutputType, + fieldDetailsList: FieldDetailsList, + path: Path, + ownTarget: CompletionTarget, + nullTarget: CompletionTarget, + ): void { + const error = locatedError( + rawError, + toNodes(fieldDetailsList), + pathToArray(path), + ); + const target = + this.validatedExecutionArgs.errorPropagation && isNonNullType(returnType) + ? nullTarget + : ownTarget; + this.collectedErrors.add(error, target.path, target); + } + + ensureValidRuntimeType( + runtimeTypeName: unknown, + returnType: GraphQLAbstractType, + fieldDetailsList: FieldDetailsList, + info: GraphQLResolveInfo, + result: unknown, + ): GraphQLObjectType { + if (runtimeTypeName == null) { + throw new GraphQLErrorClass( + `Abstract type "${returnType}" must resolve to an Object type at runtime for field "${info.parentType}.${info.fieldName}". Either the "${returnType}" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.`, + { nodes: toNodes(fieldDetailsList) }, + ); + } + if (typeof runtimeTypeName !== 'string') { + throw new GraphQLErrorClass( + `Abstract type "${returnType}" must resolve to an Object type at runtime for field "${info.parentType}.${info.fieldName}" with ` + + `value ${inspect(result)}, received "${inspect( + runtimeTypeName, + )}", which is not a valid Object type name.`, + ); + } + const runtimeType = + this.validatedExecutionArgs.schema.getType(runtimeTypeName); + if (runtimeType == null) { + throw new GraphQLErrorClass( + `Abstract type "${returnType}" was resolved to a type "${runtimeTypeName}" that does not exist inside the schema.`, + { nodes: toNodes(fieldDetailsList) }, + ); + } + if (!isObjectType(runtimeType)) { + throw new GraphQLErrorClass( + `Abstract type "${returnType}" was resolved to a non-object type "${runtimeTypeName}".`, + { nodes: toNodes(fieldDetailsList) }, + ); + } + if ( + !this.validatedExecutionArgs.schema.isSubType(returnType, runtimeType) + ) { + throw new GraphQLErrorClass( + `Runtime Object type "${runtimeType}" is not a possible type for "${returnType}".`, + { nodes: toNodes(fieldDetailsList) }, + ); + } + return runtimeType; + } + + invalidReturnTypeError( + returnType: GraphQLObjectType, + result: unknown, + fieldDetailsList: FieldDetailsList, + ): GraphQLError { + return new GraphQLErrorClass( + `Expected value of type "${returnType}" but got: ${inspect(result)}.`, + { nodes: toNodes(fieldDetailsList) }, + ); + } + + getIncrementalWork(): IncrementalWork { + const groups = this._groups; + const tasks = this._tasks; + const streams = this._streams; + const collectedErrors = this._collectedErrors; + if (collectedErrors === undefined || collectedErrors.errors.length === 0) { + const work: IncrementalWork = {}; + if (groups !== undefined) { + work.groups = groups; + } + if (tasks !== undefined) { + work.tasks = tasks; + } + if (streams !== undefined) { + work.streams = streams; + } + return work; + } + + const cancellationReason = new Error( + 'Cancelled secondary to null within original result', + ); + const filteredTasks: Array = []; + const filteredStreams: Array = []; + + if (tasks !== undefined) { + for (const task of tasks) { + if (collectedErrors.hasNulledPosition(task.path)) { + const aborted = task.computation.abort(cancellationReason); + invariant(!isPromise(aborted)); + } else { + filteredTasks.push(task); + } + } + } + + if (streams !== undefined) { + for (const stream of streams) { + if (collectedErrors.hasNulledPosition(stream.path)) { + const aborted = stream.queue.abort(cancellationReason); + invariant(!isPromise(aborted)); + } else { + filteredStreams.push(stream); + } + } + } + + const work: IncrementalWork = {}; + if (groups !== undefined) { + work.groups = groups; + } + if (tasks !== undefined) { + work.tasks = filteredTasks; + } + if (streams !== undefined) { + work.streams = filteredStreams; + } + return work; + } + + throwUnexpectedIncremental(): never { + const reason = new Error(UNEXPECTED_MULTIPLE_PAYLOADS); + this.abort(reason); + throw reason; + } + + executeRootSelectionSet( + this: CompiledExecutor<'throw'> | CompiledExecutor<'ignore'>, + serially?: boolean, + ): PromiseOrValue; + executeRootSelectionSet( + this: CompiledExecutor<'incremental'>, + serially?: boolean, + ): PromiseOrValue; + executeRootSelectionSet( + serially?: boolean, + ): PromiseOrValue { + const externalAbortSignal = this.validatedExecutionArgs.externalAbortSignal; + let removeExternalAbortListener: (() => void) | undefined; + if (externalAbortSignal) { + externalAbortSignal.throwIfAborted(); + const onExternalAbort = () => { + this.abort(externalAbortSignal.reason); + }; + removeExternalAbortListener = () => + externalAbortSignal.removeEventListener('abort', onExternalAbort); + externalAbortSignal.addEventListener('abort', onExternalAbort); + } + + const rootBox: RootBox | null> = { + data: Object.create(null), + }; + try { + const { schema, rootValue, operation, variableValues } = + this.validatedExecutionArgs; + const operationType = operation.operation; + const rootType = schema.getRootType(operationType); + if (rootType == null) { + throw new GraphQLErrorClass( + `Schema is not configured to execute ${operationType} operation.`, + { nodes: operation }, + ); + } + + const { groupedFieldSet, newDeferUsages } = + this.validatedExecutionArgs.fieldCollectors.collectRootFields( + variableValues, + rootType, + ); + + const data = Object.create(null); + rootBox.data = data; + const shouldExecuteSerially = + serially ?? operationType === OperationTypeNode.MUTATION; + const runner = new WorkQueueExecutionRunner(this); + runner.enqueue({ + kind: 'FIELD_SET', + parentType: rootType, + source: rootValue, + path: undefined, + groupedFieldSet, + target: data, + parentNullTarget: { + container: rootBox, + key: 'data', + path: undefined, + }, + serially: shouldExecuteSerially, + newDeferUsages, + deliveryGroupMap: undefined, + }); + const completed = runner.runUntilNulled(undefined); + if (completed !== undefined) { + return this.finishAsyncRootExecution( + completed, + rootBox, + removeExternalAbortListener, + ); + } + removeExternalAbortListener?.(); + } catch (error) { + removeExternalAbortListener?.(); + this.collectedErrors.add(ensureGraphQLError(error), undefined); + return this.finish(this.buildResponse(null)); + } + return this.finish(this.buildResponse(rootBox.data)); + } + + executeLeafField( + parentType: GraphQLObjectType, + source: unknown, + path: Path | undefined, + responseName: string, + fieldDetailsList: FieldDetailsList, + plan: CompiledFieldExecutionPlan, + leafType: GraphQLLeafType, + target: ObjMap, + parentNullTarget: CompletionTarget, + runner: WorkQueueExecutionRunner, + ): void { + const fieldPath = addPath(path, responseName, parentType.name); + let result: unknown; + try { + result = plan.resolveFieldValue( + this, + parentType, + source, + fieldDetailsList, + fieldPath, + ); + } catch (rawError) { + const fieldTarget: CompletionTarget = { + container: target, + key: responseName, + path: fieldPath, + }; + this.handleCompletionError( + rawError, + plan.returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + this.getNullableTarget(plan.returnType, fieldTarget, parentNullTarget), + ); + return; + } + + if (isPromiseLike(result)) { + target[responseName] = undefined; + const fieldTarget: CompletionTarget = { + container: target, + key: responseName, + path: fieldPath, + }; + const nullTarget = this.getNullableTarget( + plan.returnType, + fieldTarget, + parentNullTarget, + ); + runner.awaitValue( + result, + (resolved) => { + this.completeLeafResult( + leafType, + plan.completedNonNull, + plan.returnType, + resolved, + fieldDetailsList, + parentType, + plan.fieldDef.name, + fieldPath, + fieldTarget, + nullTarget, + ); + }, + (rawError: unknown) => { + this.handleCompletionError( + rawError, + plan.returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + }, + fieldPath, + ); + return; + } + + this.completeSynchronousLeafField( + leafType, + plan.completedNonNull, + plan.returnType, + plan.fieldDef.name, + target, + responseName, + parentNullTarget, + parentType, + fieldPath, + fieldDetailsList, + result, + ); + } + + completeSynchronousLeafField( + leafType: GraphQLLeafType, + completedNonNull: boolean, + returnType: GraphQLOutputType, + fieldName: string, + target: ObjMap, + responseName: string, + parentNullTarget: CompletionTarget, + parentType: GraphQLObjectType, + fieldPath: Path, + fieldDetailsList: FieldDetailsList, + result: unknown, + ): void { + if (result == null) { + if (completedNonNull && this.validatedExecutionArgs.errorPropagation) { + this.handleLeafFieldError( + new Error( + `Cannot return null for non-nullable field ${parentType}.${fieldName}.`, + ), + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + } else { + target[responseName] = null; + } + return; + } + + if (result instanceof Error) { + this.handleLeafFieldError( + result, + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + return; + } + try { + const coerced = leafType.coerceOutputValue(result); + if (coerced == null) { + throw new Error( + `Expected \`${inspect(leafType)}.coerceOutputValue(${inspect(result)})\` to ` + + `return non-nullable value, returned: ${inspect(coerced)}`, + ); + } + target[responseName] = coerced; + } catch (rawError) { + this.handleLeafFieldError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + } + } + + completeLeafResult( + leafType: GraphQLLeafType, + completedNonNull: boolean, + returnType: GraphQLOutputType, + result: unknown, + fieldDetailsList: FieldDetailsList, + parentType: GraphQLObjectType, + fieldName: string, + path: Path, + target: CompletionTarget, + nullTarget: CompletionTarget, + ): void { + if (result instanceof Error) { + this.handleCompletionError( + result, + returnType, + fieldDetailsList, + path, + target, + nullTarget, + ); + return; + } + + if (result == null) { + if (completedNonNull && this.validatedExecutionArgs.errorPropagation) { + this.handleCompletionError( + new Error( + `Cannot return null for non-nullable field ${parentType}.${fieldName}.`, + ), + returnType, + fieldDetailsList, + path, + target, + nullTarget, + ); + } else { + setTarget(target, null); + } + return; + } + try { + const coerced = leafType.coerceOutputValue(result); + if (coerced == null) { + throw new Error( + `Expected \`${inspect(leafType)}.coerceOutputValue(${inspect(result)})\` to ` + + `return non-nullable value, returned: ${inspect(coerced)}`, + ); + } + setTarget(target, coerced); + } catch (rawError) { + this.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + path, + target, + nullTarget, + ); + } + } + + executeObjectFieldWithoutIsTypeOf( + parentType: GraphQLObjectType, + source: unknown, + path: Path | undefined, + responseName: string, + fieldDetailsList: FieldDetailsList, + plan: CompiledFieldExecutionPlan, + objectType: GraphQLObjectType, + target: ObjMap, + parentNullTarget: CompletionTarget, + deliveryGroupMap: IncrementalPositionContext, + runner: WorkQueueExecutionRunner, + ): void { + const fieldPath = addPath(path, responseName, parentType.name); + const fieldTarget: CompletionTarget = { + container: target, + key: responseName, + path: fieldPath, + }; + const nullTarget = this.getNullableTarget( + plan.returnType, + fieldTarget, + parentNullTarget, + ); + + let result: unknown; + try { + result = plan.resolveFieldValue( + this, + parentType, + source, + fieldDetailsList, + fieldPath, + ); + } catch (rawError) { + this.handleCompletionError( + rawError, + plan.returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (isPromiseLike(result)) { + target[responseName] = undefined; + runner.awaitValue( + result, + (resolved) => { + this.completeObjectFieldWithoutIsTypeOf( + plan.returnType, + objectType, + plan.completedNonNull, + resolved, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + deliveryGroupMap, + runner, + parentType, + plan.fieldDef.name, + ); + }, + (rawError) => { + this.handleCompletionError( + rawError, + plan.returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + }, + nullTarget.path, + ); + return; + } + + this.completeObjectFieldWithoutIsTypeOf( + plan.returnType, + objectType, + plan.completedNonNull, + result, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + deliveryGroupMap, + runner, + parentType, + plan.fieldDef.name, + ); + } + + processFieldSet(job: FieldSetJob, runner: WorkQueueExecutionRunner): void { + if (this.aborted) { + throw new Error('Aborted!'); + } + + let groupedFieldSet = job.groupedFieldSet; + let deliveryGroupMap = job.deliveryGroupMap; + const newDeferUsages = job.newDeferUsages; + + if (this.mode === 'throw' && newDeferUsages.length > 0) { + this.throwUnexpectedIncremental(); + } + + if ( + this.mode === 'incremental' && + (newDeferUsages.length > 0 || deliveryGroupMap !== undefined) + ) { + invariant( + this.validatedExecutionArgs.operation.operation !== + OperationTypeNode.SUBSCRIPTION, + '`@defer` directive not supported on subscription operations. Disable `@defer` by setting the `if` argument to `false`.', + ); + const newDelivery = this.getNewDeliveryGroupMap( + newDeferUsages, + deliveryGroupMap, + job.path, + ); + deliveryGroupMap = newDelivery.newDeliveryGroupMap; + this.groups.push(...newDelivery.newDeliveryGroups); + + const plan = + this.deferUsageSet === undefined + ? buildExecutionPlan(groupedFieldSet) + : buildExecutionPlan(groupedFieldSet, this.deferUsageSet); + groupedFieldSet = plan.groupedFieldSet; + if (plan.newGroupedFieldSets.size > 0) { + this.collectExecutionGroups( + job.parentType, + job.source, + job.path, + plan.newGroupedFieldSets, + deliveryGroupMap, + ); + } + } + + if (job.serially) { + const entries = Array.from(groupedFieldSet); + const executableEntries: Array< + [string, FieldDetailsList, CompiledFieldExecutionPlan] + > = []; + for (const [responseName, fieldDetailsList] of entries) { + const plan = getCompiledFieldPlan(fieldDetailsList); + if (plan !== undefined) { + executableEntries.push([responseName, fieldDetailsList, plan]); + } + } + this.executeSerialFields( + job, + executableEntries, + deliveryGroupMap, + runner, + ); + return; + } + + for (const [responseName, fieldDetailsList] of groupedFieldSet) { + const plan = getCompiledFieldPlan(fieldDetailsList); + if (plan === undefined) { + continue; + } + if (plan.leafType !== undefined) { + this.executeLeafField( + job.parentType, + job.source, + job.path, + responseName, + fieldDetailsList, + plan, + plan.leafType, + job.target, + job.parentNullTarget, + runner, + ); + } else if ( + isObjectType(plan.nullableReturnType) && + plan.nullableReturnType.isTypeOf === undefined + ) { + this.executeObjectFieldWithoutIsTypeOf( + job.parentType, + job.source, + job.path, + responseName, + fieldDetailsList, + plan, + plan.nullableReturnType, + job.target, + job.parentNullTarget, + deliveryGroupMap, + runner, + ); + } else { + job.target[responseName] = undefined; + runner.enqueue({ + kind: 'FIELD', + parentType: job.parentType, + source: job.source, + responseName, + fieldDetailsList, + plan, + path: addPath(job.path, responseName, job.parentType.name), + target: job.target, + parentNullTarget: job.parentNullTarget, + deliveryGroupMap, + }); + } + } + } + + processField(job: FieldJob, runner: WorkQueueExecutionRunner): void { + if (this.aborted) { + throw new Error('Aborted!'); + } + + const plan = job.plan; + const fieldDef = plan.fieldDef; + const returnType = fieldDef.type; + const fieldTarget: CompletionTarget = { + container: job.target, + key: job.responseName, + path: job.path, + }; + const nullTarget = this.getNullableTarget( + returnType, + fieldTarget, + job.parentNullTarget, + ); + + let info: GraphQLResolveInfo | undefined; + let result: unknown; + try { + const resolved = plan.resolveField( + this, + job.parentType, + job.source, + job.fieldDetailsList, + job.path, + ); + info = resolved.info; + result = resolved.result; + } catch (rawError) { + this.handleCompletionError( + rawError, + returnType, + job.fieldDetailsList, + job.path, + fieldTarget, + nullTarget, + ); + return; + } + + const getInfoForCompletion = () => { + if (info !== undefined) { + return info; + } + info = plan.buildResolveInfo( + this, + job.parentType, + job.fieldDetailsList, + job.path, + ); + return info; + }; + + const nullableReturnType = plan.nullableReturnType; + const completedNonNull = plan.completedNonNull; + + if (isPromiseLike(result)) { + if ( + isObjectType(nullableReturnType) && + nullableReturnType.isTypeOf === undefined + ) { + runner.awaitValue( + result, + (resolved) => { + this.completeObjectFieldWithoutIsTypeOf( + returnType, + nullableReturnType, + completedNonNull, + resolved, + job.fieldDetailsList, + job.path, + fieldTarget, + nullTarget, + job.deliveryGroupMap, + runner, + job.parentType, + fieldDef.name, + ); + }, + (rawError) => { + this.handleCompletionError( + rawError, + returnType, + job.fieldDetailsList, + job.path, + fieldTarget, + nullTarget, + ); + }, + nullTarget.path, + ); + } else { + const completionInfo = getInfoForCompletion(); + runner.awaitValue( + result, + (resolved) => { + runner.enqueue({ + kind: 'COMPLETE', + returnType, + fieldDetailsList: job.fieldDetailsList, + info: completionInfo, + path: job.path, + result: resolved, + target: fieldTarget, + nullTarget, + deliveryGroupMap: job.deliveryGroupMap, + }); + }, + (rawError) => { + this.handleCompletionError( + rawError, + returnType, + job.fieldDetailsList, + job.path, + fieldTarget, + nullTarget, + ); + }, + nullTarget.path, + ); + } + return; + } + + if ( + isObjectType(nullableReturnType) && + nullableReturnType.isTypeOf === undefined + ) { + this.completeObjectFieldWithoutIsTypeOf( + returnType, + nullableReturnType, + completedNonNull, + result, + job.fieldDetailsList, + job.path, + fieldTarget, + nullTarget, + job.deliveryGroupMap, + runner, + job.parentType, + fieldDef.name, + ); + return; + } + + runner.enqueue({ + kind: 'COMPLETE', + returnType, + fieldDetailsList: job.fieldDetailsList, + info: getInfoForCompletion(), + path: job.path, + result, + target: fieldTarget, + nullTarget, + deliveryGroupMap: job.deliveryGroupMap, + }); + } + + completeObjectFieldWithoutIsTypeOf( + returnType: GraphQLOutputType, + objectType: GraphQLObjectType, + completedNonNull: boolean, + result: unknown, + fieldDetailsList: FieldDetailsList, + path: Path, + fieldTarget: CompletionTarget, + nullTarget: CompletionTarget, + deliveryGroupMap: IncrementalPositionContext, + runner: WorkQueueExecutionRunner, + parentType: GraphQLObjectType, + fieldName: string, + ): void { + if (result instanceof Error) { + this.handleCompletionError( + result, + returnType, + fieldDetailsList, + path, + fieldTarget, + nullTarget, + ); + return; + } + + if (result == null) { + if (completedNonNull && this.validatedExecutionArgs.errorPropagation) { + this.handleCompletionError( + new Error( + `Cannot return null for non-nullable field ${parentType}.${fieldName}.`, + ), + returnType, + fieldDetailsList, + path, + fieldTarget, + nullTarget, + ); + } else { + setTarget(fieldTarget, null); + } + return; + } + + const object = Object.create(null); + setTarget(fieldTarget, object); + const { groupedFieldSet, newDeferUsages } = + this.validatedExecutionArgs.fieldCollectors.collectSubfields( + this.validatedExecutionArgs.variableValues, + objectType, + fieldDetailsList, + ); + runner.enqueue({ + kind: 'FIELD_SET', + parentType: objectType, + source: result, + path, + groupedFieldSet, + target: object, + parentNullTarget: nullTarget, + serially: false, + newDeferUsages, + deliveryGroupMap, + }); + } + + processComplete(job: CompleteJob, runner: WorkQueueExecutionRunner): void { + if (this.aborted) { + throw new Error('Aborted!'); + } + + let returnType = job.returnType; + let completedNonNull = false; + while (isNonNullType(returnType)) { + completedNonNull = true; + returnType = returnType.ofType; + } + + const result = job.result; + if (isPromiseLike(result)) { + runner.awaitValue( + result, + (resolved) => { + runner.enqueue({ + kind: 'COMPLETE', + returnType: job.returnType, + fieldDetailsList: job.fieldDetailsList, + info: job.info, + path: job.path, + result: resolved, + target: job.target, + nullTarget: job.nullTarget, + deliveryGroupMap: job.deliveryGroupMap, + }); + }, + (rawError) => { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + }, + job.nullTarget.path, + ); + return; + } + + if (result instanceof Error) { + this.handleCompletionError( + result, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + + if (result == null) { + if (completedNonNull && this.validatedExecutionArgs.errorPropagation) { + this.handleCompletionError( + new Error( + `Cannot return null for non-nullable field ${job.info.parentType}.${job.info.fieldName}.`, + ), + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + } else { + setTarget(job.target, null); + } + return; + } + + if (isLeafType(returnType)) { + this.completeLeafInto(returnType, result, job); + return; + } + + if (isListType(returnType)) { + this.completeListInto(returnType, result, job, runner); + return; + } + + if (isAbstractType(returnType)) { + this.completeAbstractInto(returnType, result, job, runner); + return; + } + this.completeObjectInto(returnType, result, job, runner); + } + + completeLeafInto( + returnType: GraphQLLeafType, + result: unknown, + job: CompleteJob, + ): void { + try { + const coerced = returnType.coerceOutputValue(result); + if (coerced == null) { + throw new Error( + `Expected \`${inspect(returnType)}.coerceOutputValue(${inspect(result)})\` to ` + + `return non-nullable value, returned: ${inspect(coerced)}`, + ); + } + setTarget(job.target, coerced); + } catch (rawError) { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + } + } + + completeObjectInto( + returnType: GraphQLObjectType, + result: unknown, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + ): void { + if (returnType.isTypeOf) { + let isTypeOf: unknown; + try { + isTypeOf = returnType.isTypeOf( + result, + this.validatedExecutionArgs.contextValue, + job.info, + ); + } catch (rawError) { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + + if (isPromiseLike(isTypeOf)) { + runner.awaitValue( + isTypeOf, + (resolvedIsTypeOf) => { + if (resolvedIsTypeOf !== true) { + this.handleCompletionError( + this.invalidReturnTypeError( + returnType, + result, + job.fieldDetailsList, + ), + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + this.enqueueObjectSubfields(returnType, result, job, runner); + }, + (rawError) => { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + }, + job.nullTarget.path, + ); + return; + } + + if (isTypeOf !== true) { + this.handleCompletionError( + this.invalidReturnTypeError(returnType, result, job.fieldDetailsList), + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + } + + this.enqueueObjectSubfields(returnType, result, job, runner); + } + + enqueueObjectSubfields( + returnType: GraphQLObjectType, + result: unknown, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + ): void { + const object = Object.create(null); + setTarget(job.target, object); + const { groupedFieldSet, newDeferUsages } = + this.validatedExecutionArgs.fieldCollectors.collectSubfields( + this.validatedExecutionArgs.variableValues, + returnType, + job.fieldDetailsList, + ); + runner.enqueue({ + kind: 'FIELD_SET', + parentType: returnType, + source: result, + path: job.path, + groupedFieldSet, + target: object, + parentNullTarget: job.nullTarget, + serially: false, + newDeferUsages, + deliveryGroupMap: job.deliveryGroupMap, + }); + } + + completeAbstractInto( + returnType: GraphQLAbstractType, + result: unknown, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + ): void { + const resolveTypeFn = + returnType.resolveType ?? this.validatedExecutionArgs.typeResolver; + let runtimeTypeName: unknown; + try { + runtimeTypeName = resolveTypeFn( + result, + this.validatedExecutionArgs.contextValue, + job.info, + returnType, + ); + } catch (rawError) { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + + if (isPromiseLike(runtimeTypeName)) { + runner.awaitValue( + runtimeTypeName, + (resolvedRuntimeTypeName) => { + if (this.aborted) { + throw new Error('Aborted!'); + } + let runtimeType: GraphQLObjectType; + try { + runtimeType = this.ensureValidRuntimeType( + resolvedRuntimeTypeName, + returnType, + job.fieldDetailsList, + job.info, + result, + ); + } catch (rawError) { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + this.completeObjectInto(runtimeType, result, job, runner); + }, + (rawError) => { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + }, + job.nullTarget.path, + ); + return; + } + + let runtimeType: GraphQLObjectType; + try { + runtimeType = this.ensureValidRuntimeType( + runtimeTypeName, + returnType, + job.fieldDetailsList, + job.info, + result, + ); + } catch (rawError) { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + this.completeObjectInto(runtimeType, result, job, runner); + } + + completeListInto( + returnType: GraphQLList, + result: unknown, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + ): void { + const itemType = returnType.ofType; + const completedResults: Array = []; + setTarget(job.target, completedResults); + let streamUsage: StreamUsage | undefined; + try { + streamUsage = + typeof job.path.key === 'number' + ? undefined + : this.getStreamUsage(job.fieldDetailsList); + } catch (rawError) { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + + if (isAsyncIterable(result)) { + if (streamUsage === undefined) { + runner.awaitValue( + this.completeAsyncListItems( + itemType, + result, + completedResults, + job, + runner, + ), + ignoreCompletionValue, + (rawError) => { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + }, + job.nullTarget.path, + ); + return; + } + + runner.awaitValue( + this.readAsyncListInitial( + result, + streamUsage, + job.path, + job.fieldDetailsList, + ), + (read) => { + this.completeListItems( + itemType, + read.values, + completedResults, + job, + runner, + 0, + ); + if (!read.done && streamUsage !== undefined) { + this.handleStream( + read.nextIndex, + job.path, + { handle: read.iterator, isAsync: true }, + streamUsage, + job.info, + itemType, + ); + } + }, + (rawError) => { + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + }, + undefined, + ); + return; + } + + if (streamUsage === undefined && Array.isArray(result)) { + this.completeListItems( + itemType, + result, + completedResults, + job, + runner, + 0, + ); + return; + } + + if (!isIterableObject(result)) { + this.handleCompletionError( + new GraphQLErrorClass( + `Expected Iterable, but did not find one for field "${job.info.parentType}.${job.info.fieldName}".`, + ), + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + + const iterator = result[Symbol.iterator](); + const values: Array = []; + let index = 0; + try { + while (true) { + if ( + streamUsage?.initialCount === index && + this.handleStream( + index, + job.path, + { handle: iterator }, + streamUsage, + job.info, + itemType, + ) + ) { + break; + } + + const iteration = iterator.next(); + if (iteration.done) { + break; + } + values.push(iteration.value); + index++; + } + } catch (rawError) { + this.sharedExecutionContext.asyncWorkTracker.addValues( + collectIteratorPromises(iterator), + ); + this.handleCompletionError( + rawError, + job.returnType, + job.fieldDetailsList, + job.path, + job.target, + job.nullTarget, + ); + return; + } + + this.completeListItems(itemType, values, completedResults, job, runner, 0); + } + + async completeAsyncListItems( + itemType: GraphQLOutputType, + items: AsyncIterable, + completedResults: Array, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + ): Promise { + const iterator = items[Symbol.asyncIterator](); + let iteration: IteratorResult | undefined; + let index = 0; + + try { + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + iteration = await iterator.next(); + } catch (rawError) { + throw locatedError( + rawError, + toNodes(job.fieldDetailsList), + pathToArray(job.path), + ); + } + + if (this.aborted || iteration.done) { + break; + } + + this.completeListItems( + itemType, + [iteration.value], + completedResults, + job, + runner, + index, + ); + runner.drain(); + + if (this.collectedErrors.hasNulledPosition(job.path)) { + this.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + return; + } + + index++; + } + } catch (error) { + this.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + throw error; + } + + if (this.aborted) { + if (iteration?.done !== true) { + this.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + } + throw new Error('Aborted!'); + } + } + + completeListItems( + itemType: GraphQLOutputType, + values: ReadonlyArray, + completedResults: Array, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + offset: number, + ): void { + const leafInfo = getLeafCompletionInfo(itemType); + if (leafInfo !== undefined) { + this.completeLeafListItems( + itemType, + leafInfo.leafType, + leafInfo.completedNonNull, + values, + completedResults, + job, + runner, + offset, + ); + return; + } + + const end = offset + values.length; + if (completedResults.length < end) { + completedResults.length = end; + } + for (let i = values.length - 1; i >= 0; i--) { + const index = offset + i; + const itemPath = addPath(job.path, index, undefined); + const itemTarget: CompletionTarget = { + container: completedResults, + key: index, + path: itemPath, + }; + runner.enqueue({ + kind: 'COMPLETE', + returnType: itemType, + fieldDetailsList: job.fieldDetailsList, + info: job.info, + path: itemPath, + result: values[i], + target: itemTarget, + nullTarget: this.getNullableTarget( + itemType, + itemTarget, + job.nullTarget, + ), + deliveryGroupMap: job.deliveryGroupMap, + }); + } + } + + completeLeafListItems( + itemType: GraphQLOutputType, + leafType: GraphQLLeafType, + completedNonNull: boolean, + values: ReadonlyArray, + completedResults: Array, + job: CompleteJob, + runner: WorkQueueExecutionRunner, + offset: number, + ): void { + const end = offset + values.length; + if (completedResults.length < end) { + completedResults.length = end; + } + for (let i = 0; i < values.length; i++) { + const index = offset + i; + const value = values[i]; + if (isPromiseLike(value)) { + runner.awaitValue( + value, + (resolved) => { + this.completeLeafListItemAtIndex( + itemType, + leafType, + completedNonNull, + resolved, + job, + completedResults, + index, + ); + }, + (rawError) => { + this.handleLeafListItemError( + rawError, + itemType, + job, + completedResults, + index, + ); + }, + undefined, + ); + } else { + this.completeLeafListItemAtIndex( + itemType, + leafType, + completedNonNull, + value, + job, + completedResults, + index, + ); + } + } + } + + completeLeafListItemAtIndex( + itemType: GraphQLOutputType, + leafType: GraphQLLeafType, + completedNonNull: boolean, + result: unknown, + job: CompleteJob, + completedResults: Array, + index: number, + ): void { + if (result instanceof Error) { + this.handleLeafListItemError( + result, + itemType, + job, + completedResults, + index, + ); + return; + } + + if (result == null) { + const path = addPath(job.path, index, undefined); + const target: CompletionTarget = { + container: completedResults, + key: index, + path, + }; + const nullTarget = this.getNullableTarget( + itemType, + target, + job.nullTarget, + ); + if (completedNonNull && this.validatedExecutionArgs.errorPropagation) { + this.handleCompletionError( + new Error( + `Cannot return null for non-nullable field ${job.info.parentType}.${job.info.fieldName}.`, + ), + itemType, + job.fieldDetailsList, + path, + target, + nullTarget, + ); + } else { + setTarget(target, null); + } + return; + } + try { + const coerced = leafType.coerceOutputValue(result); + if (coerced == null) { + throw new Error( + `Expected \`${inspect(leafType)}.coerceOutputValue(${inspect(result)})\` to ` + + `return non-nullable value, returned: ${inspect(coerced)}`, + ); + } + completedResults[index] = coerced; + } catch (rawError) { + this.handleLeafListItemError( + rawError, + itemType, + job, + completedResults, + index, + ); + } + } + + handleLeafListItemError( + rawError: unknown, + itemType: GraphQLOutputType, + job: CompleteJob, + completedResults: Array, + index: number, + ): void { + const path = addPath(job.path, index, undefined); + const target: CompletionTarget = { + container: completedResults, + key: index, + path, + }; + this.handleCompletionError( + rawError, + itemType, + job.fieldDetailsList, + path, + target, + this.getNullableTarget(itemType, target, job.nullTarget), + ); + } + + async readAsyncListInitial( + items: AsyncIterable, + streamUsage: StreamUsage | undefined, + path: Path, + fieldDetailsList: FieldDetailsList, + ): Promise { + const values: Array = []; + const iterator = items[Symbol.asyncIterator](); + let index = 0; + const maxInitialCount = streamUsage?.initialCount; + try { + // eslint-disable-next-line no-unmodified-loop-condition + while (maxInitialCount === undefined || index < maxInitialCount) { + // eslint-disable-next-line no-await-in-loop + const iteration = await iterator.next(); + if (this.aborted || iteration.done) { + return { + values, + iterator, + nextIndex: index, + done: true, + }; + } + values.push(iteration.value); + index++; + } + } catch (rawError) { + throw locatedError( + rawError, + toNodes(fieldDetailsList), + pathToArray(path), + ); + } + return { + values, + iterator, + nextIndex: index, + done: false, + }; + } + + handleStream( + index: number, + path: Path, + iterator: StreamIteratorHandle, + streamUsage: StreamUsage, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + completeItem?: StreamItemCompleter, + ): boolean { + if (this.mode === 'ignore') { + return false; + } + if (this.mode === 'throw') { + this.throwUnexpectedIncremental(); + } + + const queue = this.buildStreamItemQueue( + index, + path, + iterator, + streamUsage.fieldDetailsList, + info, + itemType, + completeItem, + ); + this.streams.push({ + label: streamUsage.label, + path, + queue, + initialCount: index, + }); + return true; + } + + buildStreamItemQueue( + initialIndex: number, + streamPath: Path, + iterator: StreamIteratorHandle, + fieldDetailsList: FieldDetailsList, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + completeItem?: StreamItemCompleter, + ): Queue { + const { enableEarlyExecution } = this.validatedExecutionArgs; + const sharedExecutionContext = this.sharedExecutionContext; + return new Queue( + async ({ push, stop, onStop, started }) => { + const abortStreamItems = new Set<(reason?: unknown) => void>(); + let finishedNormally = false; + let stopRequested = false; + + onStop((reason) => { + stopRequested = true; + if (!finishedNormally) { + for (const abortStreamItem of abortStreamItems) { + abortStreamItem(reason); + } + if (iterator.isAsync === true) { + sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator.handle), + ); + } else { + sharedExecutionContext.asyncWorkTracker.addValues( + collectIteratorPromises(iterator.handle), + ); + } + } + }); + + await (enableEarlyExecution ? Promise.resolve() : started); + if (stopRequested) { + return; + } + + let index = initialIndex; + while (true) { + let iteration; + try { + if (iterator.isAsync === true) { + // eslint-disable-next-line no-await-in-loop + iteration = await iterator.handle.next(); + if (stopRequested) { + return; + } + } else { + iteration = iterator.handle.next(); + } + } catch (rawError) { + throw locatedError( + rawError, + toNodes(fieldDetailsList), + pathToArray(streamPath), + ); + } + + if (iteration.done) { + finishedNormally = true; + // eslint-disable-next-line no-void + void stop(); + return; + } + + const itemPath = addPath(streamPath, index, undefined); + const executor = this.createSubExecutor(); + let streamItemResult = + completeItem === undefined + ? executor.completeStreamItem( + itemPath, + iteration.value, + fieldDetailsList, + info, + itemType, + ) + : completeItem(executor, itemPath, iteration.value, index); + if (isPromise(streamItemResult)) { + if (enableEarlyExecution) { + const abortStreamItem = (reason?: unknown) => + executor.abort(reason); + abortStreamItems.add(abortStreamItem); + streamItemResult = streamItemResult.finally(() => { + abortStreamItems.delete(abortStreamItem); + }); + } else { + // eslint-disable-next-line no-await-in-loop + streamItemResult = await streamItemResult; + if (stopRequested) { + return; + } + } + } + + const pushResult = push(streamItemResult); + if (isPromise(pushResult)) { + // eslint-disable-next-line no-await-in-loop + await pushResult; + if (stopRequested) { + return; + } + } + index++; + } + }, + 100, + ); + } + + completeStreamItem( + itemPath: Path, + item: unknown, + fieldDetailsList: FieldDetailsList, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, + ): PromiseOrValue { + const rootBox: RootBox = { data: undefined }; + const runner = new WorkQueueExecutionRunner(this); + const target: CompletionTarget = { + container: rootBox, + key: 'data', + path: itemPath, + }; + runner.enqueue({ + kind: 'COMPLETE', + returnType: itemType, + fieldDetailsList, + info, + path: itemPath, + result: item, + target, + nullTarget: this.getNullableTarget(itemType, target, target), + deliveryGroupMap: undefined, + }); + const completed = runner.runUntilNulled(itemPath); + if (isPromise(completed)) { + return completed.then(() => + this.buildStreamItemResult(rootBox.data, itemPath, itemType), + ); + } + return this.buildStreamItemResult(rootBox.data, itemPath, itemType); + } + + buildStreamItemResult( + result: unknown, + itemPath: Path, + itemType: GraphQLOutputType, + ): StreamItemResult { + if ( + this.validatedExecutionArgs.errorPropagation && + isNonNullType(itemType) && + this.collectedErrors.hasNulledPosition(itemPath) + ) { + throw this.collectedErrors.firstError(); + } + const errors = this.collectedErrors.errors; + const work = this.getIncrementalWork(); + return this.finish( + errors.length > 0 + ? { value: { item: result, errors }, work } + : { value: { item: result }, work }, + ); + } + + executeExecutionGroup( + deliveryGroups: ReadonlyArray, + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + groupedFieldSet: GroupedFieldSet, + deliveryGroupMap: ReadonlyMap, + ): PromiseOrValue { + const data = Object.create(null); + const runner = new WorkQueueExecutionRunner(this); + runner.enqueue({ + kind: 'FIELD_SET', + parentType, + source: sourceValue, + path, + groupedFieldSet, + target: data, + parentNullTarget: { container: { data }, key: 'data', path }, + serially: false, + newDeferUsages: [], + deliveryGroupMap, + }); + const completed = runner.runUntilNulled(path); + if (isPromise(completed)) { + return completed.then(() => + this.buildExecutionGroupResult(deliveryGroups, path, data), + ); + } + return this.buildExecutionGroupResult(deliveryGroups, path, data); + } + + buildExecutionGroupResult( + deliveryGroups: ReadonlyArray, + path: Path | undefined, + data: ObjMap, + ): ExecutionGroupResult { + if (this.collectedErrors.hasNulledPosition(path)) { + throw this.collectedErrors.firstError(); + } + const errors = this.collectedErrors.errors; + return this.finish({ + value: errors.length + ? { deliveryGroups, path: pathToArray(path), errors, data } + : { deliveryGroups, path: pathToArray(path), data }, + work: this.getIncrementalWork(), + }); + } + + executePreplannedExecutionGroup( + deliveryGroups: ReadonlyArray, + path: Path | undefined, + sourceValue: unknown, + deliveryGroupMap: ReadonlyMap, + executeFields: PreplannedExecutionGroupExecutor, + ): PromiseOrValue { + const data = Object.create(null); + const rootBox: RootBox | null> = { data }; + const runner = new CompiledExecutionRunner(this); + executeFields( + this, + runner, + sourceValue, + data, + { + container: rootBox, + key: 'data', + path, + }, + deliveryGroupMap, + ); + const completed = runner.runUntilNulled(path); + if (isPromise(completed)) { + return completed.then(() => + this.buildExecutionGroupResult(deliveryGroups, path, data), + ); + } + return this.buildExecutionGroupResult(deliveryGroups, path, data); + } + + deferPreplannedExecutionGroup( + deferUsageSet: DeferUsageSet, + deliveryGroupMap: ReadonlyMap, + path: Path | undefined, + sourceValue: unknown, + executeFields: PreplannedExecutionGroupExecutor, + ): void { + const deliveryGroups = getDeliveryGroups(deferUsageSet, deliveryGroupMap); + const executor = this.createSubExecutor(deferUsageSet); + const executionGroup: ExecutionGroup = { + groups: deliveryGroups, + path, + computation: new Computation( + () => + executor.executePreplannedExecutionGroup( + deliveryGroups, + path, + sourceValue, + deliveryGroupMap, + executeFields, + ), + (reason) => executor.abort(reason), + ), + }; + + if (this.validatedExecutionArgs.enableEarlyExecution) { + if (this.shouldDefer(this.deferUsageSet, deferUsageSet)) { + this.sharedExecutionContext.asyncWorkTracker.add( + Promise.resolve().then(() => executionGroup.computation.prime()), + ); + } else { + executionGroup.computation.prime(); + } + } + this.tasks.push(executionGroup); + } + + collectExecutionGroups( + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + newGroupedFieldSets: Map, + deliveryGroupMap: ReadonlyMap, + ): void { + for (const [deferUsageSet, groupedFieldSet] of newGroupedFieldSets) { + const deliveryGroups = getDeliveryGroups(deferUsageSet, deliveryGroupMap); + const executor = this.createSubExecutor(deferUsageSet); + const executionGroup: ExecutionGroup = { + groups: deliveryGroups, + path, + computation: new Computation( + () => + executor.executeExecutionGroup( + deliveryGroups, + parentType, + sourceValue, + path, + groupedFieldSet, + deliveryGroupMap, + ), + (reason) => executor.abort(reason), + ), + }; + + if (this.validatedExecutionArgs.enableEarlyExecution) { + if (this.shouldDefer(this.deferUsageSet, deferUsageSet)) { + this.sharedExecutionContext.asyncWorkTracker.add( + Promise.resolve().then(() => executionGroup.computation.prime()), + ); + } else { + executionGroup.computation.prime(); + } + } + this.tasks.push(executionGroup); + } + } + + createSubExecutor(deferUsageSet?: DeferUsageSet): CompiledExecutor { + return new CompiledExecutor( + this.validatedExecutionArgs, + this.mode, + this.sharedExecutionContext, + deferUsageSet, + ); + } + + getNewDeliveryGroupMap( + newDeferUsages: ReadonlyArray, + deliveryGroupMap: ReadonlyMap | undefined, + path: Path | undefined, + ): { + newDeliveryGroups: ReadonlyArray; + newDeliveryGroupMap: ReadonlyMap; + } { + const newDeliveryGroups: Array = []; + const newDeliveryGroupMap = new Map(deliveryGroupMap); + for (const newDeferUsage of newDeferUsages) { + const parentDeferUsage = newDeferUsage.parentDeferUsage; + const parent = + parentDeferUsage === undefined + ? undefined + : getDeliveryGroup(parentDeferUsage, newDeliveryGroupMap); + const deliveryGroup: DeliveryGroup = { + path, + label: newDeferUsage.label, + parent, + }; + newDeliveryGroups.push(deliveryGroup); + newDeliveryGroupMap.set(newDeferUsage, deliveryGroup); + } + return { newDeliveryGroups, newDeliveryGroupMap }; + } + + shouldDefer( + parentDeferUsages: undefined | DeferUsageSet, + deferUsages: DeferUsageSet, + ): boolean { + return ( + parentDeferUsages === undefined || + !Array.from(deferUsages).every((deferUsage) => + parentDeferUsages.has(deferUsage), + ) + ); + } + + isCurrentDeferUsageSet(deferUsageSet: DeferUsageSet): boolean { + const currentDeferUsageSet = this.deferUsageSet; + if (currentDeferUsageSet?.size !== deferUsageSet.size) { + return false; + } + for (const deferUsage of deferUsageSet) { + if (!currentDeferUsageSet.has(deferUsage)) { + return false; + } + } + return true; + } + + getStreamUsage(fieldDetailsList: FieldDetailsList): StreamUsage | undefined { + const { operation, variableValues } = this.validatedExecutionArgs; + const compiledFieldPlan = requireCompiledFieldPlan(fieldDetailsList); + const compiledStreamDirective = compiledFieldPlan.compiledStreamDirective; + if (compiledStreamDirective === null) { + return; + } + + const stream = getCompiledDirectiveValues( + compiledStreamDirective, + variableValues, + ); + + if (!stream || stream.if === false) { + return; + } + + invariant( + typeof stream.initialCount === 'number', + 'initialCount must be a number', + ); + invariant( + stream.initialCount >= 0, + 'initialCount must be a positive integer', + ); + invariant( + operation.operation !== OperationTypeNode.SUBSCRIPTION, + '`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.', + ); + + return { + initialCount: stream.initialCount, + label: typeof stream.label === 'string' ? stream.label : undefined, + fieldDetailsList: fieldDetailsList.map((fieldDetails) => ({ + ...fieldDetails, + deferUsage: undefined, + })), + }; + } + + executeSerialFields( + fieldSetJob: FieldSetJob, + entries: ReadonlyArray< + [string, FieldDetailsList, CompiledFieldExecutionPlan] + >, + deliveryGroupMap: IncrementalPositionContext, + runner: WorkQueueExecutionRunner, + ): void { + let index = 0; + const runNext = () => { + if (index >= entries.length) { + return; + } + const [responseName, fieldDetailsList, plan] = entries[index++]; + runner.runWhenDrained(runNext); + runner.enqueue({ + kind: 'FIELD', + parentType: fieldSetJob.parentType, + source: fieldSetJob.source, + responseName, + fieldDetailsList, + plan, + path: addPath( + fieldSetJob.path, + responseName, + fieldSetJob.parentType.name, + ), + target: fieldSetJob.target, + parentNullTarget: fieldSetJob.parentNullTarget, + deliveryGroupMap, + }); + }; + runNext(); + } + + protected abortResolverSignal( + reason: unknown = resolverAbortWithoutReason, + ): void { + this._resolverAbortFinished = true; + this._resolverAbortReason = reason; + if (reason === resolverAbortWithoutReason) { + this.resolverAbortController?.abort(); + } else { + this.resolverAbortController?.abort(reason); + } + } + + private getResolverAbortSignal(): AbortSignal { + const resolverAbortController = (this.resolverAbortController ??= + new AbortController()); + if ( + this._resolverAbortFinished && + !resolverAbortController.signal.aborted + ) { + if (this._resolverAbortReason === resolverAbortWithoutReason) { + resolverAbortController.abort(); + } else { + resolverAbortController.abort(this._resolverAbortReason); + } + } + return resolverAbortController.signal; + } +} + +/** @internal */ +class WorkQueueExecutionRunner { + _settled: boolean; + private _executor: CompiledExecutor; + private _jobs: Array | undefined; + private _pending: number; + private _resolve: (() => void) | undefined; + private _reject: ((reason: unknown) => void) | undefined; + private _onDrained: (() => void) | undefined; + + constructor(executor: CompiledExecutor) { + this._executor = executor; + this._pending = 0; + this._settled = false; + } + + enqueue(job: Job): void { + (this._jobs ??= []).push(job); + } + + drain(): void { + this._drain(); + } + + runWhenDrained(callback: () => void): void { + this._onDrained = callback; + } + + awaitValue( + promise: PromiseLike, + onResolve: (value: T) => void, + onReject: (reason: unknown) => void, + _boundary: Path | undefined, + ): void { + this._pending++; + try { + promise.then( + (value) => this._completeResolved(value, onResolve), + (reason: unknown) => this._completeRejected(reason, onReject), + ); + } catch (error) { + this._pending--; + onReject(error); + } + } + + runUntilNulled(_path: Path | undefined): PromiseOrValue { + this._drain(); + if (this._pending === 0) { + return; + } + return new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + private _drain(): void { + try { + while (true) { + const jobs = this._jobs; + let job: Job | undefined; + if (jobs !== undefined) { + while ((job = jobs.pop()) !== undefined) { + switch (job.kind) { + case 'FIELD_SET': + this._executor.processFieldSet(job, this); + break; + case 'FIELD': + this._executor.processField(job, this); + break; + case 'COMPLETE': + this._executor.processComplete(job, this); + break; + } + } + } + if (this._pending > 0) { + return; + } + const onDrained = this._onDrained; + if (onDrained !== undefined) { + this._onDrained = undefined; + onDrained(); + continue; + } + this._executor.applyNulledTargets(); + if (this._resolve !== undefined) { + this._settled = true; + this._resolve(); + } + return; + } + } catch (error) { + this._fail(error); + } + } + + private _fail(error: unknown): void { + this._settled = true; + if (this._reject !== undefined) { + this._reject(error); + return; + } + throw error; + } + + private _completeResolved(value: T, onResolve: (value: T) => void): void { + if (this._settled) { + this._pending--; + return; + } + try { + onResolve(value); + this._pending--; + this._drainIfReadyOrQueued(); + } catch (error) { + this._pending--; + this._fail(error); + } + } + + private _completeRejected( + reason: unknown, + onReject: (reason: unknown) => void, + ): void { + if (this._settled) { + this._pending--; + return; + } + onReject(reason); + this._pending--; + this._drainIfReadyOrQueued(); + } + + private _drainIfReadyOrQueued(): void { + if ( + this._pending === 0 || + this._onDrained !== undefined || + (this._jobs !== undefined && this._jobs.length > 0) + ) { + this._drain(); + } + } +} + +/** @internal */ +export class CompiledExecutionRunner { + _settled: boolean; + private _executor: CompiledExecutor; + private _pending: number; + private _resolve: (() => void) | undefined; + private _reject: ((reason: unknown) => void) | undefined; + private _onDrained: (() => void) | undefined; + + constructor(executor: CompiledExecutor) { + this._executor = executor; + this._pending = 0; + this._settled = false; + } + + drain(): void { + this._drain(); + } + + runWhenDrained(callback: () => void): void { + this._onDrained = callback; + } + + runUntilNulled(_path: Path | undefined): PromiseOrValue { + this._drain(); + if (this._pending === 0) { + return; + } + return new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + _drainIfReady(): void { + if (this._pending === 0 || this._onDrained !== undefined) { + this._drain(); + } + } + + private _drain(): void { + try { + while (this._pending === 0) { + const onDrained = this._onDrained; + if (onDrained !== undefined) { + this._onDrained = undefined; + onDrained(); + continue; + } + this._executor.applyNulledTargets(); + if (this._resolve !== undefined) { + this._settled = true; + this._resolve(); + } + return; + } + } catch (error) { + this._fail(error); + } + } + + private _fail(error: unknown): void { + this._settled = true; + if (this._reject !== undefined) { + this._reject(error); + return; + } + throw error; + } +} + +class CollectedErrors { + private _errorPositions: Set | undefined; + private _errors: Array | undefined; + private _nulledTargets: Array | undefined; + + get errors(): ReadonlyArray { + return this._errors ?? emptyCollectedErrors; + } + + firstError(): GraphQLError { + const firstError = this._errors?.[0]; + invariant(firstError !== undefined); + return firstError; + } + + add( + error: GraphQLError, + path: Path | undefined, + target?: CompletionTarget, + ): void { + if (this.hasNulledPosition(path)) { + return; + } + (this._errorPositions ??= new Set()).add(path); + if (target !== undefined) { + (this._nulledTargets ??= []).push(target); + } + (this._errors ??= []).push(error); + } + + applyNulledTargets(): void { + const nulledTargets = this._nulledTargets; + if (nulledTargets === undefined) { + return; + } + for (const target of nulledTargets) { + setTarget(target, null); + } + nulledTargets.length = 0; + } + + hasNulledPosition(startPath: Path | undefined): boolean { + const errorPositions = this._errorPositions; + if (errorPositions === undefined) { + return false; + } + let path = startPath; + while (path !== undefined) { + if (errorPositions.has(path)) { + return true; + } + path = path.prev; + } + return errorPositions.has(undefined); + } +} + +const emptyCollectedErrors: ReadonlyArray = Object.freeze([]); + +function setTarget(target: CompletionTarget, value: unknown): void { + if (isArrayTarget(target)) { + target.container[target.key] = value; + return; + } + target.container[target.key] = value; +} + +function isArrayTarget( + target: CompletionTarget, +): target is ArrayCompletionTarget { + return Array.isArray(target.container); +} + +const ignoreCompletionValue = () => undefined; + +function getCompiledFieldPlan( + fieldDetailsList: FieldDetailsList, +): CompiledFieldExecutionPlan | undefined { + return fieldDetailsList[0].compiledFieldPlan; +} + +function requireCompiledFieldPlan( + fieldDetailsList: FieldDetailsList, +): CompiledFieldExecutionPlan { + const compiledFieldPlan = getCompiledFieldPlan(fieldDetailsList); + invariant(compiledFieldPlan !== undefined); + return compiledFieldPlan; +} + +function getDeliveryGroups( + deferUsageSet: ReadonlySet, + deliveryGroupMap: ReadonlyMap, +): ReadonlyArray { + const deliveryGroups: Array = []; + for (const deferUsage of deferUsageSet) { + deliveryGroups.push(getDeliveryGroup(deferUsage, deliveryGroupMap)); + } + return deliveryGroups; +} + +function getDeliveryGroup( + deferUsage: DeferUsage, + deliveryGroupMap: ReadonlyMap, +): DeliveryGroup { + const deliveryGroup = deliveryGroupMap.get(deferUsage); + invariant(deliveryGroup !== undefined); + return deliveryGroup; +} + +function getLeafCompletionInfo( + returnType: GraphQLOutputType, +): { leafType: GraphQLLeafType; completedNonNull: boolean } | undefined { + let nullableType = returnType; + let completedNonNull = false; + while (isNonNullType(nullableType)) { + completedNonNull = true; + nullableType = nullableType.ofType; + } + return isLeafType(nullableType) + ? { leafType: nullableType, completedNonNull } + : undefined; +} + +const toNodes = memoize1( + (fieldDetailsList: FieldDetailsList): ReadonlyArray => + fieldDetailsList.map((fieldDetails) => fieldDetails.node), +); diff --git a/src/execution/compile/README.md b/src/execution/compile/README.md new file mode 100644 index 0000000000..c548f8fac2 --- /dev/null +++ b/src/execution/compile/README.md @@ -0,0 +1,182 @@ +# Compiled Execution + +This folder contains the in-memory operation compiler. It turns a static +operation into reusable execution machinery. + +The compiled executor is optimized for repeated execution of the same operation +against the same schema shape. It preserves the GraphQL.js execution semantics, +including null bubbling, error paths, incremental delivery, subscriptions, +abort handling, hooks, and null-prototype result objects. + +## Architecture + +Compilation starts with `compileExecutionState()`. That shared stage validates +and stores the static operation state: schema, document, +operation, fragments, options, root values supplied at compile time, and the +field collector. + +`compileExecution()` and `compileSubscription()` wrap that state in reusable +operation objects. Each operation exposes the same runtime entrypoints as normal +execution: + +- `execute()` +- `experimentalExecuteIncrementally()` +- `executeIgnoringIncremental()` +- subscription-only `subscribe()`, `createSourceEventStream()`, + `mapSourceToResponseEvent()`, and `executeSubscriptionEvent()` + +Runtime execution uses `CompiledExecutor`. The executor owns error collection, +null bubbling, abort state, incremental work, stream queues, and shared +execution context. It uses a queue runner for field sets, field resolution, and +completion work, so asynchronous sibling work can continue while errors and +nulls are applied at the correct boundary. + +## Compilation Stages + +### Variables + +`compileVariableValues()` turns each variable definition into a compiled entry: +signature, default value, default error if any, and a compiled input coercer. + +`compileInputValue()` recursively builds coercers for non-null, list, input +object, scalar, and enum inputs. Recursive input object types use a temporary +lazy coercer while the final coercer is being built. + +`CompiledExecutionImpl` caches variable coercion results by raw +`variableValues` object identity and `maxCoercionErrors`. Reusing the same +variables object across calls can avoid repeated coercion work. + +### Input Literals and Arguments + +`compileInputLiteral()` precompiles argument literals. Static literals are +coerced once when possible. Literals containing variables become small coercer +functions that read the already-coerced runtime variables. + +`compileArgumentValues()` classifies every field argument into one of these +forms: + +- constant value; +- bare variable value; +- embedded variable value; +- invalid literal value; +- missing required argument; +- invalid default value. + +Bare variable values are arguments whose AST value is exactly a variable, such +as `field(arg: $value)`. At execution time the compiled argument reader can +mostly copy from the already-coerced operation variable map while applying the +argument's own default and nullability rules. + +Embedded variable values are argument literals where variables appear inside a +larger literal, such as `field(filter: { id: $id, tags: ["a", $tag] })`. The +compiler stores a coercer for the whole literal so execution can evaluate that +literal against the current variable values and preserve GraphQL's input +coercion semantics. + +Invalid literal values are static literals that could not be coerced during +compilation. They stay distinct from embedded variables so execution can report +the same GraphQL input validation error shape when that path is exercised. + +If all arguments for a field are constant, the compiled plan keeps a reusable +null-prototype argument map. + +### Field Collection + +`compileCollectFields()` precompiles the operation selection tree. It stores +compiled selections, fragment conditions, inclusion directives, defer +directives, stream directives, and per-field compilation caches. + +Root field collection still runs per execution because variable values can +affect inclusion and incremental directives. The expensive structural work is +compiled ahead of time. + +Subfield collection is memoized with the runtime variable values object, +concrete return type, and field details list. Repeated completion of the same +selection set and concrete type can reuse the grouped subfields. + +### Field Execution Plans + +`compileFieldExecutionPlan()` records return-type facts and selects a resolver +path: + +- schema field resolver; +- custom fallback field resolver; +- default field resolver. + +For default-resolved fields, plain source properties are the cheapest path: +when the source property is not a function, the compiled resolver returns the +property without building arguments or `GraphQLResolveInfo`. Source methods are +supported, but they require arguments and `info` because GraphQL's default +resolver calls them as functions. + +### Completion + +`CompiledExecutor` specializes several common completion paths: + +- leaf fields resolve and complete directly without enqueuing a generic field + job when the field plan already knows the leaf type; +- concrete object fields without `isTypeOf` can skip the generic complete job; +- leaf-list items use a dedicated item loop; +- arrays avoid iterator protocol overhead for list completion; +- errors are collected with null targets and applied once pending work drains. + +The compiled executor still uses shared completion methods for the full +GraphQL result coercion algorithm. + +### Incremental Delivery + +The compiled executor uses the same runtime machinery as normal incremental +execution: + +- `buildExecutionPlan()` for deferred grouped field sets; +- `Computation` for deferred execution work; +- `IncrementalPublisher` for initial/subsequent result publishing; +- `Queue` and `WorkQueue` for stream backpressure; +- `AsyncWorkTracker` and shared execution context for resolver cleanup. + +`executeIgnoringIncremental()` runs the operation while ignoring incremental +payload boundaries. `execute()` throws if the operation would unexpectedly +produce multiple payloads. `experimentalExecuteIncrementally()` enables +incremental delivery. + +### Subscriptions + +Compiled subscriptions reuse compiled variable and field machinery. The source +event stream path compiles the root subscription field, builds `info`, calls +the field's `subscribe` resolver or subscribe field resolver, validates that +the result is an async iterable, and maps each event through compiled execution. + +## Fast-Path Guidance + +Compile once and reuse the returned operation object. The compile step is where +operation validation, selection compilation, variable compilation, and field +plan setup happen. + +Keep the schema shape stable. The compiler stores field definitions, type +objects, and resolver choices from the schema it compiled against. + +Use plain source object properties for default-resolved hot fields. A plain +property can skip argument and `GraphQLResolveInfo` allocation. A source method +is correct but slower because it must be called like a resolver. + +Return synchronous values when possible. Promises are fully supported, but they +add runner pending bookkeeping and a microtask boundary. + +Return arrays for list fields when possible. Arrays take the shortest compiled +list path. Other iterables and async iterables are correct but require iterator +handling and cleanup. + +Avoid `isTypeOf` on concrete object types unless the schema needs it. Concrete +object fields without `isTypeOf` take a shorter completion path. + +Prefer stable `variableValues` object identity when repeatedly executing the +same variable set. The compiled operation can reuse the cached coercion result +for that object and error limit. + +Use static or simple variable-backed directives for hot operations. The compiler +supports dynamic directive evaluation, but static facts let more work happen +during compilation. + +Keep custom scalar coercion fast. The compiled executor calls each leaf type's +`coerceOutputValue` for output completion, so scalar implementations are part +of the hot path. diff --git a/src/execution/compile/__tests__/CompiledExecutor-test.ts b/src/execution/compile/__tests__/CompiledExecutor-test.ts new file mode 100644 index 0000000000..46189ae5b6 --- /dev/null +++ b/src/execution/compile/__tests__/CompiledExecutor-test.ts @@ -0,0 +1,1576 @@ +import { describe, it } from 'node:test'; + +import { assert, expect } from 'chai'; + +import { expectJSON } from '../../../__testUtils__/expectJSON.ts'; +import { expectPromise } from '../../../__testUtils__/expectPromise.ts'; +import { resolveOnNextTick } from '../../../__testUtils__/resolveOnNextTick.ts'; + +import type { PromiseOrValue } from '../../../jsutils/PromiseOrValue.ts'; +import { promiseWithResolvers } from '../../../jsutils/promiseWithResolvers.ts'; + +import { parse } from '../../../language/parser.ts'; + +import type { GraphQLResolveInfo } from '../../../type/definition.ts'; +import { + GraphQLInterfaceType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, +} from '../../../type/definition.ts'; +import { GraphQLString } from '../../../type/scalars.ts'; +import { GraphQLSchema } from '../../../type/schema.ts'; + +import type { DeferUsage, FieldDetailsList } from '../../collectFields.ts'; +import type { ValidatedExecutionArgs } from '../../ExecutionArgs.ts'; +import type { ExecutionResult } from '../../Executor.ts'; +import type { + DeliveryGroup, + ExperimentalIncrementalExecutionResults, + InitialIncrementalExecutionResult, + SubsequentIncrementalExecutionResult, +} from '../../incremental/IncrementalExecutor.ts'; + +import { + CompiledExecutionRunner, + CompiledExecutor, +} from '../CompiledExecutor.ts'; +import type { + CompiledExecution, + CompiledExecutionArgs, + CompileExecutionArgs, +} from '../index.ts'; +import { compileExecution } from '../index.ts'; + +describe('compiled executor', () => { + it('runs asyncWorkFinished hooks', () => { + let hookCalls = 0; + const compiled = compileExecution({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + test: { + type: GraphQLString, + resolve: () => 'ok', + }, + }, + }), + }), + document: parse('{ test }'), + hooks: { + asyncWorkFinished() { + hookCalls++; + }, + }, + }); + + assert('execute' in compiled); + expect(compiled.execute()).to.deep.equal({ data: { test: 'ok' } }); + expect(hookCalls).to.equal(1); + }); + + it('removes external abort listener when root execution setup throws', () => { + const abortController = new AbortController(); + const compiled = compileExecution({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + test: { + type: GraphQLString, + resolve: () => 'ok', + }, + }, + }), + }), + document: parse('mutation { test }'), + }); + + assert('execute' in compiled); + const result = compiled.execute({ + abortSignal: abortController.signal, + }); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Schema is not configured to execute mutation operation.', + locations: [{ line: 1, column: 1 }], + }, + ], + }); + }); + + it('aborts pending compiled execution', async () => { + const abortController = new AbortController(); + const { promise, resolve } = promiseWithResolvers(); + const { promise: lateValue, resolve: resolveLateValue } = + promiseWithResolvers(); + const { promise: lateError, reject: rejectLateError } = + promiseWithResolvers(); + let resolverAbortSignal: AbortSignal | undefined; + const testInterface = new GraphQLInterfaceType({ + name: 'AbortTest', + fields: { + value: { type: GraphQLString }, + }, + resolveType: () => 'AbortTestObject', + }); + const compiled = compileExecution({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + test: { + type: testInterface, + resolve(_source, _args, _context, info) { + resolverAbortSignal = info.getAbortSignal(); + expect(resolverAbortSignal).to.be.instanceOf(AbortSignal); + expect(resolverAbortSignal?.aborted).to.equal(false); + return promise; + }, + }, + lateValue: { + type: GraphQLString, + resolve: () => lateValue, + }, + lateError: { + type: GraphQLString, + resolve: () => lateError, + }, + }, + }), + types: [ + new GraphQLObjectType({ + name: 'AbortTestObject', + interfaces: [testInterface], + fields: { + value: { type: GraphQLString }, + }, + }), + ], + }), + document: parse('{ test { value } lateValue lateError }'), + }); + + assert('execute' in compiled); + const result = compiled.execute({ abortSignal: abortController.signal }); + abortController.abort(new Error('Custom abort error')); + + await expectPromise(result).toRejectWith('Custom abort error'); + expect(resolverAbortSignal?.aborted).to.equal(true); + + resolve({ value: 'late' }); + await resolveOnNextTick(); + await resolveOnNextTick(); + resolveLateValue('ignored'); + rejectLateError(new Error('ignored')); + await resolveOnNextTick(); + await resolveOnNextTick(); + }); + + it('aborts if the external signal fires before async root setup finishes', async () => { + const abortController = new AbortController(); + const { promise } = promiseWithResolvers(); + const compiled = compileExecution({ + schema: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + test: { + type: GraphQLString, + resolve() { + abortController.abort(new Error('Setup abort')); + return promise; + }, + }, + }, + }), + }), + document: parse('{ test }'), + }); + + assert('execute' in compiled); + const result = compiled.execute({ abortSignal: abortController.signal }); + + await expectPromise(result).toRejectWith('Setup abort'); + }); + + it('compares defer usage sets across sub executors', () => { + const executor = new CompiledExecutor( + getValidatedExecutionArgs(), + 'incremental', + ); + const parent: DeferUsage = { + label: 'parent', + parentDeferUsage: undefined, + }; + const child: DeferUsage = { + label: 'child', + parentDeferUsage: parent, + }; + + expect(executor.isCurrentDeferUsageSet(new Set([child]))).to.equal(false); + + const deferUsageSet = new Set([child]); + const subExecutor = executor.createSubExecutor(deferUsageSet); + expect(subExecutor.isCurrentDeferUsageSet(deferUsageSet)).to.equal(true); + expect(subExecutor.isCurrentDeferUsageSet(new Set([parent]))).to.equal( + false, + ); + + const other: DeferUsage = { + label: 'other', + parentDeferUsage: undefined, + }; + const otherSubExecutor = executor.createSubExecutor(new Set([other])); + expect(otherSubExecutor.isCurrentDeferUsageSet(deferUsageSet)).to.equal( + false, + ); + expect(otherSubExecutor.isCurrentDeferUsageSet(new Set([other]))).to.equal( + true, + ); + }); + + it('primes preplanned deferred execution groups already in the current defer set', () => { + const child: DeferUsage = { + label: 'child', + parentDeferUsage: undefined, + }; + const deferUsageSet = new Set([child]); + const executor = new CompiledExecutor( + getValidatedExecutionArgs({ enableEarlyExecution: true }), + 'incremental', + undefined, + deferUsageSet, + ); + const deliveryGroupMap = new Map([ + [child, { label: child.label, parent: undefined, path: undefined }], + ]); + let executed = false; + + executor.deferPreplannedExecutionGroup( + deferUsageSet, + deliveryGroupMap, + undefined, + { value: 'source' }, + (_subExecutor, _runner, source, target) => { + executed = true; + expect(source).to.deep.equal({ value: 'source' }); + expect(Object.getPrototypeOf(target)).to.equal(null); + }, + ); + + expect(executed).to.equal(true); + }); + + it('tracks deferred preplanned execution groups as background work', async () => { + const child: DeferUsage = { + label: 'child', + parentDeferUsage: undefined, + }; + const deferUsageSet = new Set([child]); + const executor = new CompiledExecutor( + getValidatedExecutionArgs({ enableEarlyExecution: true }), + 'incremental', + ); + const deliveryGroupMap = new Map([ + [child, { label: child.label, parent: undefined, path: undefined }], + ]); + let executed = false; + + executor.deferPreplannedExecutionGroup( + deferUsageSet, + deliveryGroupMap, + undefined, + { value: 'source' }, + () => { + executed = true; + }, + ); + + expect(executed).to.equal(false); + await executor.sharedExecutionContext.asyncWorkTracker.wait(); + expect(executed).to.equal(true); + }); + + it('aborts deferred preplanned execution groups before they execute', () => { + const child: DeferUsage = { + label: 'child', + parentDeferUsage: undefined, + }; + const deferUsageSet = new Set([child]); + const executor = new CompiledExecutor( + getValidatedExecutionArgs(), + 'incremental', + ); + const deliveryGroupMap = new Map([ + [child, { label: child.label, parent: undefined, path: undefined }], + ]); + + executor.deferPreplannedExecutionGroup( + deferUsageSet, + deliveryGroupMap, + undefined, + { value: 'source' }, + () => { + throw new Error('Should not execute'); + }, + ); + + const aborted = executor.tasks[0].computation.abort( + new Error('Stop deferred group'), + ); + expect(aborted).to.equal(undefined); + }); + + it('aborts pending preplanned execution groups', () => { + const child: DeferUsage = { + label: 'child', + parentDeferUsage: undefined, + }; + const deferUsageSet = new Set([child]); + const executor = new CompiledExecutor( + getValidatedExecutionArgs({ enableEarlyExecution: true }), + 'incremental', + undefined, + deferUsageSet, + ); + const deliveryGroupMap = new Map([ + [child, { label: child.label, parent: undefined, path: undefined }], + ]); + const reason = new Error('Stop pending group'); + let deferredExecutor: CompiledExecutor | undefined; + + executor.deferPreplannedExecutionGroup( + deferUsageSet, + deliveryGroupMap, + undefined, + { value: 'source' }, + (subExecutor, runner) => { + deferredExecutor = subExecutor; + setRunnerPending(runner, 1); + }, + ); + + const aborted = executor.tasks[0].computation.abort(reason); + expect(aborted).to.equal(undefined); + assert(deferredExecutor !== undefined); + expect(deferredExecutor.aborted).to.equal(true); + expect(deferredExecutor.abortReason).to.equal(reason); + }); + + it('executes asynchronous preplanned execution groups', async () => { + const child: DeferUsage = { + label: 'child', + parentDeferUsage: undefined, + }; + const deliveryGroup: DeliveryGroup = { + label: child.label, + parent: undefined, + path: undefined, + }; + const deliveryGroupMap = new Map([[child, deliveryGroup]]); + const executor = new CompiledExecutor( + getValidatedExecutionArgs(), + 'incremental', + ); + + const result = executor.executePreplannedExecutionGroup( + [deliveryGroup], + undefined, + { value: 'source' }, + deliveryGroupMap, + (...executionGroupArgs) => { + const [, runner, source, target, parentNullTarget, groups] = + executionGroupArgs; + expect(source).to.deep.equal({ value: 'source' }); + expect(parentNullTarget.key).to.equal('data'); + assert(groups !== undefined); + expect(groups.get(child)).to.equal(deliveryGroup); + target.value = 'sync'; + setRunnerPending(runner, 1); + Promise.resolve().then(() => { + target.value = 'async'; + setRunnerPending(runner, 0); + runner._drainIfReady(); + }); + }, + ); + + expect(result).to.be.instanceOf(Promise); + const resolved = await result; + expect(resolved.value.deliveryGroups).to.deep.equal([deliveryGroup]); + expect(resolved.value.path).to.deep.equal([]); + expectJSON(resolved.value.data).toDeepEqual({ value: 'async' }); + expect(resolved.work).to.deep.equal({}); + }); + + it('uses custom stream item completers', async () => { + const executor = new CompiledExecutor( + getValidatedExecutionArgs({ enableEarlyExecution: true }), + 'incremental', + ); + const streamUsage = { + label: 'items', + initialCount: 0, + fieldDetailsList: [] as unknown as FieldDetailsList, + }; + + const handled = executor.handleStream( + 0, + { prev: undefined, key: 'values', typename: undefined }, + { handle: ['value'][Symbol.iterator]() }, + streamUsage, + {} as GraphQLResolveInfo, + GraphQLString, + (_subExecutor, _itemPath, item, index) => ({ + value: { item: `${String(item)}:${index}` }, + }), + ); + + expect(handled).to.equal(true); + const results: Array = []; + for await (const batch of executor.streams[0].queue.subscribe((generator) => + Array.from(generator), + )) { + results.push(...batch); + } + expectJSON(results).toDeepEqual([{ value: { item: 'value:0' } }]); + }); + + it('resolves preplanned runners after pending work drains', async () => { + let didDrain = false; + const runner = new CompiledExecutionRunner({ + applyNulledTargets() { + return undefined; + }, + } as unknown as CompiledExecutor); + + setRunnerPending(runner, 1); + const result = runner.runUntilNulled(undefined); + runner.runWhenDrained(() => { + didDrain = true; + }); + runner._drainIfReady(); + expect(didDrain).to.equal(false); + setRunnerPending(runner, 0); + runner._drainIfReady(); + + expect(result).to.be.instanceOf(Promise); + await result; + expect(didDrain).to.equal(true); + expect(runner._settled).to.equal(true); + }); + + it('rejects preplanned runners when pending drain fails', async () => { + const error = new Error('pending drain failed'); + const runner = new CompiledExecutionRunner({ + applyNulledTargets() { + throw error; + }, + } as unknown as CompiledExecutor); + + setRunnerPending(runner, 1); + const result = runner.runUntilNulled(undefined); + setRunnerPending(runner, 0); + runner._drainIfReady(); + + await expectPromise(result).toRejectWith('pending drain failed'); + expect(runner._settled).to.equal(true); + }); + + it('covers execution runner throwing drain paths', () => { + const error = new Error('drain failed'); + const failingRunner = new CompiledExecutionRunner({ + applyNulledTargets() { + throw error; + }, + } as unknown as CompiledExecutor); + expect(() => failingRunner.drain()).to.throw(error); + }); + + it('creates already-aborted resolver signals after resolver abort finishes', () => { + const defaultAbortExecutor = new CompiledExecutor( + getValidatedExecutionArgs(), + 'throw', + ); + callAbortResolverSignal(defaultAbortExecutor); + const defaultAbortSignal = defaultAbortExecutor.getAbortSignal(); + expect(defaultAbortSignal?.aborted).to.equal(true); + expect(defaultAbortSignal?.reason).to.be.instanceOf(Error); + + const customAbortExecutor = new CompiledExecutor( + getValidatedExecutionArgs(), + 'throw', + ); + const reason = new Error('Custom resolver abort'); + callAbortResolverSignal(customAbortExecutor, reason); + const customAbortSignal = customAbortExecutor.getAbortSignal(); + expect(customAbortSignal?.aborted).to.equal(true); + expect(customAbortSignal?.reason).to.equal(reason); + }); + + it('reports scalar coercion errors from compiled leaf paths', async () => { + const scalar = new GraphQLScalarType({ + name: 'CompiledScalar', + coerceOutputValue(value) { + if (value === 'throw') { + throw new Error('Cannot coerce compiled scalar'); + } + if (value === 'null') { + return null; + } + return value; + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + syncThrow: { type: scalar, resolve: () => 'throw' }, + syncNull: { type: scalar, resolve: () => 'null' }, + asyncThrow: { + type: scalar, + resolve: () => Promise.resolve('throw'), + }, + list: { + type: new GraphQLList(scalar), + resolve: () => ['ok', 'throw', 'null'], + }, + }, + }), + }); + + const result = await executeCompiled( + schema, + '{ syncThrow syncNull asyncThrow list }', + ); + + expectJSON(result).toDeepEqual({ + data: { + syncThrow: null, + syncNull: null, + asyncThrow: null, + list: ['ok', null, null], + }, + errors: [ + { + message: 'Cannot coerce compiled scalar', + locations: [{ line: 1, column: 3 }], + path: ['syncThrow'], + }, + { + message: + 'Expected `CompiledScalar.coerceOutputValue("null")` to return non-nullable value, returned: null', + locations: [{ line: 1, column: 13 }], + path: ['syncNull'], + }, + { + message: 'Cannot coerce compiled scalar', + locations: [{ line: 1, column: 33 }], + path: ['list', 1], + }, + { + message: + 'Expected `CompiledScalar.coerceOutputValue("null")` to return non-nullable value, returned: null', + locations: [{ line: 1, column: 33 }], + path: ['list', 2], + }, + { + message: 'Cannot coerce compiled scalar', + locations: [{ line: 1, column: 22 }], + path: ['asyncThrow'], + }, + ], + }); + }); + + it('reports async scalar coercion nulls from compiled leaf paths', async () => { + const scalar = new GraphQLScalarType({ + name: 'AsyncNullScalar', + coerceOutputValue() { + return null; + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + value: { + type: scalar, + resolve: () => Promise.resolve('value'), + }, + }, + }), + }); + + const result = await executeCompiled(schema, '{ value }'); + + expectJSON(result).toDeepEqual({ + data: { value: null }, + errors: [ + { + message: + 'Expected `AsyncNullScalar.coerceOutputValue("value")` to return non-nullable value, returned: null', + locations: [{ line: 1, column: 3 }], + path: ['value'], + }, + ], + }); + }); + + it('completes serial mutation leaf fields through the generic path', async () => { + const scalar = new GraphQLScalarType({ + name: 'SerialNullScalar', + coerceOutputValue() { + return null; + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + noop: { type: GraphQLString }, + }, + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: { + value: { + type: scalar, + resolve: () => 'value', + }, + }, + }), + }); + + const result = await executeCompiled(schema, 'mutation { value }'); + + expectJSON(result).toDeepEqual({ + data: { value: null }, + errors: [ + { + message: + 'Expected `SerialNullScalar.coerceOutputValue("value")` to return non-nullable value, returned: null', + locations: [{ line: 1, column: 12 }], + path: ['value'], + }, + ], + }); + }); + + it('skips duplicate errors below an already nulled list path', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + values: { + type: new GraphQLList(new GraphQLNonNull(GraphQLString)), + resolve: () => [new Error('first'), new Error('second')], + }, + }, + }), + }); + + const result = await executeCompiled(schema, '{ values }'); + + expectJSON(result).toDeepEqual({ + data: { values: null }, + errors: [ + { + message: 'first', + locations: [{ line: 1, column: 3 }], + path: ['values', 0], + }, + ], + }); + }); + + it('uses the compiled default field resolver with function sources', async () => { + function root() { + return undefined; + } + Object.assign(root, { value: 'resolved' }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + value: { type: GraphQLString }, + }, + }), + }); + + const result = await executeCompiled(schema, '{ value }', { + rootValue: root, + }); + + expectJSON(result).toDeepEqual({ + data: { value: 'resolved' }, + }); + }); + + it('uses the compiled default field resolver with non-object sources', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + value: { type: GraphQLString }, + }, + }), + }); + + const result = await executeCompiled(schema, '{ value }', { + rootValue: 'source', + }); + + expectJSON(result).toDeepEqual({ + data: { value: null }, + }); + }); + + it('reports object completion errors from compiled object paths', async () => { + const childType = new GraphQLObjectType({ + name: 'Child', + fields: { + value: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + errorObject: { + type: childType, + resolve: () => new Error('Object resolver result error'), + }, + nullableObject: { + type: childType, + resolve: () => null, + }, + }, + }), + }); + + const result = await executeCompiled( + schema, + '{ errorObject { value } nullableObject { value } }', + ); + + expectJSON(result).toDeepEqual({ + data: { + errorObject: null, + nullableObject: null, + }, + errors: [ + { + message: 'Object resolver result error', + locations: [{ line: 1, column: 3 }], + path: ['errorObject'], + }, + ], + }); + }); + + it('propagates non-null object completion errors', async () => { + const childType = new GraphQLObjectType({ + name: 'RequiredChild', + fields: { + value: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + child: { + type: new GraphQLNonNull(childType), + resolve: () => null, + }, + }, + }), + }); + + const result = await executeCompiled(schema, '{ child { value } }'); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Cannot return null for non-nullable field Query.child.', + locations: [{ line: 1, column: 3 }], + path: ['child'], + }, + ], + }); + }); + + it('reports isTypeOf completion errors', async () => { + const checkedType = new GraphQLObjectType({ + name: 'Checked', + fields: { + value: { type: GraphQLString }, + }, + isTypeOf(value: { kind?: string }) { + if (value.kind === 'throw') { + throw new Error('isTypeOf threw'); + } + if (value.kind === 'reject') { + return Promise.reject(new Error('isTypeOf rejected')); + } + return value.kind !== 'wrong'; + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + threw: { + type: checkedType, + resolve: () => ({ kind: 'throw', value: 'THREW' }), + }, + rejected: { + type: checkedType, + resolve: () => ({ kind: 'reject', value: 'REJECTED' }), + }, + wrong: { + type: checkedType, + resolve: () => ({ kind: 'wrong', value: 'WRONG' }), + }, + passed: { + type: checkedType, + resolve: () => ({ kind: 'right', value: 'PASSED' }), + }, + }, + }), + }); + + const result = await executeCompiled( + schema, + '{ threw { value } rejected { value } wrong { value } passed { value } }', + ); + + expectJSON(result).toDeepEqual({ + data: { + threw: null, + rejected: null, + wrong: null, + passed: { value: 'PASSED' }, + }, + errors: [ + { + message: + 'Expected value of type "Checked" but got: { kind: "wrong", value: "WRONG" }.', + locations: [{ line: 1, column: 38 }], + path: ['wrong'], + }, + { + message: 'isTypeOf threw', + locations: [{ line: 1, column: 3 }], + path: ['threw'], + }, + { + message: 'isTypeOf rejected', + locations: [{ line: 1, column: 19 }], + path: ['rejected'], + }, + ], + }); + }); + + it('reports abstract type completion errors', async () => { + const nodeType = new GraphQLInterfaceType({ + name: 'Node', + fields: { + value: { type: GraphQLString }, + }, + resolveType(value: { kind?: string }) { + if (value.kind === 'throw') { + throw new Error('resolveType threw'); + } + if (value.kind === 'reject') { + return Promise.reject(new Error('resolveType rejected')); + } + if (value.kind === 'missing') { + return 'MissingType'; + } + return 'ConcreteNode'; + }, + }); + const concreteType = new GraphQLObjectType({ + name: 'ConcreteNode', + interfaces: [nodeType], + fields: { + value: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + threw: { + type: nodeType, + resolve: () => ({ kind: 'throw', value: 'THREW' }), + }, + rejected: { + type: nodeType, + resolve: () => ({ kind: 'reject', value: 'REJECTED' }), + }, + missing: { + type: nodeType, + resolve: () => ({ kind: 'missing', value: 'MISSING' }), + }, + passed: { + type: nodeType, + resolve: () => ({ kind: 'pass', value: 'PASSED' }), + }, + }, + }), + types: [concreteType], + }); + + const result = await executeCompiled( + schema, + '{ threw { value } rejected { value } missing { value } passed { value } }', + ); + + expectJSON(result).toDeepEqual({ + data: { + threw: null, + rejected: null, + missing: null, + passed: { value: 'PASSED' }, + }, + errors: [ + { + message: + 'Abstract type "Node" was resolved to a type "MissingType" that does not exist inside the schema.', + locations: [{ line: 1, column: 38 }], + path: ['missing'], + }, + { + message: 'resolveType threw', + locations: [{ line: 1, column: 3 }], + path: ['threw'], + }, + { + message: 'resolveType rejected', + locations: [{ line: 1, column: 19 }], + path: ['rejected'], + }, + ], + }); + }); + + it('continues queued compiled work after root null bubbling', async () => { + let siblingResolverCalls = 0; + const namedType = new GraphQLObjectType({ + name: 'NamedThing', + fields: { + name: { type: GraphQLString }, + }, + isTypeOf: () => true, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + skipped: { + type: namedType, + resolve() { + siblingResolverCalls++; + return { name: 'sibling' }; + }, + }, + bad: { + type: new GraphQLNonNull(namedType), + resolve: () => null, + }, + }, + }), + }); + + const result = await executeCompiled( + schema, + '{ skipped { name } bad { name } }', + ); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Cannot return null for non-nullable field Query.bad.', + locations: [{ line: 1, column: 20 }], + path: ['bad'], + }, + ], + }); + // The compiled executor runs already-queued sibling work to completion + // after null bubbling. + expect(siblingResolverCalls).to.equal(1); + }); + + it('completes async iterables and reports async iterable errors', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + asyncValues: { + type: new GraphQLList(GraphQLString), + resolve: () => + asyncIterableFrom([ + Promise.resolve('first'), + Promise.resolve('second'), + ]), + }, + failingValues: { + type: new GraphQLList(GraphQLString), + resolve: () => + rejectingAsyncIterable(new Error('Cannot read list')), + }, + }, + }), + }); + + const result = await executeCompiled( + schema, + '{ asyncValues failingValues }', + ); + + expectJSON(result).toDeepEqual({ + data: { + asyncValues: ['first', 'second'], + failingValues: null, + }, + errors: [ + { + message: 'Cannot read list', + locations: [{ line: 1, column: 15 }], + path: ['failingValues'], + }, + ], + }); + }); + + it('can ignore stream directives in compiled execution', async () => { + const compiled = compileQuery( + listSchema(['a', 'b', 'c']), + '{ values @stream(initialCount: 1) }', + ); + + const result = await Promise.resolve(compiled.executeIgnoringIncremental()); + + expectJSON(result).toDeepEqual({ + data: { + values: ['a', 'b', 'c'], + }, + }); + }); + + it('reports stream directives in non-incremental compiled execution', async () => { + await expectPromise( + Promise.resolve().then(() => + executeCompiled( + listSchema(['a', 'b', 'c']), + '{ values @stream(initialCount: 1) }', + ), + ), + ).toRejectWith( + 'Executing this GraphQL operation would unexpectedly produce multiple payloads (due to @defer or @stream directive)', + ); + }); + + it('executes compiled streams', async () => { + const compiled = compileQuery( + listSchema(asyncIterableFrom(['a', 'b', 'c'])), + '{ values @stream(initialCount: 1, label: "items") }', + ); + + const result = await collectIncrementalResults( + await compiled.experimentalExecuteIncrementally(), + ); + + expectJSON(result).toDeepEqual([ + { + data: { + values: ['a'], + }, + pending: [{ id: '0', path: ['values'], label: 'items' }], + hasNext: true, + }, + { + incremental: [{ items: ['b'], id: '0' }], + hasNext: true, + }, + { + incremental: [{ items: ['c'], id: '0' }], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('completes a short async stream during the initial pass', async () => { + const compiled = compileQuery( + listSchema(asyncIterableFrom(['a'])), + '{ values @stream(initialCount: 5) }', + ); + + const result = await collectIncrementalResults( + await compiled.experimentalExecuteIncrementally(), + ); + + expectJSON(result).toDeepEqual({ + data: { + values: ['a'], + }, + }); + }); + + it('aborts pending early stream item execution when the stream is returned', async () => { + let itemAbortSignal: AbortSignal | undefined; + const { promise: itemStarted, resolve: resolveItemStarted } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + const itemType = new GraphQLObjectType({ + name: 'PendingItem', + fields: { + value: { + type: GraphQLString, + resolve(_source, _args, _context, info) { + itemAbortSignal = info.getAbortSignal(); + resolveItemStarted(); + return new Promise((_resolve, reject) => { + itemAbortSignal?.addEventListener('abort', () => { + const reason = itemAbortSignal?.reason; + reject(reason instanceof Error ? reason : new Error('aborted')); + }); + }); + }, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + items: { + type: new GraphQLList(itemType), + resolve: () => ({ + [Symbol.asyncIterator]() { + let index = 0; + return { + async next() { + if (index++ === 0) { + return { value: {}, done: false }; + } + return new Promise>(() => { + // Keep the source open until cancellation. + }); + }, + return() { + return { value: undefined, done: true }; + }, + }; + }, + }), + }, + }, + }), + }); + const compiled = compileExecution({ + schema, + document: parse('{ items @stream(initialCount: 0) { value } }'), + enableEarlyExecution: true, + }); + assert('execute' in compiled); + + const result = await compiled.experimentalExecuteIncrementally(); + assert('initialResult' in result); + + await itemStarted; + await resolveOnNextTick(); + await result.subsequentResults.return(undefined); + + expect(itemAbortSignal?.aborted).to.equal(true); + }); + + it('stops compiled streaming while the stream queue is back-pressured', async () => { + const { promise: reachedCapacity, resolve: resolveReachedCapacity } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + let count = 0; + let done = false; + const iterator = { + [Symbol.iterator]() { + return this; + }, + next() { + if (done) { + return { value: undefined, done: true }; + } + count++; + if (count === 100) { + resolveReachedCapacity(); + } + if (count > 100) { + done = true; + } + return { value: String(count), done: false }; + }, + }; + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + values: { + type: new GraphQLList(GraphQLString), + resolve: () => iterator, + }, + }, + }), + }); + const compiled = compileExecution({ + schema, + document: parse('{ values @stream(initialCount: 0) }'), + enableEarlyExecution: true, + }); + assert('execute' in compiled); + + const result = await compiled.experimentalExecuteIncrementally(); + assert('initialResult' in result); + + await reachedCapacity; + await result.subsequentResults.return(undefined); + expect(count).to.equal(101); + }); + + it('tracks background work from nulled stream item execution', async () => { + const itemType = new GraphQLObjectType({ + name: 'StreamedBackgroundItem', + fields: { + bad: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('stream item failed'); + }, + }, + slow: { + type: GraphQLString, + resolve: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + items: { + type: new GraphQLList(itemType), + resolve: () => [{}], + }, + }, + }), + }); + const compiled = compileQuery( + schema, + '{ items @stream(initialCount: 0) { bad slow } }', + ); + + const result = await collectIncrementalResults( + await compiled.experimentalExecuteIncrementally(), + ); + + expectJSON(result).toDeepEqual([ + { + data: { items: [] }, + pending: [{ id: '0', path: ['items'] }], + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'stream item failed', + locations: [{ line: 1, column: 36 }], + path: ['items', 0, 'bad'], + }, + ], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('executes compiled deferred fragments with async fields', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + immediate: { type: GraphQLString, resolve: () => 'first' }, + deferred: { + type: GraphQLString, + resolve: async () => { + await resolveOnNextTick(); + return 'second'; + }, + }, + }, + }), + }); + const compiled = compileQuery( + schema, + '{ immediate ... @defer(label: "later") { deferred } }', + ); + + const result = await collectIncrementalResults( + await compiled.experimentalExecuteIncrementally(), + ); + + expectJSON(result).toDeepEqual([ + { + data: { immediate: 'first' }, + pending: [{ id: '0', path: [], label: 'later' }], + hasNext: true, + }, + { + incremental: [{ data: { deferred: 'second' }, id: '0' }], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('tracks background work from nulled deferred execution groups', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + bad: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('defer failed'); + }, + }, + slow: { + type: GraphQLString, + resolve: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }, + }, + }), + }); + const compiled = compileQuery( + schema, + '{ ... @defer(label: "later") { bad slow } }', + ); + + const result = await collectIncrementalResults( + await compiled.experimentalExecuteIncrementally(), + ); + + expectJSON(result).toDeepEqual([ + { + data: {}, + pending: [{ id: '0', path: [], label: 'later' }], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: 'defer failed', + locations: [{ line: 1, column: 32 }], + path: ['bad'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('reports thrown thenable errors from compiled async field setup', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + value: { + type: GraphQLString, + resolve: () => ({ + then() { + throw new Error('Thenable setup failed'); + }, + }), + }, + }, + }), + }); + + const result = await executeCompiled(schema, '{ value }'); + + expectJSON(result).toDeepEqual({ + data: { value: null }, + errors: [ + { + message: 'Thenable setup failed', + locations: [{ line: 1, column: 3 }], + path: ['value'], + }, + ], + }); + }); +}); + +function compileQuery( + schema: GraphQLSchema, + source: string, +): CompiledExecution { + const compiled = compileExecution({ schema, document: parse(source) }); + assert('execute' in compiled); + return compiled; +} + +function executeCompiled( + schema: GraphQLSchema, + source: string, + args?: CompiledExecutionArgs, +): PromiseOrValue { + return compileQuery(schema, source).execute(args); +} + +function getValidatedExecutionArgs( + args: Pick = {}, +): ValidatedExecutionArgs { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + value: { type: GraphQLString }, + }, + }), + }); + const compiled = compileExecution({ + schema, + document: parse('{ value }'), + ...args, + }); + assert('execute' in compiled); + const validatedExecutionArgs = ( + compiled as unknown as { + getValidatedExecutionArgs: () => unknown; + } + ).getValidatedExecutionArgs(); + assert( + typeof validatedExecutionArgs === 'object' && + validatedExecutionArgs !== null && + 'schema' in validatedExecutionArgs, + ); + return validatedExecutionArgs as ValidatedExecutionArgs; +} + +function callAbortResolverSignal( + executor: CompiledExecutor, + reason?: unknown, +): void { + ( + executor as unknown as { + abortResolverSignal: (reason?: unknown) => void; + } + ).abortResolverSignal(reason); +} + +function setRunnerPending( + runner: CompiledExecutionRunner, + pending: number, +): void { + (runner as unknown as { _pending: number })._pending = pending; +} + +async function collectIncrementalResults( + result: ExecutionResult | ExperimentalIncrementalExecutionResults, +): Promise< + | ExecutionResult + | ReadonlyArray< + InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult + > +> { + if (!('initialResult' in result)) { + return result; + } + + const results: Array< + InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; +} + +function listSchema(values: unknown): GraphQLSchema { + return new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + values: { + type: new GraphQLList(GraphQLString), + resolve: () => values, + }, + }, + }), + }); +} + +function asyncIterableFrom( + values: ReadonlyArray, +): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0; + return { + async next() { + const value = values[index++]; + if (index > values.length) { + return { done: true, value: undefined }; + } + await resolveOnNextTick(); + return { done: false, value }; + }, + return() { + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; +} + +function rejectingAsyncIterable(error: Error): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.reject(error); + }, + return() { + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; +} diff --git a/src/execution/compile/__tests__/compileCollectFields-test.ts b/src/execution/compile/__tests__/compileCollectFields-test.ts new file mode 100644 index 0000000000..1804ffc529 --- /dev/null +++ b/src/execution/compile/__tests__/compileCollectFields-test.ts @@ -0,0 +1,520 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import { parse } from '../../../language/parser.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import type { GroupedFieldSet } from '../../collectFields.ts'; +import { validateExecutionArgs } from '../../execute.ts'; + +import { compileCollectFields } from '../compileCollectFields.ts'; + +const schema = buildSchema(` + interface Named { + name: String + } + + interface Hidden { + hidden: String + } + + input FlagInput { + enabled: Boolean + } + + type Query implements Named { + aliasSource: String + argField(flag: Boolean): String + child: Query + deferredInline: String + deferredSpread: String + field: String + hidden: String + included: String + name: String + notIncluded: String + skipped: String + viaFragmentVariable: String + } + + type Other implements Hidden { + hidden: String + } +`); + +function collectRootFields( + query: string, + variableValues?: { readonly [variable: string]: unknown }, +) { + const { compiledCollectFields, coercedVariableValues, queryType } = + compileOperationCollectFields(query, variableValues); + + return compiledCollectFields.collectRootFields( + coercedVariableValues, + queryType, + ); +} + +function expectFieldCounts( + groupedFieldSet: GroupedFieldSet, + expectedCounts: { readonly [responseName: string]: number }, +): void { + for (const [responseName, count] of Object.entries(expectedCounts)) { + expect(groupedFieldSet.get(responseName)).to.have.lengthOf(count); + } +} + +function expectMissingFields( + groupedFieldSet: GroupedFieldSet, + responseNames: ReadonlyArray, +): void { + for (const responseName of responseNames) { + expect(groupedFieldSet.has(responseName)).to.equal(false); + } +} + +function compileOperationCollectFields( + query: string, + variableValues?: { readonly [variable: string]: unknown }, +) { + const document = parse(query, { experimentalFragmentArguments: true }); + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + variableValues, + }); + + invariant('operation' in validatedExecutionArgs); + + const queryType = schema.getQueryType(); + invariant(queryType != null); + + const compiledCollectFields = compileCollectFields( + schema, + validatedExecutionArgs.fragments, + validatedExecutionArgs.operation.selectionSet, + false, + true, + ); + + return { + compiledCollectFields, + coercedVariableValues: validatedExecutionArgs.variableValues, + queryType, + }; +} + +describe('compiledCollectFields', () => { + it('collects fields with precompiled selections and runtime variables', () => { + const { groupedFieldSet, newDeferUsages } = collectRootFields( + ` + query ( + $show: Boolean! = true + $label: String = "fromVar" + $missingShow: Boolean + $nullLabel: String + ) { + field + unknownField + alias: aliasSource + skipped @skip(if: true) + notIncluded @include(if: false) + included @include(if: $show) + streamed: field @stream + ... @skip(if: true) { + skippedInline: field + } + ... @include(if: true) { + includedInline: field + } + ... { + name + } + ... on Query { + field + } + ... on Named { + name + } + ... on Other { + hidden + } + ... on Hidden { + hidden + } + ... on MissingType { + missingType: field + } + ...Missing + ...Missing + ...SkippedFragment @skip(if: true) + ...OtherFragment + ...DeferredFragment @defer(label: "first") + ...DeferredFragment @defer(label: "second") + ... @defer(label: "inline") { + deferredInline + } + ... @defer { + unlabeledDeferred: deferredInline + } + ... @defer(if: false) { + deferredFalse: deferredInline + } + ... @defer(label: $label) { + deferredWithVariableLabel: deferredInline + } + ... @defer(if: $show, label: "variableIf") { + deferredWithVariableIf: deferredInline + } + ... @defer(if: $missingShow, label: "defaultedVariableIf") { + deferredWithDefaultedVariableIf: deferredInline + } + ... @defer(label: $nullLabel) { + deferredWithNullVariableLabel: deferredInline + } + ...ArgFragment(show: $show) + ...StaticArgFragment(show: false, label: "fragmentRuntimeLabel") + ...DefaultArgFragment + ...NoStaticArg + ...StringArg(value: "string") + ...NullArg(show: null) + ...ObjectArg(input: { enabled: false }) + ...ListArg(values: [false, true]) + ...OuterObjectArg(input: { enabled: false }) + ...OperationDependentObjectArg(input: { enabled: $show }) + ...ArgValueFragment(show: $show) + ...RepeatedFragment + ...RepeatedFragment + child { + ...StaticArgFragment(show: false) + } + } + + fragment SkippedFragment on Query { + skipped + } + + fragment OtherFragment on Other { + hidden + } + + fragment DeferredFragment on Query { + deferredSpread + } + + fragment ArgFragment($show: Boolean!) on Query { + viaFragmentVariable @include(if: $show) + } + + fragment StaticArgFragment( + $show: Boolean! + $label: String = "fragmentLabel" + ) on Query { + staticIncluded: field @skip(if: $show) + staticExcluded: field @include(if: $show) + ... @defer(label: "static") { + deferredInStatic: deferredInline + } + ... @defer(label: $label) { + deferredInStaticWithVariableLabel: deferredInline + } + ...NestedStaticArgFragment(show: $show) + } + + fragment NestedStaticArgFragment($show: Boolean!) on Query { + nestedStaticIncluded: field @skip(if: $show) + nestedStaticExcluded: field @include(if: $show) + } + + fragment DefaultArgFragment($show: Boolean = false) on Query { + defaultIncluded: field @skip(if: $show) + } + + fragment NoStaticArg($unused: Boolean) on Query { + noStaticArg: field + } + + fragment StringArg($value: String) on Query { + stringArg: field + } + + fragment NullArg($show: Boolean) on Query { + nullArg: field + } + + fragment ObjectArg($input: FlagInput) on Query { + objectArg: field + } + + fragment ListArg($values: [Boolean]) on Query { + listArg: field + } + + fragment OuterObjectArg($input: FlagInput) on Query { + ...InnerObjectArg(input: $input) + } + + fragment InnerObjectArg($input: FlagInput) on Query { + innerObjectArg: field + } + + fragment OperationDependentObjectArg($input: FlagInput) on Query { + operationDependentObjectArg: field + } + + fragment ArgValueFragment($show: Boolean!) on Query { + argField(flag: $show) + } + + fragment RepeatedFragment on Query { + repeated: field + } + `, + { show: true, label: 'runtimeLabel', nullLabel: null }, + ); + + expectFieldCounts(groupedFieldSet, { + field: 2, + unknownField: 1, + alias: 1, + included: 1, + streamed: 1, + includedInline: 1, + name: 2, + deferredSpread: 1, + deferredInline: 1, + unlabeledDeferred: 1, + deferredWithVariableLabel: 1, + deferredWithVariableIf: 1, + deferredWithDefaultedVariableIf: 1, + deferredWithNullVariableLabel: 1, + viaFragmentVariable: 1, + staticIncluded: 1, + deferredInStatic: 1, + deferredInStaticWithVariableLabel: 1, + nestedStaticIncluded: 1, + defaultIncluded: 1, + noStaticArg: 1, + stringArg: 1, + nullArg: 1, + objectArg: 1, + listArg: 1, + innerObjectArg: 1, + operationDependentObjectArg: 1, + argField: 1, + repeated: 1, + child: 1, + }); + + expectMissingFields(groupedFieldSet, [ + 'hidden', + 'notIncluded', + 'skipped', + 'skippedInline', + 'missingType', + ]); + const fieldDetails = groupedFieldSet.get('field')?.[0]; + invariant(fieldDetails != null); + expect(fieldDetails.compiledFieldPlan?.fieldDef.name).to.equal('field'); + expect(groupedFieldSet.get('unknownField')?.[0].compiledFieldPlan).to.equal( + undefined, + ); + expect(groupedFieldSet.get('deferredFalse')).to.have.lengthOf(1); + expectMissingFields(groupedFieldSet, [ + 'staticExcluded', + 'nestedStaticExcluded', + ]); + + expect(newDeferUsages.map((deferUsage) => deferUsage.label)).to.deep.equal([ + 'first', + 'inline', + undefined, + undefined, + 'variableIf', + 'defaultedVariableIf', + undefined, + 'static', + undefined, + ]); + }); + + it('collects subfields with precompiled field selection sets', () => { + const { compiledCollectFields, coercedVariableValues, queryType } = + compileOperationCollectFields(` + { + ...ChildFragment(show: false) + } + + fragment ChildFragment($show: Boolean!) on Query { + child { + ...StaticArgFragment(show: $show) + } + } + + fragment StaticArgFragment($show: Boolean!) on Query { + staticIncluded: field @skip(if: $show) + staticExcluded: field @include(if: $show) + } + `); + + const rootFields = compiledCollectFields.collectRootFields( + coercedVariableValues, + queryType, + ); + const childFields = rootFields.groupedFieldSet.get('child'); + invariant(childFields != null); + + const subfields = compiledCollectFields.collectSubfields( + coercedVariableValues, + queryType, + childFields, + ); + + expect(subfields.groupedFieldSet.get('staticIncluded')).to.have.lengthOf(1); + expect(subfields.groupedFieldSet.has('staticExcluded')).to.equal(false); + + const rawSubfields = compiledCollectFields.collectSubfields( + coercedVariableValues, + queryType, + [ + { + node: childFields[0].node, + deferUsage: undefined, + fragmentVariableValues: childFields[0].fragmentVariableValues, + staticFragmentVariableValues: + childFields[0].staticFragmentVariableValues, + compiledFieldPlan: undefined, + }, + ], + ); + + expect(rawSubfields.groupedFieldSet.get('staticIncluded')).to.have.lengthOf( + 1, + ); + + const direct = compileOperationCollectFields(` + { + child { + field + } + } + `); + const directRootFields = direct.compiledCollectFields.collectRootFields( + direct.coercedVariableValues, + direct.queryType, + ); + const directChildFields = directRootFields.groupedFieldSet.get('child'); + invariant(directChildFields != null); + + const directSubfields = direct.compiledCollectFields.collectSubfields( + direct.coercedVariableValues, + direct.queryType, + directChildFields, + ); + + const scalarFields = directSubfields.groupedFieldSet.get('field'); + invariant(scalarFields != null); + expect(scalarFields).to.have.lengthOf(1); + + const scalarSubfields = direct.compiledCollectFields.collectSubfields( + direct.coercedVariableValues, + direct.queryType, + scalarFields, + ); + + expect(scalarSubfields.groupedFieldSet.size).to.equal(0); + + const extraNode = parse('{ child { field } }').definitions[0]; + invariant(extraNode.kind === 'OperationDefinition'); + const extraField = extraNode.selectionSet.selections[0]; + invariant(extraField.kind === 'Field'); + + const extraSubfields = direct.compiledCollectFields.collectSubfields( + direct.coercedVariableValues, + direct.queryType, + [ + { + node: extraField, + deferUsage: undefined, + fragmentVariableValues: undefined, + staticFragmentVariableValues: undefined, + compiledFieldPlan: undefined, + }, + ], + ); + + expect(extraSubfields.groupedFieldSet.get('field')).to.have.lengthOf(1); + }); + + it('throws directive argument errors for non-fast-path directives', () => { + expect(() => + collectRootFields(` + { + field @include + } + `), + ).to.throw(); + + expect(() => + collectRootFields(` + { + field @include(if: null) + } + `), + ).to.throw(); + + expect(() => + collectRootFields( + ` + { + ...BadDirective(show: null) + } + + fragment BadDirective($show: Boolean) on Query { + field @include(if: $show) + } + `, + ), + ).to.throw(); + + expect(() => + collectRootFields(` + { + ...BadDefault + } + + fragment BadDefault($show: Boolean = "bad") on Query { + field @include(if: $show) + } + `), + ).to.throw(); + + expect(() => + collectRootFields(` + { + ... @defer(if: null) { + field + } + } + `), + ).to.throw(); + + expect(() => + collectRootFields(` + { + ...BadDefer(show: true) + } + + fragment BadDefer($show: Boolean) on Query { + ... @defer(if: null) { + field + } + } + `), + ).to.throw(); + }); +}); diff --git a/src/execution/compile/__tests__/compileFieldExecutionPlan-test.ts b/src/execution/compile/__tests__/compileFieldExecutionPlan-test.ts new file mode 100644 index 0000000000..4b45041660 --- /dev/null +++ b/src/execution/compile/__tests__/compileFieldExecutionPlan-test.ts @@ -0,0 +1,129 @@ +import { describe, it } from 'node:test'; + +import { assert, expect } from 'chai'; + +import { addPath } from '../../../jsutils/Path.ts'; + +import { parse } from '../../../language/parser.ts'; + +import { GraphQLObjectType } from '../../../type/definition.ts'; +import { GraphQLString } from '../../../type/scalars.ts'; +import { GraphQLSchema } from '../../../type/schema.ts'; + +import type { FieldDetailsList } from '../../collectFields.ts'; +import { validateExecutionArgs } from '../../execute.ts'; + +import { compileArgumentValues } from '../compileArgumentValues.ts'; +import { + compileFieldExecutionPlan, + compileFieldResolver, +} from '../compileFieldExecutionPlan.ts'; + +describe('compileFieldExecutionPlan', () => { + it('resolves undefined for default-resolved fields on non-object sources', () => { + const queryType = new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ query: queryType }); + const document = parse('{ foo }'); + const operation = document.definitions[0]; + assert(operation.kind === 'OperationDefinition'); + const fieldNode = operation.selectionSet.selections[0]; + assert(fieldNode.kind === 'Field'); + const fieldDef = queryType.getFields().foo; + assert(fieldDef !== undefined); + const plan = compileFieldExecutionPlan( + compileFieldResolver(fieldDef, true), + compileArgumentValues(fieldDef, fieldNode, false, undefined), + null, + ); + const validatedExecutionArgs = validateExecutionArgs({ schema, document }); + assert('schema' in validatedExecutionArgs); + const fieldDetailsList: FieldDetailsList = [ + { + node: fieldNode, + deferUsage: undefined, + fragmentVariableValues: undefined, + staticFragmentVariableValues: undefined, + compiledFieldPlan: plan, + }, + ]; + + const result = plan.resolveField( + { + validatedExecutionArgs, + getAbortSignal: () => undefined, + getAsyncHelpers: () => ({ + promiseAll: (values) => Promise.all(values), + track: () => undefined, + }), + }, + queryType, + null, + fieldDetailsList, + addPath(undefined, 'foo', 'Query'), + ); + + expect(result).to.deep.equal({ info: undefined, result: undefined }); + }); + + it('resolves fields with a runtime field resolver', () => { + const queryType = new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ query: queryType }); + const document = parse('{ foo }'); + const operation = document.definitions[0]; + assert(operation.kind === 'OperationDefinition'); + const fieldNode = operation.selectionSet.selections[0]; + assert(fieldNode.kind === 'Field'); + const fieldDef = queryType.getFields().foo; + assert(fieldDef !== undefined); + const plan = compileFieldExecutionPlan( + compileFieldResolver(fieldDef, false), + compileArgumentValues(fieldDef, fieldNode, false, undefined), + null, + ); + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + fieldResolver(_source, _args, _context, info) { + return info.fieldName; + }, + }); + assert('schema' in validatedExecutionArgs); + const fieldDetailsList: FieldDetailsList = [ + { + node: fieldNode, + deferUsage: undefined, + fragmentVariableValues: undefined, + staticFragmentVariableValues: undefined, + compiledFieldPlan: plan, + }, + ]; + + const result = plan.resolveField( + { + validatedExecutionArgs, + getAbortSignal: () => undefined, + getAsyncHelpers: () => ({ + promiseAll: (values) => Promise.all(values), + track: () => undefined, + }), + }, + queryType, + {}, + fieldDetailsList, + addPath(undefined, 'foo', 'Query'), + ); + + expect(result).to.have.property('result', 'foo'); + expect(result.info).to.have.property('fieldName', 'foo'); + }); +}); diff --git a/src/execution/compile/__tests__/compileFragmentVariables-test.ts b/src/execution/compile/__tests__/compileFragmentVariables-test.ts new file mode 100644 index 0000000000..5d95ed3b2d --- /dev/null +++ b/src/execution/compile/__tests__/compileFragmentVariables-test.ts @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import type { FragmentSpreadNode } from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import { compileFragmentVariables } from '../compileFragmentVariables.ts'; + +function getFragmentCase(query: string): { + fragmentSpread: FragmentSpreadNode; +} { + const document = parse(query, { experimentalFragmentArguments: true }); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const fragmentSpread = operation.selectionSet.selections[0]; + invariant(fragmentSpread.kind === Kind.FRAGMENT_SPREAD); + return { fragmentSpread }; +} + +describe('compileFragmentVariables', () => { + it('returns undefined when there are no variable entries', () => { + const { fragmentSpread } = getFragmentCase(` + { + ...Fragment + } + + fragment Fragment on Query { + field + } + `); + + expect( + compileFragmentVariables(fragmentSpread, Object.create(null)), + ).to.equal(undefined); + }); +}); diff --git a/src/execution/compile/__tests__/compileInclusionDirectives-test.ts b/src/execution/compile/__tests__/compileInclusionDirectives-test.ts new file mode 100644 index 0000000000..657f27a185 --- /dev/null +++ b/src/execution/compile/__tests__/compileInclusionDirectives-test.ts @@ -0,0 +1,131 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import type { + DirectiveNode, + OperationDefinitionNode, +} from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import { getVariableValues } from '../../values.ts'; + +import { + compileIncludeDirective, + compileSkipDirective, + shouldIncludeSelection, +} from '../compileInclusionDirectives.ts'; + +const schema = buildSchema('type Query { field: String }'); + +function getDirectiveNodes(query: string): { + skipDirectiveNode: DirectiveNode | undefined; + includeDirectiveNode: DirectiveNode | undefined; + operation: OperationDefinitionNode; +} { + const document = parse(query); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const fieldNode = operation.selectionSet.selections[0]; + invariant(fieldNode.kind === Kind.FIELD); + + return { + skipDirectiveNode: fieldNode.directives?.find( + (directive) => directive.name.value === 'skip', + ), + includeDirectiveNode: fieldNode.directives?.find( + (directive) => directive.name.value === 'include', + ), + operation, + }; +} + +function getCoercedVariableValues( + operation: OperationDefinitionNode, + variableValues: { readonly [variable: string]: unknown } = {}, +) { + const result = getVariableValues( + schema, + operation.variableDefinitions ?? [], + variableValues, + ); + invariant('variableValues' in result); + return result.variableValues; +} + +describe('compileInclusionDirectives', () => { + it('includes selections without inclusion directives', () => { + const { skipDirectiveNode, includeDirectiveNode, operation } = + getDirectiveNodes('{ field }'); + + expect( + shouldIncludeSelection( + { + skipDirective: compileSkipDirective(skipDirectiveNode), + includeDirective: compileIncludeDirective(includeDirectiveNode), + }, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.equal(true); + }); + + it('skips selections when @skip is true', () => { + const { skipDirectiveNode, includeDirectiveNode, operation } = + getDirectiveNodes('{ field @skip(if: true) }'); + + expect( + shouldIncludeSelection( + { + skipDirective: compileSkipDirective(skipDirectiveNode), + includeDirective: compileIncludeDirective(includeDirectiveNode), + }, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.equal(false); + }); + + it('skips selections when @include is false', () => { + const { skipDirectiveNode, includeDirectiveNode, operation } = + getDirectiveNodes('{ field @include(if: false) }'); + + expect( + shouldIncludeSelection( + { + skipDirective: compileSkipDirective(skipDirectiveNode), + includeDirective: compileIncludeDirective(includeDirectiveNode), + }, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.equal(false); + }); + + it('includes selections when @skip is false and @include is true', () => { + const { skipDirectiveNode, includeDirectiveNode, operation } = + getDirectiveNodes( + 'query ($show: Boolean!) { field @skip(if: false) @include(if: $show) }', + ); + + expect( + shouldIncludeSelection( + { + skipDirective: compileSkipDirective(skipDirectiveNode), + includeDirective: compileIncludeDirective(includeDirectiveNode), + }, + getCoercedVariableValues(operation, { show: true }), + undefined, + false, + ), + ).to.equal(true); + }); +}); diff --git a/src/execution/compile/__tests__/getCompiledArgumentValues-test.ts b/src/execution/compile/__tests__/getCompiledArgumentValues-test.ts new file mode 100644 index 0000000000..3567101adc --- /dev/null +++ b/src/execution/compile/__tests__/getCompiledArgumentValues-test.ts @@ -0,0 +1,715 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { expectMatchingValues } from '../../../__testUtils__/expectMatchingValues.ts'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import type { + FieldNode, + OperationDefinitionNode, +} from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import type { GraphQLField } from '../../../type/definition.ts'; +import { + GraphQLInputObjectType, + GraphQLObjectType, +} from '../../../type/definition.ts'; +import { GraphQLString } from '../../../type/scalars.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import type { FragmentVariableValues } from '../../collectFields.ts'; +import type { VariableValues } from '../../values.ts'; +import { getArgumentValues, getVariableValues } from '../../values.ts'; + +import { compileArgumentValues } from '../compileArgumentValues.ts'; +import { + getCompiledArgumentValue, + getCompiledArgumentValues, + UNKNOWN_ARGUMENT_VALUE, +} from '../getCompiledArgumentValues.ts'; + +const schema = buildSchema(` + input FlagInput { + enabled: Boolean + } + + input RequiredFlagInput { + enabled: Boolean + required: Boolean! + strictList: [Boolean!] + withDefault: String = "inputDefault" + } + + input ChoiceInput @oneOf { + flag: Boolean + label: String + } + + enum Flag { + DISABLED + ENABLED + } + + type Query { + field( + required: Boolean! + optional: String = "schemaDefault" + count: Int = 3 + list: [Boolean] + requiredList: [Boolean!] + nestedRequiredList: [[Boolean!]!] + input: FlagInput + requiredInput: RequiredFlagInput + inputList: [RequiredFlagInput] + choice: ChoiceInput + flag: Flag + noDefault: String + ): [String] + } +`); + +const queryType = schema.getQueryType(); +invariant(queryType != null); + +const maybeFieldDef = schema.getField(queryType, 'field'); +invariant(maybeFieldDef != null); +const fieldDef = maybeFieldDef; + +function getFieldNode(query: string): { + fieldNode: FieldNode; + operation: OperationDefinitionNode; +} { + const document = parse(query); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const fieldNode = operation.selectionSet.selections[0]; + invariant(fieldNode.kind === Kind.FIELD); + return { fieldNode, operation }; +} + +function getCoercedVariableValues( + operation: OperationDefinitionNode, + variableValues: { readonly [variable: string]: unknown }, +) { + const result = getVariableValues( + schema, + operation.variableDefinitions ?? [], + variableValues, + ); + invariant('variableValues' in result); + return result.variableValues; +} + +function compileField(fieldNode: FieldNode) { + return compileArgumentValues(fieldDef, fieldNode, false, undefined); +} + +function expectArgumentValuesMatch( + expectedFieldDef: GraphQLField, + fieldNode: FieldNode, + variableValues?: VariableValues, +) { + return expectMatchingValues([ + () => getArgumentValues(expectedFieldDef, fieldNode, variableValues), + () => + getCompiledArgumentValues( + compileArgumentValues(expectedFieldDef, fieldNode, false, undefined), + variableValues, + ), + ]); +} + +describe('getCompiledArgumentValues', () => { + it('returns a reusable constant map when all argument values are static', () => { + const { fieldNode } = getFieldNode(` + { + field( + required: true + optional: "literal" + count: 2 + list: [true, false] + input: { enabled: true } + ) + } + `); + + const compiled = compileField(fieldNode); + const args = getCompiledArgumentValues(compiled); + + expect(getCompiledArgumentValues(compiled)).to.equal(args); + expect(getCompiledArgumentValue(compiled, 'optional')).to.equal('literal'); + expect(args).to.deep.equal({ + required: true, + optional: 'literal', + count: 2, + list: [true, false], + input: { enabled: true }, + }); + }); + + it('coerces runtime variables, fragment variables, defaults, and compound values', () => { + const { fieldNode, operation } = getFieldNode(` + query ($required: Boolean!, $enabled: Boolean, $optional: String) { + field( + required: $required + optional: $optional + list: [true, $required] + input: { enabled: $enabled } + noDefault: $optional + ) + } + `); + + const variableValues = getCoercedVariableValues(operation, { + required: false, + enabled: true, + }); + const compiled = compileField(fieldNode); + const args = getCompiledArgumentValues(compiled, variableValues); + + expect(args).to.deep.equal({ + required: false, + optional: 'schemaDefault', + count: 3, + list: [true, false], + input: { enabled: true }, + }); + expect( + getCompiledArgumentValue(compiled, 'required', variableValues), + ).to.equal(false); + expect(getCompiledArgumentValue(compiled, 'list', variableValues)).to.equal( + UNKNOWN_ARGUMENT_VALUE, + ); + expect( + getCompiledArgumentValue(compiled, 'unknown', variableValues), + ).to.equal(undefined); + + const fragmentVariableValues: FragmentVariableValues = { + sources: { + required: { + signature: variableValues.sources.required.signature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { required: true }, + }; + + expect( + getCompiledArgumentValues( + compileArgumentValues( + fieldDef, + fieldNode, + false, + fragmentVariableValues, + ), + variableValues, + ).required, + ).to.equal(true); + }); + + it('uses compiled builders for variable-backed compound values', () => { + const { fieldNode, operation } = getFieldNode(` + query ($required: Boolean!, $enabled: Boolean, $optional: String) { + field( + required: true + list: [null, $optional] + requiredList: [true] + requiredInput: { enabled: $enabled, required: $required } + inputList: { required: $required } + ) + } + `); + + const variableValues = getCoercedVariableValues(operation, { + required: true, + enabled: false, + }); + const compiled = compileField(fieldNode); + + expect(getCompiledArgumentValues(compiled, variableValues)).to.deep.equal({ + required: true, + optional: 'schemaDefault', + count: 3, + list: [null, null], + requiredList: [true], + requiredInput: { + enabled: false, + required: true, + withDefault: 'inputDefault', + }, + inputList: [ + { + required: true, + withDefault: 'inputDefault', + }, + ], + }); + + expect( + getCompiledArgumentValues( + compiled, + getCoercedVariableValues(operation, { required: true }), + ).requiredInput, + ).to.deep.equal({ + required: true, + withDefault: 'inputDefault', + }); + }); + + it('falls back to validation errors for invalid compiled compound values', () => { + const invalidNonNullLiteral = getFieldNode(` + { + field(required: null) + } + `); + expect(() => + getCompiledArgumentValues(compileField(invalidNonNullLiteral.fieldNode)), + ).to.throw('Argument "Query.field(required:)" has invalid value'); + + const invalidNonNullInnerLiteral = getFieldNode(` + { + field(required: []) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(invalidNonNullInnerLiteral.fieldNode), + ), + ).to.throw('Argument "Query.field(required:)" has invalid value'); + + const invalidSingletonListItem = getFieldNode(` + query ($required: Boolean!) { + field(required: true, list: { enabled: $required }) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(invalidSingletonListItem.fieldNode), + getCoercedVariableValues(invalidSingletonListItem.operation, { + required: true, + }), + ), + ).to.throw('Argument "Query.field(list:)" has invalid value'); + + const invalidListItem = getFieldNode(` + query ($required: Boolean!) { + field(required: true, list: [{ enabled: $required }]) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(invalidListItem.fieldNode), + getCoercedVariableValues(invalidListItem.operation, { + required: true, + }), + ), + ).to.throw('Argument "Query.field(list:)" has invalid value'); + + const invalidInputShape = getFieldNode(` + { + field(required: true, requiredInput: true) + } + `); + expect(() => + getCompiledArgumentValues(compileField(invalidInputShape.fieldNode)), + ).to.throw('Argument "Query.field(requiredInput:)" has invalid value'); + + const unknownInputField = getFieldNode(` + query ($required: Boolean!) { + field(required: true, requiredInput: { required: $required, extra: true }) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(unknownInputField.fieldNode), + getCoercedVariableValues(unknownInputField.operation, { + required: true, + }), + ), + ).to.throw('Argument "Query.field(requiredInput:)" has invalid value'); + + const invalidInputFieldValue = getFieldNode(` + { + field(required: true, input: { enabled: { nested: true } }) + } + `); + expect(() => + getCompiledArgumentValues(compileField(invalidInputFieldValue.fieldNode)), + ).to.throw('Argument "Query.field(input:)" has invalid value'); + + const missingInputField = getFieldNode(` + { + field(required: true, requiredInput: { enabled: true }) + } + `); + expect(() => + getCompiledArgumentValues(compileField(missingInputField.fieldNode)), + ).to.throw('Argument "Query.field(requiredInput:)" has invalid value'); + + const missingRequiredListItem = getFieldNode(` + query ($value: Boolean) { + field(required: true, requiredList: [$value]) + } + `); + const variableValues = getCoercedVariableValues( + missingRequiredListItem.operation, + {}, + ); + expect(() => + getCompiledArgumentValues( + compileField(missingRequiredListItem.fieldNode), + variableValues, + ), + ).to.throw('Argument "Query.field(requiredList:)" has invalid value'); + + const invalidRuntimeInputField = getFieldNode(` + query ($value: Boolean) { + field( + required: true + requiredInput: { required: true, strictList: [$value] } + ) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(invalidRuntimeInputField.fieldNode), + getCoercedVariableValues(invalidRuntimeInputField.operation, {}), + ), + ).to.throw('Argument "Query.field(requiredInput:)" has invalid value'); + + const invalidNestedRequiredList = getFieldNode(` + query ($value: Boolean) { + field(required: true, nestedRequiredList: [[$value]]) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(invalidNestedRequiredList.fieldNode), + getCoercedVariableValues(invalidNestedRequiredList.operation, {}), + ), + ).to.throw('Argument "Query.field(nestedRequiredList:)" has invalid value'); + + const invalidSingletonInputList = getFieldNode(` + query ($value: Boolean) { + field(required: true, inputList: { required: $value }) + } + `); + expect(() => + getCompiledArgumentValues( + compileField(invalidSingletonInputList.fieldNode), + getCoercedVariableValues(invalidSingletonInputList.operation, {}), + ), + ).to.throw('Argument "Query.field(inputList:)" has invalid value'); + }); + + it('preserves oneOf input object coercion through compiled builders', () => { + const { fieldNode, operation } = getFieldNode(` + query ($flag: Boolean) { + field(required: true, choice: { flag: $flag }) + } + `); + const compiled = compileField(fieldNode); + + expect( + getCompiledArgumentValues( + compiled, + getCoercedVariableValues(operation, { flag: true }), + ).choice, + ).to.deep.equal({ flag: true }); + + expect(() => + getCompiledArgumentValues( + compiled, + getCoercedVariableValues(operation, {}), + ), + ).to.throw('Argument "Query.field(choice:)" has invalid value'); + + expect(() => + getCompiledArgumentValues( + compiled, + getCoercedVariableValues(operation, { flag: null }), + ), + ).to.throw('Argument "Query.field(choice:)" has invalid value'); + + const nullChoice = getFieldNode(` + { + field(required: true, choice: { flag: null }) + } + `); + expect(() => + getCompiledArgumentValues(compileField(nullChoice.fieldNode)), + ).to.throw('Argument "Query.field(choice:)" has invalid value'); + + const tooManyChoices = getFieldNode(` + { + field(required: true, choice: { flag: true, label: "x" }) + } + `); + expect(() => + getCompiledArgumentValues(compileField(tooManyChoices.fieldNode)), + ).to.throw('Argument "Query.field(choice:)" has invalid value'); + }); + + it('throws for invalid values that validation would normally reject', () => { + const invalid = getFieldNode(` + { + field(required: "bad") + } + `); + const invalidField = compileField(invalid.fieldNode); + + expect(() => getCompiledArgumentValues(invalidField)).to.throw( + 'Argument "Query.field(required:)" has invalid value', + ); + + const invalidVariable = getFieldNode(` + query ($required: Boolean) { + field(required: $required) + } + `); + const invalidVariableValues = getCoercedVariableValues( + invalidVariable.operation, + { required: null }, + ); + const invalidVariableField = compileField(invalidVariable.fieldNode); + + expect( + getCompiledArgumentValue( + invalidVariableField, + 'required', + invalidVariableValues, + ), + ).to.equal(UNKNOWN_ARGUMENT_VALUE); + expect(() => + getCompiledArgumentValues(invalidVariableField, invalidVariableValues), + ).to.throw('Argument "Query.field(required:)" has invalid value'); + + const missingRequiredVariable = getFieldNode(` + query ($required: Boolean!) { + field(required: $required) + } + `); + const missingRequiredVariableField = compileField( + missingRequiredVariable.fieldNode, + ); + + expect( + getCompiledArgumentValue(missingRequiredVariableField, 'required'), + ).to.equal(UNKNOWN_ARGUMENT_VALUE); + + const missing = getFieldNode(` + { + field + } + `); + const missingField = compileField(missing.fieldNode); + + expect(getCompiledArgumentValue(missingField, 'required')).to.equal( + UNKNOWN_ARGUMENT_VALUE, + ); + expect(() => getCompiledArgumentValues(missingField)).to.throw( + 'Argument "Query.field(required:)" of required type "Boolean!" was not provided.', + ); + }); + + it('matches getArgumentValues for invalid schema argument defaults', () => { + const invalidDefaultQuery = new GraphQLObjectType({ + name: 'InvalidDefaultQuery', + fields: { + field: { + type: GraphQLString, + args: { + input: { + type: GraphQLString, + default: { value: 123 }, + }, + }, + }, + }, + }); + const invalidDefaultField = invalidDefaultQuery.getFields().field; + + expect(() => + expectArgumentValuesMatch( + invalidDefaultField, + getFieldNode('{ field }').fieldNode, + ), + ).to.throw( + 'Argument "InvalidDefaultQuery.field(input:)" has invalid default value: String cannot represent a non string value: 123', + ); + + const invalidDefaultVariable = getFieldNode(` + query ($input: String) { + field(input: $input) + } + `); + + expect(() => + expectArgumentValuesMatch( + invalidDefaultField, + invalidDefaultVariable.fieldNode, + getCoercedVariableValues(invalidDefaultVariable.operation, {}), + ), + ).to.throw( + 'Argument "InvalidDefaultQuery.field(input:)" has invalid default value: String cannot represent a non string value: 123', + ); + + const invalidNestedDefaultQuery = new GraphQLObjectType({ + name: 'InvalidNestedDefaultQuery', + fields: { + field: { + type: GraphQLString, + args: { + input: { + type: new GraphQLInputObjectType({ + name: 'InvalidNestedDefaultInput', + fields: { + nested: { + type: GraphQLString, + default: { value: 123 }, + }, + }, + }), + default: { value: {} }, + }, + }, + }, + }, + }); + + expect(() => + expectArgumentValuesMatch( + invalidNestedDefaultQuery.getFields().field, + getFieldNode('{ field }').fieldNode, + ), + ).to.throw( + 'Argument "InvalidNestedDefaultQuery.field(input:)" has invalid default value: Expected value of type "String" to be valid, found: 123.', + ); + }); + + it('checks invalid defaults and missing arguments with fragment variables', () => { + const fragmentVariableValues: FragmentVariableValues = { + sources: Object.create(null), + coerced: Object.create(null), + }; + const invalidDefaultQuery = new GraphQLObjectType({ + name: 'InvalidDefaultWithFragmentQuery', + fields: { + field: { + type: GraphQLString, + args: { + input: { + type: GraphQLString, + default: { value: 123 }, + }, + }, + }, + }, + }); + const invalidDefaultField = invalidDefaultQuery.getFields().field; + const invalidDefaultFieldNode = getFieldNode('{ field }').fieldNode; + + expect(() => + getCompiledArgumentValues( + compileArgumentValues( + invalidDefaultField, + invalidDefaultFieldNode, + false, + fragmentVariableValues, + ), + ), + ).to.throw( + 'Argument "InvalidDefaultWithFragmentQuery.field(input:)" has invalid default value: String cannot represent a non string value: 123', + ); + + const invalidDefaultVariable = getFieldNode(` + query ($input: String) { + field(input: $input) + } + `); + + expect(() => + getCompiledArgumentValues( + compileArgumentValues( + invalidDefaultField, + invalidDefaultVariable.fieldNode, + false, + fragmentVariableValues, + ), + getCoercedVariableValues(invalidDefaultVariable.operation, {}), + ), + ).to.throw( + 'Argument "InvalidDefaultWithFragmentQuery.field(input:)" has invalid default value: String cannot represent a non string value: 123', + ); + + const missing = getFieldNode('{ field }'); + expect(() => + getCompiledArgumentValues( + compileArgumentValues( + fieldDef, + missing.fieldNode, + false, + fragmentVariableValues, + ), + ), + ).to.throw( + 'Argument "Query.field(required:)" of required type "Boolean!" was not provided.', + ); + }); + + it('validates invalid variable arguments with fragment variables', () => { + const fragmentVariableValues: FragmentVariableValues = { + sources: Object.create(null), + coerced: Object.create(null), + }; + const invalidVariable = getFieldNode(` + query ($required: Boolean) { + field(required: $required) + } + `); + + expect(() => + getCompiledArgumentValues( + compileArgumentValues( + fieldDef, + invalidVariable.fieldNode, + false, + fragmentVariableValues, + ), + getCoercedVariableValues(invalidVariable.operation, { required: null }), + ), + ).to.throw('Argument "Query.field(required:)" has invalid value'); + }); + + it('captures error suggestion behavior in the compilation', () => { + const { fieldNode } = getFieldNode(` + { + field(required: true, flag: ENABLE) + } + `); + const compiled = compileArgumentValues( + fieldDef, + fieldNode, + true, + undefined, + ); + + let thrownError: Error | undefined; + try { + getCompiledArgumentValues(compiled); + } catch (error) { + thrownError = error as Error; + } + + expect(thrownError?.message).to.contain( + 'Value "ENABLE" does not exist in "Flag" enum.', + ); + expect(thrownError?.message).not.to.contain('Did you mean'); + }); +}); diff --git a/src/execution/compile/__tests__/getCompiledDeferUsage-test.ts b/src/execution/compile/__tests__/getCompiledDeferUsage-test.ts new file mode 100644 index 0000000000..762c4e19ee --- /dev/null +++ b/src/execution/compile/__tests__/getCompiledDeferUsage-test.ts @@ -0,0 +1,145 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import type { + DirectiveNode, + OperationDefinitionNode, +} from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import { getVariableValues } from '../../values.ts'; + +import { compileDeferDirective } from '../compileDeferDirective.ts'; +import { getCompiledDeferUsage } from '../getCompiledDeferUsage.ts'; + +const schema = buildSchema('type Query { field: String }'); + +function getDeferDirectiveNode(query: string): { + directiveNode: DirectiveNode | undefined; + operation: OperationDefinitionNode; +} { + const document = parse(query); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const selection = operation.selectionSet.selections[0]; + invariant(selection.kind === Kind.INLINE_FRAGMENT); + return { + directiveNode: selection.directives?.find( + (directive) => directive.name.value === 'defer', + ), + operation, + }; +} + +function getCoercedVariableValues( + operation: OperationDefinitionNode, + variableValues: { readonly [variable: string]: unknown } = {}, +) { + const result = getVariableValues( + schema, + operation.variableDefinitions ?? [], + variableValues, + ); + invariant('variableValues' in result); + return result.variableValues; +} + +describe('getCompiledDeferUsage', () => { + it('returns undefined when there is no defer directive', () => { + const { directiveNode, operation } = + getDeferDirectiveNode('{ ... { field } }'); + + expect( + getCompiledDeferUsage( + { deferDirective: compileDeferDirective(directiveNode) }, + undefined, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.equal(undefined); + }); + + it('uses defer by default and reads static labels', () => { + const { directiveNode, operation } = getDeferDirectiveNode( + '{ ... @defer(label: "deferred") { field } }', + ); + + expect( + getCompiledDeferUsage( + { deferDirective: compileDeferDirective(directiveNode) }, + undefined, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.deep.equal({ + label: 'deferred', + parentDeferUsage: undefined, + }); + }); + + it('treats null labels as absent labels', () => { + const { directiveNode, operation } = getDeferDirectiveNode( + '{ ... @defer(label: null) { field } }', + ); + + expect( + getCompiledDeferUsage( + { deferDirective: compileDeferDirective(directiveNode) }, + undefined, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.deep.equal({ + label: undefined, + parentDeferUsage: undefined, + }); + }); + + it('skips defer usage when if is false', () => { + const { directiveNode, operation } = getDeferDirectiveNode( + '{ ... @defer(if: false) { field } }', + ); + + expect( + getCompiledDeferUsage( + { deferDirective: compileDeferDirective(directiveNode) }, + undefined, + getCoercedVariableValues(operation), + undefined, + false, + ), + ).to.equal(undefined); + }); + + it('uses runtime variables and preserves parent defer usage', () => { + const { directiveNode, operation } = getDeferDirectiveNode( + 'query ($defer: Boolean!) { ... @defer(if: $defer) { field } }', + ); + const parentDeferUsage = { + label: 'parent', + parentDeferUsage: undefined, + }; + + expect( + getCompiledDeferUsage( + { deferDirective: compileDeferDirective(directiveNode) }, + parentDeferUsage, + getCoercedVariableValues(operation, { defer: true }), + undefined, + false, + ), + ).to.deep.equal({ + label: undefined, + parentDeferUsage, + }); + }); +}); diff --git a/src/execution/compile/__tests__/getCompiledDirectiveIfValue-test.ts b/src/execution/compile/__tests__/getCompiledDirectiveIfValue-test.ts new file mode 100644 index 0000000000..e2288e3381 --- /dev/null +++ b/src/execution/compile/__tests__/getCompiledDirectiveIfValue-test.ts @@ -0,0 +1,299 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import type { + DirectiveNode, + OperationDefinitionNode, +} from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import { GraphQLNonNull } from '../../../type/definition.ts'; +import { GraphQLBoolean } from '../../../type/scalars.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import type { FragmentVariableValues } from '../../collectFields.ts'; +import type { GraphQLVariableSignature } from '../../getVariableSignature.ts'; +import { getVariableValues } from '../../values.ts'; + +import type { CompiledDirectiveArgument } from '../compileBooleanDirective.ts'; +import { compileBooleanDirective } from '../compileBooleanDirective.ts'; +import { getCompiledDirectiveIfValue } from '../getCompiledDirectiveIfValue.ts'; + +const schema = buildSchema('type Query { field: String }'); +const BOOLEAN_NON_NULL = new GraphQLNonNull(GraphQLBoolean); +const TEST_IF_ARGUMENT: CompiledDirectiveArgument = { + coordinate: '@test(if:)', + type: BOOLEAN_NON_NULL, + defaultValue: undefined, +}; +const TEST_IF_ARGUMENT_WITH_DEFAULT: CompiledDirectiveArgument = { + coordinate: '@test(if:)', + type: GraphQLBoolean, + defaultValue: true, +}; +const booleanSignature: GraphQLVariableSignature = { + name: 'show', + type: GraphQLBoolean, + default: undefined, +}; + +function getDirectiveNode(query: string): { + directiveNode: DirectiveNode | undefined; + operation: OperationDefinitionNode; +} { + const document = parse(query); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const fieldNode = operation.selectionSet.selections[0]; + invariant(fieldNode.kind === Kind.FIELD); + return { + directiveNode: fieldNode.directives?.find( + (directive) => directive.name.value === 'test', + ), + operation, + }; +} + +function getCoercedVariableValues( + operation: OperationDefinitionNode, + variableValues: { readonly [variable: string]: unknown }, +) { + const result = getVariableValues( + schema, + operation.variableDefinitions ?? [], + variableValues, + ); + invariant('variableValues' in result); + return result.variableValues; +} + +function getFragmentVariableValues(value: unknown): FragmentVariableValues { + return { + sources: { + show: { + signature: booleanSignature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { show: value }, + }; +} + +describe('getCompiledDirectiveIfValue', () => { + it('returns undefined when there is no directive', () => { + const { directiveNode, operation } = getDirectiveNode('{ field }'); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, {}), + undefined, + false, + ), + ).to.equal(undefined); + }); + + it('compiles static boolean values', () => { + const { directiveNode, operation } = getDirectiveNode( + '{ field @test(if: false) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, {}), + undefined, + false, + ), + ).to.equal(false); + }); + + it('reads runtime operation variables', () => { + const { directiveNode, operation } = getDirectiveNode( + 'query ($show: Boolean!) { field @test(if: $show) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, { show: true }), + undefined, + false, + ), + ).to.equal(true); + }); + + it('uses directive argument defaults for missing runtime variables', () => { + const { directiveNode, operation } = getDirectiveNode( + '{ field @test(if: $show) }', + ); + const compiled = compileBooleanDirective( + directiveNode, + TEST_IF_ARGUMENT_WITH_DEFAULT, + ); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, {}), + undefined, + false, + ), + ).to.equal(true); + }); + + it('throws for invalid runtime operation variables', () => { + const { directiveNode, operation } = getDirectiveNode( + 'query ($show: Boolean) { field @test(if: $show) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect(() => + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, { show: null }), + undefined, + false, + ), + ).to.throw('Argument "@test(if:)" has invalid value'); + }); + + it('reads static fragment variables before runtime variables', () => { + const { directiveNode, operation } = getDirectiveNode( + 'query ($show: Boolean!) { field @test(if: $show) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, { show: true }), + { + runtime: undefined, + static: getFragmentVariableValues(false), + }, + false, + ), + ).to.equal(false); + }); + + it('reads runtime fragment variables before operation variables', () => { + const { directiveNode, operation } = getDirectiveNode( + 'query ($show: Boolean!) { field @test(if: $show) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, { show: false }), + { + runtime: getFragmentVariableValues(true), + static: undefined, + }, + false, + ), + ).to.equal(true); + }); + + it('uses operation variables when fragment variables do not bind the variable', () => { + const { directiveNode, operation } = getDirectiveNode( + 'query ($show: Boolean!) { field @test(if: $show) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect( + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, { show: true }), + { + runtime: { + sources: Object.create(null), + coerced: Object.create(null), + }, + static: undefined, + }, + false, + ), + ).to.equal(true); + }); + + it('throws for a missing required if argument', () => { + const { directiveNode, operation } = getDirectiveNode('{ field @test }'); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect(() => + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, {}), + undefined, + false, + ), + ).to.throw( + 'Argument "@test(if:)" of required type "Boolean!" was not provided.', + ); + }); + + it('throws for invalid literal values', () => { + const { directiveNode, operation } = getDirectiveNode( + '{ field @test(if: null) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect(() => + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, {}), + undefined, + false, + ), + ).to.throw('Argument "@test(if:)" has invalid value'); + }); + + it('throws for invalid literal values with fragment variables', () => { + const { directiveNode, operation } = getDirectiveNode( + '{ field @test(if: null) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect(() => + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, {}), + { + runtime: getFragmentVariableValues(true), + static: undefined, + }, + false, + ), + ).to.throw('Argument "@test(if:)" has invalid value'); + }); + + it('throws for invalid static fragment variable values', () => { + const { directiveNode, operation } = getDirectiveNode( + 'query ($show: Boolean!) { field @test(if: $show) }', + ); + const compiled = compileBooleanDirective(directiveNode, TEST_IF_ARGUMENT); + + expect(() => + getCompiledDirectiveIfValue( + compiled, + getCoercedVariableValues(operation, { show: true }), + { + runtime: undefined, + static: getFragmentVariableValues(null), + }, + false, + ), + ).to.throw('Argument "@test(if:)" has invalid value'); + }); +}); diff --git a/src/execution/compile/__tests__/getCompiledDirectiveValues-test.ts b/src/execution/compile/__tests__/getCompiledDirectiveValues-test.ts new file mode 100644 index 0000000000..e70b5c29b3 --- /dev/null +++ b/src/execution/compile/__tests__/getCompiledDirectiveValues-test.ts @@ -0,0 +1,325 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; + +import type { + FieldNode, + OperationDefinitionNode, +} from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import type { FragmentVariableValues } from '../../collectFields.ts'; +import { getVariableValues } from '../../values.ts'; + +import { + compileStreamDirective, + withStreamDirectiveVariableValues, +} from '../compileStreamDirective.ts'; +import { getCompiledDirectiveValues } from '../getCompiledDirectiveValues.ts'; + +const schema = buildSchema('type Query { field(count: Int): [String] }'); + +function getFieldNode(query: string): { + fieldNode: FieldNode; + operation: OperationDefinitionNode; +} { + const document = parse(query); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const fieldNode = operation.selectionSet.selections[0]; + invariant(fieldNode.kind === Kind.FIELD); + return { fieldNode, operation }; +} + +function getCoercedVariableValues( + operation: OperationDefinitionNode, + variableValues: { readonly [variable: string]: unknown }, +) { + const result = getVariableValues( + schema, + operation.variableDefinitions ?? [], + variableValues, + ); + invariant('variableValues' in result); + return result.variableValues; +} + +function getStreamDirectiveNode(fieldNode: FieldNode) { + return fieldNode.directives?.find( + (directiveNode) => directiveNode.name.value === 'stream', + ); +} + +describe('getCompiledDirectiveValues', () => { + it('returns undefined when there is no stream directive', () => { + const { fieldNode } = getFieldNode('{ field }'); + const compiled = compileStreamDirective(getStreamDirectiveNode(fieldNode)); + + expect(getCompiledDirectiveValues(compiled)).to.equal(undefined); + }); + + it('compiles static stream values and directive defaults', () => { + const { fieldNode } = getFieldNode(` + { + field @stream(initialCount: 2, if: false, label: "items") + } + `); + const staticDirective = compileStreamDirective( + getStreamDirectiveNode(fieldNode), + ); + + expect(getCompiledDirectiveValues(staticDirective)).to.deep.equal({ + initialCount: 2, + if: false, + label: 'items', + }); + + const nullLabel = getFieldNode('{ field @stream(label: null) }'); + const nullLabelDirective = compileStreamDirective( + getStreamDirectiveNode(nullLabel.fieldNode), + ); + + expect(getCompiledDirectiveValues(nullLabelDirective)).to.deep.equal({ + initialCount: 0, + if: true, + }); + + const defaults = getFieldNode('{ field @stream }'); + const defaultDirective = compileStreamDirective( + getStreamDirectiveNode(defaults.fieldNode), + ); + + expect(getCompiledDirectiveValues(defaultDirective)).to.deep.equal({ + initialCount: 0, + if: true, + }); + }); + + it('does not bind variable scopes for static stream arguments', () => { + const { fieldNode, operation } = getFieldNode(` + query ($stream: Boolean!) { + field @stream(initialCount: 2, if: false) + } + `); + const variableValues = getCoercedVariableValues(operation, { + stream: true, + }); + const fragmentVariables: FragmentVariableValues = { + sources: { + stream: { + signature: variableValues.sources.stream.signature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { stream: true }, + }; + const compiled = compileStreamDirective(getStreamDirectiveNode(fieldNode)); + + expect( + withStreamDirectiveVariableValues(compiled, fragmentVariables), + ).to.equal(compiled); + expect(withStreamDirectiveVariableValues(null, fragmentVariables)).to.equal( + null, + ); + + const variableDirective = getFieldNode(` + query ($stream: Boolean!) { + field @stream(if: $stream) + } + `); + const variableDirectiveCompilation = compileStreamDirective( + getStreamDirectiveNode(variableDirective.fieldNode), + ); + expect( + withStreamDirectiveVariableValues(variableDirectiveCompilation), + ).to.equal(variableDirectiveCompilation); + expect( + withStreamDirectiveVariableValues( + variableDirectiveCompilation, + undefined, + fragmentVariables, + ), + ).not.to.equal(variableDirectiveCompilation); + }); + + it('reads operation variables and missing nullable variables', () => { + const { fieldNode, operation } = getFieldNode(` + query ($stream: Boolean!, $count: Int!, $label: String) { + field @stream(if: $stream, initialCount: $count, label: $label) + } + `); + const variableValues = getCoercedVariableValues(operation, { + stream: false, + count: 2, + }); + const variableValuesWithLabel = getCoercedVariableValues(operation, { + stream: false, + count: 2, + label: 'items', + }); + const compiled = compileStreamDirective(getStreamDirectiveNode(fieldNode)); + + expect(getCompiledDirectiveValues(compiled, variableValues)).to.deep.equal({ + initialCount: 2, + if: false, + }); + expect( + getCompiledDirectiveValues(compiled, variableValuesWithLabel), + ).to.deep.equal({ + initialCount: 2, + if: false, + }); + + const missing = getFieldNode(` + query ($stream: Boolean) { + field @stream(if: $stream) + } + `); + const missingVariableValues = getCoercedVariableValues( + missing.operation, + {}, + ); + const missingDirective = compileStreamDirective( + getStreamDirectiveNode(missing.fieldNode), + ); + + expect( + getCompiledDirectiveValues(missingDirective, missingVariableValues), + ).to.deep.equal({ + initialCount: 0, + if: true, + }); + expect(getCompiledDirectiveValues(missingDirective)).to.deep.equal({ + initialCount: 0, + if: true, + }); + }); + + it('prefers precomputed static fragment variables over runtime variables', () => { + const { fieldNode, operation } = getFieldNode(` + query ($stream: Boolean!, $count: Int!) { + field @stream(if: $stream, initialCount: $count) + } + `); + const variableValues = getCoercedVariableValues(operation, { + stream: true, + count: 2, + }); + const runtimeFragmentVariables: FragmentVariableValues = { + sources: { + stream: { + signature: variableValues.sources.stream.signature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { stream: true }, + }; + const staticFragmentVariables: FragmentVariableValues = { + sources: { + stream: { + signature: variableValues.sources.stream.signature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { stream: false }, + }; + const compiled = withStreamDirectiveVariableValues( + compileStreamDirective(getStreamDirectiveNode(fieldNode)), + runtimeFragmentVariables, + staticFragmentVariables, + ); + + expect(getCompiledDirectiveValues(compiled, variableValues)).to.deep.equal({ + initialCount: 2, + if: false, + }); + }); + + it('uses runtime fragment variables when no static value is available', () => { + const { fieldNode, operation } = getFieldNode(` + query ($stream: Boolean!, $count: Int!) { + field @stream(if: $stream, initialCount: $count) + } + `); + const variableValues = getCoercedVariableValues(operation, { + stream: true, + count: 2, + }); + const fragmentVariables: FragmentVariableValues = { + sources: { + stream: { + signature: variableValues.sources.stream.signature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { stream: false }, + }; + const compiled = withStreamDirectiveVariableValues( + compileStreamDirective(getStreamDirectiveNode(fieldNode)), + fragmentVariables, + ); + + expect(getCompiledDirectiveValues(compiled, variableValues)).to.deep.equal({ + initialCount: 2, + if: false, + }); + }); + + it('throws stored directive argument errors for values validation would reject', () => { + const invalidLiteral = getFieldNode( + '{ field @stream(initialCount: "bad") }', + ); + const invalidLiteralDirective = compileStreamDirective( + getStreamDirectiveNode(invalidLiteral.fieldNode), + ); + + expect(() => getCompiledDirectiveValues(invalidLiteralDirective)).to.throw( + 'Argument "@stream(initialCount:)" has invalid value', + ); + + const invalidVariableBackedLiteral = getFieldNode(` + query ($count: Int!) { + field @stream(initialCount: [$count]) + } + `); + const invalidVariableBackedDirective = compileStreamDirective( + getStreamDirectiveNode(invalidVariableBackedLiteral.fieldNode), + ); + const countVariableValues = getCoercedVariableValues( + invalidVariableBackedLiteral.operation, + { count: 1 }, + ); + + expect(() => + getCompiledDirectiveValues( + invalidVariableBackedDirective, + countVariableValues, + ), + ).to.throw('Argument "@stream(initialCount:)" has invalid value'); + + const invalidVariable = getFieldNode(` + query ($stream: Boolean) { + field @stream(if: $stream) + } + `); + const variableValues = getCoercedVariableValues(invalidVariable.operation, { + stream: null, + }); + const invalidVariableDirective = compileStreamDirective( + getStreamDirectiveNode(invalidVariable.fieldNode), + ); + + expect(() => + getCompiledDirectiveValues(invalidVariableDirective, variableValues), + ).to.throw('Argument "@stream(if:)" has invalid value'); + }); +}); diff --git a/src/execution/compile/__tests__/getCompiledVariableValues-test.ts b/src/execution/compile/__tests__/getCompiledVariableValues-test.ts new file mode 100644 index 0000000000..631d8f81fb --- /dev/null +++ b/src/execution/compile/__tests__/getCompiledVariableValues-test.ts @@ -0,0 +1,209 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { expectMatchingValues } from '../../../__testUtils__/expectMatchingValues.ts'; + +import { invariant } from '../../../jsutils/invariant.ts'; +import type { ReadOnlyObjMap } from '../../../jsutils/ObjMap.ts'; + +import { Parser } from '../../../language/parser.ts'; +import { TokenKind } from '../../../language/tokenKind.ts'; + +import { + GraphQLInputObjectType, + GraphQLObjectType, +} from '../../../type/definition.ts'; +import { GraphQLString } from '../../../type/scalars.ts'; +import { GraphQLSchema } from '../../../type/schema.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import { getVariableValues } from '../../values.ts'; + +import { compileVariableValues } from '../compileVariableValues.ts'; +import { getCompiledVariableValues } from '../getCompiledVariableValues.ts'; + +const schema = buildSchema(` + input Input { + required: Boolean! + optional: Int = 7 + } + + enum Color { + RED + GREEN + } + + type Query { + dummy: String + } +`); + +describe('getCompiledVariableValues', () => { + it('matches valid variable coercion, defaults, and omitted values', () => { + const result = testVariableValues( + '($required: Boolean!, $optional: Int = 3, $nullable: Boolean, $input: Input, $list: [Boolean!])', + { + required: false, + input: { required: true }, + list: [true], + }, + ); + + invariant(result.variableValues !== undefined); + expect(result.variableValues.coerced).to.deep.equal({ + required: false, + optional: 3, + input: { required: true, optional: 7 }, + list: [true], + }); + expect(result.variableValues.sources).to.have.keys([ + 'required', + 'optional', + 'nullable', + 'input', + 'list', + ]); + }); + + it('matches invalid variable coercion errors', () => { + const result = testVariableValues( + '($required: Boolean!, $input: Input, $color: Color)', + { + required: null, + input: { required: null, extra: true }, + color: 'BLUE', + }, + ); + + invariant(result.errors !== undefined); + expect(result.errors).to.have.length(4); + }); + + it('matches omitted required variable errors', () => { + const result = testVariableValues('($required: Boolean!)', {}); + + invariant(result.errors !== undefined); + expect(result.errors).to.have.length(1); + expect(result.errors[0].message).to.equal( + 'Variable "$required" has invalid value: Expected a value of non-null type "Boolean!" to be provided.', + ); + }); + + it('matches max coercion error handling', () => { + const result = testVariableValues( + '($a: Boolean!, $b: Boolean!)', + { a: null, b: null }, + { maxErrors: 1 }, + ); + + invariant(result.errors !== undefined); + expect(result.errors).to.have.length(2); + expect(result.errors[1].message).to.equal( + 'Too many errors processing variables, error limit reached. Execution aborted.', + ); + }); + + it('matches invalid variable defaults', () => { + const result = testVariableValues('($value: Int = "bad")', {}); + + invariant(result.errors !== undefined); + expect(result.errors).to.have.length(1); + expect(result.errors[0].message).to.contain( + 'Variable "$value" has invalid default value', + ); + }); + + it('matches nested default coercion errors', () => { + const invalidDefaultSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + dummy: { type: GraphQLString }, + }, + }), + types: [ + new GraphQLInputObjectType({ + name: 'InputWithInvalidFieldDefault', + fields: { + value: { type: GraphQLString, default: { value: 123 } }, + }, + }), + ], + }); + const result = testVariableValues( + '($value: InputWithInvalidFieldDefault = {})', + {}, + undefined, + invalidDefaultSchema, + ); + + invariant(result.errors !== undefined); + expect(result.errors).to.have.length(1); + expect(result.errors[0].message).to.equal( + 'Variable "$value" has invalid default value: Expected value of type "String" to be valid, found: 123.', + ); + }); + + it('matches invalid variable signatures', () => { + const result = testVariableValues('($value: Missing)', {}); + + invariant(result.errors !== undefined); + expect(result.errors).to.have.length(1); + expect(result.errors[0].message).to.equal( + 'Variable "$value" expected value of type "Missing" which cannot be used as an input type.', + ); + }); + + it('matches hidden suggestion behavior', () => { + const result = testVariableValues( + '($color: Color!)', + { color: 'INVALID' }, + { hideSuggestions: true }, + ); + + invariant(result.errors !== undefined); + expect(result.errors[0].message).to.contain( + 'Value "INVALID" does not exist in "Color" enum.', + ); + expect(result.errors[0].message).not.to.contain('Did you mean'); + }); +}); + +function testVariableValues( + variableDefinitions: string, + inputs: ReadOnlyObjMap, + options?: { maxErrors?: number; hideSuggestions?: boolean }, + testSchema = schema, +) { + const parser = new Parser(variableDefinitions); + parser.expectToken(TokenKind.SOF); + const varDefNodes = parser.parseVariableDefinitions() ?? []; + const genericOptions = + options === undefined + ? undefined + : { + ...(options.maxErrors === undefined + ? undefined + : { maxErrors: options.maxErrors }), + ...(options.hideSuggestions === undefined + ? undefined + : { hideSuggestions: options.hideSuggestions }), + }; + return expectMatchingValues([ + () => getVariableValues(testSchema, varDefNodes, inputs, genericOptions), + () => { + const compiled = compileVariableValues( + testSchema, + varDefNodes, + options?.hideSuggestions ?? false, + ); + return getCompiledVariableValues( + compiled, + inputs, + options?.maxErrors ?? 50, + ); + }, + ]); +} diff --git a/src/execution/compile/__tests__/getStaticFragmentVariableValues-test.ts b/src/execution/compile/__tests__/getStaticFragmentVariableValues-test.ts new file mode 100644 index 0000000000..abddfa78fb --- /dev/null +++ b/src/execution/compile/__tests__/getStaticFragmentVariableValues-test.ts @@ -0,0 +1,198 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { invariant } from '../../../jsutils/invariant.ts'; +import type { ObjMap } from '../../../jsutils/ObjMap.ts'; + +import type { + FragmentDefinitionNode, + FragmentSpreadNode, +} from '../../../language/ast.ts'; +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import { GraphQLBoolean } from '../../../type/scalars.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import type { FragmentVariableValues } from '../../collectFields.ts'; +import type { GraphQLVariableSignature } from '../../getVariableSignature.ts'; +import { getVariableSignature } from '../../getVariableSignature.ts'; + +import { compileFragmentVariables } from '../compileFragmentVariables.ts'; +import { getStaticFragmentVariableValues } from '../getStaticFragmentVariableValues.ts'; + +const schema = buildSchema(` + input FlagInput { + enabled: Boolean + } + + type Query { + child: Query + field: String + } +`); + +function getFragmentCase(query: string): { + fragmentSpread: FragmentSpreadNode; + fragmentDefinition: FragmentDefinitionNode; +} { + const document = parse(query, { experimentalFragmentArguments: true }); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const fragmentSpread = operation.selectionSet.selections[0]; + invariant(fragmentSpread.kind === Kind.FRAGMENT_SPREAD); + const fragmentDefinition = document.definitions[1]; + invariant(fragmentDefinition.kind === Kind.FRAGMENT_DEFINITION); + return { fragmentSpread, fragmentDefinition }; +} + +function getVariableSignatures( + fragmentDefinition: FragmentDefinitionNode, +): ObjMap { + const signatures: ObjMap = Object.create(null); + for (const variableDefinition of fragmentDefinition.variableDefinitions ?? + []) { + const signature = getVariableSignature(schema, variableDefinition); + invariant(!('message' in signature)); + signatures[signature.name] = signature; + } + return signatures; +} + +function getParentStaticValues(): FragmentVariableValues { + const signature: GraphQLVariableSignature = { + name: 'operationFlag', + type: GraphQLBoolean, + default: undefined, + }; + return { + sources: { + operationFlag: { + signature, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { operationFlag: true }, + }; +} + +describe('getStaticFragmentVariableValues', () => { + it('gets static fragment variable values', () => { + const { fragmentSpread, fragmentDefinition } = getFragmentCase(` + { + ...Fragment( + show: true + input: { enabled: false } + values: [true, false] + ) + } + + fragment Fragment( + $show: Boolean + $input: FlagInput + $values: [Boolean] + ) on Query { + field + } + `); + + const signatures = getVariableSignatures(fragmentDefinition); + const compiled = compileFragmentVariables(fragmentSpread, signatures); + + expect(getStaticFragmentVariableValues(compiled, undefined)).to.deep.equal({ + sources: { + show: { + signature: signatures.show, + value: fragmentSpread.arguments?.[0].value, + fragmentVariableValues: undefined, + }, + input: { + signature: signatures.input, + value: fragmentSpread.arguments?.[1].value, + fragmentVariableValues: undefined, + }, + values: { + signature: signatures.values, + value: fragmentSpread.arguments?.[2].value, + fragmentVariableValues: undefined, + }, + }, + coerced: { + show: true, + input: { enabled: false }, + values: [true, false], + }, + }); + }); + + it('uses static parent values for dynamic fragment variable values', () => { + const { fragmentSpread, fragmentDefinition } = getFragmentCase(` + { + ...Fragment(show: $operationFlag, values: [false, $operationFlag]) + } + + fragment Fragment($show: Boolean, $values: [Boolean]) on Query { + field + } + `); + + const compiled = compileFragmentVariables( + fragmentSpread, + getVariableSignatures(fragmentDefinition), + ); + const staticValues = getStaticFragmentVariableValues( + compiled, + getParentStaticValues(), + ); + + expect(staticValues?.coerced).to.deep.equal({ + show: true, + values: [false, true], + }); + }); + + it('uses fragment variable defaults as static values', () => { + const { fragmentSpread, fragmentDefinition } = getFragmentCase(` + { + ...Fragment + } + + fragment Fragment($show: Boolean = false) on Query { + field + } + `); + + const compiled = compileFragmentVariables( + fragmentSpread, + getVariableSignatures(fragmentDefinition), + ); + + expect( + getStaticFragmentVariableValues(compiled, undefined)?.coerced, + ).to.deep.equal({ show: false }); + }); + + it('drops invalid static values', () => { + const { fragmentSpread, fragmentDefinition } = getFragmentCase(` + { + ...Fragment(show: "bad") + } + + fragment Fragment($show: Boolean) on Query { + field + } + `); + + const compiled = compileFragmentVariables( + fragmentSpread, + getVariableSignatures(fragmentDefinition), + ); + + expect(getStaticFragmentVariableValues(compiled, undefined)).to.equal( + undefined, + ); + }); +}); diff --git a/src/execution/compile/buildValidatedExecutionArgs.ts b/src/execution/compile/buildValidatedExecutionArgs.ts new file mode 100644 index 0000000000..00610a9ab0 --- /dev/null +++ b/src/execution/compile/buildValidatedExecutionArgs.ts @@ -0,0 +1,56 @@ +import type { + GraphQLFieldResolver, + GraphQLTypeResolver, +} from '../../type/definition.ts'; + +import type { + CompiledExecutionArgs, + ValidatedExecutionArgs, +} from '../ExecutionArgs.ts'; +import type { VariableValues } from '../values.ts'; + +import type { CompiledExecutionState } from './compileExecutionState.ts'; + +/** @internal */ +interface ExecutionArgDefaults { + /** Resolver used when a field does not define its own resolver. */ + fieldResolver: GraphQLFieldResolver; + /** Resolver used when an abstract type does not define its own resolver. */ + typeResolver: GraphQLTypeResolver; + /** Resolver used for the root subscription field. */ + subscribeFieldResolver: GraphQLFieldResolver; +} + +/** @internal */ +export function buildValidatedExecutionArgs( + compiledExecution: CompiledExecutionState, + args: CompiledExecutionArgs, + variableValues: VariableValues, + defaultResolvers: ExecutionArgDefaults, +): ValidatedExecutionArgs { + return { + schema: compiledExecution.schema, + document: compiledExecution.document, + fragmentDefinitions: compiledExecution.fragmentDefinitions, + fragments: compiledExecution.fragments, + rootValue: args.rootValue, + contextValue: args.contextValue, + operation: compiledExecution.operation, + variableValues, + rawVariableValues: args.variableValues, + fieldResolver: + compiledExecution.fieldResolver ?? defaultResolvers.fieldResolver, + typeResolver: + compiledExecution.typeResolver ?? defaultResolvers.typeResolver, + subscribeFieldResolver: + compiledExecution.subscribeFieldResolver ?? + defaultResolvers.subscribeFieldResolver, + hideSuggestions: compiledExecution.hideSuggestions, + errorPropagation: compiledExecution.errorPropagation, + externalAbortSignal: args.abortSignal ?? undefined, + enableEarlyExecution: compiledExecution.enableEarlyExecution, + enableBatchResolvers: compiledExecution.enableBatchResolvers, + hooks: compiledExecution.hooks, + fieldCollectors: compiledExecution, + }; +} diff --git a/src/execution/compile/compileArgumentValues.ts b/src/execution/compile/compileArgumentValues.ts new file mode 100644 index 0000000000..3a2e4e8aff --- /dev/null +++ b/src/execution/compile/compileArgumentValues.ts @@ -0,0 +1,207 @@ +import type { ObjMap } from '../../jsutils/ObjMap.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import type { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { FieldNode, ValueNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { GraphQLArgument, GraphQLField } from '../../type/definition.ts'; +import { isNonNullType, isRequiredArgument } from '../../type/definition.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; + +import type { InputLiteralCoercer } from './compileInputValue.ts'; +import { + compileInputLiteral, + getDefaultInputValue, + isStaticInputLiteral, +} from './compileInputValue.ts'; + +/** @internal */ +export type ArgumentValueEntry = + | ConstantArgumentValueEntry + | BareVariableArgumentValueEntry + | EmbeddedVariableArgumentValueEntry + | InvalidLiteralArgumentValueEntry + | InvalidDefaultArgumentValueEntry + | MissingRequiredArgumentValueEntry; + +/** @internal */ +export interface ConstantArgumentValueEntry { + kind: 'constant'; + name: string; + value: unknown; +} + +/** @internal */ +export interface BareVariableArgumentValueEntry { + kind: 'bareVariable'; + name: string; + argDef: GraphQLArgument; + variableName: string; + valueNode: ValueNode; + valueBuilder: InputLiteralCoercer; + defaultValue: unknown; + defaultValueError: GraphQLError | undefined; + isNonNull: boolean; + isRequired: boolean; +} + +/** @internal */ +export interface EmbeddedVariableArgumentValueEntry { + kind: 'embeddedVariable'; + name: string; + argDef: GraphQLArgument; + valueNode: ValueNode; + valueBuilder: InputLiteralCoercer; +} + +/** @internal */ +export interface InvalidLiteralArgumentValueEntry { + kind: 'invalidLiteral'; + name: string; + argDef: GraphQLArgument; + valueNode: ValueNode; + valueBuilder: InputLiteralCoercer; +} + +/** @internal */ +export interface InvalidDefaultArgumentValueEntry { + kind: 'invalidDefault'; + name: string; + argDef: GraphQLArgument; + error: GraphQLError; +} + +/** @internal */ +export interface MissingRequiredArgumentValueEntry { + kind: 'missing'; + name: string; + argDef: GraphQLArgument; +} + +/** @internal */ +export interface CompiledArgumentValues { + node: FieldNode; + entries: ReadonlyArray; + entryByName: ObjMap; + constantValues: ObjMap | undefined; + fragmentVariableValues: FragmentVariableValues | undefined; + hideSuggestions: boolean; +} + +/** @internal */ +export function compileArgumentValues( + fieldDef: GraphQLField, + fieldNode: FieldNode, + hideSuggestions: boolean, + fragmentVariableValues: FragmentVariableValues | undefined, +): CompiledArgumentValues { + const argValueNodeMap = new Map(); + for (const argNode of fieldNode.arguments ?? []) { + argValueNodeMap.set(argNode.name.value, argNode.value); + } + const entries: Array = []; + const entryByName: ObjMap = Object.create(null); + const constantValues: ObjMap = Object.create(null); + let allConstant = true; + + for (const argDef of fieldDef.args) { + const entry = compileArgumentValueEntry( + argDef, + argValueNodeMap.get(argDef.name), + ); + if (entry === undefined) { + continue; + } + + entries.push(entry); + entryByName[entry.name] = entry; + if (entry.kind === 'constant') { + constantValues[entry.name] = entry.value; + } else { + allConstant = false; + } + } + + return { + node: fieldNode, + entries, + entryByName, + constantValues: allConstant ? constantValues : undefined, + fragmentVariableValues, + hideSuggestions, + }; +} + +function compileArgumentValueEntry( + argDef: GraphQLArgument, + valueNode: ValueNode | undefined, +): ArgumentValueEntry | undefined { + if (valueNode === undefined) { + if (isRequiredArgument(argDef)) { + return { kind: 'missing', name: argDef.name, argDef }; + } + + try { + const defaultValue = getDefaultInputValue(argDef); + return defaultValue === undefined + ? undefined + : { kind: 'constant', name: argDef.name, value: defaultValue }; + } catch (error) { + return { + kind: 'invalidDefault', + name: argDef.name, + argDef, + error: ensureGraphQLError(error), + }; + } + } + + const valueBuilder = compileInputLiteral(valueNode, argDef.type); + + if (valueNode.kind === Kind.VARIABLE) { + let defaultValue; + let defaultValueError; + try { + defaultValue = getDefaultInputValue(argDef); + } catch (error) { + defaultValueError = ensureGraphQLError(error); + } + return { + kind: 'bareVariable', + name: argDef.name, + argDef, + variableName: valueNode.name.value, + valueNode, + valueBuilder, + defaultValue, + defaultValueError, + isNonNull: isNonNullType(argDef.type), + isRequired: isRequiredArgument(argDef), + }; + } + + if (isStaticInputLiteral(valueNode)) { + const coercedValue = valueBuilder(); + if (coercedValue !== undefined) { + return { kind: 'constant', name: argDef.name, value: coercedValue }; + } + return { + kind: 'invalidLiteral', + name: argDef.name, + argDef, + valueNode, + valueBuilder, + }; + } + + return { + kind: 'embeddedVariable', + name: argDef.name, + argDef, + valueNode, + valueBuilder, + }; +} diff --git a/src/execution/compile/compileBooleanDirective.ts b/src/execution/compile/compileBooleanDirective.ts new file mode 100644 index 0000000000..4ec17d2d02 --- /dev/null +++ b/src/execution/compile/compileBooleanDirective.ts @@ -0,0 +1,48 @@ +import type { DirectiveNode, ValueNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { GraphQLInputType } from '../../type/definition.ts'; + +/** @internal */ +export interface CompiledDirectiveArgument { + coordinate: string; + type: GraphQLInputType; + defaultValue: unknown; +} + +/** @internal */ +export interface CompiledBooleanDirective { + node: DirectiveNode; + ifArgument: CompiledDirectiveArgument; + hasIfArgument: boolean; + ifValueNode: ValueNode | undefined; + ifBooleanValue: boolean | undefined; + ifVariableName: string | undefined; +} + +/** @internal */ +export function compileBooleanDirective( + directiveNode: DirectiveNode | undefined, + ifArgument: CompiledDirectiveArgument, +): CompiledBooleanDirective | undefined { + if (directiveNode === undefined) { + return; + } + + let ifValue; + for (const argumentNode of directiveNode.arguments ?? []) { + if (argumentNode.name.value === 'if') { + ifValue = argumentNode.value; + break; + } + } + return { + node: directiveNode, + ifArgument, + hasIfArgument: ifValue !== undefined, + ifValueNode: ifValue, + ifBooleanValue: ifValue?.kind === Kind.BOOLEAN ? ifValue.value : undefined, + ifVariableName: + ifValue?.kind === Kind.VARIABLE ? ifValue.name.value : undefined, + }; +} diff --git a/src/execution/compile/compileCollectFields.ts b/src/execution/compile/compileCollectFields.ts new file mode 100644 index 0000000000..73670aaf7f --- /dev/null +++ b/src/execution/compile/compileCollectFields.ts @@ -0,0 +1,763 @@ +import { AccumulatorMap } from '../../jsutils/AccumulatorMap.ts'; +import { memoize3 } from '../../jsutils/memoize3.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; + +import type { + DirectiveNode, + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + SelectionSetNode, +} from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { + GraphQLField, + GraphQLObjectType, + GraphQLType, +} from '../../type/definition.ts'; +import { isAbstractType } from '../../type/definition.ts'; +import type { GraphQLSchema } from '../../type/schema.ts'; + +import { typeFromAST } from '../../utilities/typeFromAST.ts'; + +import type { + DeferUsage, + FieldDetails, + FieldDetailsList, + FragmentDetails, + FragmentVariableValues, + RootFieldCollection, + SubfieldCollection, +} from '../collectFields.ts'; +import type { FieldCollectors } from '../ExecutionArgs.ts'; +import type { VariableValues } from '../values.ts'; +import { getFragmentVariableValues } from '../values.ts'; + +import type { CompiledArgumentValues } from './compileArgumentValues.ts'; +import { compileArgumentValues } from './compileArgumentValues.ts'; +import type { DeferDirectiveCompilation } from './compileDeferDirective.ts'; +import { compileDeferDirective } from './compileDeferDirective.ts'; +import type { + CompiledFieldExecutionPlan, + CompiledFieldResolver, +} from './compileFieldExecutionPlan.ts'; +import { + compileFieldExecutionPlan, + compileFieldResolver, +} from './compileFieldExecutionPlan.ts'; +import type { + CompiledFragmentVariables, + FragmentVariables, +} from './compileFragmentVariables.ts'; +import { compileFragmentVariables } from './compileFragmentVariables.ts'; +import type { InclusionDirectiveCompilation } from './compileInclusionDirectives.ts'; +import { + compileIncludeDirective, + compileSkipDirective, + shouldIncludeSelection, +} from './compileInclusionDirectives.ts'; +import type { CompiledStreamDirective } from './compileStreamDirective.ts'; +import { + compileStreamDirective, + withStreamDirectiveVariableValues, +} from './compileStreamDirective.ts'; +import { getCompiledDeferUsage } from './getCompiledDeferUsage.ts'; +import { getStaticFragmentVariableValues } from './getStaticFragmentVariableValues.ts'; + +/* eslint-disable max-params */ + +interface CompiledSelectionSet { + selections: ReadonlyArray; +} + +type CompiledSelection = + | CompiledField + | CompiledInlineFragment + | CompiledFragmentSpread; + +interface CompiledField extends InclusionDirectiveCompilation { + kind: Kind.FIELD; + node: FieldNode; + fieldName: string; + responseName: string; + selectionSet: CompiledSelectionSet | undefined; + compilationsByFieldDef: WeakMap< + GraphQLField, + FieldDefinitionCompilation + >; + compiledStreamDirective: CompiledStreamDirective; +} + +interface FieldDefinitionCompilation { + argumentValues: CompiledArgumentValues; + resolver: CompiledFieldResolver; + fieldPlan: CompiledFieldExecutionPlan | undefined; + fieldDetails: FieldDetails | undefined; + fieldPlanByArgumentValues: WeakMap< + CompiledArgumentValues, + Map + >; +} + +interface CompiledInlineFragment + extends InclusionDirectiveCompilation, DeferDirectiveCompilation { + kind: Kind.INLINE_FRAGMENT; + condition: CompiledFragmentCondition; + selectionSet: CompiledSelectionSet; +} + +interface CompiledFragmentSpread + extends InclusionDirectiveCompilation, DeferDirectiveCompilation { + kind: Kind.FRAGMENT_SPREAD; + node: FragmentSpreadNode; + fragmentName: string; + compiledFragmentVariables: CompiledFragmentVariables | undefined; +} + +interface CompiledFragment { + details: FragmentDetails; + condition: CompiledFragmentCondition; + selectionSet: CompiledSelectionSet; +} + +type CompiledFragmentCondition = GraphQLType | null | undefined; + +interface CollectFieldsContext { + schema: GraphQLSchema; + fragments: ObjMap; + variableValues: VariableValues; + runtimeType: GraphQLObjectType; + visitedFragmentNames: Map; + hideSuggestions: boolean; + usesDefaultFieldResolver: boolean; +} + +const SKIP_DIRECTIVE_NAME = 'skip'; +const INCLUDE_DIRECTIVE_NAME = 'include'; +const DEFER_DIRECTIVE_NAME = 'defer'; +const STREAM_DIRECTIVE_NAME = 'stream'; + +/** @internal */ +export function compileCollectFields( + schema: GraphQLSchema, + fragments: ObjMap, + rootSelectionSet: SelectionSetNode, + hideSuggestions: boolean, + usesDefaultFieldResolver: boolean, +): FieldCollectors { + const compiledSelectionSetByFieldNode = new WeakMap< + FieldNode, + CompiledSelectionSet + >(); + const compiledRootSelectionSet = compileSelectionSet(rootSelectionSet); + const compiledFragments: ObjMap = Object.create(null); + for (const [fragmentName, details] of Object.entries(fragments)) { + compiledFragments[fragmentName] = { + details, + condition: compileFragmentCondition(details.definition), + selectionSet: compileSelectionSet(details.definition.selectionSet), + }; + } + + const collectRootFields = ( + variableValues: VariableValues, + rootType: GraphQLObjectType, + ): RootFieldCollection => { + const groupedFieldSet = new AccumulatorMap(); + const newDeferUsages: Array = []; + + collectFieldsImpl( + createContext(variableValues, rootType), + compiledRootSelectionSet, + groupedFieldSet, + newDeferUsages, + ); + + return { + groupedFieldSet, + newDeferUsages, + forbiddenDirectiveInstances: [], + }; + }; + + const collectSubfields = memoize3( + ( + variableValues: VariableValues, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + ): SubfieldCollection => { + const context = createContext(variableValues, returnType); + const subGroupedFieldSet = new AccumulatorMap(); + const newDeferUsages: Array = []; + + for (const fieldDetail of fieldDetailsList) { + const selectionSet = getCompiledFieldSelectionSet(fieldDetail); + if (selectionSet) { + const { + deferUsage, + fragmentVariableValues, + staticFragmentVariableValues, + } = fieldDetail; + collectFieldsImpl( + context, + selectionSet, + subGroupedFieldSet, + newDeferUsages, + deferUsage, + fragmentVariableValues, + staticFragmentVariableValues, + ); + } + } + + return { + groupedFieldSet: subGroupedFieldSet, + newDeferUsages, + }; + }, + ); + + return { collectRootFields, collectSubfields }; + + function getCompiledFieldSelectionSet( + fieldDetail: FieldDetails, + ): CompiledSelectionSet | undefined { + const selectionSet = fieldDetail.node.selectionSet; + return selectionSet === undefined + ? undefined + : (compiledSelectionSetByFieldNode.get(fieldDetail.node) ?? + compileSelectionSet(selectionSet)); + } + + function createContext( + variableValues: VariableValues, + runtimeType: GraphQLObjectType, + ): CollectFieldsContext { + return { + schema, + fragments: compiledFragments, + variableValues, + runtimeType, + visitedFragmentNames: new Map(), + hideSuggestions, + usesDefaultFieldResolver, + }; + } + + function compileSelectionSet( + selectionSet: SelectionSetNode, + ): CompiledSelectionSet { + return { + selections: selectionSet.selections.map(compileSelection), + }; + } + + function compileSelection( + selection: SelectionSetNode['selections'][number], + ): CompiledSelection { + switch (selection.kind) { + case Kind.FIELD: { + const directives = getFieldDirectiveNodes(selection); + const selectionSet = + selection.selectionSet === undefined + ? undefined + : compileSelectionSet(selection.selectionSet); + if (selectionSet !== undefined) { + compiledSelectionSetByFieldNode.set(selection, selectionSet); + } + return { + kind: Kind.FIELD, + node: selection, + fieldName: selection.name.value, + responseName: selection.alias + ? selection.alias.value + : selection.name.value, + selectionSet, + compilationsByFieldDef: new WeakMap(), + compiledStreamDirective: compileStreamDirective( + directives.streamDirectiveNode, + ), + skipDirective: compileSkipDirective(directives.skipDirectiveNode), + includeDirective: compileIncludeDirective( + directives.includeDirectiveNode, + ), + }; + } + case Kind.INLINE_FRAGMENT: { + const directives = getFragmentDirectiveNodes(selection); + return { + kind: Kind.INLINE_FRAGMENT, + condition: compileFragmentCondition(selection), + selectionSet: compileSelectionSet(selection.selectionSet), + skipDirective: compileSkipDirective(directives.skipDirectiveNode), + includeDirective: compileIncludeDirective( + directives.includeDirectiveNode, + ), + deferDirective: compileDeferDirective(directives.deferDirectiveNode), + }; + } + case Kind.FRAGMENT_SPREAD: { + const directives = getFragmentDirectiveNodes(selection); + return { + kind: Kind.FRAGMENT_SPREAD, + node: selection, + fragmentName: selection.name.value, + compiledFragmentVariables: getCompiledFragmentVariables(selection), + skipDirective: compileSkipDirective(directives.skipDirectiveNode), + includeDirective: compileIncludeDirective( + directives.includeDirectiveNode, + ), + deferDirective: compileDeferDirective(directives.deferDirectiveNode), + }; + } + } + } + + function getCompiledFragmentVariables( + fragmentSpreadNode: FragmentSpreadNode, + ): CompiledFragmentVariables | undefined { + const fragmentVariableSignatures = + fragments[fragmentSpreadNode.name.value]?.variableSignatures; + return fragmentVariableSignatures === undefined + ? undefined + : compileFragmentVariables( + fragmentSpreadNode, + fragmentVariableSignatures, + ); + } + + function compileFragmentCondition( + fragment: FragmentDefinitionNode | InlineFragmentNode, + ): CompiledFragmentCondition { + return fragment.typeCondition === undefined + ? null + : typeFromAST(schema, fragment.typeCondition); + } +} + +function collectFieldsImpl( + context: CollectFieldsContext, + selectionSet: CompiledSelectionSet, + groupedFieldSet: AccumulatorMap, + newDeferUsages: Array, + deferUsage?: DeferUsage, + fragmentVariableValues?: FragmentVariableValues, + staticFragmentVariableValues?: FragmentVariableValues, +): void { + const fragmentVariables = getFragmentVariables( + fragmentVariableValues, + staticFragmentVariableValues, + ); + + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: { + if (!shouldIncludeNode(context, selection, fragmentVariables)) { + continue; + } + const fieldDef = context.schema.getField( + context.runtimeType, + selection.fieldName, + ); + const fieldDetails = + fieldDef === undefined + ? { + node: selection.node, + deferUsage, + fragmentVariableValues, + staticFragmentVariableValues, + compiledFieldPlan: undefined, + } + : deferUsage === undefined && + fragmentVariableValues === undefined && + staticFragmentVariableValues === undefined + ? getOrCompileFieldDetails(selection, fieldDef, context) + : { + node: selection.node, + deferUsage, + fragmentVariableValues, + staticFragmentVariableValues, + compiledFieldPlan: getOrCompileFieldExecutionPlan( + selection, + fieldDef, + context, + fragmentVariableValues, + staticFragmentVariableValues, + ), + }; + groupedFieldSet.add(selection.responseName, fieldDetails); + break; + } + case Kind.INLINE_FRAGMENT: { + if ( + !shouldIncludeNode(context, selection, fragmentVariables) || + !doesFragmentConditionMatch(context, selection.condition) + ) { + continue; + } + + const newDeferUsage = getDeferUsage( + context, + selection, + deferUsage, + fragmentVariables, + ); + + if (newDeferUsage) { + newDeferUsages.push(newDeferUsage); + } + collectFieldsImpl( + context, + selection.selectionSet, + groupedFieldSet, + newDeferUsages, + newDeferUsage ?? deferUsage, + fragmentVariableValues, + staticFragmentVariableValues, + ); + break; + } + case Kind.FRAGMENT_SPREAD: + collectFragmentSpread( + context, + selection, + groupedFieldSet, + newDeferUsages, + deferUsage, + fragmentVariableValues, + staticFragmentVariableValues, + fragmentVariables, + ); + } + } +} + +function getFragmentVariables( + runtime: FragmentVariableValues | undefined, + staticValues: FragmentVariableValues | undefined, +): FragmentVariables | undefined { + return runtime === undefined && staticValues === undefined + ? undefined + : { runtime, static: staticValues }; +} + +function getOrCompileFieldDetails( + selection: CompiledField, + fieldDef: GraphQLField, + context: CollectFieldsContext, +): FieldDetails { + const compilation = getOrCompileFieldDefinition(selection, fieldDef, context); + let fieldDetails = compilation.fieldDetails; + if (fieldDetails === undefined) { + fieldDetails = { + node: selection.node, + deferUsage: undefined, + fragmentVariableValues: undefined, + staticFragmentVariableValues: undefined, + compiledFieldPlan: getOrCompileFieldExecutionPlanForDefinition( + selection, + compilation, + undefined, + undefined, + ), + }; + compilation.fieldDetails = fieldDetails; + } + return fieldDetails; +} + +function getOrCompileFieldExecutionPlan( + selection: CompiledField, + fieldDef: GraphQLField, + context: CollectFieldsContext, + fragmentVariableValues: FragmentVariableValues | undefined, + staticFragmentVariableValues: FragmentVariableValues | undefined, +): CompiledFieldExecutionPlan { + const compilation = getOrCompileFieldDefinition(selection, fieldDef, context); + return getOrCompileFieldExecutionPlanForDefinition( + selection, + compilation, + fragmentVariableValues, + staticFragmentVariableValues, + ); +} + +function getOrCompileFieldExecutionPlanForDefinition( + selection: CompiledField, + compilation: FieldDefinitionCompilation, + fragmentVariableValues: FragmentVariableValues | undefined, + staticFragmentVariableValues: FragmentVariableValues | undefined, +): CompiledFieldExecutionPlan { + if ( + fragmentVariableValues === undefined && + staticFragmentVariableValues === undefined + ) { + let compiledFieldPlan = compilation.fieldPlan; + if (compiledFieldPlan === undefined) { + compiledFieldPlan = compileFieldExecutionPlan( + compilation.resolver, + getCompiledArgsWithFragmentValues(compilation, undefined), + selection.compiledStreamDirective, + ); + compilation.fieldPlan = compiledFieldPlan; + } + return compiledFieldPlan; + } + + const compiledArgumentValues = getCompiledArgsWithFragmentValues( + compilation, + fragmentVariableValues, + ); + const compiledStreamDirective = withStreamDirectiveVariableValues( + selection.compiledStreamDirective, + fragmentVariableValues, + staticFragmentVariableValues, + ); + + let compiledFieldPlanByStreamDirective = + compilation.fieldPlanByArgumentValues.get(compiledArgumentValues); + if (compiledFieldPlanByStreamDirective === undefined) { + compiledFieldPlanByStreamDirective = new Map(); + compilation.fieldPlanByArgumentValues.set( + compiledArgumentValues, + compiledFieldPlanByStreamDirective, + ); + } + + let compiledFieldPlan = compiledFieldPlanByStreamDirective.get( + compiledStreamDirective, + ); + if (compiledFieldPlan === undefined) { + compiledFieldPlan = compileFieldExecutionPlan( + compilation.resolver, + compiledArgumentValues, + compiledStreamDirective, + ); + compiledFieldPlanByStreamDirective.set( + compiledStreamDirective, + compiledFieldPlan, + ); + } + return compiledFieldPlan; +} + +function getOrCompileFieldDefinition( + selection: CompiledField, + fieldDef: GraphQLField, + context: CollectFieldsContext, +): FieldDefinitionCompilation { + let compilation = selection.compilationsByFieldDef.get(fieldDef); + if (compilation === undefined) { + compilation = { + argumentValues: compileArgumentValues( + fieldDef, + selection.node, + context.hideSuggestions, + undefined, + ), + resolver: compileFieldResolver( + fieldDef, + context.usesDefaultFieldResolver, + ), + fieldPlan: undefined, + fieldDetails: undefined, + fieldPlanByArgumentValues: new WeakMap(), + }; + selection.compilationsByFieldDef.set(fieldDef, compilation); + } + return compilation; +} + +function getCompiledArgsWithFragmentValues( + compilation: FieldDefinitionCompilation, + fragmentVariableValues: FragmentVariableValues | undefined, +): CompiledArgumentValues { + const compiledArgumentValues = compilation.argumentValues; + return fragmentVariableValues === undefined || + compiledArgumentValues.constantValues !== undefined + ? compiledArgumentValues + : { ...compiledArgumentValues, fragmentVariableValues }; +} + +function collectFragmentSpread( + context: CollectFieldsContext, + selection: CompiledFragmentSpread, + groupedFieldSet: AccumulatorMap, + newDeferUsages: Array, + deferUsage: DeferUsage | undefined, + fragmentVariableValues: FragmentVariableValues | undefined, + staticFragmentVariableValues: FragmentVariableValues | undefined, + fragmentVariables: FragmentVariables | undefined, +): void { + if (!shouldIncludeNode(context, selection, fragmentVariables)) { + return; + } + + const fragment = context.fragments[selection.fragmentName]; + if ( + fragment === undefined || + !doesFragmentConditionMatch(context, fragment.condition) + ) { + return; + } + + const newDeferUsage = getDeferUsage( + context, + selection, + deferUsage, + fragmentVariables, + ); + const visitedAsDeferred = context.visitedFragmentNames.get( + selection.fragmentName, + ); + + let maybeNewDeferUsage: DeferUsage | undefined; + if (!newDeferUsage) { + if (visitedAsDeferred === false) { + return; + } + context.visitedFragmentNames.set(selection.fragmentName, false); + maybeNewDeferUsage = deferUsage; + } else { + if (visitedAsDeferred !== undefined) { + return; + } + context.visitedFragmentNames.set(selection.fragmentName, true); + newDeferUsages.push(newDeferUsage); + maybeNewDeferUsage = newDeferUsage; + } + + const fragmentVariableSignatures = fragment.details.variableSignatures; + let newFragmentVariableValues: FragmentVariableValues | undefined; + let newStaticFragmentVariableValues: FragmentVariableValues | undefined; + if (fragmentVariableSignatures) { + newFragmentVariableValues = getFragmentVariableValues( + selection.node, + fragmentVariableSignatures, + context.variableValues, + fragmentVariableValues, + context.hideSuggestions, + ); + newStaticFragmentVariableValues = getStaticFragmentVariableValues( + selection.compiledFragmentVariables, + staticFragmentVariableValues, + ); + } + + collectFieldsImpl( + context, + fragment.selectionSet, + groupedFieldSet, + newDeferUsages, + maybeNewDeferUsage, + newFragmentVariableValues, + newStaticFragmentVariableValues, + ); +} + +function shouldIncludeNode( + context: CollectFieldsContext, + selection: InclusionDirectiveCompilation, + fragmentVariables: FragmentVariables | undefined, +): boolean { + return shouldIncludeSelection( + selection, + context.variableValues, + fragmentVariables, + context.hideSuggestions, + ); +} + +function getDeferUsage( + context: CollectFieldsContext, + selection: DeferDirectiveCompilation, + parentDeferUsage: DeferUsage | undefined, + fragmentVariables: FragmentVariables | undefined, +): DeferUsage | undefined { + return getCompiledDeferUsage( + selection, + parentDeferUsage, + context.variableValues, + fragmentVariables, + context.hideSuggestions, + ); +} + +function doesFragmentConditionMatch( + context: CollectFieldsContext, + conditionalType: CompiledFragmentCondition, +): boolean { + if (conditionalType === null) { + return true; + } + if (conditionalType === undefined) { + return false; + } + if (conditionalType === context.runtimeType) { + return true; + } + if (isAbstractType(conditionalType)) { + return context.schema.isSubType(conditionalType, context.runtimeType); + } + return false; +} + +interface FieldDirectiveNodes { + skipDirectiveNode: DirectiveNode | undefined; + includeDirectiveNode: DirectiveNode | undefined; + streamDirectiveNode: DirectiveNode | undefined; +} + +interface FragmentDirectiveNodes { + skipDirectiveNode: DirectiveNode | undefined; + includeDirectiveNode: DirectiveNode | undefined; + deferDirectiveNode: DirectiveNode | undefined; +} + +function getFieldDirectiveNodes(node: FieldNode): FieldDirectiveNodes { + let skipDirectiveNode; + let includeDirectiveNode; + let streamDirectiveNode; + + for (const directiveNode of node.directives ?? []) { + switch (directiveNode.name.value) { + case SKIP_DIRECTIVE_NAME: + skipDirectiveNode = directiveNode; + break; + case INCLUDE_DIRECTIVE_NAME: + includeDirectiveNode = directiveNode; + break; + case STREAM_DIRECTIVE_NAME: + streamDirectiveNode = directiveNode; + break; + } + } + + return { skipDirectiveNode, includeDirectiveNode, streamDirectiveNode }; +} + +function getFragmentDirectiveNodes( + node: FragmentSpreadNode | InlineFragmentNode, +): FragmentDirectiveNodes { + let skipDirectiveNode; + let includeDirectiveNode; + let deferDirectiveNode; + + for (const directiveNode of node.directives ?? []) { + switch (directiveNode.name.value) { + case SKIP_DIRECTIVE_NAME: + skipDirectiveNode = directiveNode; + break; + case INCLUDE_DIRECTIVE_NAME: + includeDirectiveNode = directiveNode; + break; + case DEFER_DIRECTIVE_NAME: + deferDirectiveNode = directiveNode; + break; + } + } + + return { skipDirectiveNode, includeDirectiveNode, deferDirectiveNode }; +} diff --git a/src/execution/compile/compileDeferDirective.ts b/src/execution/compile/compileDeferDirective.ts new file mode 100644 index 0000000000..dd4d9f4a68 --- /dev/null +++ b/src/execution/compile/compileDeferDirective.ts @@ -0,0 +1,50 @@ +import type { DirectiveNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import { GraphQLNonNull } from '../../type/definition.ts'; +import { GraphQLBoolean } from '../../type/scalars.ts'; + +import type { + CompiledBooleanDirective, + CompiledDirectiveArgument, +} from './compileBooleanDirective.ts'; +import { compileBooleanDirective } from './compileBooleanDirective.ts'; + +/** @internal */ +export interface DeferDirectiveCompilation { + deferDirective: CompiledDeferDirective | undefined; +} + +/** @internal */ +export interface CompiledDeferDirective extends CompiledBooleanDirective { + label: string | undefined; +} + +const DEFER_IF_ARGUMENT: CompiledDirectiveArgument = { + coordinate: '@defer(if:)', + type: new GraphQLNonNull(GraphQLBoolean), + defaultValue: true, +}; + +/** @internal */ +export function compileDeferDirective( + directiveNode: DirectiveNode | undefined, +): CompiledDeferDirective | undefined { + const compiled = compileBooleanDirective(directiveNode, DEFER_IF_ARGUMENT); + if (compiled === undefined || directiveNode === undefined) { + return; + } + + let labelValue; + for (const argumentNode of directiveNode.arguments ?? []) { + if (argumentNode.name.value === 'label') { + labelValue = argumentNode.value; + break; + } + } + + return { + ...compiled, + label: labelValue?.kind === Kind.STRING ? labelValue.value : undefined, + }; +} diff --git a/src/execution/compile/compileExecutionState.ts b/src/execution/compile/compileExecutionState.ts new file mode 100644 index 0000000000..48bb7e2759 --- /dev/null +++ b/src/execution/compile/compileExecutionState.ts @@ -0,0 +1,165 @@ +import type { Maybe } from '../../jsutils/Maybe.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; + +import { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { + DocumentNode, + FragmentDefinitionNode, + OperationDefinitionNode, + VariableDefinitionNode, +} from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { + GraphQLFieldResolver, + GraphQLTypeResolver, +} from '../../type/definition.ts'; +import { GraphQLDisableErrorPropagationDirective } from '../../type/directives.ts'; +import { assertValidSchema } from '../../type/index.ts'; +import type { GraphQLSchema } from '../../type/schema.ts'; + +import type { FragmentDetails } from '../collectFields.ts'; +import type { + CompileExecutionArgs, + ExecutionHooks, + FieldCollectors, +} from '../ExecutionArgs.ts'; +import { getVariableSignature } from '../getVariableSignature.ts'; + +import { compileCollectFields } from './compileCollectFields.ts'; + +/** @internal */ +export interface CompiledExecutionState extends FieldCollectors { + /** Schema used for execution. */ + schema: GraphQLSchema; + /** Parsed GraphQL document being executed. */ + document: DocumentNode; + /** Fragment definitions keyed by fragment name. */ + fragmentDefinitions: ObjMap; + /** Fragment details keyed by fragment name. */ + fragments: ObjMap; + /** Operation definition selected for execution. */ + operation: OperationDefinitionNode; + /** Operation variable definitions. */ + variableDefinitions: ReadonlyArray; + /** Whether suggestion text should be omitted from execution errors. */ + hideSuggestions: boolean; + /** Whether execution should use error propagation. */ + errorPropagation: boolean; + /** Resolver used when a field does not define its own resolver. */ + fieldResolver?: Maybe>; + /** Resolver used when an abstract type does not define its own resolver. */ + typeResolver?: Maybe>; + /** Resolver used for the root subscription field. */ + subscribeFieldResolver?: Maybe>; + /** Whether incremental execution may begin eligible work early. */ + enableEarlyExecution: boolean; + /** Whether experimental field batch resolvers should be used. */ + enableBatchResolvers: boolean; + /** Execution hooks invoked during this operation. */ + hooks?: ExecutionHooks | undefined; +} + +/** @internal */ +export function compileExecutionState( + args: CompileExecutionArgs, +): ReadonlyArray | CompiledExecutionState { + const { schema, document, operationName } = args; + + // If the schema used for execution is invalid, throw an error. + assertValidSchema(schema); + + let operation: OperationDefinitionNode | undefined; + const errors: Array = []; + const fragmentDefinitions: ObjMap = + Object.create(null); + const fragments: ObjMap = Object.create(null); + for (const definition of document.definitions) { + switch (definition.kind) { + case Kind.OPERATION_DEFINITION: + if (operationName == null) { + if (operation !== undefined) { + return [ + new GraphQLError( + 'Must provide operation name if query contains multiple operations.', + ), + ]; + } + operation = definition; + } else if (definition.name?.value === operationName) { + operation = definition; + } + break; + case Kind.FRAGMENT_DEFINITION: { + fragmentDefinitions[definition.name.value] = definition; + let variableSignatures; + if (definition.variableDefinitions) { + variableSignatures = Object.create(null); + for (const varDef of definition.variableDefinitions) { + const signature = getVariableSignature(schema, varDef); + if (signature instanceof GraphQLError) { + errors.push(signature); + continue; + } + variableSignatures[signature.name] = signature; + } + } + fragments[definition.name.value] = { definition, variableSignatures }; + break; + } + default: + // ignore non-executable definitions + } + } + + if (!operation) { + if (operationName != null) { + return [new GraphQLError(`Unknown operation named "${operationName}".`)]; + } + return [new GraphQLError('Must provide an operation.')]; + } + if (errors.length > 0) { + return errors; + } + const selectedOperation = operation; + + const errorPropagation = !selectedOperation.directives?.find( + (directive) => + directive.name.value === GraphQLDisableErrorPropagationDirective.name, + ); + const hideSuggestions = args.hideSuggestions ?? false; + const compiledCollectFields = compileCollectFields( + schema, + fragments, + selectedOperation.selectionSet, + hideSuggestions, + args.fieldResolver == null, + ); + + return { + schema, + document, + fragmentDefinitions, + fragments, + operation: selectedOperation, + variableDefinitions: selectedOperation.variableDefinitions ?? [], + hideSuggestions, + errorPropagation, + fieldResolver: args.fieldResolver, + typeResolver: args.typeResolver, + subscribeFieldResolver: args.subscribeFieldResolver, + enableEarlyExecution: args.enableEarlyExecution === true, + enableBatchResolvers: args.enableBatchResolvers === true, + hooks: args.hooks ?? undefined, + collectRootFields: compiledCollectFields.collectRootFields, + collectSubfields: compiledCollectFields.collectSubfields, + }; +} + +/** @internal */ +export function isExecutionErrors( + value: ReadonlyArray | CompiledExecutionState, +): value is ReadonlyArray { + return Array.isArray(value); +} diff --git a/src/execution/compile/compileFieldExecutionPlan.ts b/src/execution/compile/compileFieldExecutionPlan.ts new file mode 100644 index 0000000000..7512d954df --- /dev/null +++ b/src/execution/compile/compileFieldExecutionPlan.ts @@ -0,0 +1,377 @@ +import { memoize1 } from '../../jsutils/memoize1.ts'; +import type { Path } from '../../jsutils/Path.ts'; + +import type { FieldNode } from '../../language/ast.ts'; + +import type { + GraphQLField, + GraphQLFieldResolver, + GraphQLLeafType, + GraphQLNullableOutputType, + GraphQLObjectType, + GraphQLOutputType, + GraphQLResolveInfo, + GraphQLResolveInfoHelpers, +} from '../../type/definition.ts'; +import { isLeafType, isNonNullType } from '../../type/definition.ts'; + +import type { FieldDetailsList } from '../collectFields.ts'; +import type { ValidatedExecutionArgs } from '../ExecutionArgs.ts'; + +import type { CompiledArgumentValues } from './compileArgumentValues.ts'; +import type { CompiledStreamDirective } from './compileStreamDirective.ts'; +import { getCompiledArgumentValues } from './getCompiledArgumentValues.ts'; + +/** @internal */ +export interface CompiledResolvedField { + info: GraphQLResolveInfo | undefined; + result: unknown; +} + +/** @internal */ +export interface CompiledExecutionRuntime { + validatedExecutionArgs: ValidatedExecutionArgs; + getAbortSignal: () => AbortSignal | undefined; + getAsyncHelpers: () => GraphQLResolveInfoHelpers; +} + +/** @internal */ +export interface CompiledFieldExecutionPlan { + fieldDef: GraphQLField; + returnType: GraphQLOutputType; + nullableReturnType: GraphQLNullableOutputType; + completedNonNull: boolean; + leafType: GraphQLLeafType | undefined; + compiledArgumentValues: CompiledArgumentValues; + compiledStreamDirective: CompiledStreamDirective; + resolveField: ( + runtime: CompiledExecutionRuntime, + parentType: GraphQLObjectType, + source: unknown, + fieldDetailsList: FieldDetailsList, + path: Path, + ) => CompiledResolvedField; + resolveFieldValue: ( + runtime: CompiledExecutionRuntime, + parentType: GraphQLObjectType, + source: unknown, + fieldDetailsList: FieldDetailsList, + path: Path, + ) => unknown; + buildResolveInfo: ( + runtime: CompiledExecutionRuntime, + parentType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + path: Path, + ) => GraphQLResolveInfo; +} + +/** @internal */ +export interface CompiledFieldResolver { + fieldDef: GraphQLField; + returnType: GraphQLOutputType; + nullableReturnType: GraphQLNullableOutputType; + completedNonNull: boolean; + leafType: GraphQLLeafType | undefined; + fieldResolveFn: GraphQLFieldResolver | undefined; + usesDefaultFieldResolver: boolean; +} + +/** @internal */ +export function compileFieldResolver( + fieldDef: GraphQLField, + usesDefaultFieldResolver: boolean, +): CompiledFieldResolver { + const returnType = fieldDef.type; + const { nullableReturnType, completedNonNull } = + getNullableReturnType(returnType); + return { + fieldDef, + returnType, + nullableReturnType, + completedNonNull, + leafType: isLeafType(nullableReturnType) ? nullableReturnType : undefined, + fieldResolveFn: fieldDef.resolve, + usesDefaultFieldResolver, + }; +} + +/** @internal */ +export function compileFieldExecutionPlan( + fieldResolver: CompiledFieldResolver, + compiledArgumentValues: CompiledArgumentValues, + compiledStreamDirective: CompiledStreamDirective, +): CompiledFieldExecutionPlan { + const { + fieldDef, + returnType, + nullableReturnType, + completedNonNull, + leafType, + fieldResolveFn, + usesDefaultFieldResolver, + } = fieldResolver; + const fieldNodes = [compiledArgumentValues.node]; + let resolveField: CompiledFieldExecutionPlan['resolveField']; + let resolveFieldValue: CompiledFieldExecutionPlan['resolveFieldValue']; + + if (fieldResolveFn === undefined) { + if (usesDefaultFieldResolver) { + resolveField = function resolveDefaultField( + runtime, + parentType, + source, + fieldDetailsList, + path, + ) { + const object = getObjectLikeSource(source); + if (object !== undefined) { + const property = getObjectProperty(object, fieldDef.name); + if (typeof property !== 'function') { + return { info: undefined, result: property }; + } + + const info = buildCompiledResolveInfo( + runtime, + parentType, + fieldDetailsList, + path, + ); + const { contextValue, variableValues } = + runtime.validatedExecutionArgs; + const args = getCompiledArgumentValues( + compiledArgumentValues, + variableValues, + ); + return { + info, + result: property.call(object, args, contextValue, info), + }; + } + + return { info: undefined, result: undefined }; + }; + + resolveFieldValue = function resolveDefaultFieldValue( + runtime, + parentType, + source, + fieldDetailsList, + path, + ) { + const object = getObjectLikeSource(source); + if (object !== undefined) { + const property = getObjectProperty(object, fieldDef.name); + if (typeof property !== 'function') { + return property; + } + + const info = buildCompiledResolveInfo( + runtime, + parentType, + fieldDetailsList, + path, + ); + const { contextValue, variableValues } = + runtime.validatedExecutionArgs; + const args = getCompiledArgumentValues( + compiledArgumentValues, + variableValues, + ); + return property.call(object, args, contextValue, info); + } + + return undefined; + }; + } else { + resolveField = function resolveRuntimeField( + runtime, + parentType, + source, + fieldDetailsList, + path, + ) { + const validatedExecutionArgs = runtime.validatedExecutionArgs; + const info = buildCompiledResolveInfo( + runtime, + parentType, + fieldDetailsList, + path, + ); + const args = getCompiledArgumentValues( + compiledArgumentValues, + validatedExecutionArgs.variableValues, + ); + return { + info, + result: validatedExecutionArgs.fieldResolver( + source, + args, + validatedExecutionArgs.contextValue, + info, + ), + }; + }; + + resolveFieldValue = function resolveRuntimeFieldValue( + runtime, + parentType, + source, + fieldDetailsList, + path, + ) { + const validatedExecutionArgs = runtime.validatedExecutionArgs; + const info = buildCompiledResolveInfo( + runtime, + parentType, + fieldDetailsList, + path, + ); + const args = getCompiledArgumentValues( + compiledArgumentValues, + validatedExecutionArgs.variableValues, + ); + return validatedExecutionArgs.fieldResolver( + source, + args, + validatedExecutionArgs.contextValue, + info, + ); + }; + } + } else { + const resolveFn = fieldResolveFn; + + resolveField = function resolveFieldWithResolver( + runtime, + parentType, + source, + fieldDetailsList, + path, + ) { + const validatedExecutionArgs = runtime.validatedExecutionArgs; + + const info = buildCompiledResolveInfo( + runtime, + parentType, + fieldDetailsList, + path, + ); + + const args = getCompiledArgumentValues( + compiledArgumentValues, + validatedExecutionArgs.variableValues, + ); + return { + info, + result: resolveFn( + source, + args, + validatedExecutionArgs.contextValue, + info, + ), + }; + }; + + resolveFieldValue = function resolveFieldValueWithResolver( + runtime, + parentType, + source, + fieldDetailsList, + path, + ) { + const validatedExecutionArgs = runtime.validatedExecutionArgs; + const info = buildCompiledResolveInfo( + runtime, + parentType, + fieldDetailsList, + path, + ); + + const args = getCompiledArgumentValues( + compiledArgumentValues, + validatedExecutionArgs.variableValues, + ); + return resolveFn(source, args, validatedExecutionArgs.contextValue, info); + }; + } + + return { + fieldDef, + returnType, + nullableReturnType, + completedNonNull, + leafType, + compiledArgumentValues, + compiledStreamDirective, + resolveField, + resolveFieldValue, + buildResolveInfo: buildCompiledResolveInfo, + }; + + function buildCompiledResolveInfo( + runtime: CompiledExecutionRuntime, + parentType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + path: Path, + ): GraphQLResolveInfo { + const { + fragmentDefinitions, + operation, + rootValue, + schema, + variableValues, + } = runtime.validatedExecutionArgs; + return { + fieldName: fieldDef.name, + fieldNodes: getFieldNodes(fieldDetailsList), + returnType, + parentType, + path, + schema, + fragments: fragmentDefinitions, + rootValue, + operation, + variableValues, + getAbortSignal: runtime.getAbortSignal, + getAsyncHelpers: runtime.getAsyncHelpers, + }; + } + + function getFieldNodes( + fieldDetailsList: FieldDetailsList, + ): ReadonlyArray { + return fieldDetailsList.length === 1 + ? fieldNodes + : toNodes(fieldDetailsList); + } +} + +function getNullableReturnType(returnType: GraphQLOutputType): { + nullableReturnType: GraphQLNullableOutputType; + completedNonNull: boolean; +} { + let nullableReturnType = returnType; + let completedNonNull = false; + while (isNonNullType(nullableReturnType)) { + completedNonNull = true; + nullableReturnType = nullableReturnType.ofType; + } + return { nullableReturnType, completedNonNull }; +} + +function getObjectLikeSource(source: unknown): object | undefined { + return (typeof source === 'object' && source !== null) || + typeof source === 'function' + ? source + : undefined; +} + +function getObjectProperty(object: object, fieldName: string): unknown { + return (object as { [key: string]: unknown })[fieldName]; +} + +const toNodes = memoize1( + (fieldDetailsList: FieldDetailsList): ReadonlyArray => + fieldDetailsList.map((fieldDetails) => fieldDetails.node), +); diff --git a/src/execution/compile/compileFragmentVariables.ts b/src/execution/compile/compileFragmentVariables.ts new file mode 100644 index 0000000000..ed4112f138 --- /dev/null +++ b/src/execution/compile/compileFragmentVariables.ts @@ -0,0 +1,111 @@ +import type { ObjMap } from '../../jsutils/ObjMap.ts'; + +import type { + FragmentArgumentNode, + FragmentSpreadNode, + ValueNode, +} from '../../language/ast.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; +import type { GraphQLVariableSignature } from '../getVariableSignature.ts'; + +import type { InputLiteralCoercer } from './compileInputValue.ts'; +import { + compileInputLiteral, + isStaticInputLiteral, +} from './compileInputValue.ts'; + +/** @internal */ +export interface FragmentVariables { + runtime: FragmentVariableValues | undefined; + static: FragmentVariableValues | undefined; +} + +/** @internal */ +export interface CompiledFragmentVariables { + entries: ReadonlyArray; +} + +/** @internal */ +export type CompiledFragmentVariableEntry = + | StaticCompiledFragmentVariableEntry + | DynamicCompiledFragmentVariableEntry; + +/** @internal */ +export interface StaticCompiledFragmentVariableEntry { + name: string; + signature: GraphQLVariableSignature; + sourceValueNode: ValueNode | undefined; + staticValue: unknown; +} + +/** @internal */ +export interface DynamicCompiledFragmentVariableEntry { + name: string; + signature: GraphQLVariableSignature; + sourceValueNode: ValueNode; + valueNode: ValueNode; + valueBuilder: InputLiteralCoercer; +} + +/** @internal */ +export function compileFragmentVariables( + fragmentSpreadNode: FragmentSpreadNode, + variableSignatures: ObjMap, +): CompiledFragmentVariables | undefined { + const argNodeMap = new Map(); + for (const argNode of fragmentSpreadNode.arguments ?? []) { + argNodeMap.set(argNode.name.value, argNode); + } + const entries: Array = []; + + for (const [name, variableSignature] of Object.entries(variableSignatures)) { + const argumentNode = argNodeMap.get(name); + const valueNode = argumentNode?.value ?? variableSignature.default?.literal; + const entry = + valueNode === undefined + ? undefined + : compileFragmentVariableEntry( + name, + variableSignature, + valueNode, + argumentNode?.value, + ); + + if (entry) { + entries.push(entry); + } + } + + return entries.length === 0 ? undefined : { entries }; +} + +function compileFragmentVariableEntry( + name: string, + signature: GraphQLVariableSignature, + valueNode: ValueNode, + sourceValueNode: ValueNode | undefined, +): CompiledFragmentVariableEntry | undefined { + const valueBuilder = compileInputLiteral(valueNode, signature.type); + if (isStaticInputLiteral(valueNode)) { + const staticValue = valueBuilder(); + return staticValue === undefined + ? undefined + : { + name, + signature, + sourceValueNode, + staticValue, + }; + } + + if (sourceValueNode !== undefined) { + return { + name, + signature, + sourceValueNode, + valueNode, + valueBuilder, + }; + } +} diff --git a/src/execution/compile/compileInclusionDirectives.ts b/src/execution/compile/compileInclusionDirectives.ts new file mode 100644 index 0000000000..05d553f2c9 --- /dev/null +++ b/src/execution/compile/compileInclusionDirectives.ts @@ -0,0 +1,82 @@ +import type { DirectiveNode } from '../../language/ast.ts'; + +import { GraphQLNonNull } from '../../type/definition.ts'; +import { GraphQLBoolean } from '../../type/scalars.ts'; + +import type { VariableValues } from '../values.ts'; + +import type { + CompiledBooleanDirective, + CompiledDirectiveArgument, +} from './compileBooleanDirective.ts'; +import { compileBooleanDirective } from './compileBooleanDirective.ts'; +import type { FragmentVariables } from './compileFragmentVariables.ts'; +import { getCompiledDirectiveIfValue } from './getCompiledDirectiveIfValue.ts'; + +/** @internal */ +export interface InclusionDirectiveCompilation { + skipDirective: CompiledBooleanDirective | undefined; + includeDirective: CompiledBooleanDirective | undefined; +} + +const BOOLEAN_NON_NULL = new GraphQLNonNull(GraphQLBoolean); + +const SKIP_IF_ARGUMENT: CompiledDirectiveArgument = { + coordinate: '@skip(if:)', + type: BOOLEAN_NON_NULL, + defaultValue: undefined, +}; +const INCLUDE_IF_ARGUMENT: CompiledDirectiveArgument = { + coordinate: '@include(if:)', + type: BOOLEAN_NON_NULL, + defaultValue: undefined, +}; + +/** @internal */ +export function compileSkipDirective( + directiveNode: DirectiveNode | undefined, +): CompiledBooleanDirective | undefined { + return compileBooleanDirective(directiveNode, SKIP_IF_ARGUMENT); +} + +/** @internal */ +export function compileIncludeDirective( + directiveNode: DirectiveNode | undefined, +): CompiledBooleanDirective | undefined { + return compileBooleanDirective(directiveNode, INCLUDE_IF_ARGUMENT); +} + +/** @internal */ +export function shouldIncludeSelection( + selection: InclusionDirectiveCompilation, + variableValues: VariableValues, + fragmentVariables: FragmentVariables | undefined, + hideSuggestions: boolean, +): boolean { + const skipDirective = selection.skipDirective; + if (skipDirective !== undefined) { + const skip = getCompiledDirectiveIfValue( + skipDirective, + variableValues, + fragmentVariables, + hideSuggestions, + ); + if (skip === true) { + return false; + } + } + + const includeDirective = selection.includeDirective; + if (includeDirective !== undefined) { + const include = getCompiledDirectiveIfValue( + includeDirective, + variableValues, + fragmentVariables, + hideSuggestions, + ); + if (include === false) { + return false; + } + } + return true; +} diff --git a/src/execution/compile/compileInputValue.ts b/src/execution/compile/compileInputValue.ts new file mode 100644 index 0000000000..5fe9f0edcd --- /dev/null +++ b/src/execution/compile/compileInputValue.ts @@ -0,0 +1,572 @@ +import { inspect } from '../../jsutils/inspect.ts'; +import { invariant } from '../../jsutils/invariant.ts'; +import { isIterableObject } from '../../jsutils/isIterableObject.ts'; +import { isObjectLike } from '../../jsutils/isObjectLike.ts'; +import type { Maybe } from '../../jsutils/Maybe.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import type { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { ObjectFieldNode, ValueNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { + GraphQLDefaultInput, + GraphQLInputObjectType, + GraphQLInputType, + GraphQLLeafType, +} from '../../type/definition.ts'; +import { + assertLeafType, + isInputObjectType, + isListType, + isNonNullType, + isRequiredInputField, +} from '../../type/definition.ts'; + +import { replaceVariables } from '../../utilities/replaceVariables.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; +import type { VariableValues } from '../values.ts'; + +/** @internal */ +export type InputValueCoercer = (inputValue: unknown) => unknown; + +/** @internal */ +export type InputLiteralCoercer = ( + variableValues?: Maybe, + fragmentVariableValues?: Maybe, +) => unknown; + +interface InputObjectFieldDefinition { + name: string; + defaultValue: CompiledDefaultInputValue; + isRequired: boolean; +} + +type InputObjectLiteralField = + | MissingInputObjectLiteralField + | PresentInputObjectLiteralField; + +interface MissingInputObjectLiteralField extends InputObjectFieldDefinition { + kind: 'missing'; +} + +interface PresentInputObjectLiteralField extends InputObjectFieldDefinition { + kind: 'present'; + valueNode: ValueNode; + valueBuilder: InputLiteralCoercer; +} + +interface CompiledDefaultInputValue { + value: unknown; + error: GraphQLError | undefined; +} + +interface InputValue { + type: GraphQLInputType; + default?: GraphQLDefaultInput | undefined; + defaultValue?: unknown; + /** @private */ + _memoizedCoercedDefaultValue?: unknown; +} + +/** @internal */ +export function compileInputValue(type: GraphQLInputType): InputValueCoercer { + return compileInputValueImpl(type, new Map()); +} + +function compileInputValueImpl( + type: GraphQLInputType, + cache: Map, +): InputValueCoercer { + const cachedCoercer = cache.get(type); + if (cachedCoercer !== undefined) { + return cachedCoercer; + } + + const coercerRef: { current: InputValueCoercer | undefined } = { + current: undefined, + }; + const lazyCoercer: InputValueCoercer = (inputValue) => { + invariant(coercerRef.current !== undefined); + return coercerRef.current(inputValue); + }; + cache.set(type, lazyCoercer); + const coercer = compileInputValueUncached(type, cache); + coercerRef.current = coercer; + cache.set(type, coercer); + return coercer; +} + +function compileInputValueUncached( + type: GraphQLInputType, + cache: Map, +): InputValueCoercer { + if (isNonNullType(type)) { + const innerCoercer = compileInputValueImpl(type.ofType, cache); + return (inputValue) => + inputValue == null ? undefined : innerCoercer(inputValue); + } + + if (isListType(type)) { + return compileListInputValue(type.ofType, cache); + } + + if (isInputObjectType(type)) { + const fieldDefs = type.getFields(); + const fieldEntries: Array = []; + for (const field of Object.values(fieldDefs)) { + fieldEntries.push({ + name: field.name, + defaultValue: compileDefaultInputValue(field), + isRequired: isRequiredInputField(field), + }); + } + + const fieldCoercers: ObjMap = Object.create(null); + for (const field of Object.values(fieldDefs)) { + fieldCoercers[field.name] = compileInputValueImpl(field.type, cache); + } + + return (inputValue) => { + if (inputValue == null) { + return null; + } + + if (!isObjectLike(inputValue) || Array.isArray(inputValue)) { + return; + } + + const coercedValue: ObjMap = Object.create(null); + let definedFieldCount = 0; + for (const fieldName of Object.keys(inputValue)) { + if (inputValue[fieldName] === undefined) { + continue; + } + definedFieldCount++; + if (fieldDefs[fieldName] === undefined) { + return; + } + } + + for (const fieldEntry of fieldEntries) { + const fieldValue = inputValue[fieldEntry.name]; + if (fieldValue === undefined) { + if (fieldEntry.isRequired) { + return; + } + const defaultValue = getCompiledDefaultInputValue( + fieldEntry.defaultValue, + ); + if (defaultValue !== undefined) { + coercedValue[fieldEntry.name] = defaultValue; + } + continue; + } + + const coercedField = fieldCoercers[fieldEntry.name](fieldValue); + if (coercedField === undefined) { + return; + } + coercedValue[fieldEntry.name] = coercedField; + } + + if (type.isOneOf) { + const keys = Object.keys(coercedValue); + if (definedFieldCount !== 1 || keys.length !== 1) { + return; + } + + if (coercedValue[keys[0]] === null) { + return; + } + } + + return coercedValue; + }; + } + + const leafType = assertLeafType(type); + return (inputValue) => { + if (inputValue == null) { + return null; + } + + try { + return leafType.coerceInputValue(inputValue); + } catch (_error) { + // Invalid: ignore error and intentionally return no value. + } + }; +} + +function compileListInputValue( + itemType: GraphQLInputType, + cache: Map, +): InputValueCoercer { + const itemCoercer = compileInputValueImpl(itemType, cache); + return (inputValue) => { + if (inputValue == null) { + return null; + } + + if (!isIterableObject(inputValue)) { + const coercedItem = itemCoercer(inputValue); + return coercedItem === undefined ? undefined : [coercedItem]; + } + + const coercedValue: Array = []; + for (const itemValue of inputValue) { + const coercedItem = itemCoercer(itemValue); + if (coercedItem === undefined) { + return; + } + coercedValue.push(coercedItem); + } + return coercedValue; + }; +} + +/** @internal */ +export function compileInputLiteral( + valueNode: ValueNode, + type: GraphQLInputType, +): InputLiteralCoercer { + if (valueNode.kind === Kind.VARIABLE) { + return compileVariableInputLiteral(valueNode.name.value, type); + } + + if (isNonNullType(type)) { + if (valueNode.kind === Kind.NULL) { + return invalidInputLiteral; + } + + const innerCoercer = compileInputLiteral(valueNode, type.ofType); + return (variableValues, fragmentVariableValues) => + innerCoercer(variableValues, fragmentVariableValues); + } + + if (valueNode.kind === Kind.NULL) { + return nullInputLiteral; + } + + if (isListType(type)) { + return compileListInputLiteral(valueNode, type.ofType); + } + + if (isInputObjectType(type)) { + return compileInputObjectLiteral(valueNode, type); + } + + const leafType = assertLeafType(type); + if (isStaticInputLiteral(valueNode)) { + const coercedValue = coerceLeafInputLiteral(valueNode, leafType); + return coercedValue === undefined + ? invalidInputLiteral + : () => coercedValue; + } + + return (variableValues, fragmentVariableValues) => + coerceLeafInputLiteral( + valueNode, + leafType, + variableValues, + fragmentVariableValues, + ); +} + +function compileVariableInputLiteral( + variableName: string, + type: GraphQLInputType, +): InputLiteralCoercer { + return isNonNullType(type) + ? (variableValues, fragmentVariableValues) => { + const value = + fragmentVariableValues == null + ? variableValues?.coerced[variableName] + : getCoercedVariableValue( + variableName, + variableValues, + fragmentVariableValues, + ); + return value ?? undefined; + } + : (variableValues, fragmentVariableValues) => + fragmentVariableValues == null + ? variableValues?.coerced[variableName] + : getCoercedVariableValue( + variableName, + variableValues, + fragmentVariableValues, + ); +} + +function compileListInputLiteral( + valueNode: ValueNode, + itemType: GraphQLInputType, +): InputLiteralCoercer { + if (valueNode.kind !== Kind.LIST) { + const itemCoercer = compileInputLiteral(valueNode, itemType); + return (variableValues, fragmentVariableValues) => { + const itemValue = itemCoercer(variableValues, fragmentVariableValues); + return itemValue === undefined ? undefined : [itemValue]; + }; + } + + const itemNodes = valueNode.values; + const itemCoercers = itemNodes.map((itemNode) => + compileInputLiteral(itemNode, itemType), + ); + + return (variableValues, fragmentVariableValues) => { + const coercedValue = new Array(itemCoercers.length); + for (let i = 0; i < itemCoercers.length; ++i) { + let itemValue = itemCoercers[i](variableValues, fragmentVariableValues); + if (itemValue === undefined) { + const itemNode = itemNodes[i]; + if ( + itemNode.kind === Kind.VARIABLE && + !isNonNullType(itemType) && + isMissingVariable( + itemNode.name.value, + variableValues, + fragmentVariableValues, + ) + ) { + itemValue = null; + } else { + return; + } + } + coercedValue[i] = itemValue; + } + return coercedValue; + }; +} + +function compileInputObjectLiteral( + valueNode: ValueNode, + type: GraphQLInputObjectType, +): InputLiteralCoercer { + if (valueNode.kind !== Kind.OBJECT) { + return invalidInputLiteral; + } + + const fieldDefs = type.getFields(); + const fieldNodesByName: ObjMap = Object.create(null); + let fieldNodeCount = 0; + for (const fieldNode of valueNode.fields) { + const fieldName = fieldNode.name.value; + if (fieldDefs[fieldName] === undefined) { + return invalidInputLiteral; + } + if (fieldNodesByName[fieldName] === undefined) { + fieldNodeCount++; + } + fieldNodesByName[fieldName] = fieldNode; + } + + const fieldEntries: Array = []; + for (const field of Object.values(fieldDefs)) { + const fieldNode = fieldNodesByName[field.name]; + if (fieldNode === undefined) { + fieldEntries.push({ + kind: 'missing', + name: field.name, + defaultValue: compileDefaultInputValue(field), + isRequired: isRequiredInputField(field), + }); + continue; + } + + fieldEntries.push({ + kind: 'present', + name: field.name, + valueNode: fieldNode.value, + valueBuilder: compileInputLiteral(fieldNode.value, field.type), + defaultValue: compileDefaultInputValue(field), + isRequired: isRequiredInputField(field), + }); + } + + return (variableValues, fragmentVariableValues) => { + const coercedValue: ObjMap = Object.create(null); + for (const fieldEntry of fieldEntries) { + if ( + fieldEntry.kind === 'missing' || + (fieldEntry.valueNode.kind === Kind.VARIABLE && + isMissingVariable( + fieldEntry.valueNode.name.value, + variableValues, + fragmentVariableValues, + )) + ) { + if (fieldEntry.isRequired) { + return; + } + const defaultValue = getCompiledDefaultInputValue( + fieldEntry.defaultValue, + ); + if (defaultValue !== undefined) { + coercedValue[fieldEntry.name] = defaultValue; + } + continue; + } + + const fieldValue = fieldEntry.valueBuilder( + variableValues, + fragmentVariableValues, + ); + if (fieldValue === undefined) { + return; + } + coercedValue[fieldEntry.name] = fieldValue; + } + + if (type.isOneOf) { + const coercedKeys = Object.keys(coercedValue); + if (fieldNodeCount !== 1 || coercedKeys.length !== 1) { + return; + } + + const fieldName = coercedKeys[0]; + const fieldNode = fieldNodesByName[fieldName]; + if ( + fieldNode.value.kind === Kind.NULL || + coercedValue[fieldName] === null + ) { + return; + } + } + + return coercedValue; + }; +} + +function coerceLeafInputLiteral( + valueNode: ValueNode, + type: GraphQLLeafType, + variableValues?: Maybe, + fragmentVariableValues?: Maybe, +): unknown { + try { + return type.coerceInputLiteral + ? type.coerceInputLiteral( + replaceVariables(valueNode, variableValues, fragmentVariableValues), + ) + : type.parseLiteral(valueNode, variableValues?.coerced); + } catch (_error) { + // Invalid: ignore error and intentionally return no value. + } +} + +function getCoercedVariableValue( + variableName: string, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): unknown { + return getScopedVariableValues( + variableName, + variableValues, + fragmentVariableValues, + )?.coerced[variableName]; +} + +function getScopedVariableValues( + variableName: string, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): Maybe { + return fragmentVariableValues != null && + variableName in fragmentVariableValues.sources + ? fragmentVariableValues + : variableValues; +} + +function isMissingVariable( + variableName: string, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): boolean { + return fragmentVariableValues == null + ? variableValues?.coerced[variableName] === undefined + : getCoercedVariableValue( + variableName, + variableValues, + fragmentVariableValues, + ) === undefined; +} + +function invalidInputLiteral(): undefined { + return undefined; +} + +function nullInputLiteral(): null { + return null; +} + +/** @internal */ +export function isStaticInputLiteral(valueNode: ValueNode): boolean { + switch (valueNode.kind) { + case Kind.VARIABLE: + return false; + case Kind.LIST: + return valueNode.values.every(isStaticInputLiteral); + case Kind.OBJECT: + return valueNode.fields.every((field) => + isStaticInputLiteral(field.value), + ); + default: + return true; + } +} + +/** @internal */ +export function getDefaultInputValue(inputValue: InputValue): unknown { + let coercedDefaultValue = inputValue._memoizedCoercedDefaultValue; + if (coercedDefaultValue !== undefined) { + return coercedDefaultValue; + } + + const defaultInput = inputValue.default; + if (defaultInput !== undefined) { + coercedDefaultValue = defaultInput.literal + ? compileInputLiteral(defaultInput.literal, inputValue.type)() + : compileInputValue(inputValue.type)(defaultInput.value); + invariant( + coercedDefaultValue !== undefined, + `Expected value of type "${inputValue.type}" to be valid, found: ${inspect( + defaultInput.literal ?? defaultInput.value, + )}.`, + ); + inputValue._memoizedCoercedDefaultValue = coercedDefaultValue; + return coercedDefaultValue; + } + + const defaultValue = inputValue.defaultValue; + if (defaultValue !== undefined) { + inputValue._memoizedCoercedDefaultValue = defaultValue; + } + return defaultValue; +} + +function compileDefaultInputValue( + inputValue: InputValue, +): CompiledDefaultInputValue { + try { + return { value: getDefaultInputValue(inputValue), error: undefined }; + } catch (error) { + return { value: undefined, error: ensureGraphQLError(error) }; + } +} + +function getCompiledDefaultInputValue( + defaultValue: CompiledDefaultInputValue, +): unknown { + if (defaultValue.error !== undefined) { + throw defaultValue.error; + } + return defaultValue.value; +} diff --git a/src/execution/compile/compileStreamDirective.ts b/src/execution/compile/compileStreamDirective.ts new file mode 100644 index 0000000000..bd3c42aa1c --- /dev/null +++ b/src/execution/compile/compileStreamDirective.ts @@ -0,0 +1,167 @@ +import type { Maybe } from '../../jsutils/Maybe.ts'; + +import type { DirectiveNode, ValueNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { GraphQLInputType } from '../../type/definition.ts'; +import { GraphQLNonNull } from '../../type/definition.ts'; +import { GraphQLBoolean, GraphQLInt } from '../../type/scalars.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; + +import { + compileInputLiteral, + isStaticInputLiteral, +} from './compileInputValue.ts'; + +/** @internal */ +export type CompiledStreamArgument = + | StaticStreamArgument + | VariableStreamArgument + | InvalidStreamArgument; + +/** @internal */ +export interface StaticStreamArgument { + kind: 'static'; + value: unknown; +} + +/** @internal */ +export interface VariableStreamArgument { + kind: 'variable'; + argument: StreamArgumentDefinition; + variableName: string; + defaultValue: unknown; +} + +/** @internal */ +export interface InvalidStreamArgument { + kind: 'invalid'; + argument: StreamArgumentDefinition; + valueNode: ValueNode; +} + +/** @internal */ +export interface StreamDirectiveCompilation { + initialCount: CompiledStreamArgument; + if: CompiledStreamArgument; + label: string | undefined; + usesVariableValues: boolean; + fragmentVariableValues: FragmentVariableValues | undefined; + staticFragmentVariableValues: FragmentVariableValues | undefined; +} + +/** @internal */ +export type CompiledStreamDirective = StreamDirectiveCompilation | null; + +/** @internal */ +export interface StreamArgumentDefinition { + coordinate: string; + type: GraphQLInputType; + defaultValue: unknown; +} + +const STREAM_INITIAL_COUNT_ARGUMENT: StreamArgumentDefinition = { + coordinate: '@stream(initialCount:)', + type: new GraphQLNonNull(GraphQLInt), + defaultValue: 0, +}; + +const STREAM_IF_ARGUMENT: StreamArgumentDefinition = { + coordinate: '@stream(if:)', + type: new GraphQLNonNull(GraphQLBoolean), + defaultValue: true, +}; + +/** @internal */ +export function compileStreamDirective( + directiveNode: DirectiveNode | undefined, +): CompiledStreamDirective { + if (directiveNode === undefined) { + return null; + } + + let initialCountValueNode; + let ifValueNode; + let labelValueNode; + for (const argumentNode of directiveNode.arguments ?? []) { + switch (argumentNode.name.value) { + case 'initialCount': + initialCountValueNode = argumentNode.value; + break; + case 'if': + ifValueNode = argumentNode.value; + break; + case 'label': + labelValueNode = argumentNode.value; + break; + } + } + + const initialCount = compileStreamArgument( + STREAM_INITIAL_COUNT_ARGUMENT, + initialCountValueNode, + ); + const ifValue = compileStreamArgument(STREAM_IF_ARGUMENT, ifValueNode); + + return { + initialCount, + if: ifValue, + label: + labelValueNode?.kind === Kind.STRING ? labelValueNode.value : undefined, + usesVariableValues: + initialCount.kind === 'variable' || ifValue.kind === 'variable', + fragmentVariableValues: undefined, + staticFragmentVariableValues: undefined, + }; +} + +/** @internal */ +export function withStreamDirectiveVariableValues( + compiled: CompiledStreamDirective, + fragmentVariableValues?: Maybe, + staticFragmentVariableValues?: Maybe, +): CompiledStreamDirective { + if ( + compiled === null || + !compiled.usesVariableValues || + (fragmentVariableValues == null && staticFragmentVariableValues == null) + ) { + return compiled; + } + + return { + ...compiled, + fragmentVariableValues: fragmentVariableValues ?? undefined, + staticFragmentVariableValues: staticFragmentVariableValues ?? undefined, + }; +} + +function compileStreamArgument( + argument: StreamArgumentDefinition, + valueNode: ValueNode | undefined, +): CompiledStreamArgument { + if (valueNode === undefined) { + return { + kind: 'static', + value: argument.defaultValue, + }; + } + + if (valueNode.kind === Kind.VARIABLE) { + return { + kind: 'variable', + argument, + variableName: valueNode.name.value, + defaultValue: argument.defaultValue, + }; + } + + const valueBuilder = compileInputLiteral(valueNode, argument.type); + const coercedValue = isStaticInputLiteral(valueNode) + ? valueBuilder() + : undefined; + return coercedValue !== undefined + ? { kind: 'static', value: coercedValue } + : { kind: 'invalid', argument, valueNode }; +} diff --git a/src/execution/compile/compileVariableValues.ts b/src/execution/compile/compileVariableValues.ts new file mode 100644 index 0000000000..ccc40b75a7 --- /dev/null +++ b/src/execution/compile/compileVariableValues.ts @@ -0,0 +1,79 @@ +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { VariableDefinitionNode } from '../../language/ast.ts'; + +import type { GraphQLSchema } from '../../type/schema.ts'; + +import type { GraphQLVariableSignature } from '../getVariableSignature.ts'; +import { getVariableSignature } from '../getVariableSignature.ts'; + +import type { InputValueCoercer } from './compileInputValue.ts'; +import { + compileInputValue, + getDefaultInputValue, +} from './compileInputValue.ts'; + +/** @internal */ +export interface CompiledVariableValues { + entries: ReadonlyArray; + hideSuggestions: boolean; +} + +/** @internal */ +export type CompiledVariableDefinition = + | ValidVariableDefinition + | InvalidVariableDefinition; + +/** @internal */ +export interface ValidVariableDefinition { + kind: 'valid'; + node: VariableDefinitionNode; + signature: GraphQLVariableSignature; + valueCoercer: InputValueCoercer; + defaultValue: unknown; + defaultError: GraphQLError | undefined; +} + +/** @internal */ +export interface InvalidVariableDefinition { + kind: 'invalid'; + error: GraphQLError; +} + +/** @internal */ +export function compileVariableValues( + schema: GraphQLSchema, + variableDefinitions: ReadonlyArray, + hideSuggestions: boolean, +): CompiledVariableValues { + const entries: Array = []; + for (const variableDefinition of variableDefinitions) { + const signature = getVariableSignature(schema, variableDefinition); + if (signature instanceof GraphQLError) { + entries.push({ kind: 'invalid', error: signature }); + continue; + } + + let defaultValue: unknown; + let defaultError: GraphQLError | undefined; + if (signature.default !== undefined) { + try { + defaultValue = getDefaultInputValue(signature); + } catch (error) { + defaultError = ensureGraphQLError(error); + } + } + + entries.push({ + kind: 'valid', + node: variableDefinition, + signature, + valueCoercer: compileInputValue(signature.type), + defaultValue, + defaultError, + }); + } + + return { entries, hideSuggestions }; +} diff --git a/src/execution/compile/getCompiledArgumentValues.ts b/src/execution/compile/getCompiledArgumentValues.ts new file mode 100644 index 0000000000..1fd04f03cb --- /dev/null +++ b/src/execution/compile/getCompiledArgumentValues.ts @@ -0,0 +1,351 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import type { Maybe } from '../../jsutils/Maybe.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import { printPathArray } from '../../jsutils/printPathArray.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import { GraphQLError } from '../../error/GraphQLError.ts'; + +import { validateDefaultInput } from '../../type/validate.ts'; + +import { validateInputLiteral } from '../../utilities/validateInputValue.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; +import type { VariableValues } from '../values.ts'; + +import type { + BareVariableArgumentValueEntry, + CompiledArgumentValues, + EmbeddedVariableArgumentValueEntry, + InvalidLiteralArgumentValueEntry, +} from './compileArgumentValues.ts'; + +/** @internal */ +export const UNKNOWN_ARGUMENT_VALUE: unique symbol = Symbol( + 'UNKNOWN_ARGUMENT_VALUE', +); + +/** @internal */ +export function getCompiledArgumentValues( + compiled: CompiledArgumentValues, + variableValues?: Maybe, +): ObjMap { + const constantValues = compiled.constantValues; + if (constantValues !== undefined) { + return constantValues; + } + + const coercedValues: ObjMap = Object.create(null); + const { fragmentVariableValues } = compiled; + if (fragmentVariableValues === undefined) { + const variableCoercedValues = variableValues?.coerced; + for (const entry of compiled.entries) { + switch (entry.kind) { + case 'constant': + coercedValues[entry.name] = entry.value; + break; + case 'bareVariable': + coerceVariableArgumentWithoutFragmentValues( + coercedValues, + entry, + compiled.node, + variableValues, + variableCoercedValues, + compiled.hideSuggestions, + ); + break; + case 'embeddedVariable': + case 'invalidLiteral': + coerceArgumentValueNode( + coercedValues, + entry, + variableValues, + undefined, + compiled.hideSuggestions, + ); + break; + case 'invalidDefault': + return throwInvalidDefaultArgument( + entry.argDef, + entry.error, + compiled.node, + compiled.hideSuggestions, + ); + case 'missing': + throw new GraphQLError( + `Argument "${entry.argDef}" of required type "${entry.argDef.type}" was not provided.`, + { nodes: compiled.node }, + ); + } + } + + return coercedValues; + } + + for (const entry of compiled.entries) { + switch (entry.kind) { + case 'constant': + coercedValues[entry.name] = entry.value; + break; + case 'bareVariable': + coerceVariableArgument( + coercedValues, + entry, + compiled.node, + variableValues, + fragmentVariableValues, + compiled.hideSuggestions, + ); + break; + case 'embeddedVariable': + case 'invalidLiteral': + coerceArgumentValueNode( + coercedValues, + entry, + variableValues, + fragmentVariableValues, + compiled.hideSuggestions, + ); + break; + case 'invalidDefault': + return throwInvalidDefaultArgument( + entry.argDef, + entry.error, + compiled.node, + compiled.hideSuggestions, + ); + case 'missing': + throw new GraphQLError( + `Argument "${entry.argDef}" of required type "${entry.argDef.type}" was not provided.`, + { nodes: compiled.node }, + ); + } + } + + return coercedValues; +} + +/** @internal */ +export function getCompiledArgumentValue( + compiled: CompiledArgumentValues, + name: string, + variableValues?: Maybe, +): unknown { + const entry = compiled.entryByName[name]; + if (entry === undefined) { + return undefined; + } + + switch (entry.kind) { + case 'constant': + return entry.value; + case 'bareVariable': + return getVariableArgumentValue( + entry, + variableValues, + compiled.fragmentVariableValues, + ); + case 'embeddedVariable': + case 'invalidLiteral': + case 'invalidDefault': + case 'missing': + return UNKNOWN_ARGUMENT_VALUE; + } +} + +// eslint-disable-next-line max-params +function coerceVariableArgumentWithoutFragmentValues( + coercedValues: ObjMap, + entry: BareVariableArgumentValueEntry, + node: CompiledArgumentValues['node'], + variableValues: Maybe, + variableCoercedValues: ObjMap | undefined, + hideSuggestions: boolean, +): void { + let value: unknown; + if ( + variableCoercedValues === undefined || + !(entry.variableName in variableCoercedValues) + ) { + value = + entry.isRequired || entry.defaultValueError !== undefined + ? UNKNOWN_ARGUMENT_VALUE + : entry.defaultValue; + } else { + value = variableCoercedValues[entry.variableName]; + if (value == null && entry.isNonNull) { + value = UNKNOWN_ARGUMENT_VALUE; + } + } + + if (value !== UNKNOWN_ARGUMENT_VALUE) { + if (value !== undefined || entry.defaultValue !== undefined) { + coercedValues[entry.name] = value; + } + return; + } + + if (entry.defaultValueError !== undefined && !entry.isRequired) { + throwInvalidDefaultArgument( + entry.argDef, + entry.defaultValueError, + node, + hideSuggestions, + ); + } + coerceArgumentValueNode( + coercedValues, + entry, + variableValues, + undefined, + hideSuggestions, + ); +} + +// eslint-disable-next-line max-params +function coerceVariableArgument( + coercedValues: ObjMap, + entry: BareVariableArgumentValueEntry, + node: CompiledArgumentValues['node'], + variableValues: Maybe, + fragmentVariableValues: Maybe, + hideSuggestions: boolean, +): void { + const value = getVariableArgumentValue( + entry, + variableValues, + fragmentVariableValues, + ); + if ( + value === UNKNOWN_ARGUMENT_VALUE && + entry.defaultValueError !== undefined && + !entry.isRequired + ) { + throwInvalidDefaultArgument( + entry.argDef, + entry.defaultValueError, + node, + hideSuggestions, + ); + } + if (value !== UNKNOWN_ARGUMENT_VALUE) { + if (value !== undefined || entry.defaultValue !== undefined) { + coercedValues[entry.name] = value; + } + return; + } + + coerceArgumentValueNode( + coercedValues, + entry, + variableValues, + fragmentVariableValues, + hideSuggestions, + ); +} + +function throwInvalidDefaultArgument( + argDef: BareVariableArgumentValueEntry['argDef'], + rawError: unknown, + node: + | BareVariableArgumentValueEntry['valueNode'] + | CompiledArgumentValues['node'], + hideSuggestions: boolean, +): never { + const defaultInput = argDef.default; + if (defaultInput !== undefined) { + let reportedValidationError = false; + validateDefaultInput( + defaultInput, + argDef.type, + (error, path) => { + reportedValidationError = true; + error.message = `Argument "${argDef}" has invalid default value${printPathArray( + path, + )}: ${error.message}`; + throw error; + }, + hideSuggestions, + ); + /* node:coverage ignore next 3 */ + if (reportedValidationError) { + invariant(false, 'Invalid default value'); + } + } + + const error = ensureGraphQLError(rawError); + throw new GraphQLError( + `Argument "${argDef}" has invalid default value: ${error.message}`, + { nodes: node, originalError: error }, + ); +} + +function getVariableArgumentValue( + entry: BareVariableArgumentValueEntry, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): unknown { + const scopedVariableValues = getScopedVariableValues( + entry.variableName, + variableValues, + fragmentVariableValues, + ); + if ( + scopedVariableValues == null || + !(entry.variableName in scopedVariableValues.coerced) + ) { + if (entry.isRequired || entry.defaultValueError !== undefined) { + return UNKNOWN_ARGUMENT_VALUE; + } + return entry.defaultValue; + } + + const value = scopedVariableValues.coerced[entry.variableName]; + return value == null && entry.isNonNull ? UNKNOWN_ARGUMENT_VALUE : value; +} + +function getScopedVariableValues( + variableName: string, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): Maybe { + return fragmentVariableValues !== undefined && + fragmentVariableValues !== null && + variableName in fragmentVariableValues.sources + ? fragmentVariableValues + : variableValues; +} + +function coerceArgumentValueNode( + coercedValues: ObjMap, + entry: + | BareVariableArgumentValueEntry + | EmbeddedVariableArgumentValueEntry + | InvalidLiteralArgumentValueEntry, + variableValues: Maybe, + fragmentVariableValues: Maybe, + hideSuggestions: boolean, +): void { + const coercedValue = entry.valueBuilder( + variableValues, + fragmentVariableValues, + ); + if (coercedValue === undefined) { + validateInputLiteral( + entry.valueNode, + entry.argDef.type, + (error, path) => { + error.message = `Argument "${ + entry.argDef + }" has invalid value${printPathArray(path)}: ${error.message}`; + throw error; + }, + variableValues, + fragmentVariableValues, + hideSuggestions, + ); + /* node:coverage ignore next */ + invariant(false, 'Invalid argument'); + } + coercedValues[entry.name] = coercedValue; +} diff --git a/src/execution/compile/getCompiledDeferUsage.ts b/src/execution/compile/getCompiledDeferUsage.ts new file mode 100644 index 0000000000..beb14f1b93 --- /dev/null +++ b/src/execution/compile/getCompiledDeferUsage.ts @@ -0,0 +1,37 @@ +import type { DeferUsage } from '../collectFields.ts'; +import type { VariableValues } from '../values.ts'; + +import type { DeferDirectiveCompilation } from './compileDeferDirective.ts'; +import type { FragmentVariables } from './compileFragmentVariables.ts'; +import { getCompiledDirectiveIfValue } from './getCompiledDirectiveIfValue.ts'; + +/** @internal */ +export function getCompiledDeferUsage( + selection: DeferDirectiveCompilation, + parentDeferUsage: DeferUsage | undefined, + variableValues: VariableValues, + fragmentVariables: FragmentVariables | undefined, + hideSuggestions: boolean, +): DeferUsage | undefined { + const deferDirective = selection.deferDirective; + if (deferDirective === undefined) { + return; + } + + const ifValue = deferDirective.hasIfArgument + ? getCompiledDirectiveIfValue( + deferDirective, + variableValues, + fragmentVariables, + hideSuggestions, + ) + : true; + if (ifValue === false) { + return; + } + + return { + label: deferDirective.label, + parentDeferUsage, + }; +} diff --git a/src/execution/compile/getCompiledDirectiveIfValue.ts b/src/execution/compile/getCompiledDirectiveIfValue.ts new file mode 100644 index 0000000000..89e657c05a --- /dev/null +++ b/src/execution/compile/getCompiledDirectiveIfValue.ts @@ -0,0 +1,132 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import { printPathArray } from '../../jsutils/printPathArray.ts'; + +import { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { ValueNode } from '../../language/ast.ts'; + +import { validateInputLiteral } from '../../utilities/validateInputValue.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; +import type { VariableValues } from '../values.ts'; + +import type { + CompiledBooleanDirective, + CompiledDirectiveArgument, +} from './compileBooleanDirective.ts'; +import type { FragmentVariables } from './compileFragmentVariables.ts'; + +/** @internal */ +export function getCompiledDirectiveIfValue( + directive: CompiledBooleanDirective | undefined, + variableValues: VariableValues, + fragmentVariables: FragmentVariables | undefined, + hideSuggestions: boolean, +): boolean | undefined { + if (directive === undefined) { + return; + } + + const ifBooleanValue = directive.ifBooleanValue; + if (ifBooleanValue !== undefined) { + return ifBooleanValue; + } + + const ifVariableName = directive.ifVariableName; + if (ifVariableName !== undefined) { + const ifArgument = directive.ifArgument; + if (fragmentVariables === undefined) { + const coercedValues = variableValues.coerced; + if (ifVariableName in coercedValues) { + const value = coercedValues[ifVariableName]; + if (value != null) { + return value === true; + } + } else { + const defaultValue = ifArgument.defaultValue; + if (defaultValue !== undefined) { + return defaultValue === true; + } + } + } else { + const staticCoercedValues = fragmentVariables.static?.coerced; + const staticValue = staticCoercedValues?.[ifVariableName]; + if ( + staticCoercedValues !== undefined && + ifVariableName in staticCoercedValues + ) { + if (staticValue != null) { + return staticValue === true; + } + const ifValueNode = directive.ifValueNode; + invariant(ifValueNode !== undefined, 'Expected variable value node.'); + throwInvalidDirectiveArgumentValue( + ifArgument, + ifValueNode, + variableValues, + fragmentVariables.static, + hideSuggestions, + ); + } + + const runtimeFragmentVariables = fragmentVariables.runtime; + const scopedVariableValues = + runtimeFragmentVariables !== undefined && + ifVariableName in runtimeFragmentVariables.sources + ? runtimeFragmentVariables + : variableValues; + if (ifVariableName in scopedVariableValues.coerced) { + const value = scopedVariableValues.coerced[ifVariableName]; + if (value != null) { + return value === true; + } + } else { + const defaultValue = ifArgument.defaultValue; + if (defaultValue !== undefined) { + return defaultValue === true; + } + } + } + } + + const ifValueNode = directive.ifValueNode; + const ifArgument = directive.ifArgument; + if (ifValueNode === undefined) { + throw new GraphQLError( + `Argument "${ifArgument.coordinate}" of required type "${ifArgument.type}" was not provided.`, + { nodes: directive.node }, + ); + } + + return throwInvalidDirectiveArgumentValue( + ifArgument, + ifValueNode, + variableValues, + fragmentVariables?.runtime, + hideSuggestions, + ); +} + +function throwInvalidDirectiveArgumentValue( + argument: CompiledDirectiveArgument, + valueNode: ValueNode, + variableValues: VariableValues, + fragmentVariableValues: FragmentVariableValues | undefined, + hideSuggestions: boolean, +): never { + validateInputLiteral( + valueNode, + argument.type, + (error, path) => { + error.message = `Argument "${ + argument.coordinate + }" has invalid value${printPathArray(path)}: ${error.message}`; + throw error; + }, + variableValues, + fragmentVariableValues, + hideSuggestions, + ); + /* node:coverage ignore next */ + invariant(false, 'Invalid argument'); +} diff --git a/src/execution/compile/getCompiledDirectiveValues.ts b/src/execution/compile/getCompiledDirectiveValues.ts new file mode 100644 index 0000000000..12053a025c --- /dev/null +++ b/src/execution/compile/getCompiledDirectiveValues.ts @@ -0,0 +1,161 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import type { Maybe } from '../../jsutils/Maybe.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import { printPathArray } from '../../jsutils/printPathArray.ts'; + +import type { ValueNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import { isNonNullType } from '../../type/definition.ts'; + +import { validateInputLiteral } from '../../utilities/validateInputValue.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; +import type { VariableValues } from '../values.ts'; + +import type { + CompiledStreamArgument, + CompiledStreamDirective, + StreamArgumentDefinition, + VariableStreamArgument, +} from './compileStreamDirective.ts'; + +/** @internal */ +export function getCompiledDirectiveValues( + compiled: CompiledStreamDirective, + variableValues?: Maybe, +): undefined | ObjMap { + if (compiled === null) { + return; + } + + const initialCount = getStreamArgumentValue( + compiled.initialCount, + variableValues, + compiled.fragmentVariableValues, + compiled.staticFragmentVariableValues, + ); + const ifValue = getStreamArgumentValue( + compiled.if, + variableValues, + compiled.fragmentVariableValues, + compiled.staticFragmentVariableValues, + ); + + const stream: ObjMap = Object.create(null); + stream.initialCount = initialCount; + stream.if = ifValue; + if (compiled.label !== undefined) { + stream.label = compiled.label; + } + return stream; +} + +function getStreamArgumentValue( + argument: CompiledStreamArgument, + variableValues: Maybe, + fragmentVariableValues: Maybe, + staticFragmentVariableValues: Maybe, +): unknown { + switch (argument.kind) { + case 'static': + return argument.value; + case 'variable': + return getVariableStreamArgumentValue( + argument, + variableValues, + fragmentVariableValues, + staticFragmentVariableValues, + ); + case 'invalid': + return getInvalidStreamArgumentValue( + argument.argument, + argument.valueNode, + variableValues, + fragmentVariableValues, + ); + } +} + +function getVariableStreamArgumentValue( + argument: VariableStreamArgument, + variableValues: Maybe, + fragmentVariableValues: Maybe, + staticFragmentVariableValues: Maybe, +): unknown { + const staticCoercedValues = staticFragmentVariableValues?.coerced; + if ( + staticCoercedValues !== undefined && + argument.variableName in staticCoercedValues + ) { + return getValidatedVariableValue( + argument, + staticCoercedValues[argument.variableName], + variableValues, + fragmentVariableValues, + ); + } + + const scopedVariableValues = + fragmentVariableValues != null && + argument.variableName in fragmentVariableValues.sources + ? fragmentVariableValues + : variableValues; + + if ( + scopedVariableValues == null || + !(argument.variableName in scopedVariableValues.coerced) + ) { + return argument.defaultValue; + } + + return getValidatedVariableValue( + argument, + scopedVariableValues.coerced[argument.variableName], + variableValues, + fragmentVariableValues, + ); +} + +function getValidatedVariableValue( + argument: VariableStreamArgument, + value: unknown, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): unknown { + if (value == null && isNonNullType(argument.argument.type)) { + return getInvalidStreamArgumentValue( + argument.argument, + { + kind: Kind.VARIABLE, + name: { kind: Kind.NAME, value: argument.variableName }, + }, + variableValues, + fragmentVariableValues, + ); + } + return value; +} + +function getInvalidStreamArgumentValue( + argument: StreamArgumentDefinition, + valueNode: ValueNode, + variableValues: Maybe, + fragmentVariableValues: Maybe, +): never { + validateInputLiteral( + valueNode, + argument.type, + (error, path) => { + error.message = `Argument "${ + argument.coordinate + }" has invalid value${printPathArray(path)}: ${error.message}`; + throw error; + }, + variableValues, + fragmentVariableValues, + false, + ); + /* node:coverage ignore next */ + invariant(false, 'Invalid argument'); +} diff --git a/src/execution/compile/getCompiledVariableValues.ts b/src/execution/compile/getCompiledVariableValues.ts new file mode 100644 index 0000000000..b20cb829fc --- /dev/null +++ b/src/execution/compile/getCompiledVariableValues.ts @@ -0,0 +1,180 @@ +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import { printPathArray } from '../../jsutils/printPathArray.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import { GraphQLError } from '../../error/GraphQLError.ts'; + +import { isNonNullType } from '../../type/definition.ts'; +import { validateDefaultInput } from '../../type/validate.ts'; + +import { validateInputValue } from '../../utilities/validateInputValue.ts'; + +import type { VariableValues, VariableValuesOrErrors } from '../values.ts'; + +import type { + CompiledVariableValues, + ValidVariableDefinition, +} from './compileVariableValues.ts'; + +/** @internal */ +export function getCompiledVariableValues( + compiled: CompiledVariableValues, + inputs: { readonly [variable: string]: unknown }, + maxErrors: number, +): VariableValuesOrErrors { + const errors: Array = []; + const onError = (error: GraphQLError) => { + if (errors.length >= maxErrors) { + throw new GraphQLError( + 'Too many errors processing variables, error limit reached. Execution aborted.', + ); + } + errors.push(error); + }; + + try { + const variableValues = coerceCompiledVariableValues( + compiled, + inputs, + onError, + ); + if (errors.length === 0) { + return { variableValues }; + } + } catch (error) { + errors.push(ensureGraphQLError(error)); + } + + return { errors }; +} + +function coerceCompiledVariableValues( + compiled: CompiledVariableValues, + inputs: { readonly [variable: string]: unknown }, + onError: (error: GraphQLError) => void, +): VariableValues { + const sources: ObjMap = + Object.create(null); + const coerced: ObjMap = Object.create(null); + + for (const entry of compiled.entries) { + if (entry.kind === 'invalid') { + onError(entry.error); + continue; + } + + const { signature } = entry; + const varName = signature.name; + const value = hasOwn(inputs, varName) ? inputs[varName] : undefined; + if (value === undefined) { + sources[varName] = { signature }; + if (signature.default !== undefined) { + useVariableDefaultValue( + entry, + coerced, + onError, + compiled.hideSuggestions, + ); + } else if (isNonNullType(signature.type)) { + reportInvalidVariableValue( + entry, + value, + onError, + compiled.hideSuggestions, + ); + } + continue; + } + + sources[varName] = { signature, value }; + const coercedValue = entry.valueCoercer(value); + if (coercedValue !== undefined) { + coerced[varName] = coercedValue; + } else { + reportInvalidVariableValue( + entry, + value, + onError, + compiled.hideSuggestions, + ); + } + } + + return { sources, coerced }; +} + +function useVariableDefaultValue( + entry: ValidVariableDefinition, + coerced: ObjMap, + onError: (error: GraphQLError) => void, + hideSuggestions: boolean, +): void { + if (entry.defaultError === undefined) { + coerced[entry.signature.name] = entry.defaultValue; + return; + } + + const defaultInput = entry.signature.default; + // Defensive: compiled variable defaults are only used when a default exists. + /* node:coverage ignore next 3 */ + if (defaultInput === undefined) { + throw entry.defaultError; + } + + let reportedValidationError = false; + validateDefaultInput( + defaultInput, + entry.signature.type, + (defaultError, path) => { + reportedValidationError = true; + onError( + new GraphQLError( + `Variable "$${entry.signature.name}" has invalid default value${printPathArray( + path, + )}: ${defaultError.message}`, + { nodes: entry.node }, + ), + ); + }, + hideSuggestions, + ); + + if (!reportedValidationError) { + onError( + new GraphQLError( + `Variable "$${entry.signature.name}" has invalid default value: ${entry.defaultError.message}`, + { nodes: entry.node }, + ), + ); + } +} + +function reportInvalidVariableValue( + entry: ValidVariableDefinition, + value: unknown, + onError: (error: GraphQLError) => void, + hideSuggestions: boolean, +): void { + validateInputValue( + value, + entry.signature.type, + (error, path) => { + onError( + new GraphQLError( + `Variable "$${entry.signature.name}" has invalid value${printPathArray( + path, + )}: ${error.message}`, + { nodes: entry.node, originalError: error }, + ), + ); + }, + hideSuggestions, + ); +} + +function hasOwn( + object: { readonly [key: string]: unknown }, + key: string, +): boolean { + return Object.hasOwn(object, key); +} diff --git a/src/execution/compile/getStaticFragmentVariableValues.ts b/src/execution/compile/getStaticFragmentVariableValues.ts new file mode 100644 index 0000000000..88de7073b1 --- /dev/null +++ b/src/execution/compile/getStaticFragmentVariableValues.ts @@ -0,0 +1,96 @@ +import type { ObjMap } from '../../jsutils/ObjMap.ts'; + +import type { ValueNode } from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; + +import type { FragmentVariableValues } from '../collectFields.ts'; + +import type { + CompiledFragmentVariables, + DynamicCompiledFragmentVariableEntry, +} from './compileFragmentVariables.ts'; + +/** @internal */ +export function getStaticFragmentVariableValues( + compiledFragmentVariables: CompiledFragmentVariables | undefined, + parentStaticValues: FragmentVariableValues | undefined, +): FragmentVariableValues | undefined { + if (compiledFragmentVariables === undefined) { + return; + } + + let sources: ObjMap | undefined; + let coerced: ObjMap | undefined; + for (const entry of compiledFragmentVariables.entries) { + const coercedValue = + 'staticValue' in entry + ? entry.staticValue + : getStaticValueIfAvailable(entry, parentStaticValues); + + if (coercedValue === undefined) { + continue; + } + + const source = + entry.sourceValueNode === undefined + ? { + signature: entry.signature, + value: undefined, + fragmentVariableValues: undefined, + } + : parentStaticValues === undefined + ? { + signature: entry.signature, + value: entry.sourceValueNode, + fragmentVariableValues: undefined, + } + : { + signature: entry.signature, + value: entry.sourceValueNode, + fragmentVariableValues: parentStaticValues, + }; + + const staticSources = (sources ??= Object.create(null)); + const staticCoerced = (coerced ??= Object.create(null)); + staticSources[entry.name] = source; + staticCoerced[entry.name] = coercedValue; + } + + return sources === undefined || coerced === undefined + ? undefined + : { sources, coerced }; +} + +function getStaticValueIfAvailable( + entry: DynamicCompiledFragmentVariableEntry, + parentStaticValues: FragmentVariableValues | undefined, +): unknown { + if (!canUseStaticValue(entry.valueNode, parentStaticValues)) { + return; + } + + return entry.valueBuilder(undefined, parentStaticValues); +} + +function canUseStaticValue( + valueNode: ValueNode, + parentStaticValues: FragmentVariableValues | undefined, +): boolean { + switch (valueNode.kind) { + case Kind.VARIABLE: + return ( + parentStaticValues !== undefined && + valueNode.name.value in parentStaticValues.coerced + ); + case Kind.LIST: + return valueNode.values.every((item) => + canUseStaticValue(item, parentStaticValues), + ); + case Kind.OBJECT: + return valueNode.fields.every((field) => + canUseStaticValue(field.value, parentStaticValues), + ); + default: + return true; + } +} diff --git a/src/execution/compile/index.ts b/src/execution/compile/index.ts new file mode 100644 index 0000000000..e9256834e2 --- /dev/null +++ b/src/execution/compile/index.ts @@ -0,0 +1,619 @@ +/** + * Compile GraphQL operations for repeated execution. + * @packageDocumentation + */ + +import { inspect } from '../../jsutils/inspect.ts'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.ts'; +import { isObjectLike } from '../../jsutils/isObjectLike.ts'; +import { isPromise, isPromiseLike } from '../../jsutils/isPromise.ts'; +import { addPath, pathToArray } from '../../jsutils/Path.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import { GraphQLError } from '../../error/GraphQLError.ts'; +import { locatedError } from '../../error/locatedError.ts'; + +import type { + FieldNode, + SubscriptionOperationDefinitionNode, +} from '../../language/ast.ts'; +import { isSubscriptionOperationDefinitionNode } from '../../language/predicates.ts'; + +import type { + GraphQLFieldResolver, + GraphQLTypeResolver, +} from '../../type/definition.ts'; + +import { buildResolveInfo } from '../buildResolveInfo.ts'; +import { cancellablePromise } from '../cancellablePromise.ts'; +import type { FieldDetailsList } from '../collectFields.ts'; +import { createSharedExecutionContext } from '../createSharedExecutionContext.ts'; +import type { + CompiledExecutionArgs, + CompileExecutionArgs, + RootSelectionSetExecutor, + ValidatedExecutionArgs, + ValidatedSubscriptionArgs, +} from '../ExecutionArgs.ts'; +import { EMPTY_VARIABLE_VALUES } from '../ExecutionArgs.ts'; +import type { ExecutionResult } from '../Executor.ts'; +import type { ExperimentalIncrementalExecutionResults } from '../incremental/IncrementalExecutor.ts'; +import { mapAsyncIterable } from '../mapAsyncIterable.ts'; +import type { VariableValuesOrErrors } from '../values.ts'; + +import { buildValidatedExecutionArgs } from './buildValidatedExecutionArgs.ts'; +import { CompiledExecutor } from './CompiledExecutor.ts'; +import type { CompiledExecutionState } from './compileExecutionState.ts'; +import { + compileExecutionState, + isExecutionErrors, +} from './compileExecutionState.ts'; +import type { CompiledVariableValues } from './compileVariableValues.ts'; +import { compileVariableValues } from './compileVariableValues.ts'; +import { getCompiledArgumentValues } from './getCompiledArgumentValues.ts'; +import { getCompiledVariableValues } from './getCompiledVariableValues.ts'; + +export type { + CompiledExecutionArgs, + CompileExecutionArgs, + RootSelectionSetExecutor, +} from '../ExecutionArgs.ts'; + +/** + * Compiled execution operation with reusable validation and field collection. + * @category Execution + */ +export interface CompiledExecution { + /** Execute the operation. */ + execute: (args?: CompiledExecutionArgs) => PromiseOrValue; + /** Execute the operation with incremental delivery enabled. */ + experimentalExecuteIncrementally: ( + args?: CompiledExecutionArgs, + ) => PromiseOrValue< + ExecutionResult | ExperimentalIncrementalExecutionResults + >; + /** @internal */ + executeIgnoringIncremental: ( + args?: CompiledExecutionArgs, + ) => PromiseOrValue< + ExecutionResult | ExperimentalIncrementalExecutionResults + >; +} + +/** + * Compiled subscription operation with reusable validation and field collection. + * @category Execution + */ +export interface CompiledSubscription extends CompiledExecution { + /** Execute the subscription operation once for a single source event. */ + executeSubscriptionEvent: ( + args: ValidatedSubscriptionArgs, + ) => PromiseOrValue; + /** Create the subscription source event stream. */ + createSourceEventStream: ( + args: ValidatedSubscriptionArgs, + ) => PromiseOrValue | ExecutionResult>; + /** Map a source event stream to execution results. */ + mapSourceToResponseEvent: ( + args: ValidatedSubscriptionArgs, + sourceEventStream: AsyncIterable, + rootSelectionSetExecutor?: RootSelectionSetExecutor, + ) => AsyncGenerator | ExecutionResult; + /** Run the full subscription pipeline. */ + subscribe: ( + args?: CompiledExecutionArgs, + ) => PromiseOrValue< + AsyncGenerator | ExecutionResult + >; +} + +interface CompiledSubscriptionState extends CompiledExecutionState { + operation: SubscriptionOperationDefinitionNode; +} + +const compiledDefaultTypeResolver: GraphQLTypeResolver = + function (value, contextValue, info, abstractType) { + if (isObjectLike(value) && typeof value.__typename === 'string') { + return value.__typename; + } + + const possibleTypes = info.schema.getPossibleTypes(abstractType); + const promisedIsTypeOfResults: Array> = []; + + try { + for (let i = 0; i < possibleTypes.length; i++) { + const type = possibleTypes[i]; + + if (type.isTypeOf) { + const isTypeOfResult = type.isTypeOf(value, contextValue, info); + + if (isPromiseLike(isTypeOfResult)) { + promisedIsTypeOfResults[i] = isTypeOfResult; + } else if (isTypeOfResult) { + if (promisedIsTypeOfResults.length) { + info.getAsyncHelpers().track(promisedIsTypeOfResults); + } + return type.name; + } + } + } + } catch (error) { + if (promisedIsTypeOfResults.length) { + info.getAsyncHelpers().track(promisedIsTypeOfResults); + } + throw error; + } + + if (promisedIsTypeOfResults.length) { + return info + .getAsyncHelpers() + .promiseAll(promisedIsTypeOfResults) + .then((isTypeOfResults) => { + for (let i = 0; i < isTypeOfResults.length; i++) { + if (isTypeOfResults[i]) { + return possibleTypes[i].name; + } + } + }); + } + }; + +const compiledDefaultFieldResolver: GraphQLFieldResolver = + function (source: any, args, contextValue, info) { + if (isObjectLike(source) || typeof source === 'function') { + const property = source[info.fieldName]; + if (typeof property === 'function') { + return property.call(source, args, contextValue, info); + } + return property; + } + }; + +const defaultResolvers = { + fieldResolver: compiledDefaultFieldResolver, + typeResolver: compiledDefaultTypeResolver, + subscribeFieldResolver: compiledDefaultFieldResolver, +}; + +/** + * Compiles a GraphQL execution operation for repeated execution. + * @param args - Static execution arguments to compile. + * @returns A compiled execution operation, or validation errors. + * @example + * ```ts + * import assert from 'node:assert'; + * import { parse } from 'graphql/language'; + * import { buildSchema } from 'graphql/utilities'; + * import { compileExecution } from 'graphql/execution'; + * + * const schema = buildSchema('type Query { greeting: String }'); + * const compiled = compileExecution({ + * schema, + * document: parse('{ greeting }'), + * }); + * + * assert('execute' in compiled); + * + * const result = await compiled.execute({ + * rootValue: { greeting: 'Hello' }, + * }); + * result; // => { data: { greeting: 'Hello' } } + * ``` + * @category Execution + */ +export function compileExecution( + args: CompileExecutionArgs, +): ReadonlyArray | CompiledExecution { + const compiledExecution = compileExecutionState(args); + if (isExecutionErrors(compiledExecution)) { + return compiledExecution; + } + return new CompiledExecutionImpl(compiledExecution); +} + +/** + * Compiles a GraphQL subscription operation for repeated subscription work. + * @param args - Static execution arguments to compile. + * @returns A compiled subscription operation, or validation errors. + * @example + * ```ts + * import assert from 'node:assert'; + * import { parse } from 'graphql/language'; + * import { buildSchema } from 'graphql/utilities'; + * import { compileSubscription } from 'graphql/execution'; + * + * async function* greetings() { + * yield { greeting: 'Hello' }; + * } + * + * const schema = buildSchema(` + * type Query { + * noop: String + * } + * + * type Subscription { + * greeting: String + * } + * `); + * const compiled = compileSubscription({ + * schema, + * document: parse('subscription { greeting }'), + * }); + * + * assert('subscribe' in compiled); + * + * const result = await compiled.subscribe({ + * rootValue: { greeting: () => greetings() }, + * }); + * assert('next' in result); + * ``` + * @category Execution + */ +export function compileSubscription( + args: CompileExecutionArgs, +): ReadonlyArray | CompiledSubscription { + const compiledExecution = compileExecutionState(args); + if (isExecutionErrors(compiledExecution)) { + return compiledExecution; + } + assertSubscriptionCompiledExecution(compiledExecution); + return new CompiledSubscriptionImpl(compiledExecution); +} + +class CompiledExecutionImpl implements CompiledExecution { + protected _compiledExecution: CompiledExecutionState; + private _compiledVariableValues: CompiledVariableValues; + private _variableValuesCache: WeakMap< + { readonly [variable: string]: unknown }, + Map + >; + + constructor(compiledExecution: CompiledExecutionState) { + this._compiledExecution = compiledExecution; + this._compiledVariableValues = compileVariableValues( + compiledExecution.schema, + compiledExecution.variableDefinitions, + compiledExecution.hideSuggestions, + ); + this._variableValuesCache = new WeakMap(); + } + + execute(args: CompiledExecutionArgs = {}): PromiseOrValue { + return this.executeWithValidatedArgs(args, (validatedExecutionArgs) => + new CompiledExecutor( + validatedExecutionArgs, + 'throw', + ).executeRootSelectionSet(), + ); + } + + experimentalExecuteIncrementally( + args: CompiledExecutionArgs = {}, + ): PromiseOrValue { + return this.executeWithValidatedArgs(args, (validatedExecutionArgs) => + new CompiledExecutor( + validatedExecutionArgs, + 'incremental', + ).executeRootSelectionSet(), + ); + } + + executeIgnoringIncremental( + args: CompiledExecutionArgs = {}, + ): PromiseOrValue { + return this.executeWithValidatedArgs(args, (validatedExecutionArgs) => + new CompiledExecutor( + validatedExecutionArgs, + 'ignore', + ).executeRootSelectionSet(), + ); + } + + protected getValidatedExecutionArgs( + args: CompiledExecutionArgs = {}, + ): ReadonlyArray | ValidatedExecutionArgs { + const variableValuesOrErrors = this.getVariableValues(args); + if (variableValuesOrErrors.errors) { + return variableValuesOrErrors.errors; + } + + return buildValidatedExecutionArgs( + this._compiledExecution, + args, + variableValuesOrErrors.variableValues, + defaultResolvers, + ); + } + + protected withCompiledFieldCollectors( + validatedExecutionArgs: T, + ): T { + if (validatedExecutionArgs.fieldCollectors !== this._compiledExecution) { + return { + ...validatedExecutionArgs, + fieldCollectors: this._compiledExecution, + }; + } + return validatedExecutionArgs; + } + + private executeWithValidatedArgs( + args: CompiledExecutionArgs, + execute: ( + validatedExecutionArgs: ValidatedExecutionArgs, + ) => PromiseOrValue, + ): PromiseOrValue { + const validatedExecutionArgs = this.getValidatedExecutionArgs(args); + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } + return execute(validatedExecutionArgs); + } + + private getVariableValues( + args: CompiledExecutionArgs, + ): VariableValuesOrErrors { + const rawVariableValues = args.variableValues ?? EMPTY_VARIABLE_VALUES; + const maxCoercionErrors = args.options?.maxCoercionErrors ?? 50; + let variableValuesByMaxErrors = + this._variableValuesCache.get(rawVariableValues); + if (variableValuesByMaxErrors === undefined) { + variableValuesByMaxErrors = new Map(); + this._variableValuesCache.set( + rawVariableValues, + variableValuesByMaxErrors, + ); + } + + let variableValuesOrErrors = + variableValuesByMaxErrors.get(maxCoercionErrors); + if (variableValuesOrErrors === undefined) { + variableValuesOrErrors = getCompiledVariableValues( + this._compiledVariableValues, + rawVariableValues, + maxCoercionErrors, + ); + variableValuesByMaxErrors.set(maxCoercionErrors, variableValuesOrErrors); + } + return variableValuesOrErrors; + } +} + +class CompiledSubscriptionImpl + extends CompiledExecutionImpl + implements CompiledSubscription +{ + executeSubscriptionEvent( + validatedExecutionArgs: ValidatedSubscriptionArgs, + ): PromiseOrValue { + return new CompiledExecutor( + this.withCompiledFieldCollectors(validatedExecutionArgs), + 'throw', + ).executeRootSelectionSet(false); + } + + createSourceEventStream( + validatedExecutionArgs: ValidatedSubscriptionArgs, + ): PromiseOrValue | ExecutionResult> { + return createCompiledSourceEventStream( + this.withCompiledFieldCollectors(validatedExecutionArgs), + ); + } + + mapSourceToResponseEvent( + validatedExecutionArgs: ValidatedSubscriptionArgs, + sourceEventStream: AsyncIterable, + rootSelectionSetExecutor?: RootSelectionSetExecutor, + ): AsyncGenerator | ExecutionResult { + return mapCompiledSourceToResponseEvent( + this.withCompiledFieldCollectors(validatedExecutionArgs), + sourceEventStream, + rootSelectionSetExecutor ?? this.executeSubscriptionEvent.bind(this), + ); + } + + subscribe( + args: CompiledExecutionArgs = {}, + ): PromiseOrValue< + AsyncGenerator | ExecutionResult + > { + const validatedExecutionArgs = this.getValidatedSubscriptionArgs(args); + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } + + const resultOrStream = createCompiledSourceEventStream( + validatedExecutionArgs, + ); + + if (isPromise(resultOrStream)) { + return resultOrStream.then((resolvedResultOrStream) => + isAsyncIterable(resolvedResultOrStream) + ? mapCompiledSourceToResponseEvent( + validatedExecutionArgs, + resolvedResultOrStream, + this.executeSubscriptionEvent.bind(this), + ) + : resolvedResultOrStream, + ); + } + + return isAsyncIterable(resultOrStream) + ? mapCompiledSourceToResponseEvent( + validatedExecutionArgs, + resultOrStream, + this.executeSubscriptionEvent.bind(this), + ) + : resultOrStream; + } + + private getValidatedSubscriptionArgs( + args: CompiledExecutionArgs, + ): ReadonlyArray | ValidatedSubscriptionArgs { + const validatedExecutionArgs = this.getValidatedExecutionArgs(args); + if (!('schema' in validatedExecutionArgs)) { + return validatedExecutionArgs; + } + // CompiledSubscriptionImpl is only constructed for subscription operations. + return validatedExecutionArgs as ValidatedSubscriptionArgs; + } +} + +function createCompiledSourceEventStream( + validatedExecutionArgs: ValidatedSubscriptionArgs, +): PromiseOrValue | ExecutionResult> { + if (!('operation' in validatedExecutionArgs)) { + throw new GraphQLError( + 'Passing ExecutionArgs to createSourceEventStream() was removed in graphql-js@17.0.0; call validateSubscriptionArgs() first and pass the result instead, or use subscribe() for the full subscription pipeline.', + ); + } + + try { + const eventStream = executeCompiledSubscription(validatedExecutionArgs); + if (isPromise(eventStream)) { + return eventStream.then(undefined, (error: unknown) => ({ + errors: [ensureGraphQLError(error)], + })); + } + + return eventStream; + } catch (error) { + return { errors: [ensureGraphQLError(error)] }; + } +} + +function mapCompiledSourceToResponseEvent( + validatedExecutionArgs: ValidatedSubscriptionArgs, + sourceEventStream: AsyncIterable, + rootSelectionSetExecutor: RootSelectionSetExecutor, +): AsyncGenerator { + function mapFn(payload: unknown): PromiseOrValue { + const perEventExecutionArgs: ValidatedSubscriptionArgs = { + ...validatedExecutionArgs, + rootValue: payload, + }; + return rootSelectionSetExecutor(perEventExecutionArgs); + } + + const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; + if (externalAbortSignal) { + const generator = mapAsyncIterable(sourceEventStream, mapFn); + return { + ...generator, + next: () => cancellablePromise(generator.next(), externalAbortSignal), + }; + } + return mapAsyncIterable(sourceEventStream, mapFn); +} + +function executeCompiledSubscription( + validatedExecutionArgs: ValidatedSubscriptionArgs, +): PromiseOrValue> { + const { + schema, + rootValue, + contextValue, + operation, + variableValues, + externalAbortSignal, + } = validatedExecutionArgs; + + const rootType = schema.getSubscriptionType(); + if (rootType == null) { + throw new GraphQLError( + 'Schema is not configured to execute subscription operation.', + { nodes: operation }, + ); + } + + const { groupedFieldSet } = + validatedExecutionArgs.fieldCollectors.collectRootFields( + variableValues, + rootType, + ); + + const firstRootField = groupedFieldSet.entries().next(); + if (firstRootField.done === true) { + throw new GraphQLError('Subscription operation must select a field.'); + } + const [responseName, fieldDetailsList] = firstRootField.value; + const firstFieldDetails = fieldDetailsList[0]; + const firstNode = firstFieldDetails.node; + const compiledFieldPlan = firstFieldDetails.compiledFieldPlan; + const fieldName = firstNode.name.value; + const fieldNodes = toNodes(fieldDetailsList); + if (compiledFieldPlan === undefined) { + throw new GraphQLError( + `The subscription field "${fieldName}" is not defined.`, + { nodes: fieldNodes }, + ); + } + const fieldDef = compiledFieldPlan.fieldDef; + + const sharedExecutionContext = + createSharedExecutionContext(externalAbortSignal); + const path = addPath(undefined, responseName, rootType.name); + const info = buildResolveInfo( + validatedExecutionArgs, + fieldDef, + fieldNodes, + rootType, + path, + sharedExecutionContext.getAbortSignal, + sharedExecutionContext.getAsyncHelpers, + ); + + try { + const args = getCompiledArgumentValues( + compiledFieldPlan.compiledArgumentValues, + variableValues, + ); + + const resolveFn = + fieldDef.subscribe ?? validatedExecutionArgs.subscribeFieldResolver; + const result = resolveFn(rootValue, args, contextValue, info); + + if (isPromiseLike(result)) { + const promisedResult = Promise.resolve(result); + const promise = externalAbortSignal + ? cancellablePromise(promisedResult, externalAbortSignal) + : promisedResult; + return promise + .then(assertEventStream) + .then(undefined, (error: unknown) => { + throw locatedError(error, fieldNodes, pathToArray(path)); + }); + } + return assertEventStream(result); + } catch (error) { + throw locatedError(error, fieldNodes, pathToArray(path)); + } +} + +function assertEventStream(result: unknown): AsyncIterable { + if (result instanceof Error) { + throw result; + } + + if (!isAsyncIterable(result)) { + throw new GraphQLError( + 'Subscription field must return Async Iterable. ' + + `Received: ${inspect(result)}.`, + ); + } + + return result; +} + +function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { + return fieldDetailsList.map((fieldDetails) => fieldDetails.node); +} + +function assertSubscriptionCompiledExecution( + compiledExecution: CompiledExecutionState, +): asserts compiledExecution is CompiledSubscriptionState { + if (!isSubscriptionOperationDefinitionNode(compiledExecution.operation)) { + throw new GraphQLError('Expected subscription operation.'); + } +} diff --git a/src/execution/createSharedExecutionContext.ts b/src/execution/createSharedExecutionContext.ts index 5cc97a4219..95f86ac7c2 100644 --- a/src/execution/createSharedExecutionContext.ts +++ b/src/execution/createSharedExecutionContext.ts @@ -14,24 +14,31 @@ export interface SharedExecutionContext { /** @internal */ export function createSharedExecutionContext( - abortSignal: AbortSignal | undefined, + abortSignal: AbortSignal | undefined | (() => AbortSignal | undefined), ): SharedExecutionContext { - const asyncWorkTracker = new AsyncWorkTracker(); + let asyncWorkTracker: AsyncWorkTracker | undefined; let resolveInfoHelpers: GraphQLResolveInfoHelpers | undefined; + const getAsyncWorkTracker = (): AsyncWorkTracker => + (asyncWorkTracker ??= new AsyncWorkTracker()); + const getAbortSignal = + typeof abortSignal === 'function' ? abortSignal : () => abortSignal; + const promiseAll = ( values: ReadonlyArray | T>, - ): Promise> => asyncWorkTracker.promiseAllTrackOnReject(values); + ): Promise> => getAsyncWorkTracker().promiseAllTrackOnReject(values); const getAsyncHelpers = (): GraphQLResolveInfoHelpers => (resolveInfoHelpers ??= { promiseAll, - track: (maybePromises) => asyncWorkTracker.addValues(maybePromises), + track: (maybePromises) => getAsyncWorkTracker().addValues(maybePromises), }); return { - asyncWorkTracker, - getAbortSignal: () => abortSignal, + get asyncWorkTracker() { + return getAsyncWorkTracker(); + }, + getAbortSignal, getAsyncHelpers, promiseAll, }; diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 4dea28d5c4..1aa40ff2f2 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -41,15 +41,19 @@ import { import { buildResolveInfo } from './buildResolveInfo.ts'; import { cancellablePromise } from './cancellablePromise.ts'; import type { FieldDetailsList, FragmentDetails } from './collectFields.ts'; -import { collectFields } from './collectFields.ts'; import { createSharedExecutionContext } from './createSharedExecutionContext.ts'; import type { ExecutionArgs, + RootSelectionSetExecutor, ValidatedExecutionArgs, ValidatedSubscriptionArgs, } from './ExecutionArgs.ts'; import type { ExecutionResult } from './Executor.ts'; -import { Executor } from './Executor.ts'; +import { + collectRootFields, + createFieldCollectors, + Executor, +} from './Executor.ts'; import { ExecutorThrowingOnIncremental } from './ExecutorThrowingOnIncremental.ts'; import type { GraphQLVariableSignature } from './getVariableSignature.ts'; import { getVariableSignature } from './getVariableSignature.ts'; @@ -61,10 +65,10 @@ import { getArgumentValues, getVariableValues } from './values.ts'; const UNEXPECTED_EXPERIMENTAL_DIRECTIVES = 'The provided schema unexpectedly contains experimental directives (@defer or @stream). These directives may only be utilized if experimental execution features are explicitly enabled.'; -/** Function used to execute a validated root selection set for a subscription event. */ -export type RootSelectionSetExecutor = ( - validatedExecutionArgs: ValidatedSubscriptionArgs, -) => PromiseOrValue; +export type { + ExecutionArgs, + RootSelectionSetExecutor, +} from './ExecutionArgs.ts'; /** * Implements the "Executing requests" section of the GraphQL specification. @@ -743,6 +747,7 @@ export function validateExecutionArgs( subscribeFieldResolver, abortSignal: externalAbortSignal, enableEarlyExecution, + enableBatchResolvers, hooks, options, } = args; @@ -850,7 +855,7 @@ export function validateExecutionArgs( directive.name.value === GraphQLDisableErrorPropagationDirective.name, ); - return { + const validatedExecutionArgs = { schema, document, fragmentDefinitions, @@ -866,9 +871,14 @@ export function validateExecutionArgs( errorPropagation, externalAbortSignal: externalAbortSignal ?? undefined, enableEarlyExecution: enableEarlyExecution === true, + enableBatchResolvers: enableBatchResolvers === true, hooks: hooks ?? undefined, rawVariableValues, - }; + } as ValidatedExecutionArgs; + validatedExecutionArgs.fieldCollectors = createFieldCollectors( + validatedExecutionArgs, + ); + return validatedExecutionArgs; } /** @@ -1078,7 +1088,6 @@ function executeSubscription( ): PromiseOrValue> { const { schema, - fragments, rootValue, contextValue, operation, @@ -1095,20 +1104,16 @@ function executeSubscription( ); } - const { groupedFieldSet } = collectFields( - schema, - fragments, - variableValues, + const { groupedFieldSet } = collectRootFields( + validatedExecutionArgs, rootType, - operation.selectionSet, - hideSuggestions, ); - const firstRootField = groupedFieldSet.entries().next().value as [ - string, - FieldDetailsList, - ]; - const [responseName, fieldDetailsList] = firstRootField; + const firstRootField = groupedFieldSet.entries().next(); + if (firstRootField.done === true) { + throw new GraphQLError('Subscription operation must select a field.'); + } + const [responseName, fieldDetailsList] = firstRootField.value; const firstFieldDetails = fieldDetailsList[0]; const firstNode = firstFieldDetails.node; const fieldName = firstNode.name.value; diff --git a/src/execution/getStreamUsage.ts b/src/execution/getStreamUsage.ts index 4450611ec7..51ca1066a3 100644 --- a/src/execution/getStreamUsage.ts +++ b/src/execution/getStreamUsage.ts @@ -29,11 +29,12 @@ export function getStreamUsage( const { operation, variableValues } = validatedExecutionArgs; // validation only allows equivalent streams on multiple fields, so it is // safe to only check the first fieldNode for the stream directive + const firstFieldDetails = fieldDetailsList[0]; const stream = getDirectiveValues( GraphQLStreamDirective, - fieldDetailsList[0].node, + firstFieldDetails.node, variableValues, - fieldDetailsList[0].fragmentVariableValues, + firstFieldDetails.fragmentVariableValues, ); if (!stream) { @@ -61,9 +62,8 @@ export function getStreamUsage( const streamedFieldDetailsList: FieldDetailsList = fieldDetailsList.map( (fieldDetails) => ({ - node: fieldDetails.node, + ...fieldDetails, deferUsage: undefined, - fragmentVariableValues: fieldDetails.fragmentVariableValues, }), ); diff --git a/src/execution/incremental/IncrementalExecutor.ts b/src/execution/incremental/IncrementalExecutor.ts index 4601546f75..1e724b0773 100644 --- a/src/execution/incremental/IncrementalExecutor.ts +++ b/src/execution/incremental/IncrementalExecutor.ts @@ -341,6 +341,8 @@ interface ExecutionGroup extends Task< computation: Computation; } +type IncrementalPositionContext = ReadonlyMap; + /** @internal */ export interface DeliveryGroup extends Group { path: Path | undefined; @@ -397,7 +399,7 @@ export interface StreamItemResult { /** @internal */ export class IncrementalExecutor< TExperimental = ExperimentalIncrementalExecutionResults, -> extends Executor, TExperimental> { +> extends Executor { deferUsageSet?: DeferUsageSet | undefined; groups: Array; tasks: Array; diff --git a/src/execution/incremental/__tests__/defer-test.ts b/src/execution/incremental/__tests__/defer-test.ts index 4b9676398c..f6383a9fb1 100644 --- a/src/execution/incremental/__tests__/defer-test.ts +++ b/src/execution/incremental/__tests__/defer-test.ts @@ -20,14 +20,11 @@ import { import { GraphQLID, GraphQLString } from '../../../type/scalars.ts'; import { GraphQLSchema } from '../../../type/schema.ts'; -import { buildSchema } from '../../../utilities/buildASTSchema.ts'; - -import { experimentalExecuteIncrementally } from '../../execute.ts'; - -import type { - InitialIncrementalExecutionResult, - SubsequentIncrementalExecutionResult, -} from '../IncrementalExecutor.ts'; +import { + completeDirectly, + completeExecution, + experimentalExecuteIncrementally, +} from '../../__tests__/executeTestUtils.ts'; const friendType = new GraphQLObjectType({ fields: { @@ -130,6 +127,23 @@ const heroType = new GraphQLObjectType({ name: 'Hero', }); +const cancellationUserType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + name: 'User', +}); + +const cancellationTodoType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + items: { type: new GraphQLList(GraphQLString) }, + author: { type: cancellationUserType }, + }, + name: 'Todo', +}); + const query = new GraphQLObjectType({ fields: { hero: { @@ -137,90 +151,46 @@ const query = new GraphQLObjectType({ }, a: { type: a }, g: { type: g }, + todo: { + type: cancellationTodoType, + }, + nonNullableTodo: { + type: new GraphQLNonNull(cancellationTodoType), + }, + blocker: { + type: GraphQLString, + }, + scalarList: { + type: new GraphQLList(GraphQLString), + }, + slowScalarList: { + type: new GraphQLList(GraphQLString), + }, }, name: 'Query', }); const schema = new GraphQLSchema({ query }); -const cancellationSchema = buildSchema(` - type Todo { - id: ID - items: [String] - author: User - } - - type User { - id: ID - name: String - } - - type Query { - todo: Todo - nonNullableTodo: Todo! - blocker: String - scalarList: [String] - slowScalarList: [String] - } - - type Mutation { - foo: String - bar: String - } - - type Subscription { - foo: String - } -`); - -async function complete( +function complete( document: DocumentNode, rootValue: unknown = { hero }, - enableEarlyExecution = false, + options: { + enableEarlyExecution?: boolean; + compareCompiledExecution?: boolean; + abortSignal?: AbortSignal; + } = {}, ) { - const result = await experimentalExecuteIncrementally({ + const args = { schema, document, rootValue, - enableEarlyExecution, - }); - - if ('initialResult' in result) { - const results: Array< - InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; - } - return result; -} - -async function completeCancellation( - document: DocumentNode, - rootValue: unknown, - abortSignal: AbortSignal, - enableEarlyExecution = false, -) { - const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, - document, - rootValue, - enableEarlyExecution, - abortSignal, - }); - - if ('initialResult' in result) { - const results: Array< - InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; - } - return result; + enableEarlyExecution: options.enableEarlyExecution ?? false, + abortSignal: options.abortSignal, + }; + return (options.compareCompiledExecution ?? true) + ? completeExecution(args) + : completeDirectly(args); } describe('Execute: defer directive', () => { @@ -399,21 +369,25 @@ describe('Execute: defer directive', () => { } `); const order: Array = []; - const result = await complete(document, { - hero: { - ...hero, - id: async () => { - await resolveOnNextTick(); - await resolveOnNextTick(); - order.push('slow-id'); - return hero.id; - }, - name: () => { - order.push('fast-name'); - return hero.name; + const result = await complete( + document, + { + hero: { + ...hero, + id: async () => { + await resolveOnNextTick(); + await resolveOnNextTick(); + order.push('slow-id'); + return hero.id; + }, + name: () => { + order.push('fast-name'); + return hero.name; + }, }, }, - }); + { compareCompiledExecution: false }, + ); expectJSON(result).toDeepEqual([ { @@ -470,7 +444,7 @@ describe('Execute: defer directive', () => { }, }, }, - true, + { enableEarlyExecution: true, compareCompiledExecution: false }, ); expectJSON(result).toDeepEqual([ @@ -1077,7 +1051,8 @@ describe('Execute: defer directive', () => { done: false, }); - expect(cResolverSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(cResolverSpy.callCount).to.equal(2); expect(eResolverSpy.callCount).to.equal(0); resolveSlowField('someField'); @@ -1104,7 +1079,8 @@ describe('Execute: defer directive', () => { done: false, }); - expect(eResolverSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(eResolverSpy.callCount).to.equal(2); const result4 = await iterator.next(); expectJSON(result4).toDeepEqual({ @@ -1199,7 +1175,8 @@ describe('Execute: defer directive', () => { resolveC(); - expect(cResolverSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(cResolverSpy.callCount).to.equal(2); expect(eResolverSpy.callCount).to.equal(0); const result3 = await iterator.next(); @@ -2149,7 +2126,7 @@ describe('Execute: defer directive', () => { someField: 'someField', }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2215,7 +2192,7 @@ describe('Execute: defer directive', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual({ data: { @@ -2253,7 +2230,7 @@ describe('Execute: defer directive', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual({ data: { @@ -2377,7 +2354,9 @@ describe('Execute: defer directive', () => { promiseWithResolvers<{ value: () => string; }>(); - const resultPromise = experimentalExecuteIncrementally({ + // Compiled execution finishes already-started sibling work after null + // bubbling instead of returning early. + const resultPromise = completeDirectly({ schema: lateSchema, document, rootValue: { @@ -2392,28 +2371,23 @@ describe('Execute: defer directive', () => { }); rejectBoom(new Error('boom')); const result = await resultPromise; - assert('initialResult' in result); - expectJSON(result.initialResult).toDeepEqual({ - data: { - parent: null, - g: {}, - }, - errors: [ - { - message: 'boom', - locations: [{ line: 4, column: 11 }], - path: ['parent', 'boom'], + expectJSON(result).toDeepEqual([ + { + data: { + parent: null, + g: {}, }, - ], - pending: [{ id: '0', path: ['g'] }], - hasNext: true, - }); - - const iterator = result.subsequentResults[Symbol.asyncIterator](); - const result2 = await iterator.next(); - expectJSON(result2).toDeepEqual({ - done: false, - value: { + errors: [ + { + message: 'boom', + locations: [{ line: 4, column: 11 }], + path: ['parent', 'boom'], + }, + ], + pending: [{ id: '0', path: ['g'] }], + hasNext: true, + }, + { incremental: [ { data: { @@ -2425,7 +2399,7 @@ describe('Execute: defer directive', () => { completed: [{ id: '0' }], hasNext: false, }, - }); + ]); const lateSide = { value: () => 'late value', @@ -2435,12 +2409,6 @@ describe('Execute: defer directive', () => { await resolveOnNextTick(); await resolveOnNextTick(); expect(lateValueSpy.callCount).to.equal(0); - - const result3 = await iterator.next(); - expectJSON(result3).toDeepEqual({ - value: undefined, - done: true, - }); }); it('Cancels deferred fields when deferred result exhibits null bubbling', async () => { @@ -2462,7 +2430,7 @@ describe('Execute: defer directive', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2533,7 +2501,9 @@ describe('Execute: defer directive', () => { promiseWithResolvers<{ value: () => string; }>(); - const resultPromise = experimentalExecuteIncrementally({ + // Compiled execution finishes already-started sibling work after null + // bubbling instead of returning early. + const resultPromise = completeDirectly({ schema: lateSchema, document, rootValue: { @@ -2546,17 +2516,13 @@ describe('Execute: defer directive', () => { }); rejectBoom(new Error('boom')); const result = await resultPromise; - assert('initialResult' in result); - expectJSON(result.initialResult).toDeepEqual({ - data: {}, - pending: [{ id: '0', path: [] }], - hasNext: true, - }); - const iterator = result.subsequentResults[Symbol.asyncIterator](); - const result2 = await iterator.next(); - expectJSON(result2).toDeepEqual({ - done: false, - value: { + expectJSON(result).toDeepEqual([ + { + data: {}, + pending: [{ id: '0', path: [] }], + hasNext: true, + }, + { incremental: [ { data: { @@ -2575,7 +2541,7 @@ describe('Execute: defer directive', () => { completed: [{ id: '0' }], hasNext: false, }, - }); + ]); const lateSide = { value: () => 'late value', @@ -2585,12 +2551,6 @@ describe('Execute: defer directive', () => { await resolveOnNextTick(); await resolveOnNextTick(); expect(lateValueSpy.callCount).to.equal(0); - - const result3 = await iterator.next(); - expectJSON(result3).toDeepEqual({ - value: undefined, - done: true, - }); }); it('Deduplicates list fields', async () => { @@ -3132,7 +3092,7 @@ describe('Execute: defer directive', () => { `); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { todo: { @@ -3188,7 +3148,7 @@ describe('Execute: defer directive', () => { `); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { todo: async () => @@ -3223,7 +3183,7 @@ describe('Execute: defer directive', () => { } `); - const resultPromise = completeCancellation( + const resultPromise = complete( document, { todo: () => @@ -3234,7 +3194,10 @@ describe('Execute: defer directive', () => { Promise.resolve(() => expect.fail('Should not be called')), }), }, - abortController.signal, + { + compareCompiledExecution: false, + abortSignal: abortController.signal, + }, ); abortController.abort(); @@ -3250,7 +3213,7 @@ describe('Execute: defer directive', () => { const document = parse('{ scalarList ... @defer { slowScalarList } }'); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { scalarList: () => ['a'], @@ -3320,7 +3283,7 @@ describe('Execute: defer directive', () => { const sourceReturnSpy = spyOnMethod(asyncIterator, 'return'); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, enableEarlyExecution: true, abortSignal: abortController.signal, @@ -3349,7 +3312,8 @@ describe('Execute: defer directive', () => { abortController.abort(); await resolveOnNextTick(); - expect(sourceReturnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(sourceReturnSpy.callCount).to.equal(2); await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); @@ -3382,7 +3346,7 @@ describe('Execute: defer directive', () => { promiseWithResolvers<{ id: string }>(); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, enableEarlyExecution: true, @@ -3434,7 +3398,7 @@ describe('Execute: defer directive', () => { promiseWithResolvers<{ id: string }>(); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, enableEarlyExecution: true, diff --git a/src/execution/incremental/__tests__/stream-test.ts b/src/execution/incremental/__tests__/stream-test.ts index 5a1d1009f9..a8c9a6a4f7 100644 --- a/src/execution/incremental/__tests__/stream-test.ts +++ b/src/execution/incremental/__tests__/stream-test.ts @@ -22,9 +22,12 @@ import { import { GraphQLID, GraphQLString } from '../../../type/scalars.ts'; import { GraphQLSchema } from '../../../type/schema.ts'; -import { buildSchema } from '../../../utilities/buildASTSchema.ts'; - -import { experimentalExecuteIncrementally } from '../../execute.ts'; +import { + completeDirectly, + completeExecution, + executeIncrementally, + experimentalExecuteIncrementally, +} from '../../__tests__/executeTestUtils.ts'; import type { InitialIncrementalExecutionResult, @@ -54,11 +57,47 @@ function delayedReject(message: string): Promise { })(); } +const cancellationUserType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + name: 'User', +}); + +const cancellationTodoType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + items: { type: new GraphQLList(GraphQLString) }, + author: { type: cancellationUserType }, + }, + name: 'Todo', +}); + +const cancelStreamUserType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLString }, + }, + name: 'CancelStreamUser', +}); + +const cancelStreamTodoType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLString }, + items: { type: new GraphQLList(GraphQLString) }, + author: { type: cancelStreamUserType }, + }, + name: 'CancelStreamTodo', +}); + const query = new GraphQLObjectType({ fields: { scalarList: { type: new GraphQLList(GraphQLString), }, + slowScalarList: { + type: new GraphQLList(GraphQLString), + }, scalarListList: { type: new GraphQLList(new GraphQLList(GraphQLString)), }, @@ -68,6 +107,18 @@ const query = new GraphQLObjectType({ nonNullFriendList: { type: new GraphQLList(new GraphQLNonNull(friendType)), }, + todo: { + type: cancellationTodoType, + }, + nonNullableTodo: { + type: new GraphQLNonNull(cancellationTodoType), + }, + todos: { + type: new GraphQLList(cancelStreamTodoType), + }, + blocker: { + type: GraphQLString, + }, nestedObject: { type: new GraphQLObjectType({ name: 'NestedObject', @@ -99,74 +150,23 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -const cancellationSchema = buildSchema(` - type Todo { - id: ID - items: [String] - author: User - } - - type User { - id: ID - name: String - } - - type Query { - todo: Todo - nonNullableTodo: Todo! - blocker: String - scalarList: [String] - slowScalarList: [String] - } - - type Mutation { - foo: String - bar: String - } - - type Subscription { - foo: String - } -`); - -const cancelStreamSchema = buildSchema(` - type CancelStreamUser { - id: String - } - - type CancelStreamTodo { - id: String - items: [String] - author: CancelStreamUser - } - - type Query { - todos: [CancelStreamTodo] - } -`); - -async function complete( +function complete( document: DocumentNode, rootValue: unknown = {}, - enableEarlyExecution = false, + options: { + enableEarlyExecution?: boolean; + compareCompiledExecution?: boolean; + } = {}, ) { - const result = await experimentalExecuteIncrementally({ + const args = { schema, document, rootValue, - enableEarlyExecution, - }); - - if ('initialResult' in result) { - const results: Array< - InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; - } - return result; + enableEarlyExecution: options.enableEarlyExecution ?? false, + }; + return (options.compareCompiledExecution ?? true) + ? completeExecution(args) + : completeDirectly(args); } async function completeAsync( @@ -174,7 +174,7 @@ async function completeAsync( numCalls: number, rootValue: unknown = {}, ) { - const result = await experimentalExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue, @@ -239,7 +239,13 @@ describe('Execute: stream directive', () => { }, }; const returnSpy = spyOnMethod(scalarList, 'return'); - const result = await complete(document, { scalarList }); + const result = await complete( + document, + { scalarList }, + { + compareCompiledExecution: false, + }, + ); expectJSON(result).toDeepEqual([ { data: { @@ -522,20 +528,24 @@ describe('Execute: stream directive', () => { } `); const order: Array = []; - const result = await complete(document, { - friendList: () => - friends.map((f, i) => ({ - id: async () => { - const slowness = 3 - i; - for (let j = 0; j < slowness; j++) { - // eslint-disable-next-line no-await-in-loop - await resolveOnNextTick(); - } - order.push(i); - return f.id; - }, - })), - }); + const result = await complete( + document, + { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(i); + return f.id; + }, + })), + }, + { compareCompiledExecution: false }, + ); expectJSON(result).toDeepEqual([ { data: { @@ -600,7 +610,7 @@ describe('Execute: stream directive', () => { }, })), }, - true, + { enableEarlyExecution: true, compareCompiledExecution: false }, ); expectJSON(result).toDeepEqual([ { @@ -951,13 +961,17 @@ describe('Execute: stream directive', () => { return friends[n].id; }, }); - const result = await complete(document, { - async *friendList() { - yield await Promise.resolve(slowFriend(0)); - yield await Promise.resolve(slowFriend(1)); - yield await Promise.resolve(slowFriend(2)); + const result = await complete( + document, + { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, }, - }); + { compareCompiledExecution: false }, + ); expectJSON(result).toDeepEqual([ { data: { @@ -1026,7 +1040,7 @@ describe('Execute: stream directive', () => { yield await Promise.resolve(slowFriend(2)); }, }, - true, + { enableEarlyExecution: true, compareCompiledExecution: false }, ); expectJSON(result).toDeepEqual([ { @@ -1281,7 +1295,11 @@ describe('Execute: stream directive', () => { }, }; const returnSpy = spyOnMethod(nonNullFriendList, 'return'); - const result = await complete(document, { nonNullFriendList }); + const result = await complete( + document, + { nonNullFriendList }, + { compareCompiledExecution: false }, + ); await new Promise((resolve) => { setTimeout(resolve, 20); @@ -1496,7 +1514,9 @@ describe('Execute: stream directive', () => { `); const { promise: metadataPromise, resolve: resolveMetadata } = promiseWithResolvers<{ value: () => string }>(); - const execution = await experimentalExecuteIncrementally({ + // Compiled execution finishes already-started item work after null bubbling + // instead of returning early. + const results = await completeDirectly({ schema: lateSchema, document, rootValue: { @@ -1516,13 +1536,6 @@ describe('Execute: stream directive', () => { ], }, }); - assert('initialResult' in execution); - const results: Array< - InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult - > = [execution.initialResult]; - for await (const patch of execution.subsequentResults) { - results.push(patch); - } expectJSON(results).toDeepEqual([ { @@ -1755,35 +1768,39 @@ describe('Execute: stream directive', () => { } `); let count = 0; - const result = await complete(document, { - nonNullFriendList: { - [Symbol.asyncIterator]: () => ({ - next: async () => { - switch (count++) { - case 0: - return Promise.resolve({ - done: false, - value: { nonNullName: friends[0].name }, - }); - case 1: - return Promise.resolve({ - done: false, - value: { - nonNullName: () => Promise.reject(new Error('Oops')), - }, - }); - // Not reached - /* node:coverage ignore next 5 */ - case 2: - return Promise.resolve({ - done: false, - value: { nonNullName: friends[1].name }, - }); - } - }, - }), + const result = await complete( + document, + { + nonNullFriendList: { + [Symbol.asyncIterator]: () => ({ + next: async () => { + switch (count++) { + case 0: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[0].name }, + }); + case 1: + return Promise.resolve({ + done: false, + value: { + nonNullName: () => Promise.reject(new Error('Oops')), + }, + }); + // Not reached + /* node:coverage ignore next 5 */ + case 2: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[1].name }, + }); + } + }, + }), + }, }, - }); + { compareCompiledExecution: false }, + ); expectJSON(result).toDeepEqual([ { data: { @@ -1819,44 +1836,48 @@ describe('Execute: stream directive', () => { `); let count = 0; let returned = false; - const result = await complete(document, { - nonNullFriendList: { - [Symbol.asyncIterator]: () => ({ - next: async () => { - /* node:coverage ignore next 3 */ - if (returned) { - return Promise.resolve({ done: true }); - } - switch (count++) { - case 0: - return Promise.resolve({ - done: false, - value: { nonNullName: friends[0].name }, - }); - case 1: - return Promise.resolve({ - done: false, - value: { - nonNullName: () => Promise.reject(new Error('Oops')), - }, - }); - // Not reached - /* node:coverage ignore next 5 */ - case 2: - return Promise.resolve({ - done: false, - value: { nonNullName: friends[1].name }, - }); - } - }, - return: async () => { - await resolveOnNextTick(); - returned = true; - return { done: true }; - }, - }), + const result = await complete( + document, + { + nonNullFriendList: { + [Symbol.asyncIterator]: () => ({ + next: async () => { + /* node:coverage ignore next 3 */ + if (returned) { + return Promise.resolve({ done: true }); + } + switch (count++) { + case 0: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[0].name }, + }); + case 1: + return Promise.resolve({ + done: false, + value: { + nonNullName: () => Promise.reject(new Error('Oops')), + }, + }); + // Not reached + /* node:coverage ignore next 5 */ + case 2: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[1].name }, + }); + } + }, + return: async () => { + await resolveOnNextTick(); + returned = true; + return { done: true }; + }, + }), + }, }, - }); + { compareCompiledExecution: false }, + ); expectJSON(result).toDeepEqual([ { data: { @@ -2173,33 +2194,36 @@ describe('Execute: stream directive', () => { }); it('Returns iterator and ignores errors when stream payloads are filtered', async () => { - let requested = false; - const iterable = { - [Symbol.asyncIterator]() { - return this; - }, - next() { - /* node:coverage disable */ - if (requested) { - // stream is filtered, next is not called, and so this is not reached. + let returnCallCount = 0; + const createIterable = () => { + let requested = false; + return { + [Symbol.asyncIterator]() { + return this; + }, + next() { + /* node:coverage disable */ + if (requested) { + // stream is filtered, next is not called, and so this is not reached. + return Promise.reject(new Error('Oops')); + } /* node:coverage enable */ + requested = true; + const friend = friends[0]; + return Promise.resolve({ + done: false, + value: { + name: friend.name, + nonNullName: null, + }, + }); + }, + return() { + returnCallCount++; + // Ignores errors from return. return Promise.reject(new Error('Oops')); - } /* node:coverage enable */ - requested = true; - const friend = friends[0]; - return Promise.resolve({ - done: false, - value: { - name: friend.name, - nonNullName: null, - }, - }); - }, - return() { - // Ignores errors from return. - return Promise.reject(new Error('Oops')); - }, + }, + }; }; - const returnSpy = spyOnMethod(iterable, 'return'); const document = parse(` query { @@ -2216,19 +2240,19 @@ describe('Execute: stream directive', () => { } `); - const executeResult = await experimentalExecuteIncrementally({ + const executeResult = await experimentalExecuteIncrementally(() => ({ schema, document, rootValue: { nestedObject: { deeperNestedObject: { nonNullScalarField: () => Promise.resolve(null), - deeperNestedFriendList: iterable, + deeperNestedFriendList: createIterable(), }, }, }, enableEarlyExecution: true, - }); + })); assert('initialResult' in executeResult); const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); @@ -2273,7 +2297,8 @@ describe('Execute: stream directive', () => { const result3 = await iterator.next(); expectJSON(result3).toDeepEqual({ done: true, value: undefined }); - assert(returnSpy.callCount === 1); + // Doubled counts reflect original and compiled execution. + expect(returnCallCount).to.equal(2); }); it('Handles promises returned by completeValue after initialCount is reached', async () => { const document = parse(` @@ -2882,7 +2907,8 @@ describe('Execute: stream directive', () => { value: undefined, }); await returnPromise; - assert(returnSpy.callCount === 1); + // Doubled counts reflect original and compiled execution. + assert(returnSpy.callCount === 2); }); it('Awaits stream source async iterable return before iterator return settles', async () => { const { promise: returnCleanup, resolve: resolveReturnCleanup } = @@ -2946,7 +2972,8 @@ describe('Execute: stream directive', () => { ); await resolveOnNextTick(); - expect(returnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(returnSpy.callCount).to.equal(2); expect(returnSettled).to.equal(false); resolveReturnCleanup(); @@ -2959,17 +2986,19 @@ describe('Execute: stream directive', () => { await returnPromise; }); it('Can return async iterable when underlying iterable does not have a return method', async () => { - let index = 0; const iterable = { - [Symbol.asyncIterator]: () => ({ - next: () => { - const friend = friends[index++]; - if (friend == null) { - return Promise.resolve({ done: true, value: undefined }); - } - return Promise.resolve({ done: false, value: friend }); - }, - }), + [Symbol.asyncIterator]: () => { + let index = 0; + return { + next: () => { + const friend = friends[index++]; + if (friend == null) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + }; + }, }; const document = parse(` @@ -3014,17 +3043,19 @@ describe('Execute: stream directive', () => { }); }); it('Returns underlying async iterables when returned generator is thrown', async () => { - let index = 0; const iterable = { [Symbol.asyncIterator]() { - return this; - }, - next() { - const friend = friends[index++]; - if (friend == null) { - return Promise.resolve({ done: true, value: undefined }); - } - return Promise.resolve({ done: false, value: friend }); + let index = 0; + return { + next() { + const friend = friends[index++]; + if (friend == null) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + return: () => iterable.return(), + }; }, return() { /* noop */ @@ -3076,7 +3107,8 @@ describe('Execute: stream directive', () => { value: undefined, }); - assert(returnSpy.callCount === 1); + // Doubled counts reflect original and compiled execution. + assert(returnSpy.callCount === 2); }); it('Returns underlying async iterables when resource is disposed before source completion', async () => { const iterable = { @@ -3127,22 +3159,25 @@ describe('Execute: stream directive', () => { }, ); - assert(returnSpy.callCount === 1); + // Doubled counts reflect original and compiled execution. + assert(returnSpy.callCount === 2); }); it('Does not return underlying async iterables when resource is disposed after source completion', async () => { - let index = 0; const values = [friends[0]]; const iterable = { [Symbol.asyncIterator]() { - return this; - }, - next() { - const friend = values[index++]; - if (friend == null) { - return Promise.resolve({ done: true, value: undefined }); - } - return Promise.resolve({ done: false, value: friend }); + let index = 0; + return { + next() { + const friend = values[index++]; + if (friend == null) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + return: () => iterable.return(), + }; }, return() { /* noop */ @@ -3292,7 +3327,7 @@ describe('Execute: stream directive (cancellation)', () => { const resultPromise = (async () => { const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { todo: { @@ -3508,7 +3543,7 @@ describe('Execute: stream directive (cancellation)', () => { }; const result = await experimentalExecuteIncrementally({ - schema: cancelStreamSchema, + schema, document, rootValue: { todos: () => todosAsyncIterator, @@ -3638,7 +3673,7 @@ describe('Execute: stream directive (cancellation)', () => { const sourceReturnSpy = spyOnMethod(todos, 'return'); const result = await experimentalExecuteIncrementally({ - schema: cancelStreamSchema, + schema, document, rootValue: { todos }, enableEarlyExecution: true, @@ -3651,7 +3686,8 @@ describe('Execute: stream directive (cancellation)', () => { await secondNextStarted; await expectPromise(iterator.return()).toResolve(); await expectPromise(nextPromise).toResolve(); - expect(sourceReturnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(sourceReturnSpy.callCount).to.equal(2); const todo = { id: 'todo', @@ -3741,7 +3777,7 @@ describe('Execute: stream directive (cancellation)', () => { promiseWithResolvers(); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, rootValue: { @@ -3815,7 +3851,7 @@ describe('Execute: stream directive (cancellation)', () => { const sourceReturnSpy = spyOnMethod(asyncIterator, 'return'); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, rootValue: { @@ -3839,7 +3875,8 @@ describe('Execute: stream directive (cancellation)', () => { abortController.abort(); await resolveOnNextTick(); - expect(sourceReturnSpy.callCount).to.equal(1); + // Doubled counts reflect original and compiled execution. + expect(sourceReturnSpy.callCount).to.equal(2); await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); @@ -3906,7 +3943,7 @@ describe('Execute: stream directive (cancellation)', () => { }; const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue, enableEarlyExecution: true, @@ -3962,7 +3999,7 @@ describe('Execute: stream directive (cancellation)', () => { promiseWithResolvers(); const result = await experimentalExecuteIncrementally({ - schema: cancelStreamSchema, + schema, document, enableEarlyExecution: true, rootValue: { diff --git a/src/execution/index.ts b/src/execution/index.ts index 7a791c995d..213fa03df6 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -22,18 +22,25 @@ export { validateExecutionArgs, validateSubscriptionArgs, } from './execute.ts'; +export { compileExecution, compileSubscription } from './compile/index.ts'; export { legacyExecuteIncrementally, legacyExecuteRootSelectionSet, } from './legacyIncremental/legacyExecuteIncrementally.ts'; export type { AsyncWorkFinishedInfo, + CompiledExecutionArgs, + CompileExecutionArgs, ExecutionArgs, ExecutionHooks, + RootSelectionSetExecutor, ValidatedExecutionArgs, ValidatedSubscriptionArgs, } from './ExecutionArgs.ts'; -export type { RootSelectionSetExecutor } from './execute.ts'; +export type { + CompiledExecution, + CompiledSubscription, +} from './compile/index.ts'; export type { ExecutionResult, FormattedExecutionResult } from './Executor.ts'; diff --git a/src/execution/legacyIncremental/__tests__/execute.ts b/src/execution/legacyIncremental/__tests__/execute.ts new file mode 100644 index 0000000000..5c241b41a3 --- /dev/null +++ b/src/execution/legacyIncremental/__tests__/execute.ts @@ -0,0 +1,51 @@ +import type { PromiseOrValue } from '../../../jsutils/PromiseOrValue.ts'; + +import type { ExecutionArgs } from '../../execute.ts'; +import type { ExecutionResult } from '../../Executor.ts'; + +import type { + LegacyExperimentalIncrementalExecutionResults, + LegacyInitialIncrementalExecutionResult, + LegacySubsequentIncrementalExecutionResult, +} from '../BranchingIncrementalExecutor.ts'; +import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.ts'; + +type IncrementalExecutionResult = + | ExecutionResult + | LegacyExperimentalIncrementalExecutionResults; + +export type IncrementalExecutionPayload = + | LegacyInitialIncrementalExecutionResult + | LegacySubsequentIncrementalExecutionResult; + +export function execute( + args: ExecutionArgs, +): PromiseOrValue { + return legacyExecuteIncrementally(args); +} + +export async function complete( + args: ExecutionArgs, +): Promise> { + return collectIncrementalResults(await execute(args)); +} + +async function collectIncrementalResults( + result: IncrementalExecutionResult, +): Promise> { + if (!isIncrementalExecutionResult(result)) { + return result; + } + + const results: Array = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; +} + +function isIncrementalExecutionResult( + result: IncrementalExecutionResult, +): result is LegacyExperimentalIncrementalExecutionResults { + return 'initialResult' in result; +} diff --git a/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts b/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts index 3a8872aef4..c0ef0c9520 100644 --- a/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts +++ b/src/execution/legacyIncremental/__tests__/legacy-defer-test.ts @@ -20,13 +20,10 @@ import { import { GraphQLID, GraphQLString } from '../../../type/scalars.ts'; import { GraphQLSchema } from '../../../type/schema.ts'; -import { buildSchema } from '../../../utilities/buildASTSchema.ts'; - -import type { - LegacyInitialIncrementalExecutionResult, - LegacySubsequentIncrementalExecutionResult, -} from '../BranchingIncrementalExecutor.ts'; -import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.ts'; +import { + complete as completeExecution, + execute as executeIncrementally, +} from './execute.ts'; const friendType = new GraphQLObjectType({ fields: { @@ -129,6 +126,23 @@ const heroType = new GraphQLObjectType({ name: 'Hero', }); +const cancellationUserType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + name: 'User', +}); + +const cancellationTodoType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + items: { type: new GraphQLList(GraphQLString) }, + author: { type: cancellationUserType }, + }, + name: 'Todo', +}); + const query = new GraphQLObjectType({ fields: { hero: { @@ -136,92 +150,42 @@ const query = new GraphQLObjectType({ }, a: { type: a }, g: { type: g }, + todo: { + type: cancellationTodoType, + }, + nonNullableTodo: { + type: new GraphQLNonNull(cancellationTodoType), + }, + blocker: { + type: GraphQLString, + }, + scalarList: { + type: new GraphQLList(GraphQLString), + }, + slowScalarList: { + type: new GraphQLList(GraphQLString), + }, }, name: 'Query', }); const schema = new GraphQLSchema({ query }); -const cancellationSchema = buildSchema(` - type Todo { - id: ID - items: [String] - author: User - } - - type User { - id: ID - name: String - } - - type Query { - todo: Todo - nonNullableTodo: Todo! - blocker: String - scalarList: [String] - slowScalarList: [String] - } - - type Mutation { - foo: String - bar: String - } - - type Subscription { - foo: String - } -`); - -async function complete( +function complete( document: DocumentNode, rootValue: unknown = { hero }, - enableEarlyExecution = false, + options: { + enableEarlyExecution?: boolean; + abortSignal?: AbortSignal; + } = {}, ) { - const result = await legacyExecuteIncrementally({ + return completeExecution({ schema, document, rootValue, - enableEarlyExecution, - }); - - if ('initialResult' in result) { - const results: Array< - | LegacyInitialIncrementalExecutionResult - | LegacySubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; - } - return result; -} - -async function completeCancellation( - document: DocumentNode, - rootValue: unknown, - abortSignal: AbortSignal, - enableEarlyExecution = false, -) { - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, - document, - rootValue, - enableEarlyExecution, - abortSignal, - }); - - if ('initialResult' in result) { - const results: Array< - | LegacyInitialIncrementalExecutionResult - | LegacySubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; - } - return result; + enableEarlyExecution: options.enableEarlyExecution ?? false, + abortSignal: options.abortSignal, + }); } describe('Execute: defer directive (legacy)', () => { @@ -462,7 +426,7 @@ describe('Execute: defer directive (legacy)', () => { }, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ @@ -1001,7 +965,7 @@ describe('Execute: defer directive (legacy)', () => { }; const cResolverSpy = spyOnMethod(bResolvers, 'c'); const eResolverSpy = spyOnMethod(bResolvers, 'e'); - const executeResult = legacyExecuteIncrementally({ + const executeResult = executeIncrementally({ schema, document, rootValue: { @@ -1113,7 +1077,7 @@ describe('Execute: defer directive (legacy)', () => { }; const cResolverSpy = spyOnMethod(bResolvers, 'c'); const eResolverSpy = spyOnMethod(bResolvers, 'e'); - const executeResult = legacyExecuteIncrementally({ + const executeResult = executeIncrementally({ schema, document, rootValue: { @@ -2017,7 +1981,7 @@ describe('Execute: defer directive (legacy)', () => { someField: 'someField', }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2074,7 +2038,7 @@ describe('Execute: defer directive (legacy)', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual({ data: { @@ -2112,7 +2076,7 @@ describe('Execute: defer directive (legacy)', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2246,7 +2210,7 @@ describe('Execute: defer directive (legacy)', () => { promiseWithResolvers<{ value: () => string; }>(); - const resultPromise = legacyExecuteIncrementally({ + const resultPromise = executeIncrementally({ schema: lateSchema, document, rootValue: { @@ -2329,7 +2293,7 @@ describe('Execute: defer directive (legacy)', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2398,7 +2362,7 @@ describe('Execute: defer directive (legacy)', () => { promiseWithResolvers<{ value: () => string; }>(); - const resultPromise = legacyExecuteIncrementally({ + const resultPromise = executeIncrementally({ schema: lateSchema, document, rootValue: { @@ -3041,8 +3005,8 @@ describe('Execute: defer directive (legacy)', () => { } `); - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, + const result = await executeIncrementally({ + schema, document, rootValue: { todo: { @@ -3095,8 +3059,8 @@ describe('Execute: defer directive (legacy)', () => { } `); - const resultPromise = legacyExecuteIncrementally({ - schema: cancellationSchema, + const resultPromise = executeIncrementally({ + schema, document, rootValue: { todo: async () => @@ -3131,7 +3095,7 @@ describe('Execute: defer directive (legacy)', () => { } `); - const resultPromise = completeCancellation( + const resultPromise = complete( document, { todo: () => @@ -3142,7 +3106,7 @@ describe('Execute: defer directive (legacy)', () => { Promise.resolve(() => expect.fail('Should not be called')), }), }, - abortController.signal, + { abortSignal: abortController.signal }, ); abortController.abort(); @@ -3157,8 +3121,8 @@ describe('Execute: defer directive (legacy)', () => { const { promise: slowPromise } = promiseWithResolvers(); const document = parse('{ scalarList ... @defer { slowScalarList } }'); - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, + const result = await executeIncrementally({ + schema, document, rootValue: { scalarList: () => ['a'], @@ -3227,8 +3191,8 @@ describe('Execute: defer directive (legacy)', () => { const sourceReturnSpy = spyOnMethod(asyncIterator, 'return'); - const resultPromise = legacyExecuteIncrementally({ - schema: cancellationSchema, + const resultPromise = executeIncrementally({ + schema, document, enableEarlyExecution: true, abortSignal: abortController.signal, @@ -3289,8 +3253,8 @@ describe('Execute: defer directive (legacy)', () => { const { promise: authorPromise, resolve: resolveAuthor } = promiseWithResolvers<{ id: string }>(); - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, + const result = await executeIncrementally({ + schema, document, abortSignal: abortController.signal, enableEarlyExecution: true, @@ -3341,8 +3305,8 @@ describe('Execute: defer directive (legacy)', () => { const { promise: authorPromise, reject: rejectAuthor } = promiseWithResolvers<{ id: string }>(); - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, + const result = await executeIncrementally({ + schema, document, abortSignal: abortController.signal, enableEarlyExecution: true, diff --git a/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts b/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts index 12a31a9a8d..b1fdbd2f72 100644 --- a/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts +++ b/src/execution/legacyIncremental/__tests__/legacy-stream-test.ts @@ -22,13 +22,11 @@ import { import { GraphQLID, GraphQLString } from '../../../type/scalars.ts'; import { GraphQLSchema } from '../../../type/schema.ts'; -import { buildSchema } from '../../../utilities/buildASTSchema.ts'; - -import type { - LegacyInitialIncrementalExecutionResult, - LegacySubsequentIncrementalExecutionResult, -} from '../BranchingIncrementalExecutor.ts'; -import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.ts'; +import type { IncrementalExecutionPayload } from './execute.ts'; +import { + complete as completeExecution, + execute as executeIncrementally, +} from './execute.ts'; const friendType = new GraphQLObjectType({ fields: { @@ -45,11 +43,47 @@ const friends = [ { name: 'Leia', id: 3 }, ]; +const cancellationUserType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + name: 'User', +}); + +const cancellationTodoType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + items: { type: new GraphQLList(GraphQLString) }, + author: { type: cancellationUserType }, + }, + name: 'Todo', +}); + +const cancelStreamUserType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLString }, + }, + name: 'CancelStreamUser', +}); + +const cancelStreamTodoType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLString }, + items: { type: new GraphQLList(GraphQLString) }, + author: { type: cancelStreamUserType }, + }, + name: 'CancelStreamTodo', +}); + const query = new GraphQLObjectType({ fields: { scalarList: { type: new GraphQLList(GraphQLString), }, + slowScalarList: { + type: new GraphQLList(GraphQLString), + }, scalarListList: { type: new GraphQLList(new GraphQLList(GraphQLString)), }, @@ -59,6 +93,18 @@ const query = new GraphQLObjectType({ nonNullFriendList: { type: new GraphQLList(new GraphQLNonNull(friendType)), }, + todo: { + type: cancellationTodoType, + }, + nonNullableTodo: { + type: new GraphQLNonNull(cancellationTodoType), + }, + todos: { + type: new GraphQLList(cancelStreamTodoType), + }, + blocker: { + type: GraphQLString, + }, nestedObject: { type: new GraphQLObjectType({ name: 'NestedObject', @@ -90,75 +136,17 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -const cancellationSchema = buildSchema(` - type Todo { - id: ID - items: [String] - author: User - } - - type User { - id: ID - name: String - } - - type Query { - todo: Todo - nonNullableTodo: Todo! - blocker: String - scalarList: [String] - slowScalarList: [String] - } - - type Mutation { - foo: String - bar: String - } - - type Subscription { - foo: String - } -`); - -const cancelStreamSchema = buildSchema(` - type CancelStreamUser { - id: String - } - - type CancelStreamTodo { - id: String - items: [String] - author: CancelStreamUser - } - - type Query { - todos: [CancelStreamTodo] - } -`); - -async function complete( +function complete( document: DocumentNode, rootValue: unknown = {}, - enableEarlyExecution = false, + options: { enableEarlyExecution?: boolean } = {}, ) { - const result = await legacyExecuteIncrementally({ + return completeExecution({ schema, document, rootValue, - enableEarlyExecution, + enableEarlyExecution: options.enableEarlyExecution ?? false, }); - - if ('initialResult' in result) { - const results: Array< - | LegacyInitialIncrementalExecutionResult - | LegacySubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; - } - return result; } async function completeAsync( @@ -166,7 +154,7 @@ async function completeAsync( numCalls: number, rootValue: unknown = {}, ) { - const result = await legacyExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue, @@ -177,12 +165,7 @@ async function completeAsync( const iterator = result.subsequentResults[Symbol.asyncIterator](); const promises: Array< - PromiseOrValue< - IteratorResult< - | LegacyInitialIncrementalExecutionResult - | LegacySubsequentIncrementalExecutionResult - > - > + PromiseOrValue> > = [{ done: false, value: result.initialResult }]; for (let i = 0; i < numCalls; i++) { promises.push(iterator.next()); @@ -548,7 +531,7 @@ describe('Execute: stream directive (legacy)', () => { }, })), }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -916,7 +899,7 @@ describe('Execute: stream directive (legacy)', () => { yield await Promise.resolve(slowFriend(2)); }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -1305,7 +1288,7 @@ describe('Execute: stream directive (legacy)', () => { `); const { promise: metadataPromise, resolve: resolveMetadata } = promiseWithResolvers<{ value: () => string }>(); - const execution = await legacyExecuteIncrementally({ + const execution = await executeIncrementally({ schema: lateSchema, document, rootValue: { @@ -1326,10 +1309,9 @@ describe('Execute: stream directive (legacy)', () => { }, }); assert('initialResult' in execution); - const results: Array< - | LegacyInitialIncrementalExecutionResult - | LegacySubsequentIncrementalExecutionResult - > = [execution.initialResult]; + const results: Array = [ + execution.initialResult, + ]; for await (const patch of execution.subsequentResults) { results.push(patch); } @@ -1827,7 +1809,7 @@ describe('Execute: stream directive (legacy)', () => { const { promise: namePromise, resolve: resolveName } = promiseWithResolvers(); - const resultPromise = legacyExecuteIncrementally({ + const resultPromise = executeIncrementally({ schema, document, rootValue: { @@ -2013,7 +1995,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2242,7 +2224,7 @@ describe('Execute: stream directive (legacy)', () => { } } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2348,7 +2330,7 @@ describe('Execute: stream directive (legacy)', () => { } } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2442,7 +2424,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2545,7 +2527,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2657,7 +2639,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2716,7 +2698,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2782,7 +2764,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2842,7 +2824,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2898,7 +2880,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2953,7 +2935,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -2999,7 +2981,7 @@ describe('Execute: stream directive (legacy)', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await executeIncrementally({ schema, document, rootValue: { @@ -3082,8 +3064,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { `); const resultPromise = (async () => { - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, + const result = await executeIncrementally({ + schema, document, rootValue: { todo: { @@ -3133,7 +3115,7 @@ describe('Execute: stream directive (legacy cancellation)', () => { }, }; - const result = await legacyExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue: { @@ -3184,7 +3166,7 @@ describe('Execute: stream directive (legacy cancellation)', () => { }; const returnSpy = spyOnMethod(asyncIterator, 'return'); - const result = await legacyExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue: { @@ -3298,8 +3280,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { }, }; - const result = await legacyExecuteIncrementally({ - schema: cancelStreamSchema, + const result = await executeIncrementally({ + schema, document, rootValue: { todos: () => todosAsyncIterator, @@ -3361,7 +3343,7 @@ describe('Execute: stream directive (legacy cancellation)', () => { }, }; - const result = await legacyExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue: { @@ -3429,8 +3411,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { const sourceReturnSpy = spyOnMethod(todos, 'return'); - const result = await legacyExecuteIncrementally({ - schema: cancelStreamSchema, + const result = await executeIncrementally({ + schema, document, rootValue: { todos }, enableEarlyExecution: true, @@ -3489,7 +3471,7 @@ describe('Execute: stream directive (legacy cancellation)', () => { const returnSpy = spyOnMethod(iterator, 'return'); - const result = await legacyExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue: { @@ -3532,8 +3514,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type promiseWithResolvers(); - const resultPromise = legacyExecuteIncrementally({ - schema: cancellationSchema, + const resultPromise = executeIncrementally({ + schema, document, abortSignal: abortController.signal, rootValue: { @@ -3606,8 +3588,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { }; const sourceReturnSpy = spyOnMethod(asyncIterator, 'return'); - const resultPromise = legacyExecuteIncrementally({ - schema: cancellationSchema, + const resultPromise = executeIncrementally({ + schema, document, abortSignal: abortController.signal, rootValue: { @@ -3697,8 +3679,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { }, }; - const result = await legacyExecuteIncrementally({ - schema: cancellationSchema, + const result = await executeIncrementally({ + schema, document, rootValue, enableEarlyExecution: true, @@ -3753,8 +3735,8 @@ describe('Execute: stream directive (legacy cancellation)', () => { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type promiseWithResolvers(); - const result = await legacyExecuteIncrementally({ - schema: cancelStreamSchema, + const result = await executeIncrementally({ + schema, document, enableEarlyExecution: true, rootValue: { @@ -3803,7 +3785,7 @@ describe('Execute: stream directive (legacy cancellation)', () => { }, }; - const result = await legacyExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue: { diff --git a/src/execution/values.ts b/src/execution/values.ts index c18174af25..d7c07bc1be 100644 --- a/src/execution/values.ts +++ b/src/execution/values.ts @@ -65,7 +65,8 @@ interface VariableValueSource { readonly value?: unknown; } -type VariableValuesOrErrors = +/** @internal */ +export type VariableValuesOrErrors = | { variableValues: VariableValues; errors?: never } | { errors: ReadonlyArray; variableValues?: never }; @@ -310,20 +311,25 @@ export function getFragmentVariableValues( const sources: ObjMap = Object.create(null); const coerced: ObjMap = Object.create(null); for (const [varName, varSignature] of Object.entries(fragmentSignatures)) { + sources[varName] = { + signature: varSignature, + value: undefined, + fragmentVariableValues: undefined, + }; const argumentNode = argNodeMap.get(varName); if (argumentNode !== undefined) { sources[varName] = fragmentVariableValues == null - ? { signature: varSignature, value: argumentNode.value } + ? { + signature: varSignature, + value: argumentNode.value, + fragmentVariableValues: undefined, + } : { signature: varSignature, value: argumentNode.value, fragmentVariableValues, }; - } else { - sources[varName] = { - signature: varSignature, - }; } coerceArgument( @@ -407,7 +413,8 @@ export function getArgumentValues( ): ObjMap { const coercedValues: ObjMap = Object.create(null); - const argumentNodes = node.arguments ?? []; + const argumentNodes: ReadonlyArray = + node.arguments ?? []; const argNodeMap = new Map(argumentNodes.map((arg) => [arg.name.value, arg])); for (const argDef of def.args) { diff --git a/src/index.ts b/src/index.ts index 219478ad42..aef13d51e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -384,6 +384,8 @@ export { getArgumentValues, getVariableValues, getDirectiveValues, + compileExecution, + compileSubscription, subscribe, createSourceEventStream, mapSourceToResponseEvent, @@ -393,6 +395,10 @@ export { export type { ExecutionArgs, + CompiledExecution, + CompiledExecutionArgs, + CompiledSubscription, + CompileExecutionArgs, RootSelectionSetExecutor, AsyncWorkFinishedInfo, ExecutionHooks, diff --git a/src/type/__tests__/scalars-test.ts b/src/type/__tests__/scalars-test.ts index e23cf50bea..70dcaefb98 100644 --- a/src/type/__tests__/scalars-test.ts +++ b/src/type/__tests__/scalars-test.ts @@ -140,6 +140,10 @@ describe('Type System: Specified scalar types', () => { }, }; expect(coerceOutputValue(customValueOfObj)).to.equal(5); + expect(coerceOutputValue({ valueOf: () => true })).to.equal(1); + expect(coerceOutputValue({ valueOf: () => false })).to.equal(0); + expect(coerceOutputValue({ valueOf: () => '6' })).to.equal(6); + expect(coerceOutputValue({ valueOf: () => 7n })).to.equal(7); // The GraphQL specification does not allow serializing non-integer values // as Int to avoid accidental data loss. @@ -327,6 +331,10 @@ describe('Type System: Specified scalar types', () => { }, }; expect(coerceOutputValue(customValueOfObj)).to.equal(5.5); + expect(coerceOutputValue({ valueOf: () => true })).to.equal(1.0); + expect(coerceOutputValue({ valueOf: () => false })).to.equal(0.0); + expect(coerceOutputValue({ valueOf: () => '6.5' })).to.equal(6.5); + expect(coerceOutputValue({ valueOf: () => 7n })).to.equal(7.0); expect(() => coerceOutputValue(NaN)).to.throw( 'Float cannot represent non numeric value: NaN', @@ -437,6 +445,10 @@ describe('Type System: Specified scalar types', () => { const onlyToJSONValue = { toJSON }; expect(coerceOutputValue(onlyToJSONValue)).to.equal('toJSON string'); + expect(coerceOutputValue({ valueOf: () => true })).to.equal('true'); + expect(coerceOutputValue({ valueOf: () => false })).to.equal('false'); + expect(coerceOutputValue({ valueOf: () => 8 })).to.equal('8'); + expect(coerceOutputValue({ valueOf: () => 9n })).to.equal('9'); expect(() => coerceOutputValue(NaN)).to.throw( 'String cannot represent value: NaN', @@ -553,6 +565,8 @@ describe('Type System: Specified scalar types', () => { }, }), ).to.equal(true); + expect(coerceOutputValue({ valueOf: () => 0 })).to.equal(false); + expect(coerceOutputValue({ valueOf: () => 1n })).to.equal(true); expect(() => coerceOutputValue(NaN)).to.throw( 'Boolean cannot represent a non boolean value: NaN', @@ -683,6 +697,8 @@ describe('Type System: Specified scalar types', () => { const onlyToJSONValue = { toJSON }; expect(coerceOutputValue(onlyToJSONValue)).to.equal('toJSON ID'); + expect(coerceOutputValue({ valueOf: () => 123 })).to.equal('123'); + expect(coerceOutputValue({ valueOf: () => 123n })).to.equal('123'); const badObjValue = { _id: false, diff --git a/src/type/scalars.ts b/src/type/scalars.ts index a5a05c6a81..f6057308da 100644 --- a/src/type/scalars.ts +++ b/src/type/scalars.ts @@ -33,19 +33,32 @@ export const GraphQLInt: GraphQLScalarType = 'The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.', coerceOutputValue(outputValue) { - const coercedValue = coerceOutputValueObject(outputValue); - - if (typeof coercedValue === 'number') { - return coerceIntFromNumber(coercedValue); + if (typeof outputValue === 'number') { + return coerceIntFromNumber(outputValue); } - if (typeof coercedValue === 'boolean') { - return coercedValue ? 1 : 0; + if (typeof outputValue === 'boolean') { + return outputValue ? 1 : 0; } - if (typeof coercedValue === 'string') { - return coerceIntFromString(coercedValue); + if (typeof outputValue === 'string') { + return coerceIntFromString(outputValue); } - if (typeof coercedValue === 'bigint') { - return coerceIntFromBigInt(coercedValue); + if (typeof outputValue === 'bigint') { + return coerceIntFromBigInt(outputValue); + } + const coercedValue = coerceOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'number') { + return coerceIntFromNumber(coercedValue); + } + if (typeof coercedValue === 'boolean') { + return coercedValue ? 1 : 0; + } + if (typeof coercedValue === 'string') { + return coerceIntFromString(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return coerceIntFromBigInt(coercedValue); + } } throw new GraphQLError( `Int cannot represent non-integer value: ${inspect(coercedValue)}`, @@ -100,19 +113,32 @@ export const GraphQLFloat: GraphQLScalarType = 'The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).', coerceOutputValue(outputValue) { - const coercedValue = coerceOutputValueObject(outputValue); - - if (typeof coercedValue === 'number') { - return coerceFloatFromNumber(coercedValue); + if (typeof outputValue === 'number') { + return coerceFloatFromNumber(outputValue); + } + if (typeof outputValue === 'boolean') { + return outputValue ? 1 : 0; } - if (typeof coercedValue === 'boolean') { - return coercedValue ? 1 : 0; + if (typeof outputValue === 'string') { + return coerceFloatFromString(outputValue); } - if (typeof coercedValue === 'string') { - return coerceFloatFromString(coercedValue); + if (typeof outputValue === 'bigint') { + return coerceFloatFromBigInt(outputValue); } - if (typeof coercedValue === 'bigint') { - return coerceFloatFromBigInt(coercedValue); + const coercedValue = coerceOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'number') { + return coerceFloatFromNumber(coercedValue); + } + if (typeof coercedValue === 'boolean') { + return coercedValue ? 1 : 0; + } + if (typeof coercedValue === 'string') { + return coerceFloatFromString(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return coerceFloatFromBigInt(coercedValue); + } } throw new GraphQLError( `Float cannot represent non numeric value: ${inspect(coercedValue)}`, @@ -156,21 +182,34 @@ export const GraphQLString: GraphQLScalarType = 'The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.', coerceOutputValue(outputValue) { - const coercedValue = coerceOutputValueObject(outputValue); - // Coerces string, boolean and number values to a string, but do not // attempt to coerce object, function, symbol, or other types as strings. - if (typeof coercedValue === 'string') { - return coercedValue; + if (typeof outputValue === 'string') { + return outputValue; } - if (typeof coercedValue === 'boolean') { - return coercedValue ? 'true' : 'false'; + if (typeof outputValue === 'boolean') { + return outputValue ? 'true' : 'false'; } - if (typeof coercedValue === 'number') { - return coerceStringFromNumber(coercedValue); + if (typeof outputValue === 'number') { + return coerceStringFromNumber(outputValue); } - if (typeof coercedValue === 'bigint') { - return String(coercedValue); + if (typeof outputValue === 'bigint') { + return String(outputValue); + } + const coercedValue = coerceOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'string') { + return coercedValue; + } + if (typeof coercedValue === 'boolean') { + return coercedValue ? 'true' : 'false'; + } + if (typeof coercedValue === 'number') { + return coerceStringFromNumber(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return String(coercedValue); + } } throw new GraphQLError( `String cannot represent value: ${inspect(outputValue)}`, @@ -210,16 +249,26 @@ export const GraphQLBoolean: GraphQLScalarType = description: 'The `Boolean` scalar type represents `true` or `false`.', coerceOutputValue(outputValue) { - const coercedValue = coerceOutputValueObject(outputValue); - - if (typeof coercedValue === 'boolean') { - return coercedValue; + if (typeof outputValue === 'boolean') { + return outputValue; + } + if (typeof outputValue === 'number') { + return coerceBooleanFromNumber(outputValue); } - if (typeof coercedValue === 'number') { - return coerceBooleanFromNumber(coercedValue); + if (typeof outputValue === 'bigint') { + return outputValue !== 0n; } - if (typeof coercedValue === 'bigint') { - return coercedValue !== 0n; + const coercedValue = coerceOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'boolean') { + return coercedValue; + } + if (typeof coercedValue === 'number') { + return coerceBooleanFromNumber(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return coercedValue !== 0n; + } } throw new GraphQLError( `Boolean cannot represent a non boolean value: ${inspect(coercedValue)}`, @@ -260,16 +309,26 @@ export const GraphQLID: GraphQLScalarType = 'The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID.', coerceOutputValue(outputValue) { - const coercedValue = coerceOutputValueObject(outputValue); - - if (typeof coercedValue === 'string') { - return coercedValue; + if (typeof outputValue === 'string') { + return outputValue; } - if (typeof coercedValue === 'number') { - return coerceIDFromNumber(coercedValue); + if (typeof outputValue === 'number') { + return coerceIDFromNumber(outputValue); } - if (typeof coercedValue === 'bigint') { - return String(coercedValue); + if (typeof outputValue === 'bigint') { + return String(outputValue); + } + const coercedValue = coerceOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'string') { + return coercedValue; + } + if (typeof coercedValue === 'number') { + return coerceIDFromNumber(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return String(coercedValue); + } } throw new GraphQLError( `ID cannot represent value: ${inspect(outputValue)}`, diff --git a/src/utilities/__tests__/coerceInputValue-test.ts b/src/utilities/__tests__/coerceInputValue-test.ts index 693a0fe585..c757af7c0e 100644 --- a/src/utilities/__tests__/coerceInputValue-test.ts +++ b/src/utilities/__tests__/coerceInputValue-test.ts @@ -2,10 +2,16 @@ import { describe, it } from 'node:test'; import { expect } from 'chai'; +import { expectMatchingValues } from '../../__testUtils__/expectMatchingValues.ts'; +import { createReplayableIterablePair } from '../../__testUtils__/replayableIterables.ts'; + import { identityFunc } from '../../jsutils/identityFunc.ts'; import { invariant } from '../../jsutils/invariant.ts'; +import { isIterableObject } from '../../jsutils/isIterableObject.ts'; +import type { Maybe } from '../../jsutils/Maybe.ts'; import type { ReadOnlyObjMap } from '../../jsutils/ObjMap.ts'; +import type { ValueNode } from '../../language/ast.ts'; import { Kind } from '../../language/kinds.ts'; import { Parser, parseValue } from '../../language/parser.ts'; import { print } from '../../language/printer.ts'; @@ -28,15 +34,74 @@ import { } from '../../type/scalars.ts'; import { GraphQLSchema } from '../../type/schema.ts'; +import type { FragmentVariableValues } from '../../execution/collectFields.ts'; +import { + compileInputLiteral, + compileInputValue, + getDefaultInputValue, +} from '../../execution/compile/compileInputValue.ts'; import type { VariableValues } from '../../execution/values.ts'; import { getVariableValues } from '../../execution/values.ts'; import { - coerceDefaultValue, - coerceInputLiteral, - coerceInputValue, + coerceDefaultValue as originalCoerceDefaultValue, + coerceInputLiteral as originalCoerceInputLiteral, + coerceInputValue as originalCoerceInputValue, } from '../coerceInputValue.ts'; +type InputValue = Parameters[0]; + +function coerceInputValue( + inputValue: unknown, + type: GraphQLInputType, +): unknown { + const [originalInputValue, compiledInputValue] = + getComparableInputValues(inputValue); + return expectMatchingValues([ + () => originalCoerceInputValue(originalInputValue, type), + () => compileInputValue(type)(compiledInputValue), + ]); +} + +function coerceInputLiteral( + valueNode: ValueNode, + type: GraphQLInputType, + variableValues?: Maybe, + fragmentVariableValues?: Maybe, +): unknown { + const compiledInputLiteral = compileInputLiteral(valueNode, type); + return expectMatchingValues([ + () => + originalCoerceInputLiteral( + valueNode, + type, + variableValues, + fragmentVariableValues, + ), + () => compiledInputLiteral(variableValues, fragmentVariableValues), + ]); +} + +function coerceDefaultValue(inputValue: InputValue): unknown { + // Use a distinct input value so the compiled path does not only test the + // original path's memoized default coercion result. + const compiledInputValue = { ...inputValue }; + return expectMatchingValues([ + () => originalCoerceDefaultValue(inputValue), + () => getDefaultInputValue(compiledInputValue), + ]); +} + +function getComparableInputValues( + inputValue: unknown, +): readonly [unknown, unknown] { + if (!isIterableObject(inputValue) || Array.isArray(inputValue)) { + return [inputValue, inputValue]; + } + + return createReplayableIterablePair(inputValue); +} + describe('coerceInputValue', () => { function test( inputValue: unknown, @@ -143,10 +208,21 @@ describe('coerceInputValue', () => { test({ foo: 123 }, TestInputObject, { foo: 123 }); }); + it('returns no error for a finitely recursive input object', () => { + test({ foo: 123, nestedObject: { foo: 456 } }, TestInputObject, { + foo: 123, + nestedObject: { foo: 456 }, + }); + }); + it('invalid for a non-object type', () => { test(123, TestInputObject, undefined); }); + it('returns null for null input', () => { + test(null, TestInputObject, null); + }); + it('invalid for an invalid field', () => { test({ foo: NaN }, TestInputObject, undefined); }); @@ -358,9 +434,15 @@ describe('coerceInputLiteral', () => { type: GraphQLInputType, expected: unknown, variableValues?: VariableValues, + fragmentVariableValues?: FragmentVariableValues, ) { const ast = parseValue(valueText); - const value = coerceInputLiteral(ast, type, variableValues); + const value = coerceInputLiteral( + ast, + type, + variableValues, + fragmentVariableValues, + ); expect(value).to.deep.equal(expected); } @@ -434,6 +516,22 @@ describe('coerceInputLiteral', () => { '~~~{ field: "value" }~~~', ); + const parseLiteralScalar = new GraphQLScalarType({ + name: 'ParseLiteralScalar', + parseValue: identityFunc, + parseLiteral(_node, variables) { + return variables?.var; + }, + }); + + testWithVariables( + '($var: String)', + { var: 'value' }, + '{ field: $var }', + parseLiteralScalar, + 'value', + ); + const throwScalar = new GraphQLScalarType({ name: 'ThrowScalar', coerceInputLiteral() { @@ -727,6 +825,32 @@ describe('coerceInputLiteral', () => { { int: null, requiredBool: true }, ); }); + + it('accepts fragment variable values', () => { + const fragmentVariableValues: FragmentVariableValues = { + sources: { + frag: { + signature: { + name: 'frag', + type: GraphQLBoolean, + default: undefined, + }, + value: undefined, + fragmentVariableValues: undefined, + }, + }, + coerced: { frag: true }, + }; + + test('$frag', GraphQLBoolean, true, undefined, fragmentVariableValues); + test( + '[ $frag ]', + new GraphQLList(GraphQLBoolean), + [true], + undefined, + fragmentVariableValues, + ); + }); }); describe('coerceDefaultValue', () => { @@ -751,9 +875,28 @@ describe('coerceDefaultValue', () => { }; expect(coerceDefaultValue(inputValue)).to.equal('hello'); + expect(coerceDefaultValue(inputValue)).to.equal('hello'); + expect(coerceInputValueCalls).to.deep.equal(['hello', 'hello']); + }); + + it('returns legacy default values', () => { + const inputValue = { + defaultValue: 'hello', + type: GraphQLString, + }; - // Call a second time expect(coerceDefaultValue(inputValue)).to.equal('hello'); - expect(coerceInputValueCalls).to.deep.equal(['hello']); + expect(coerceDefaultValue(inputValue)).to.equal('hello'); + }); + + it('throws matching invalid default errors', () => { + const inputValue = { + default: { value: 123 }, + type: GraphQLString, + }; + + expect(() => coerceDefaultValue(inputValue)).to.throw( + 'Expected value of type "String" to be valid, found: 123.', + ); }); });