diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d1d82ca21..0dc1acc0a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,29 @@ jobs: - name: Run Tests with coverage run: npm run testonly:cover + generated-coverage: + name: Run generated executor coverage + runs-on: ubuntu-latest + permissions: + contents: read # for actions/checkout + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + cache: npm + node-version-file: '.node-version' + + - name: Install Dependencies + run: npm ci --ignore-scripts + + - name: Run generated executor coverage + run: npm run generate:coverage + codeql: name: Run CodeQL security scan runs-on: ubuntu-latest 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/benchmark/generate/generated-async-root-fields-benchmark.js b/benchmark/generate/generated-async-root-fields-benchmark.js new file mode 100644 index 0000000000..34612b1bf0 --- /dev/null +++ b/benchmark/generate/generated-async-root-fields-benchmark.js @@ -0,0 +1,65 @@ +/* eslint-disable n/no-top-level-await */ + +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +import { createGeneratedExecution } from './generatedExecution.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 generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'async-root-fields', +); +const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Generated 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/generate/generated-field-argument-values-benchmark.js b/benchmark/generate/generated-field-argument-values-benchmark.js new file mode 100644 index 0000000000..55f9858904 --- /dev/null +++ b/benchmark/generate/generated-field-argument-values-benchmark.js @@ -0,0 +1,108 @@ +/* eslint-disable n/no-top-level-await */ + +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +import { createGeneratedExecution } from './generatedExecution.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 GeneratedArgumentValues($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 generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'field-argument-values', +); +const compiled = + generated ?? + (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: 'Generated 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/generate/generated-introspectionFromSchema-benchmark.js b/benchmark/generate/generated-introspectionFromSchema-benchmark.js new file mode 100644 index 0000000000..21471b5f44 --- /dev/null +++ b/benchmark/generate/generated-introspectionFromSchema-benchmark.js @@ -0,0 +1,42 @@ +/* eslint-disable n/no-top-level-await */ + +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'; + +import { createGeneratedExecution } from './generatedExecution.js'; + +const schema = buildSchema(bigSchemaSDL, { assumeValid: true }); +const document = parse(getIntrospectionQuery()); + +const generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'introspection-from-schema', +); +const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Generated Execute Introspection Query', + measure: () => { + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute() + : compiled.executeRootSelectionSet(); + } + + return execution.executeSync({ schema, document }); + }, +}; diff --git a/benchmark/generate/generated-list-async-benchmark.js b/benchmark/generate/generated-list-async-benchmark.js new file mode 100644 index 0000000000..061cd12e1f --- /dev/null +++ b/benchmark/generate/generated-list-async-benchmark.js @@ -0,0 +1,60 @@ +/* eslint-disable n/no-top-level-await */ + +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +import { createGeneratedExecution } from './generatedExecution.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 generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'list-async', +); +const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Generated 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/generate/generated-list-async-non-null-items-benchmark.js b/benchmark/generate/generated-list-async-non-null-items-benchmark.js new file mode 100644 index 0000000000..5584646e8d --- /dev/null +++ b/benchmark/generate/generated-list-async-non-null-items-benchmark.js @@ -0,0 +1,60 @@ +/* eslint-disable n/no-top-level-await */ + +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +import { createGeneratedExecution } from './generatedExecution.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 generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'list-async-non-null-items', +); +const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Generated 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/generate/generated-list-sync-benchmark.js b/benchmark/generate/generated-list-sync-benchmark.js new file mode 100644 index 0000000000..eecd4f7013 --- /dev/null +++ b/benchmark/generate/generated-list-sync-benchmark.js @@ -0,0 +1,60 @@ +/* eslint-disable n/no-top-level-await */ + +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +import { createGeneratedExecution } from './generatedExecution.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 generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'list-sync', +); +const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +export const benchmark = { + name: 'Generated 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/generate/generated-reference-few-resolvers-benchmark.js b/benchmark/generate/generated-reference-few-resolvers-benchmark.js new file mode 100644 index 0000000000..befef4a844 --- /dev/null +++ b/benchmark/generate/generated-reference-few-resolvers-benchmark.js @@ -0,0 +1,25 @@ +/* eslint-disable n/no-top-level-await */ + +/* + * Scenario source: graphql-jit README benchmark "fewResolvers". + * README: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/README.md#benchmarks + * Code: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/src/__benchmarks__/schema-few-resolvers.ts + * README reference on Node 16.13.0, hardware-dependent: + * graphql-js 16.x.x 26,620 ops/sec; graphql-jit 339,223 ops/sec. + */ + +import { + blogVariables, + createFewResolversSchema, + createGeneratedBenchmark, + fewResolversDocument, +} from './referenceScenarios.js'; + +export const benchmark = await createGeneratedBenchmark({ + document: fewResolversDocument, + importMetaURL: import.meta.url, + name: 'Generated Reference Few Resolvers', + schema: createFewResolversSchema(), + tmpName: 'reference-few-resolvers', + variableValues: blogVariables, +}); diff --git a/benchmark/generate/generated-reference-introspection-benchmark.js b/benchmark/generate/generated-reference-introspection-benchmark.js new file mode 100644 index 0000000000..642fb3f07e --- /dev/null +++ b/benchmark/generate/generated-reference-introspection-benchmark.js @@ -0,0 +1,26 @@ +/* eslint-disable n/no-top-level-await */ + +/* + * Scenario source: graphql-jit README benchmark "introspection". + * README: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/README.md#benchmarks + * Code: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/src/__benchmarks__/benchmarks.ts + * README reference on Node 16.13.0, hardware-dependent: + * graphql-js 16.x.x 1,941 ops/sec; graphql-jit 6,158 ops/sec. + */ + +import { parse } from 'graphql/language/parser.js'; +import { getIntrospectionQuery } from 'graphql/utilities/getIntrospectionQuery.js'; + +import { + createGeneratedBenchmark, + createNestedArraysSchema, +} from './referenceScenarios.js'; + +export const benchmark = await createGeneratedBenchmark({ + document: parse(getIntrospectionQuery({ descriptions: true })), + importMetaURL: import.meta.url, + name: 'Generated Reference Introspection', + schema: createNestedArraysSchema(), + tmpName: 'reference-introspection', + variableValues: {}, +}); diff --git a/benchmark/generate/generated-reference-many-resolvers-benchmark.js b/benchmark/generate/generated-reference-many-resolvers-benchmark.js new file mode 100644 index 0000000000..389aad9a52 --- /dev/null +++ b/benchmark/generate/generated-reference-many-resolvers-benchmark.js @@ -0,0 +1,25 @@ +/* eslint-disable n/no-top-level-await */ + +/* + * Scenario source: graphql-jit README benchmark "manyResolvers". + * README: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/README.md#benchmarks + * Code: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/src/__benchmarks__/schema-many-resolvers.ts + * README reference on Node 16.13.0, hardware-dependent: + * graphql-js 16.x.x 16,415 ops/sec; graphql-jit 178,331 ops/sec. + */ + +import { + blogVariables, + createGeneratedBenchmark, + createManyResolversSchema, + manyResolversDocument, +} from './referenceScenarios.js'; + +export const benchmark = await createGeneratedBenchmark({ + document: manyResolversDocument, + importMetaURL: import.meta.url, + name: 'Generated Reference Many Resolvers', + schema: createManyResolversSchema(), + tmpName: 'reference-many-resolvers', + variableValues: blogVariables, +}); diff --git a/benchmark/generate/generated-reference-nested-arrays-benchmark.js b/benchmark/generate/generated-reference-nested-arrays-benchmark.js new file mode 100644 index 0000000000..eaf6c6d1d4 --- /dev/null +++ b/benchmark/generate/generated-reference-nested-arrays-benchmark.js @@ -0,0 +1,25 @@ +/* eslint-disable n/no-top-level-await */ + +/* + * Scenario source: graphql-jit README benchmark "nestedArrays". + * README: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/README.md#benchmarks + * Code: https://github.com/zalando-incubator/graphql-jit/blob/174585f7ae9d9dde17ff80edffaca77d4c145d7f/src/__benchmarks__/schema-nested-array.ts + * README reference on Node 16.13.0, hardware-dependent: + * graphql-js 16.x.x 127 ops/sec; graphql-jit 1,316 ops/sec. + */ + +import { + blogVariables, + createGeneratedBenchmark, + createNestedArraysSchema, + nestedArraysDocument, +} from './referenceScenarios.js'; + +export const benchmark = await createGeneratedBenchmark({ + document: nestedArraysDocument, + importMetaURL: import.meta.url, + name: 'Generated Reference Nested Arrays', + schema: createNestedArraysSchema(), + tmpName: 'reference-nested-arrays', + variableValues: blogVariables, +}); diff --git a/benchmark/generate/generated-variable-field-collection-benchmark.js b/benchmark/generate/generated-variable-field-collection-benchmark.js new file mode 100644 index 0000000000..1e9d2df7c7 --- /dev/null +++ b/benchmark/generate/generated-variable-field-collection-benchmark.js @@ -0,0 +1,101 @@ +/* eslint-disable n/no-top-level-await */ + +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { buildSchema } from 'graphql/utilities/buildASTSchema.js'; + +import { createGeneratedExecution } from './generatedExecution.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 GeneratedExecute($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 generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + import.meta.url, + 'variable-field-collection', +); +const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); +if (Array.isArray(compiled)) { + throw compiled[0]; +} + +let flag = false; + +export const benchmark = { + name: 'Generated 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/benchmark/generate/generatedExecution.js b/benchmark/generate/generatedExecution.js new file mode 100644 index 0000000000..4446f59220 --- /dev/null +++ b/benchmark/generate/generatedExecution.js @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import url from 'node:url'; + +export async function createGeneratedExecution( + execution, + staticArgs, + factoryArgs, + importMetaURL, + name, +) { + if (typeof execution.generateExecution !== 'function') { + return undefined; + } + + const source = execution.generateExecution(staticArgs); + if (Array.isArray(source)) { + throw source[0]; + } + + const require = createRequire(importMetaURL); + const executionPath = require.resolve('graphql/execution/index.js'); + const packageDir = path.dirname(path.dirname(executionPath)); + const projectDir = path.dirname(path.dirname(packageDir)); + const generatedDir = path.join( + os.tmpdir(), + 'graphql-js-generated', + 'benchmarks', + path.basename(projectDir), + ); + fs.mkdirSync(generatedDir, { recursive: true }); + linkGraphQLPackage(generatedDir, packageDir); + + const generatedPath = path.join(generatedDir, `${name}.mjs`); + fs.writeFileSync(generatedPath, source); + + const generatedModule = await import( + url.pathToFileURL(generatedPath).href + `?v=${Date.now()}` + ); + const generated = generatedModule.createCompiledExecution(factoryArgs); + if (Array.isArray(generated)) { + throw generated[0]; + } + return generated; +} + +function linkGraphQLPackage(generatedDir, packageDir) { + const nodeModulesDir = path.join(generatedDir, 'node_modules'); + const linkPath = path.join(nodeModulesDir, 'graphql'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + + try { + if (fs.realpathSync(linkPath) === fs.realpathSync(packageDir)) { + return; + } + } catch { + // Create or replace below. + } + + fs.rmSync(linkPath, { force: true, recursive: true }); + fs.symlinkSync(packageDir, linkPath, 'dir'); +} diff --git a/benchmark/generate/referenceScenarios.js b/benchmark/generate/referenceScenarios.js new file mode 100644 index 0000000000..6dbdb171bc --- /dev/null +++ b/benchmark/generate/referenceScenarios.js @@ -0,0 +1,597 @@ +import * as execution from 'graphql/execution/index.js'; +import { parse } from 'graphql/language/parser.js'; +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from 'graphql/type/definition.js'; +import { + GraphQLBoolean, + GraphQLID, + GraphQLInt, + GraphQLString, +} from 'graphql/type/scalars.js'; +import { GraphQLSchema } from 'graphql/type/schema.js'; + +import { createGeneratedExecution } from './generatedExecution.js'; + +export const blogVariables = { id: '2', width: 300, height: 500 }; + +export async function createGeneratedBenchmark({ + document, + importMetaURL, + name, + schema, + tmpName, + variableValues, +}) { + const generated = await createGeneratedExecution( + execution, + { schema, document }, + { schema }, + importMetaURL, + tmpName, + ); + const compiled = + generated ?? + (typeof execution.compileExecution === 'function' + ? execution.compileExecution({ schema, document }) + : undefined); + if (Array.isArray(compiled)) { + throw compiled[0]; + } + + return { + name, + measure: () => { + const runtimeArgs = { variableValues }; + if (compiled !== undefined) { + return 'execute' in compiled + ? compiled.execute(runtimeArgs) + : compiled.executeRootSelectionSet(runtimeArgs); + } + + return execution.execute({ schema, document, variableValues }); + }, + }; +} + +export function createFewResolversSchema() { + const BlogImage = new GraphQLObjectType({ + name: 'Image', + fields: { + url: { type: GraphQLString }, + width: { type: GraphQLInt }, + height: { type: GraphQLInt }, + }, + }); + + const BlogAuthor = new GraphQLObjectType({ + name: 'Author', + fields: () => ({ + id: { type: GraphQLString }, + name: { type: GraphQLString }, + pic: { + args: { width: { type: GraphQLInt }, height: { type: GraphQLInt } }, + type: BlogImage, + resolve: (obj, { width, height }) => obj.pic(width, height), + }, + recentArticle: { type: BlogArticle }, + }), + }); + + const BlogArticle = new GraphQLObjectType({ + name: 'Article', + fields: { + id: { type: new GraphQLNonNull(GraphQLID) }, + isPublished: { type: GraphQLBoolean }, + author: { type: BlogAuthor }, + title: { type: GraphQLString }, + body: { type: GraphQLString }, + keywords: { type: new GraphQLList(GraphQLString) }, + }, + }); + + const BlogQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + article: { + type: BlogArticle, + args: { id: { type: GraphQLID } }, + resolve: (_source, { id }) => article(id), + }, + feed: { + type: new GraphQLList(BlogArticle), + resolve: () => + Promise.resolve([ + article(1), + article(2), + article(3), + article(4), + article(5), + article(6), + article(7), + article(8), + article(9), + article(10), + ]), + }, + }, + }); + + const johnSmith = { + id: 123, + name: 'John Smith', + pic: (width, height) => getPic(123, width, height), + recentArticle: null, + }; + johnSmith.recentArticle = article(1); + + function article(id) { + return { + id, + isPublished: true, + author: johnSmith, + title: 'My Article ' + String(id), + body: 'This is a post', + hidden: 'This data is not exposed in the schema', + keywords: ['foo', 'bar', 1, true, null], + }; + } + + function getPic(uid, width, height) { + return { + url: `cdn://${uid}`, + width: `${width}`, + height: `${height}`, + }; + } + + return new GraphQLSchema({ query: BlogQuery }); +} + +export const fewResolversDocument = parse(` + query ($id: ID! = "1", $width: Int = 640, $height: Int = 480) { + feed { + __typename + id + title + } + article(id: $id) { + ...articleFields + author { + __typename + id + name + pic(width: $width, height: $height) { + __typename + url + width + height + } + recentArticle { + ...articleFields + keywords + } + } + } + } + + fragment articleFields on Article { + __typename + id + isPublished + title + body + hidden + notdefined + } +`); + +export function createManyResolversSchema() { + const BlogImage = new GraphQLObjectType({ + name: 'Image', + fields: { + url: { + type: GraphQLString, + resolve: (image) => Promise.resolve(image.url), + }, + width: { + type: GraphQLInt, + resolve: (image) => Promise.resolve(image.width), + }, + height: { + type: GraphQLInt, + resolve: (image) => Promise.resolve(image.height), + }, + }, + }); + + const BlogAuthor = new GraphQLObjectType({ + name: 'Author', + fields: () => ({ + id: { + type: GraphQLString, + resolve: (author) => Promise.resolve(author.id), + }, + name: { + type: GraphQLString, + resolve: (author) => Promise.resolve(author.name), + }, + pic: { + args: { width: { type: GraphQLInt }, height: { type: GraphQLInt } }, + type: BlogImage, + resolve: (obj, { width, height }) => obj.pic(width, height), + }, + recentArticle: { + type: BlogArticle, + resolve: (author) => Promise.resolve(author.recentArticle), + }, + }), + }); + + const BlogArticle = new GraphQLObjectType({ + name: 'Article', + fields: { + id: { + type: new GraphQLNonNull(GraphQLID), + resolve: (blogArticle) => Promise.resolve(blogArticle.id), + }, + isPublished: { + type: GraphQLBoolean, + resolve: (blogArticle) => Promise.resolve(blogArticle.isPublished), + }, + author: { type: BlogAuthor }, + title: { + type: GraphQLString, + resolve: (blogArticle) => + Promise.resolve(blogArticle && blogArticle.title), + }, + body: { + type: GraphQLString, + resolve: (blogArticle) => Promise.resolve(blogArticle.body), + }, + keywords: { + type: new GraphQLList(GraphQLString), + resolve: (blogArticle) => Promise.resolve(blogArticle.keywords), + }, + }, + }); + + const BlogQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + article: { + type: BlogArticle, + args: { id: { type: GraphQLID } }, + resolve: (_source, { id }) => article(id), + }, + feed: { + type: new GraphQLList(BlogArticle), + resolve: () => + Promise.resolve([ + article(1), + article(2), + article(3), + article(4), + article(5), + article(6), + article(7), + article(8), + article(9), + article(10), + ]), + }, + }, + }); + + const johnSmith = { + id: 123, + name: 'John Smith', + pic: (width, height) => getPic(123, width, height), + recentArticle: null, + }; + johnSmith.recentArticle = article(1); + + function article(id) { + return { + id, + isPublished: true, + author: johnSmith, + title: 'My Article ' + String(id), + body: 'This is a post', + hidden: 'This data is not exposed in the schema', + keywords: ['foo', 'bar', 1, true, null], + }; + } + + function getPic(uid, width, height) { + return { + url: `cdn://${uid}`, + width: `${width}`, + height: `${height}`, + }; + } + + return new GraphQLSchema({ query: BlogQuery }); +} + +export const manyResolversDocument = parse(` + query ($id: ID! = "1", $width: Int = 640, $height: Int = 480) { + feed { + __typename + id + title + } + article(id: $id) { + ...articleFields + author { + __typename + id + name + pic(width: $width, height: $height) { + __typename + url + width + height + } + articles { + ...articleFields + keywords + badges { + color + text + } + adverts { + text + image { + url + width + height + } + } + } + } + } + } + + fragment articleFields on Article { + __typename + id + isPublished + title + body + hidden + notdefined + } +`); + +export function createNestedArraysSchema() { + const articlesCount = 25; + const badgesCount = 25; + const advertsCount = 25; + + const BlogImage = new GraphQLObjectType({ + name: 'Image', + fields: { + url: { + type: GraphQLString, + resolve: (image) => Promise.resolve(image.url), + }, + width: { + type: GraphQLInt, + resolve: (image) => Promise.resolve(image.width), + }, + height: { + type: GraphQLInt, + resolve: (image) => Promise.resolve(image.height), + }, + }, + }); + + const articles = []; + const badges = []; + const adverts = []; + + const BlogAuthor = new GraphQLObjectType({ + name: 'Author', + fields: () => ({ + id: { + type: GraphQLString, + resolve: (author) => Promise.resolve(author.id), + }, + name: { + type: GraphQLString, + resolve: (author) => Promise.resolve(author.name), + }, + pic: { + args: { width: { type: GraphQLInt }, height: { type: GraphQLInt } }, + type: BlogImage, + resolve: (obj, { width, height }) => obj.pic(width, height), + }, + articles: { + type: new GraphQLList(BlogArticle), + resolve: () => Promise.resolve(articles), + }, + }), + }); + + const BlogArticleBadge = new GraphQLObjectType({ + name: 'ArticleBadge', + fields: { + color: { + type: GraphQLString, + resolve: (badge) => Promise.resolve(badge && badge.color), + }, + text: { + type: GraphQLString, + resolve: (badge) => Promise.resolve(badge && badge.text), + }, + }, + }); + + const BlogArticleAdvert = new GraphQLObjectType({ + name: 'ArticleAdvert', + fields: { + text: { + type: GraphQLString, + resolve: (advert) => Promise.resolve(advert && advert.text), + }, + image: { + type: BlogImage, + resolve: (advert) => Promise.resolve(advert && advert.image), + }, + }, + }); + + const BlogArticle = new GraphQLObjectType({ + name: 'Article', + fields: { + id: { + type: new GraphQLNonNull(GraphQLID), + resolve: (blogArticle) => Promise.resolve(blogArticle.id), + }, + isPublished: { + type: GraphQLBoolean, + resolve: (blogArticle) => Promise.resolve(blogArticle.isPublished), + }, + author: { type: BlogAuthor }, + title: { + type: GraphQLString, + resolve: (blogArticle) => + Promise.resolve(blogArticle && blogArticle.title), + }, + body: { + type: GraphQLString, + resolve: (blogArticle) => Promise.resolve(blogArticle.body), + }, + keywords: { + type: new GraphQLList(GraphQLString), + resolve: (blogArticle) => Promise.resolve(blogArticle.keywords), + }, + badges: { type: new GraphQLList(BlogArticleBadge) }, + adverts: { type: new GraphQLList(BlogArticleAdvert) }, + }, + }); + + const BlogQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + article: { + type: BlogArticle, + args: { id: { type: GraphQLID } }, + resolve: (_source, { id }) => article(id), + }, + feed: { + type: new GraphQLList(BlogArticle), + resolve: () => + Promise.resolve([ + article(1), + article(2), + article(3), + article(4), + article(5), + article(6), + article(7), + article(8), + article(9), + article(10), + ]), + }, + }, + }); + + for (let i = 0; i < badgesCount; i++) { + badges.push({ color: 'color' + String(i), text: 'text' + String(i) }); + } + + for (let i = 0; i < advertsCount; i++) { + adverts.push({ text: 'text' + String(i), image: getPic(i, 100, 200) }); + } + + const johnSmith = { + id: 123, + name: 'John Smith', + pic: (width, height) => getPic(123, width, height), + recentArticle: null, + }; + johnSmith.recentArticle = article(1); + + function article(id) { + return { + id, + isPublished: true, + author: johnSmith, + title: 'My Article ' + String(id), + body: 'This is a post', + hidden: 'This data is not exposed in the schema', + keywords: ['foo', 'bar', 1, true, null], + badges, + adverts, + }; + } + + for (let i = 0; i < articlesCount; i++) { + articles.push(article(i)); + } + + function getPic(uid, width, height) { + return { + url: `cdn://${uid}`, + width: `${width}`, + height: `${height}`, + }; + } + + return new GraphQLSchema({ query: BlogQuery }); +} + +export const nestedArraysDocument = parse(` + query ($id: ID! = "1", $width: Int = 640, $height: Int = 480) { + feed { + __typename + id + title + } + article(id: $id) { + ...articleFields + author { + __typename + id + name + pic(width: $width, height: $height) { + __typename + url + width + height + } + articles { + ...articleFields + keywords + badges { + color + text + } + adverts { + text + image { + url + width + height + } + } + } + } + } + } + + fragment articleFields on Article { + __typename + id + isPublished + title + body + hidden + notdefined + } +`); diff --git a/cspell.yml b/cspell.yml index edc9af76c5..9b2d58d7c1 100644 --- a/cspell.yml +++ b/cspell.yml @@ -40,11 +40,18 @@ ignoreRegExpList: words: - graphiql + - incubator + - jit - Jsdocs + - backpressure - metafield + - microtask + - notdefined + - replayable - thunked - uncoerce - uncoerced + - zalando # Different names used inside tests - Skywalker diff --git a/eslint.config.mjs b/eslint.config.mjs index a6f08d52c6..cf75de1cfb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -456,6 +456,34 @@ export default defineConfig( message: 'String literals should not contain trailing spaces. If needed for tests please disable locally using eslint comment', }, + { + selector: 'ImportDeclaration[source.value=/^(?:node:)?vm$/]', + message: + 'Do not evaluate generated code through VM APIs. Write generated code to a module and import it normally.', + }, + { + selector: + "CallExpression[callee.name='require'][arguments.0.value=/^(?:node:)?vm$/]", + message: + 'Do not evaluate generated code through VM APIs. Write generated code to a module and import it normally.', + }, + { + selector: 'ImportExpression[source.value=/^data:/]', + message: + 'Do not import generated code from data URLs. Write generated code to a module and import it normally.', + }, + { + selector: + "CallExpression[callee.object.name='vm'][callee.property.name=/^(?:compileFunction|runInContext|runInNewContext|runInThisContext)$/]", + message: + 'Do not evaluate generated code through VM APIs. Write generated code to a module and import it normally.', + }, + { + selector: + "NewExpression[callee.object.name='vm'][callee.property.name='Script']", + message: + 'Do not evaluate generated code through VM APIs. Write generated code to a module and import it normally.', + }, ], 'no-return-assign': 'error', 'no-script-url': 'error', diff --git a/package.json b/package.json index d497ebc62d..284f59c8a3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "changelog": "node --experimental-strip-types resources/gen-changelog.ts", "release:prepare": "node --experimental-strip-types resources/release-prepare.ts", "release:metadata": "node --experimental-strip-types resources/release-metadata.ts", + "generate:execution-fixtures": "node --experimental-strip-types resources/generate-execution-fixtures.ts", "benchmark": "node --experimental-strip-types resources/benchmark.ts", "test": "npm run lint && npm run check && npm run testonly:cover && npm run prettier:check && npm run prettier:examples:check && npm run check:spelling && npm run check:integrations", "lint": "eslint --cache --max-warnings 0 .", @@ -42,6 +43,8 @@ "check:deno": "npm run build:deno && deno publish --dry-run --allow-dirty --config denoDist/jsr.json", "testonly": "npm run node:test -- \"src/**/__tests__/**/*-test.ts\"", "testonly:cover": "npm run node:test -- --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-exclude=\"src/**/__tests__/**\" --test-coverage-exclude=\"**/*-test.ts\" --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 \"src/**/__tests__/**/*-test.ts\"", + "generate:coverage": "node --experimental-strip-types resources/generated-coverage.ts --complete", + "testonly:cover:generated": "npm run generate:coverage", "testonly:watch": "npm run node:test -- --watch \"src/**/__tests__/**/*-test.ts\"", "prettier": "prettier --cache --cache-strategy metadata --write --list-different .", "prettier:check": "prettier --cache --cache-strategy metadata --check .", 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/resources/generate-execution-coverage-fixtures.ts b/resources/generate-execution-coverage-fixtures.ts new file mode 100644 index 0000000000..beb7eff8e5 --- /dev/null +++ b/resources/generate-execution-coverage-fixtures.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createRootStringCoverageSchema, + rootStringCoverageDocument, +} from '../src/execution/generate/__tests__/generatedCoverageFixtures.ts'; +import { generateExecution } from '../src/execution/generate/index.ts'; + +const fixtureDir = path.join( + process.cwd(), + 'reports/generated-execution-coverage-fixtures', +); + +export const generatedExecutionCoverageFixtureDir: string = fixtureDir; + +export interface GeneratedExecutionCoverageFixtureSource { + filename: string; + source: string; +} + +export function getGeneratedExecutionCoverageFixtureSources( + outputDir: string = fixtureDir, +): ReadonlyArray { + return [ + { + filename: 'root-string.mjs', + source: generatedCoverageFixtureSource( + generateExecution({ + schema: createRootStringCoverageSchema(), + document: rootStringCoverageDocument, + }), + outputDir, + ), + }, + ]; +} + +export function writeGeneratedExecutionCoverageFixtures( + outputDir: string = fixtureDir, +): string { + fs.rmSync(outputDir, { force: true, recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }); + for (const fixture of getGeneratedExecutionCoverageFixtureSources( + outputDir, + )) { + fs.writeFileSync(path.join(outputDir, fixture.filename), fixture.source); + } + return outputDir; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + writeGeneratedExecutionCoverageFixtures(); +} + +function generatedCoverageFixtureSource( + sourceOrErrors: ReadonlyArray | string, + outputDir: string, +): string { + if (typeof sourceOrErrors !== 'string') { + throw sourceOrErrors[0]; + } + const sourceImportPrefix = path + .relative(outputDir, path.join(process.cwd(), 'src')) + .replaceAll(path.sep, '/'); + return sourceOrErrors + .replaceAll("from 'graphql/", `from '${sourceImportPrefix}/`) + .replaceAll(".js';", ".ts';"); +} diff --git a/resources/generate-execution-fixtures.ts b/resources/generate-execution-fixtures.ts new file mode 100644 index 0000000000..c12d454e72 --- /dev/null +++ b/resources/generate-execution-fixtures.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createKitchenSinkFixtureSchema, + incrementalFixtureDocument, + queryFixtureDocument, + subscriptionFixtureDocument, +} from '../src/execution/generate/__tests__/generatedFixtureSchemas.ts'; +import { + generateExecution, + generateSubscription, +} from '../src/execution/generate/index.ts'; + +const fixtureDir = path.join( + process.cwd(), + 'reports/generated-execution-fixtures', +); + +export const generatedExecutionFixtureDir: string = fixtureDir; + +export interface GeneratedExecutionFixtureSource { + filename: string; + source: string; +} + +export function getGeneratedExecutionFixtureSources( + outputDir: string = fixtureDir, +): ReadonlyArray { + return [ + { + filename: 'query.mjs', + source: generatedFixtureSource( + generateExecution({ + schema: createKitchenSinkFixtureSchema(), + document: queryFixtureDocument, + }), + outputDir, + ), + }, + { + filename: 'incremental.mjs', + source: generatedFixtureSource( + generateExecution({ + schema: createKitchenSinkFixtureSchema(), + document: incrementalFixtureDocument, + }), + outputDir, + ), + }, + { + filename: 'subscription.mjs', + source: generatedFixtureSource( + generateSubscription({ + schema: createKitchenSinkFixtureSchema(), + document: subscriptionFixtureDocument, + }), + outputDir, + ), + }, + ]; +} + +export function writeGeneratedExecutionFixtures( + outputDir: string = fixtureDir, +): string { + fs.rmSync(outputDir, { force: true, recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }); + for (const fixture of getGeneratedExecutionFixtureSources(outputDir)) { + fs.writeFileSync(path.join(outputDir, fixture.filename), fixture.source); + } + return outputDir; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + writeGeneratedExecutionFixtures(); +} + +function generatedFixtureSource( + sourceOrErrors: ReadonlyArray | string, + outputDir: string, +): string { + if (typeof sourceOrErrors !== 'string') { + throw sourceOrErrors[0]; + } + const sourceImportPrefix = path + .relative(outputDir, path.join(process.cwd(), 'src')) + .replaceAll(path.sep, '/'); + return sourceOrErrors + .replaceAll("from 'graphql/", `from '${sourceImportPrefix}/`) + .replaceAll(".js';", ".ts';"); +} diff --git a/resources/generated-coverage.ts b/resources/generated-coverage.ts new file mode 100644 index 0000000000..a06880cd8f --- /dev/null +++ b/resources/generated-coverage.ts @@ -0,0 +1,313 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { generatedExecutionCoverageFixtureDir } from './generate-execution-coverage-fixtures.ts'; + +interface V8CoverageRange { + startOffset: number; + endOffset: number; + count: number; +} + +interface V8CoverageFunction { + functionName: string; + ranges: ReadonlyArray; +} + +interface V8CoverageScript { + url: string; + functions: ReadonlyArray; +} + +interface V8CoverageFile { + result: ReadonlyArray; +} + +interface CoverageRange { + functionName: string; + startOffset: number; + endOffset: number; + count: number; +} + +const fixtureDir = generatedExecutionCoverageFixtureDir; +const coverageDir = path.join( + process.cwd(), + 'reports/generated-fixture-coverage-v8', +); +const minimumRangeCoveragePercent = getMinimumRangeCoveragePercent(); +const requireCompleteCoverage = + process.argv.includes('--complete') || + process.env.GRAPHQL_JS_GENERATED_COVERAGE_STRICT === '1'; + +fs.rmSync(fixtureDir, { force: true, recursive: true }); +fs.rmSync(coverageDir, { force: true, recursive: true }); +fs.mkdirSync(coverageDir, { recursive: true }); + +const testResult = spawnSync( + process.execPath, + [ + '--experimental-strip-types', + '--throw-deprecation', + '--test', + '--experimental-test-isolation=none', + '--test-concurrency=1', + '--test-reporter=dot', + 'src/execution/generate/__tests__/generated-coverage-test.ts', + ], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + NODE_V8_COVERAGE: coverageDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, +); + +process.stdout.write(testResult.stdout); +process.stderr.write(testResult.stderr); +if (testResult.status !== 0) { + process.exit(testResult.status ?? 1); +} + +const generatedFiles = fs + .readdirSync(fixtureDir) + .filter((filename) => filename.endsWith('.mjs')) + .sort(); +if (generatedFiles.length === 0) { + throw new Error('Expected generated execution modules to be written.'); +} + +const coverageByPath = readGeneratedCoverage(); +if (coverageByPath.size === 0) { + throw new Error('Expected V8 coverage for generated execution modules.'); +} +if (coverageByPath.size !== generatedFiles.length) { + throw new Error( + `Expected V8 coverage for ${String( + generatedFiles.length, + )} generated execution modules, got ${String(coverageByPath.size)}.`, + ); +} + +let coveredRangeCount = 0; +let rangeCount = 0; +const coverageSummaries: Array<{ + filepath: string; + coveredRangeCount: number; + rangeCount: number; +}> = []; +const uncoveredRanges: Array<{ + filepath: string; + range: CoverageRange; +}> = []; + +for (const [filepath, ranges] of coverageByPath) { + const source = fs.readFileSync(filepath, 'utf8'); + const ignoredLines = getIgnoredCoverageLines(source); + let fileCoveredRangeCount = 0; + let fileRangeCount = 0; + for (const range of ranges) { + if (isIgnoredRange(source, range, ignoredLines)) { + continue; + } + rangeCount++; + fileRangeCount++; + if (range.count === 0) { + uncoveredRanges.push({ filepath, range }); + } else { + coveredRangeCount++; + fileCoveredRangeCount++; + } + } + coverageSummaries.push({ + filepath, + coveredRangeCount: fileCoveredRangeCount, + rangeCount: fileRangeCount, + }); +} + +const coveragePercent = + rangeCount === 0 ? 100 : (coveredRangeCount / rangeCount) * 100; +console.log(''); +console.log( + `Generated modules covered: ${String(coverageByPath.size)}/${String( + generatedFiles.length, + )}`, +); +console.log( + `Generated range coverage: ${coveragePercent.toFixed(2)}% ` + + `(${String(coveredRangeCount)}/${String(rangeCount)})`, +); +for (const summary of coverageSummaries) { + const fileCoveragePercent = + summary.rangeCount === 0 + ? 100 + : (summary.coveredRangeCount / summary.rangeCount) * 100; + console.log( + ` ${path.basename(summary.filepath)}: ${fileCoveragePercent.toFixed( + 2, + )}% (${String(summary.coveredRangeCount)}/${String(summary.rangeCount)})`, + ); +} + +if (uncoveredRanges.length !== 0) { + console.log(''); + console.log('First uncovered generated ranges:'); + for (const { filepath, range } of uncoveredRanges.slice(0, 20)) { + const source = fs.readFileSync(filepath, 'utf8'); + console.log( + `${path.basename(filepath)} ${offsetToLocation( + source, + range.startOffset, + )} ${range.functionName || ''}`, + ); + console.log(snippet(source, range)); + } +} + +if (coveragePercent < minimumRangeCoveragePercent) { + console.error( + `Generated range coverage ${coveragePercent.toFixed( + 2, + )}% does not meet the ${minimumRangeCoveragePercent.toFixed(2)}% floor.`, + ); + process.exitCode = 1; +} + +if (requireCompleteCoverage && uncoveredRanges.length !== 0) { + console.error('Generated range coverage is not complete.'); + process.exitCode = 1; +} + +function getMinimumRangeCoveragePercent(): number { + const arg = process.argv.find((value) => value.startsWith('--min-ranges=')); + const rawValue = + arg?.slice('--min-ranges='.length) ?? + process.env.GRAPHQL_JS_GENERATED_COVERAGE_MIN_RANGES ?? + '0'; + const minimum = Number(rawValue); + if (!Number.isFinite(minimum) || minimum < 0 || minimum > 100) { + throw new Error( + `Invalid generated coverage minimum range percentage: ${rawValue}`, + ); + } + return minimum; +} + +function readGeneratedCoverage(): Map> { + const rangesByPath = new Map>(); + const fixtureDirURL = pathToFileURL(`${fixtureDir}${path.sep}`).href; + + for (const filename of fs.readdirSync(coverageDir)) { + if (!filename.endsWith('.json')) { + continue; + } + const coverageFile = JSON.parse( + fs.readFileSync(path.join(coverageDir, filename), 'utf8'), + ) as V8CoverageFile; + + for (const script of coverageFile.result) { + if (!script.url.startsWith(fixtureDirURL)) { + continue; + } + const filepath = fileURLToPath(script.url); + const rangesByKey = rangesByPath.get(filepath) ?? new Map(); + rangesByPath.set(filepath, rangesByKey); + + for (const fn of script.functions) { + for (const range of fn.ranges) { + const key = [ + fn.functionName, + String(range.startOffset), + String(range.endOffset), + ].join(':'); + const existing = rangesByKey.get(key); + if (existing === undefined) { + rangesByKey.set(key, { + functionName: fn.functionName, + startOffset: range.startOffset, + endOffset: range.endOffset, + count: range.count, + }); + } else { + existing.count += range.count; + } + } + } + } + } + + return new Map( + Array.from(rangesByPath, ([filepath, rangesByKey]) => [ + filepath, + Array.from(rangesByKey.values()), + ]), + ); +} + +function offsetToLocation(source: string, offset: number): string { + let line = 1; + let column = 1; + for (let index = 0; index < offset; index++) { + if (source.charCodeAt(index) === 10) { + line++; + column = 1; + } else { + column++; + } + } + return `${String(line)}:${String(column)}`; +} + +function getIgnoredCoverageLines(source: string): ReadonlySet { + const ignoredLines = new Set(); + const lines = source.split('\n'); + for (let index = 0; index < lines.length; index++) { + const match = /node:coverage ignore next(?:\s+(\d+))?/.exec(lines[index]); + if (match === null) { + continue; + } + const lineCount = match[1] === undefined ? 1 : Number(match[1]); + if (!Number.isSafeInteger(lineCount) || lineCount < 1) { + throw new Error(`Invalid coverage ignore line count: ${match[1]}`); + } + for (let offset = 1; offset <= lineCount; offset++) { + ignoredLines.add(index + 1 + offset); + } + } + return ignoredLines; +} + +function isIgnoredRange( + source: string, + range: CoverageRange, + ignoredLines: ReadonlySet, +): boolean { + return ignoredLines.has(offsetToLine(source, range.startOffset)); +} + +function offsetToLine(source: string, offset: number): number { + let line = 1; + for (let index = 0; index < offset; index++) { + if (source.charCodeAt(index) === 10) { + line++; + } + } + return line; +} + +function snippet(source: string, range: CoverageRange): string { + return source + .slice(range.startOffset, range.endOffset) + .split('\n') + .slice(0, 4) + .join('\n') + .trim() + .replaceAll(/\s+/g, ' ') + .slice(0, 180); +} 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..2ebe26bf35 100644 --- a/src/execution/__tests__/cancellation-test.ts +++ b/src/execution/__tests__/cancellation-test.ts @@ -26,12 +26,15 @@ 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 { + COMPARISON_COUNT, execute, experimentalExecuteIncrementally, subscribe, -} from '../execute.ts'; -import { legacyExecuteIncrementally } from '../legacyIncremental/legacyExecuteIncrementally.ts'; +} from './executeTestUtils.ts'; const schema = buildSchema(` type Todo { @@ -272,7 +275,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 +790,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 +906,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 +953,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 +991,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 +1030,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 +1102,7 @@ describe('Execute: Cancellation', () => { await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); - expect(returnSpy.callCount).to.equal(1); + expect(returnSpy.callCount).to.equal(COMPARISON_COUNT); }); it('ignores async iterator return promise rejections after aborting list completion', async () => { @@ -1146,6 +1150,6 @@ describe('Execute: Cancellation', () => { await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); - expect(returnSpy.callCount).to.equal(1); + expect(returnSpy.callCount).to.equal(COMPARISON_COUNT); }); }); 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..2d8d6a4ef2 --- /dev/null +++ b/src/execution/__tests__/executeTestUtils.ts @@ -0,0 +1,1098 @@ +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 { inspect } from '../../jsutils/inspect.ts'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.ts'; +import { isIterableObject } from '../../jsutils/isIterableObject.ts'; +import { isObjectLike } from '../../jsutils/isObjectLike.ts'; +import { isPromise, isPromiseLike } from '../../jsutils/isPromise.ts'; +import { pathToArray } from '../../jsutils/Path.ts'; +import { printPathArray } from '../../jsutils/printPathArray.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 { VariableDefinitionNode } from '../../language/ast.ts'; +import { parse } from '../../language/parser.ts'; + +import { + GraphQLNonNull, + isAbstractType, + isLeafType, + isListType, + isNonNullType, + isObjectType, +} from '../../type/definition.ts'; +import { + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, +} from '../../type/introspection.ts'; +import { + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, +} from '../../type/scalars.ts'; +import type { GraphQLSchema } from '../../type/schema.ts'; +import { validateDefaultInput } from '../../type/validate.ts'; + +import { validateInputValue } from '../../utilities/validateInputValue.ts'; + +import { cancellablePromise } from '../cancellablePromise.ts'; +import { collectIteratorPromises } from '../collectIteratorPromises.ts'; +import { compileArgumentValues } from '../compile/compileArgumentValues.ts'; +import { compileCollectFields } from '../compile/compileCollectFields.ts'; +import { + CompiledExecutionRunner, + CompiledExecutor, +} from '../compile/CompiledExecutor.ts'; +import { + compileExecutionState, + isExecutionErrors, +} from '../compile/compileExecutionState.ts'; +import { + compileFieldExecutionPlan, + compileFieldResolver, +} from '../compile/compileFieldExecutionPlan.ts'; +import { compileVariableValues } from '../compile/compileVariableValues.ts'; +import { getCompiledArgumentValues } from '../compile/getCompiledArgumentValues.ts'; +import { getCompiledVariableValues } from '../compile/getCompiledVariableValues.ts'; +import { compileExecution, compileSubscription } from '../compile/index.ts'; +import { createSharedExecutionContext } from '../createSharedExecutionContext.ts'; +import { + createSourceEventStream as originalCreateSourceEventStream, + defaultFieldResolver, + defaultTypeResolver, + 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 { + ExecutionArgs, + ValidatedSubscriptionArgs, +} from '../ExecutionArgs.ts'; +import { EMPTY_VARIABLE_VALUES } from '../ExecutionArgs.ts'; +import type { ExecutionResult } from '../Executor.ts'; +import { generateExecution, generateSubscription } from '../generate/index.ts'; +import type { + ExperimentalIncrementalExecutionResults, + InitialIncrementalExecutionResult, + SubsequentIncrementalExecutionResult, +} from '../incremental/IncrementalExecutor.ts'; +import { legacyExecuteIncrementally } from '../legacyIncremental/legacyExecuteIncrementally.ts'; +import { mapAsyncIterable } from '../mapAsyncIterable.ts'; +import { returnIteratorCatchingErrors } from '../returnIteratorCatchingErrors.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 +>(); + +// Number of implementations compared by these test helpers. +export const COMPARISON_COUNT = 3; + +export function execute( + args: ExecutionArgsInput, +): PromiseOrValue { + return expectMatchingExecutionResults([ + () => originalExecute(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + () => executeGenerated(getExecutionArgs(args), 'execute'), + ]); +} + +export function executeSync(args: ExecutionArgsInput): ExecutionResult { + return expectMatchingExecutionResults([ + () => originalExecuteSync(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + () => executeGenerated(getExecutionArgs(args), 'execute'), + ]) as ExecutionResult; +} + +export function executeWithAllMethods( + args: ExecutionArgsInput, +): PromiseOrValue { + return expectMatchingExecutionResults([ + () => originalExecute(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + () => executeGenerated(getExecutionArgs(args), 'execute'), + () => originalExecuteIgnoringIncremental(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'executeIgnoringIncremental'), + () => + executeGenerated(getExecutionArgs(args), 'executeIgnoringIncremental'), + () => originalExperimentalExecuteIncrementally(getExecutionArgs(args)), + () => + executeCompiled( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ), + () => + executeGenerated( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ), + () => legacyExecuteIncrementally(getExecutionArgs(args)), + ]); +} + +export function executeSyncWithAllMethods( + args: ExecutionArgsInput, +): ExecutionResult { + return expectMatchingExecutionResults([ + () => originalExecuteSync(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'execute'), + () => executeGenerated(getExecutionArgs(args), 'execute'), + () => originalExecuteIgnoringIncremental(getExecutionArgs(args)), + () => executeCompiled(getExecutionArgs(args), 'executeIgnoringIncremental'), + () => + executeGenerated(getExecutionArgs(args), 'executeIgnoringIncremental'), + () => originalExperimentalExecuteIncrementally(getExecutionArgs(args)), + () => + executeCompiled( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ), + () => + executeGenerated( + 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; + } + }, + () => subscribeGenerated(getExecutionArgs(args)), + ]); +} + +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 }; + }, + () => { + const subscriptionArgs = getSubscriptionExecutionArgs( + validatedSubscriptionArgs, + ); + const generatedSubscription = + createGeneratedSubscription(subscriptionArgs); + return 'createSourceEventStream' in generatedSubscription + ? generatedSubscription.createSourceEventStream( + validatedSubscriptionArgs, + ) + : { errors: generatedSubscription }; + }, + ]) 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 }; + }, + () => { + const generatedSubscription = + createGeneratedSubscription(subscriptionArgs); + return 'executeSubscriptionEvent' in generatedSubscription + ? generatedSubscription.executeSubscriptionEvent(args) + : { errors: generatedSubscription }; + }, + ]); +} + +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 }; + const generatedSubscription = createGeneratedSubscription(subscriptionArgs); + const generatedResult = + 'mapSourceToResponseEvent' in generatedSubscription + ? generatedSubscription.mapSourceToResponseEvent( + validatedSubscriptionArgs, + replaySourceEventStream, + wrappedRootSelectionSetExecutor, + ) + : { errors: generatedSubscription }; + + if (!isAsyncIterable(result)) { + assert(!isAsyncIterable(compiledResult)); + assert(!isAsyncIterable(generatedResult)); + const comparedResult = expectMatchingExecutionResults([ + () => result, + () => compiledResult, + () => generatedResult, + ]); + assert(!isPromise(comparedResult)); + return comparedResult; + } + + assert(isAsyncIterable(compiledResult)); + assert(isAsyncIterable(generatedResult)); + return expectMatchingAsyncIterables([ + result, + compiledResult, + generatedResult, + ]); +} + +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', + ); + const generatedResult = executeGenerated( + getExecutionArgs(args), + 'experimentalExecuteIncrementally', + ); + const results = [result, compiledResult, generatedResult]; + + if (results.some(isPromise)) { + return Promise.allSettled( + results.map((item) => Promise.resolve(item)), + ).then((settledResults) => { + const outcomes: Array> = + settledResults.map((settledResult) => + settledResult.status === 'fulfilled' + ? { kind: 'value', value: settledResult.value } + : { kind: 'error', error: settledResult.reason }, + ); + if (outcomes.some((outcome) => outcome.kind === 'error')) { + return expectMatchingOutcomes(outcomes); + } + return compareIncrementalExecutionResults( + outcomes.map((outcome) => { + assert(outcome.kind === 'value'); + return outcome.value; + }), + ); + }); + } + + return compareIncrementalExecutionResults( + results.map((item) => { + assert(!isPromise(item)); + return item; + }), + ); +} + +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 executeGenerated( + args: ExecutionArgs, + method: CompiledExecutionMethod, +): PromiseOrValue { + const generatedExecutionSource = generateExecution(args); + if (typeof generatedExecutionSource !== 'string') { + if (isStaticGenerationBoundaryErrors(generatedExecutionSource)) { + return executeCompiled(args, method); + } + return { errors: generatedExecutionSource }; + } + + const createCompiledExecution = getGeneratedExecutionFactory( + generatedExecutionSource, + ); + const generatedExecution = createCompiledExecution(args); + if ('execute' in generatedExecution) { + return generatedExecution[method](args); + } + return { errors: generatedExecution }; +} + +function isStaticGenerationBoundaryErrors( + errors: ReadonlyArray, +): boolean { + return ( + errors.length === 1 && + errors[0] instanceof GraphQLError && + errors[0].message === + 'Operation cannot be fully represented as static generated source.' + ); +} + +function subscribeGenerated( + args: ExecutionArgs, +): PromiseOrValue { + try { + const generatedSubscription = createGeneratedSubscription(args); + return 'subscribe' in generatedSubscription + ? generatedSubscription.subscribe(args) + : { errors: generatedSubscription }; + } catch (error) { + if ( + error instanceof Error && + error.message === 'Expected subscription operation.' + ) { + throw error; + } + return { errors: [error] } as ExecutionResult; + } +} + +function createGeneratedSubscription( + args: ExecutionArgs, +): ReturnType { + const generatedSubscriptionSource = generateSubscription(args); + if (typeof generatedSubscriptionSource !== 'string') { + if (isStaticGenerationBoundaryErrors(generatedSubscriptionSource)) { + return compileSubscription(args); + } + if ( + generatedSubscriptionSource.some( + (error) => error.message === 'Expected subscription operation.', + ) + ) { + throw new GraphQLError('Expected subscription operation.'); + } + return generatedSubscriptionSource; + } + + const createCompiledSubscription = getGeneratedSubscriptionFactory( + generatedSubscriptionSource, + ); + return createCompiledSubscription(args); +} + +type GeneratedExecutionFactory = ( + args: ExecutionArgs, +) => ReturnType; + +type GeneratedSubscriptionFactory = ( + args: ExecutionArgs, +) => ReturnType; + +const generatedExecutionFactoryCache = new Map< + string, + GeneratedExecutionFactory +>(); + +const generatedSubscriptionFactoryCache = new Map< + string, + GeneratedSubscriptionFactory +>(); + +const generatedExecutionDeps = { + compileExecution, + compileSubscription, + isObjectLike, + inspect, + pathToArray, + isPromiseLike, + printPathArray, + ensureGraphQLError, + GraphQLError, + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, + GraphQLNonNull, + locatedError, + defaultFieldResolver, + defaultTypeResolver, + compileExecutionState, + EMPTY_VARIABLE_VALUES, + isExecutionErrors, + parse, + CompiledExecutor, + cancellablePromise, + createSharedExecutionContext, + mapAsyncIterable, + collectIteratorPromises, + compileArgumentValues, + compileCollectFields, + compileFieldExecutionPlan, + compileFieldResolver, + compileVariableValues, + getCompiledVariableValues, + getCompiledArgumentValues, + CompiledExecutionRunner, + returnIteratorCatchingErrors, + isAsyncIterable, + isIterableObject, + isAbstractType, + isLeafType, + isListType, + isNonNullType, + isObjectType, + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, + validateDefaultInput, + validateInputValue, +}; + +function getGeneratedExecutionFactory( + source: string, +): GeneratedExecutionFactory { + let factory = generatedExecutionFactoryCache.get(source); + if (factory === undefined) { + const transformedSource = source + .replace(/^import[\s\S]*?;\n/gm, '') + .replace( + 'export function createCompiledExecution(args) {', + 'function createCompiledExecution(args) {', + ) + .replace('\nexport default createCompiledExecution;\n', '\n'); + const wrappedSource = `"use strict"; +const { + compileExecution, + isObjectLike, + inspect, + pathToArray, + isPromiseLike, + printPathArray, + ensureGraphQLError, + GraphQLError, + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, + GraphQLNonNull, + locatedError, + defaultFieldResolver, + defaultTypeResolver, + compileExecutionState, + EMPTY_VARIABLE_VALUES, + isExecutionErrors, + parse, + CompiledExecutor, + createSharedExecutionContext, + collectIteratorPromises, + compileArgumentValues, + compileCollectFields, + compileFieldExecutionPlan, + compileFieldResolver, + compileVariableValues, + getCompiledVariableValues, + getCompiledArgumentValues, + CompiledExecutionRunner, + returnIteratorCatchingErrors, + isAsyncIterable, + isIterableObject, + isAbstractType, + isLeafType, + isListType, + isNonNullType, + isObjectType, + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, + validateDefaultInput, + validateInputValue, +} = __deps; +${transformedSource} +return createCompiledExecution;`; + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const createFactory = new Function('__deps', wrappedSource) as ( + deps: typeof generatedExecutionDeps, + ) => GeneratedExecutionFactory; + factory = createFactory(generatedExecutionDeps); + generatedExecutionFactoryCache.set(source, factory); + } + return factory; +} + +function getGeneratedSubscriptionFactory( + source: string, +): GeneratedSubscriptionFactory { + let factory = generatedSubscriptionFactoryCache.get(source); + if (factory === undefined) { + const transformedSource = source + .replace(/^import[\s\S]*?;\n/gm, '') + .replace( + 'export function createCompiledSubscription(args) {', + 'function createCompiledSubscription(args) {', + ) + .replace('\nexport default createCompiledSubscription;\n', '\n'); + const wrappedSource = `"use strict"; +const { + compileExecution, + compileSubscription, + isObjectLike, + inspect, + pathToArray, + isPromiseLike, + printPathArray, + ensureGraphQLError, + GraphQLError, + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, + GraphQLNonNull, + locatedError, + defaultFieldResolver, + defaultTypeResolver, + compileExecutionState, + EMPTY_VARIABLE_VALUES, + isExecutionErrors, + parse, + CompiledExecutor, + cancellablePromise, + createSharedExecutionContext, + mapAsyncIterable, + collectIteratorPromises, + compileArgumentValues, + compileCollectFields, + compileFieldExecutionPlan, + compileFieldResolver, + compileVariableValues, + getCompiledVariableValues, + getCompiledArgumentValues, + CompiledExecutionRunner, + returnIteratorCatchingErrors, + isAsyncIterable, + isIterableObject, + isAbstractType, + isLeafType, + isListType, + isNonNullType, + isObjectType, + SchemaMetaFieldDef, + TypeMetaFieldDef, + TypeNameMetaFieldDef, + validateDefaultInput, + validateInputValue, +} = __deps; +${transformedSource} +return createCompiledSubscription;`; + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const createFactory = new Function('__deps', wrappedSource) as ( + deps: typeof generatedExecutionDeps, + ) => GeneratedSubscriptionFactory; + factory = createFactory(generatedExecutionDeps); + generatedSubscriptionFactoryCache.set(source, factory); + } + return factory; +} + +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 compareIncrementalExecutionResults( + results: ReadonlyArray, +): IncrementalExecutionResult { + const [firstResult] = results; + assert(firstResult !== undefined, 'Expected at least one execution result.'); + + if (!isIncrementalExecutionResult(firstResult)) { + for (const result of results) { + assert( + !isIncrementalExecutionResult(result), + 'Received an invalid mixture of execution results and incremental execution results.', + ); + } + return expectMatchingValues(results.map((result) => () => result)); + } + + const incrementalResults = results.map((result) => { + assert( + isIncrementalExecutionResult(result), + 'Received an invalid mixture of execution results and incremental execution results.', + ); + return result; + }); + expectMatchingValues( + incrementalResults.map( + (result) => () => normalizeIncrementalPayloads([result.initialResult]), + ), + ); + + const payloadsByResult: Array> = + incrementalResults.map((result) => [result.initialResult]); + return { + initialResult: firstResult.initialResult, + subsequentResults: expectMatchingAsyncIterablesConcurrently( + incrementalResults.map((result) => result.subsequentResults), + (payloadBatches) => { + for (let i = 0; i < payloadBatches.length; i++) { + const payloads = payloadsByResult[i]; + const subsequentPayloads = payloadBatches[i]; + assert(payloads !== undefined); + assert(subsequentPayloads !== undefined); + payloads.push(...subsequentPayloads); + } + expectMatchingValues( + payloadsByResult.map( + (payloads) => () => normalizeIncrementalPayloads(payloads), + ), + ); + }, + ), + }; +} + +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..c409fced8d 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, @@ -1080,6 +1149,22 @@ describe('Execute: Handles basic execution tasks', () => { }, ], }); + expectJSON( + executeSync({ + schema, + document, + operationName: 'M', + abortSignal: new AbortController().signal, + }), + ).toDeepEqual({ + data: null, + errors: [ + { + message: 'Schema is not configured to execute mutation operation.', + locations: [{ line: 3, column: 7 }], + }, + ], + }); expectJSON( executeSync({ schema, document, operationName: 'S' }), @@ -1344,6 +1429,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 +1474,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 +1646,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 +1662,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 +1698,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 +1707,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..3547e8e260 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,12 @@ import type { import type { ExecutionResult } from '../Executor.ts'; import { runAsyncWorkFinishedHook } from '../hooks.ts'; +import { + COMPARISON_COUNT, + execute, + experimentalExecuteIncrementally, +} from './executeTestUtils.ts'; + const executeHookSchema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', @@ -102,7 +107,7 @@ describe('Execute: Hooks', () => { }, }); await hooksFinished; - expect(calls).to.deep.equal(['asyncWork']); + expect(calls).to.deep.equal(expectedAsyncWorkCalls()); }); it('runs post execution hooks synchronously when no async work is tracked', () => { @@ -123,7 +128,7 @@ describe('Execute: Hooks', () => { test: 'ok', }, }); - expect(calls).to.deep.equal(['asyncWork']); + expect(calls).to.deep.equal(expectedAsyncWorkCalls()); }); it('runs post execution hooks for asynchronous execution', async () => { @@ -164,7 +169,7 @@ describe('Execute: Hooks', () => { }, }); await hooksFinished; - expect(calls).to.deep.equal(['asyncWork']); + expect(calls).to.deep.equal(expectedAsyncWorkCalls()); }); it('ignores async-work tracker wait rejection', async () => { @@ -313,7 +318,7 @@ describe('Execute: Hooks', () => { test: 'ok', }, }); - expect(calls).to.deep.equal(['asyncWork']); + expect(calls).to.deep.equal(expectedAsyncWorkCalls()); }); it('wrapper returns a promise and resolves after asyncWorkFinished for track(...) side effects', async () => { @@ -367,7 +372,7 @@ describe('Execute: Hooks', () => { test: 'ok', }, }); - expect(calls).to.deep.equal(['asyncWork']); + expect(calls).to.deep.equal(expectedAsyncWorkCalls()); }); it('runs post execution hooks for aborted execution', async () => { @@ -420,7 +425,7 @@ describe('Execute: Hooks', () => { resolveCleanup('done'); await asyncWorkFinished; - expect(calls).to.deep.equal(['asyncWork']); + expect(calls).to.deep.equal(expectedAsyncWorkCalls()); }); it('fires asyncWorkFinished after async iterator return cleanup', async () => { @@ -561,6 +566,10 @@ describe('Execute: Hooks', () => { expect(nextResult.value.hasNext).to.equal(false); await asyncWorkFinished; await asyncWorkObserved; - expect(asyncWorkFinishedSpy.callCount).to.equal(1); + expect(asyncWorkFinishedSpy.callCount).to.equal(COMPARISON_COUNT); }); }); + +function expectedAsyncWorkCalls(): ReadonlyArray { + return Array.from({ length: COMPARISON_COUNT }, () => 'asyncWork'); +} 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..1a5738b0e6 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 { COMPARISON_COUNT, 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,54 @@ describe('Execute: Accepts any iterable as list value', () => { }, ], }); - expect(nextCalls).to.equal(2); - expect(returnSpy.callCount).to.equal(0); + expect(nextCalls).to.equal(2 * COMPARISON_COUNT); + 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 +185,41 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { }, ], }); - expect(nextCalls).to.equal(3); - expect(returnSpy.callCount).to.equal(0); + expect(nextCalls).to.equal(3 * COMPARISON_COUNT); + 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 +229,8 @@ describe('Execute: Handles abrupt completion in synchronous iterables', () => { }, ], }); - expect(index).to.equal(4); - expect(returnSpy.callCount).to.equal(0); + expect(nextCalls).to.equal(4 * COMPARISON_COUNT); + expect(returnCallCount).to.equal(0); }); it('handles iterator errors with later pending promises without calling `return`', async () => { @@ -211,32 +241,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 +290,8 @@ 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); + expect(nextCalls).to.equal(4 * COMPARISON_COUNT); + expect(returnCallCount).to.equal(0); expect(unhandledRejection).to.equal(null); }); @@ -266,30 +302,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 +353,26 @@ 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); + expect(nextCalls).to.equal(4 * COMPARISON_COUNT); + 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 +577,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 +601,28 @@ 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); + expect(returnCallCount).to.equal(COMPARISON_COUNT); }); 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 +632,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..6ceebde3da 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -22,16 +22,19 @@ 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 { + COMPARISON_COUNT, 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 +142,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 +197,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 +223,7 @@ describe('Subscription Initialization Phase', () => { }); expect(() => - createSourceEventStream({ + originalCreateSourceEventStream({ schema, document: parse('subscription { foo }'), } as never), @@ -226,6 +232,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 +284,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 +454,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 +538,28 @@ describe('Subscription Initialization Phase', () => { done: true, value: undefined, }); - expect(count).to.equal(1); + expect(count).to.equal(COMPARISON_COUNT); + }); + + 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,7 @@ describe('Subscription Initialization Phase', () => { }); assert(isAsyncIterable(subscription)); - expect(fooSpy.callCount).to.equal(1); + expect(fooSpy.callCount).to.equal(COMPARISON_COUNT); expect(barSpy.callCount).to.equal(0); expect(await subscription.next()).to.have.property('done', false); @@ -531,6 +647,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 +756,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 +779,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 +790,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..36897c18ab --- /dev/null +++ b/src/execution/compile/CompiledExecutor.ts @@ -0,0 +1,3030 @@ +/** @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; + } + + 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; + }); + } + + _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; + } + + private _completeResolved(value: T, onResolve: (value: T) => void): void { + if (this._settled) { + this._pending--; + return; + } + try { + onResolve(value); + this._pending--; + this._drainIfReady(); + } catch (error) { + this._pending--; + this._fail(error); + } + } + + private _completeRejected( + reason: unknown, + onReject: (reason: unknown) => void, + ): void { + if (this._settled) { + this._pending--; + return; + } + try { + onReject(reason); + this._pending--; + this._drainIfReady(); + } catch (error) { + this._pending--; + this._fail(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..4a19ee56ef --- /dev/null +++ b/src/execution/compile/__tests__/CompiledExecutor-test.ts @@ -0,0 +1,1684 @@ +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('covers preplanned runner awaitValue edge paths', async () => { + const thenError = new Error('then failed'); + const thenRunner = createExecutionRunner(); + let rejectedReason: unknown; + thenRunner.awaitValue( + { + then() { + throw thenError; + }, + }, + () => { + throw new Error('unexpected resolve'); + }, + (reason) => { + rejectedReason = reason; + }, + undefined, + ); + expect(rejectedReason).to.equal(thenError); + expect(getRunnerPending(thenRunner)).to.equal(0); + + const { promise: settledResolvePromise, resolve: resolveSettled } = + promiseWithResolvers(); + const settledResolveRunner = createExecutionRunner(); + let resolved = false; + settledResolveRunner.awaitValue( + settledResolvePromise, + () => { + resolved = true; + }, + () => { + throw new Error('unexpected reject'); + }, + undefined, + ); + settledResolveRunner._settled = true; + resolveSettled('ignored'); + await resolveOnNextTick(); + expect(resolved).to.equal(false); + expect(getRunnerPending(settledResolveRunner)).to.equal(0); + + const { promise: settledRejectPromise, reject: rejectSettled } = + promiseWithResolvers(); + const settledRejectRunner = createExecutionRunner(); + let settledRejectedReason: unknown; + settledRejectRunner.awaitValue( + settledRejectPromise, + () => { + throw new Error('unexpected resolve'); + }, + (reason) => { + settledRejectedReason = reason; + }, + undefined, + ); + settledRejectRunner._settled = true; + rejectSettled(new Error('ignored')); + await resolveOnNextTick(); + expect(settledRejectedReason).to.equal(undefined); + expect(getRunnerPending(settledRejectRunner)).to.equal(0); + + const resolveError = new Error('resolve failed'); + const resolveRunner = createExecutionRunner(); + resolveRunner.awaitValue( + Promise.resolve('value'), + () => { + throw resolveError; + }, + () => { + throw new Error('unexpected reject'); + }, + undefined, + ); + await expectPromise(resolveRunner.runUntilNulled(undefined)).toRejectWith( + 'resolve failed', + ); + expect(resolveRunner._settled).to.equal(true); + + const rejectError = new Error('reject failed'); + const rejectRunner = createExecutionRunner(); + rejectRunner.awaitValue( + Promise.reject(new Error('source failed')), + () => { + throw new Error('unexpected resolve'); + }, + () => { + throw rejectError; + }, + undefined, + ); + await expectPromise(rejectRunner.runUntilNulled(undefined)).toRejectWith( + 'reject failed', + ); + expect(rejectRunner._settled).to.equal(true); + }); + + 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; +} + +function getRunnerPending(runner: CompiledExecutionRunner): number { + return (runner as unknown as { _pending: number })._pending; +} + +function createExecutionRunner(): CompiledExecutionRunner { + return new CompiledExecutionRunner({ + applyNulledTargets() { + return undefined; + }, + } as unknown as CompiledExecutor); +} + +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/generate/README.md b/src/execution/generate/README.md new file mode 100644 index 0000000000..b69c61e071 --- /dev/null +++ b/src/execution/generate/README.md @@ -0,0 +1,307 @@ +# Generated Execution + +This folder contains the source generator for specialized GraphQL execution. +It builds on the compiler, but it is a separate execution strategy. + +`generateExecution()` and `generateSubscription()` accept the same static +arguments as `compileExecution()` and `compileSubscription()`. They return an +ECMAScript module source string. That source can be written to disk and imported +like normal application code; the generator does not use `eval`, `new Function`, +or a runtime code loader. + +The generated module exports: + +- `createCompiledExecution(args)` for query and mutation operations; +- `createCompiledSubscription(args)` for subscription operations. + +The factory accepts runtime values that cannot be serialized into source, such +as the schema instance, resolver functions, hooks, and per-process options. The +returned object has the same execution API as the compiled operation. + +## Relationship To The Compiler + +The generator uses the compiler as its front end: + +- `compileExecutionState()` validates and selects the operation. +- compiler helpers coerce static literals and build argument plans. +- generated execution still uses `CompiledExecutor` for shared error + collection, abort state, incremental publishing, stream queues, and + null-bubbling state. + +After that handoff, the generator diverges from the in-memory compiler. Instead +of keeping generic field collectors and queue jobs, it builds an `OperationPlan` +and emits hard-coded source for the exact operation. + +The compiler is a reusable in-memory plan. The generator is an importable SDK +module specialized to one operation. + +## Architecture + +Generation has three layers: + +1. Build static operation facts from the compiler state. +2. Convert those facts into an `OperationPlan`. +3. Emit module source from the `OperationPlan`. + +The emitted module contains: + +- static document parsing and static operation/fragment references; +- a factory that binds the generated code to a runtime schema; +- a minimal validated execution args builder; +- generated variable coercion helpers when possible; +- generated argument helpers; +- one binding function per selection set and concrete parent type; +- one execution function per selection set; +- one specialized field function per non-leaf field; +- inlined leaf-field code in the parent selection-set function; +- switch dispatchers for abstract possible types; +- helper functions only for completion shapes that the operation uses. + +Factory-time binding checks that the runtime schema still matches the facts +used to generate source. If a field, resolver mode, scalar coercer, object +`isTypeOf` shape, root type, or possible type no longer matches, the factory +throws an incompatibility error instead of silently using generic execution. + +Generated result maps use `Object.create(null)` to preserve the 17.x +null-prototype result contract and avoid prototype-name hazards. + +## Optimization Paths + +### Root Execution + +If no root field can null the whole response, generated execution writes +directly into the response data object. If a root field can bubble null to the +root, it uses a small root box so null propagation can replace `data`. + +For non-incremental operations without async hooks or external abort signals, +the generated finish path builds `{ data }` or `{ errors, data }` directly. +Incremental operations use the shared executor finish path because they must +construct publisher work. + +### Field Collection + +Supported generated operations do not collect ordinary fields at runtime. The +generator pre-collects the operation into a field tree per selection set and +per concrete parent type. + +Fields with the same response name are merged at generation time only when +their field name, arguments, output kind, resolver mode, nullability, stream +plan, and child possible types match. Non-mergeable fields need runtime field +planning rather than static source emission. + +### Resolver Binding + +Each field is assigned one resolver mode: + +- `default`: no schema resolver and no custom fallback field resolver. The + generated code reads the source property directly. If it is not a function, + no argument object or `GraphQLResolveInfo` object is created. +- `field`: the schema field has a resolver. The binder captures that exact + resolver and generated code calls it directly. +- `customDefault`: the field has no resolver but a custom fallback + `fieldResolver` was provided. The binder captures that fallback. + +Promise-valued field results are supported. They do not disable generated +execution. The generated code increments the runner's pending count, installs +resolve/reject callbacks, and resumes that field's completion when the promise +settles. Synchronous values avoid that bookkeeping. + +### `GraphQLResolveInfo` + +Generated code builds `GraphQLResolveInfo` only when the current path needs it: + +- explicit field resolvers; +- custom fallback field resolvers; +- default-resolved source functions; +- abstract `resolveType`; +- object `isTypeOf`; +- stream item completion; +- subscription source execution. + +Plain default property reads do not allocate `info`. + +The generated `info` object has hard-coded field name, field nodes, return +type, parent type, path, static fragments, static operation, variable values, +and shared helper accessors. + +### Leaf Fields + +Leaf fields are inlined into the parent selection-set function. The generated +code resolves the field, handles promise/null/error cases, and completes the +leaf value without a generic field job. + +For built-in output scalars (`Boolean`, `Float`, `ID`, `Int`, and `String`), +the factory verifies that the runtime scalar still uses the built-in +`coerceOutputValue`. If it does, generated code uses primitive coercion +shortcuts before falling through to the full generated scalar helper. Custom +scalars and modified built-ins use the bound `coerceOutputValue` method. + +### Object Fields + +Concrete object fields without `isTypeOf` have the shortest object completion +path. Nullable object fields whose children cannot null the parent can create +the child data object, assign it, and call the child selection-set function +without allocating a success completion target. + +Concrete object fields with `isTypeOf` are still specialized, but generated +code must call `isTypeOf`. Promise-valued `isTypeOf` results are supported by +the runner pending path. + +### Abstract Fields + +For interfaces and unions, generation computes every possible concrete type +and emits one child selection-set function per possible object type. Runtime +execution still calls the abstract type's `resolveType` or the configured type +resolver, validates the returned type name, and dispatches through a hard-coded +switch. + +If the resolved concrete type has `isTypeOf`, generated code calls it. Promise +results from `resolveType` and `isTypeOf` are supported. + +### Lists + +Generated list completion is specialized by item shape: + +- leaf lists use a leaf-list loop and built-in scalar shortcuts when possible; +- object lists use object-list item functions and a synchronous object-item + path when the item is non-null, non-error, non-promise, and does not require + `isTypeOf`; +- abstract lists resolve the runtime type per item and dispatch to generated + concrete selection-set functions. + +Arrays are the fastest list return shape because generated code can preallocate +the result array. Other synchronous iterables and async iterables are supported +with iterator cleanup and async tracking. + +Promise-valued list items are supported through per-item pending work. + +### Arguments + +Fields with no arguments reuse a frozen empty null-prototype argument object. + +Constant serializable arguments are coerced at generation time and emitted as a +frozen null-prototype object. Bare variable arguments, such as `arg: $value`, +and embedded variable arguments, such as `arg: { id: $id }`, are inlined when +the operation's variable definitions prove the needed values are always defined +or always non-null. + +Argument shapes that cannot be serialized or inlined safely stay outside the +static source path rather than adding a generic argument evaluator to the hot +field path. + +### Variables + +Operations with no variables reuse an empty `{ sources, coerced }` pair. + +When all variables are built-in scalars, generated code first tries a cheap +provided-values path that validates primitive values without constructing +detailed errors. If that path fails, it runs the general generated path so +GraphQL-compliant variable errors are produced. + +Scalar and enum variables can use generated coercion plans. Complex variable +types such as lists and input objects currently use the compiled variable +coercion helper inside the generated module. They remain correct, but they are +not the fastest generated variable path. + +### Inclusion Directives + +Static `@skip` and `@include` values are folded at generation time. Statically +skipped selections are removed from the emitted field tree. Non-null boolean +variables can become generated condition checks. Dynamic inclusion conditions +outside those shapes need runtime selection planning. + +### Defer and Stream + +Static `@defer` and `@stream` are compiled into generated delivery groups and +stream handlers. Generated execution uses the shared `CompiledExecutor`, +`IncrementalPublisher`, `Queue`, and backpressure machinery. + +`@stream` is supported on generated list fields when `initialCount` is static +and `if` is static or expressible as the supported variable condition. Stream +on subscription operations stays on the runtime execution path. + +Nested or otherwise complex defer usage sets currently stay on the compiled +runtime incremental planner. The in-memory compiled executor supports broader +incremental shapes. + +### Subscriptions + +Generated subscriptions emit a specialized subscription-source function for +the selected root subscription field. That path binds the field's `subscribe` +function or subscribe field resolver, builds `GraphQLResolveInfo`, validates +the async iterable result, and maps source events through the generated event +execution path. + +The generated subscription object exposes the same subscription API as the +compiled subscription object. + +### Error Handling and Null Bubbling + +Generated execution uses the shared collected-error and null-target model from +`CompiledExecutor`. Field errors are recorded with a path and target. The +runner applies nulled targets when pending work drains, which lets sibling work +continue while preserving final GraphQL nullability semantics. + +Generated code emits specialized error paths for field resolver errors, scalar +coercion errors, object completion errors, list item errors, iterable failures, +abstract runtime type errors, subscription source failures, aborts, and +unexpected incremental delivery. + +## Staying On The Fastest Generated Path + +Generate once, write the source, and import it normally. Re-generating source +inside a request path defeats the point of the generator. + +Keep the runtime schema shape identical to the generated assumptions. Do not +change whether a hot field has a resolver, whether an object type has +`isTypeOf`, whether a built-in scalar has its built-in coercer, or whether an +abstract type's possible types exist. + +Use plain object properties for default-resolved hot fields. Source methods are +correct but need arguments and `GraphQLResolveInfo`. + +Return synchronous values when practical. Promises are supported and localized, +but they add pending bookkeeping and microtask work. + +Return arrays for list fields when practical. Generic iterables and async +iterables are correct but slower. + +Avoid `isTypeOf` on concrete object types unless the schema needs it. It forces +extra completion work. + +Keep built-in scalar implementations unmodified to use primitive scalar +shortcuts. Custom scalars are correct but call their coercer. + +Use static directive values or non-null boolean variables for hot `@skip`, +`@include`, `@defer`, and `@stream` paths. + +Prefer built-in scalar, scalar, or enum variables on hot generated operations. +Complex variable input types currently use the compiled variable coercion +helper. + +Avoid fragment arguments and fragment variable definitions in generated hot +operations for now. The compiler supports more of that generic machinery than +the source generator. + +## Dynamic Boundaries + +The generator returns GraphQL errors instead of source when a static source file +would need runtime planning decisions. These are dynamic boundaries, not +invalid execution semantics. Known static-generation boundaries include: + +- no root type for the operation; +- requesting subscription generation for a non-subscription operation; +- fragment spreads with arguments; +- fragment definitions with variable definitions; +- dynamic inclusion directives that cannot be reduced to static values or + non-null variable checks; +- dynamic `@defer` arguments; +- nested or complex defer usage sets; +- dynamic `@stream` arguments outside the supported static/variable shape; +- `@stream` on subscription operations; +- `@stream` on non-list fields; +- argument coercion shapes that cannot be serialized or inlined safely; +- non-mergeable fields with the same response name. + +For these shapes, use `compileExecution()` or regular execution so those +runtime decisions happen in the executor that already handles them. diff --git a/src/execution/generate/__tests__/generate-test.ts b/src/execution/generate/__tests__/generate-test.ts new file mode 100644 index 0000000000..cbe5d51d59 --- /dev/null +++ b/src/execution/generate/__tests__/generate-test.ts @@ -0,0 +1,2720 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import url from 'node:url'; + +import { assert, expect } from 'chai'; + +import { expectJSON } from '../../../__testUtils__/expectJSON.ts'; + +import { parse } from '../../../language/parser.ts'; + +import { + assertObjectType, + assertScalarType, + GraphQLInputObjectType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLString, +} from '../../../type/index.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import type { + CompiledExecution, + CompiledSubscription, +} from '../../compile/index.ts'; +import { compileExecution, compileSubscription } from '../../compile/index.ts'; + +import { generateExecution, generateSubscription } from '../index.ts'; + +describe('generateExecution', () => { + it('generates a compiled execution module with matching behavior', async () => { + const schema = buildSchema(` + type Query { + greeting(name: String!): String + count: Int + } + `); + const document = parse(` + query GeneratedExecution($name: String!, $skipCount: Boolean!) { + greeting(name: $name) + count @skip(if: $skipCount) + } + `); + const fieldResolver = (_source: unknown, args: { name?: string }) => + args.name ?? 3; + + const compiled = compileExecution({ schema, document, fieldResolver }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ + schema, + document, + fieldResolver, + }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ + schema, + fieldResolver, + }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { + variableValues: { name: 'Ada', skipCount: false }, + }; + expectJSON( + await Promise.resolve(generated.execute(runtimeArgs)), + ).toDeepEqual(await Promise.resolve(compiled.execute(runtimeArgs))); + }); + + it('reports operations outside the static generation boundary', () => { + const schema = buildSchema(` + type Query { + greeting: String + } + `); + const document = parse( + ` + { + ...Greeting(enabled: true) + } + + fragment Greeting($enabled: Boolean!) on Query { + greeting @include(if: $enabled) + } + `, + { experimentalFragmentArguments: true }, + ); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(generatedSource).to.have.lengthOf(1); + expect(generatedSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + }); + + it('generates source for documents without location data', () => { + const schema = buildSchema(` + type Query { + value: String + } + `); + const generatedSource = generateExecution({ + schema, + document: parse('{ value }', { noLocation: true }), + }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).to.contain('noLocation: true'); + }); + + it('reports generation validation errors and incompatible operations', () => { + const schema = buildSchema(` + type Query { + value: String + } + `); + const executionErrors = generateExecution({ + schema, + document: parse('query One { value } query Two { value }'), + }); + expect(executionErrors).to.be.an('array'); + + const subscriptionErrors = generateSubscription({ + schema, + document: parse('query One { value } query Two { value }'), + }); + expect(subscriptionErrors).to.be.an('array'); + + const querySubscriptionSource = generateSubscription({ + schema, + document: parse('query GeneratedExecution { value }'), + }); + expect(querySubscriptionSource).to.be.an('array'); + assert(Array.isArray(querySubscriptionSource)); + expect(querySubscriptionSource[0]?.message).to.equal( + 'Expected subscription operation.', + ); + + const missingRootSource = generateExecution({ + schema, + document: parse('mutation GeneratedExecution { value }'), + }); + expect(missingRootSource).to.be.an('array'); + assert(Array.isArray(missingRootSource)); + expect(missingRootSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + }); + + it('reports dynamic generated planning cases', () => { + const schema = buildSchema(` + input FilterInput { + required: Int! + known: Int + } + + type Query { + a(arg: Int): String + b: String + required(value: Int!): String + input(value: FilterInput): String + int(value: Int): String + item: Item + node: Node + items: [String] + value: String + values(value: [Int]): String + } + + type Item { + child: Item + id: ID + } + + interface Node { + id: ID + } + + type User implements Node { + id: ID + child: Item + } + `); + const cases = [ + '{ a @include }', + '{ a @include(if: 1) }', + '{ a @include(if: null) }', + 'query($show: Boolean) { a @include(if: $show) }', + 'query($defer: Boolean!) { ... @defer(if: $defer) { a } }', + '{ ... @defer(if: null) { a } }', + '{ ... @defer { ... @defer { a } } }', + '{ a ... @defer { a } }', + '{ item { id ... @defer { id } } }', + 'query($defer: Boolean!) { ...Frag @defer(if: $defer) } fragment Frag on Query { a }', + 'query($label: String!) { ... @defer(label: $label) { a } }', + '{ values @stream(if: null) }', + '{ values @stream(if: 1) }', + '{ values @stream(if: 1.5) }', + '{ values @stream(if: "bad") }', + 'query($count: Int!) { values @stream(initialCount: $count) }', + 'query($label: String!) { values @stream(label: $label) }', + '{ value @stream(initialCount: 0) }', + '{ value { id } }', + 'query($value: Int!) { int(value: [$value]) }', + 'query($value: Int!) { values(value: [[$value]]) }', + 'query($value: Int!) { input(value: { required: $value, unknown: $value }) }', + 'query($value: Int!) { input(value: { known: $value }) }', + 'query($value: Int!) { input(value: { required: $value, known: RED }) }', + 'query($value: Int!) { input(value: { required: $value, known: { value: $value } }) }', + '{ required }', + '{ item @stream(initialCount: 0) { id } }', + '{ items { id } }', + '{ same: a same: b }', + '{ same: a(arg: 1) same: a(arg: 2) }', + 'query($show: Boolean) { ...Frag @include(if: $show) } fragment Frag on Query { a }', + '{ ...Frag } fragment Frag on Query { same: a same: b }', + '{ ... on Query { same: a same: b } }', + 'query { ...Frag } fragment Frag($show: Boolean!) on Query { a }', + '{ node { ... on User { id { missing } } } }', + '{ item { same: id } item { same: child { id } } }', + '{ node { ... on User { same: id } } node { ... on User { same: child { id } } } }', + '{ same: node { ... on User { id } } same: item { id } }', + ]; + + for (const source of cases) { + const generatedSource = generateExecution({ + schema, + document: parse(source, { experimentalFragmentArguments: true }), + }); + if (typeof generatedSource === 'string') { + throw new Error(`Expected generation to fail for: ${source}`); + } + expect(generatedSource).to.have.lengthOf(1); + expect(generatedSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + } + + const missingFragmentSource = generateExecution({ + schema, + document: parse('{ ...Missing }'), + }); + expect(missingFragmentSource).to.be.a('string'); + }); + + it('covers generated explicit nulls in dynamic input objects', async () => { + const schema = buildSchema(` + input FilterInput { + required: Int! + known: Int + } + + type Query { + input(value: FilterInput): String! + } + `); + const document = parse(` + query GeneratedExecution($value: Int!) { + input(value: { required: $value, known: null }) + } + `); + const rootValue = { + input({ value }: { value: { required: number; known: null } }) { + expect(Object.getPrototypeOf(value)).to.equal(null); + return `${value.required}:${String(value.known)}`; + }, + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: { value: 7 }, + }); + }); + + it('covers generated mixed dynamic input literals', async () => { + const schema = buildSchema(` + input NestedInput { + value: Int + } + + input FilterInput { + nested: NestedInput + known: Int + values: [Int] + } + + type Query { + input(value: FilterInput): String! + } + `); + const document = parse(` + query GeneratedExecution($value: Int!) { + input( + value: { + known: $value + nested: { value: $value } + values: [1, $value, null] + } + ) + } + `); + const rootValue = { + input({ value }: { value: { values: ReadonlyArray } }) { + expect(Object.getPrototypeOf(value)).to.equal(null); + expect(value.values).to.deep.equal([1, 7, null]); + return 'ok'; + }, + }; + + const nonStaticSource = generateExecution({ + schema, + document: parse(` + query GeneratedExecution($value: Int!) { + input(value: { known: "invalid", nested: { value: $value } }) + } + `), + }); + if (typeof nonStaticSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(nonStaticSource).to.have.lengthOf(1); + expect(nonStaticSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + + const wrongShapeSource = generateExecution({ + schema, + document: parse(` + query GeneratedExecution($value: Int!) { + input(value: { nested: 1, values: [$value] }) + } + `), + }); + if (typeof wrongShapeSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(wrongShapeSource).to.have.lengthOf(1); + expect(wrongShapeSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: { value: 7 }, + }); + }); + + it('covers generated combined inclusion conditions', async () => { + const schema = buildSchema(` + type Query { + value: String + } + `); + const document = parse(` + query GeneratedExecution($outer: Boolean!, $inner: Boolean!) { + ...ValueFragment @include(if: $outer) + } + + fragment ValueFragment on Query { + value @include(if: $inner) + } + `); + const rootValue = { value: 'included' }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: { inner: true, outer: true }, + }); + }); + + it('covers generated disabled inclusion and defer directives', async () => { + const schema = buildSchema(` + type Query { + value: String + } + `); + const document = parse(` + { + skipped: value @include(if: false) + ...SkippedFragment @skip(if: true) + ... @defer(if: false) { + included: value + } + } + + fragment SkippedFragment on Query { + fragmentValue: value + } + `); + const rootValue = { value: 'included' }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('covers generated invalid nullable variables for non-null arguments', async () => { + const schema = buildSchema(` + type Query { + check(id: ID!): String + } + `); + const document = parse(` + query GeneratedExecution($id: ID) { + check(id: $id) + } + `); + const rootValue = { + check() { + return 'unexpected'; + }, + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: {}, + }); + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: { id: null }, + }); + }); + + it('covers generated variable argument default values', async () => { + const schema = buildSchema(` + type Query { + requiredWithDefault(value: String! = "required-default"): String! + optionalWithDefault(value: String = "optional-default"): String! + } + `); + const document = parse(` + query GeneratedExecution($value: String) { + requiredWithDefault(value: $value) + optionalWithDefault(value: $value) + } + `); + const rootValue = { + requiredWithDefault(args: { value: string }) { + expect(Object.getPrototypeOf(args)).to.equal(null); + return args.value; + }, + optionalWithDefault(args: { value: string | null }) { + expect(Object.getPrototypeOf(args)).to.equal(null); + return String(args.value); + }, + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('covers serializable and non-serializable variable defaults', () => { + function sourceForDefault(defaultLiteral: string, defaultValue: unknown) { + const schema = buildSchema(` + scalar DefaultScalar + + type Query { + echo(value: DefaultScalar): String + } + `); + const scalar = assertScalarType(schema.getType('DefaultScalar')); + scalar.coerceInputLiteral = () => defaultValue; + const generatedSource = generateExecution({ + schema, + document: parse(` + query GeneratedExecution($value: DefaultScalar = ${defaultLiteral}) { + echo(value: $value) + } + `), + }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + return generatedSource; + } + + function sourceForThrowingDefault() { + const schema = buildSchema(` + scalar DefaultScalar + + type Query { + echo(value: DefaultScalar): String + } + `); + const scalar = assertScalarType(schema.getType('DefaultScalar')); + scalar.coerceInputLiteral = () => { + throw new Error('Bad default.'); + }; + const generatedSource = generateExecution({ + schema, + document: parse(` + query GeneratedExecution($value: DefaultScalar = "throw") { + echo(value: $value) + } + `), + }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + return generatedSource; + } + + expect(sourceForDefault('"array"', [1, { two: 2 }])).to.be.a('string'); + expect(sourceForDefault('"object"', { 'a-b': 1 })).to.be.a('string'); + expect(sourceForThrowingDefault()).to.contain('getCompiledVariableValues'); + + const customPrototype = Object.create({ inherited: true }) as { + value?: string; + }; + customPrototype.value = 'custom'; + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + + for (const [literal, value] of [ + ['"custom"', customPrototype], + ['"cycle"', cyclic], + ['"undefined"', { value: undefined }], + ['"undefinedArray"', [undefined]], + ['"bigint"', 1n], + ] as const) { + expect(sourceForDefault(literal, value)).to.contain( + 'getCompiledVariableValues', + ); + } + }); + + it('covers generated planner fallbacks for variable and argument defaults', () => { + const throwingDefaultScalar = new GraphQLScalarType({ + name: 'ThrowingDefaultScalar', + coerceInputValue: (value) => value, + coerceInputLiteral() { + throw new Error('Bad default.'); + }, + }); + const throwingDefaultSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + echo: { + type: GraphQLString, + args: { value: { type: throwingDefaultScalar } }, + }, + }, + }), + }); + const throwingVariableDefaultSource = generateExecution({ + schema: throwingDefaultSchema, + document: parse(` + query GeneratedExecution($value: ThrowingDefaultScalar = "throw") { + echo(value: $value) + } + `), + }); + if (typeof throwingVariableDefaultSource !== 'string') { + throw throwingVariableDefaultSource[0]; + } + expect(throwingVariableDefaultSource).to.contain( + 'getCompiledVariableValues', + ); + + function expectNonStaticDefaultArgument( + argType: GraphQLScalarType | GraphQLNonNull, + defaultValue: unknown, + ) { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + echo: { + type: GraphQLString, + args: { + value: { + type: argType, + defaultValue, + }, + }, + }, + }, + }), + }); + const generatedSource = generateExecution({ + schema, + document: parse(` + query GeneratedExecution($value: CustomDefaultScalar) { + echo(value: $value) + } + `), + }); + if (typeof generatedSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(generatedSource).to.have.lengthOf(1); + expect(generatedSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + } + + const customDefaultScalar = new GraphQLScalarType({ + name: 'CustomDefaultScalar', + coerceInputValue: (value) => value, + coerceInputLiteral: () => undefined, + }); + const customPrototype = Object.create({ inherited: true }) as { + value?: string; + }; + customPrototype.value = 'custom'; + expectNonStaticDefaultArgument( + new GraphQLNonNull(customDefaultScalar), + customPrototype, + ); + expectNonStaticDefaultArgument(customDefaultScalar, customPrototype); + + const inputWithDefault = new GraphQLInputObjectType({ + name: 'InputWithDefault', + fields: { + known: { type: GraphQLString }, + omitted: { + type: customDefaultScalar, + defaultValue: customPrototype, + }, + }, + }); + const inputDefaultSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + input: { + type: GraphQLString, + args: { value: { type: inputWithDefault } }, + }, + }, + }), + }); + const inputDefaultSource = generateExecution({ + schema: inputDefaultSchema, + document: parse(` + query GeneratedExecution($known: String!) { + input(value: { known: $known }) + } + `), + }); + if (typeof inputDefaultSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(inputDefaultSource).to.have.lengthOf(1); + expect(inputDefaultSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + }); + + it('covers serializable and non-serializable constant argument values', () => { + const serializableSource = generateExecution({ + schema: buildSchema(` + type Query { + echo(values: [Int]): String + } + `), + document: parse('{ echo(values: [1, 2]) }'), + }); + if (typeof serializableSource !== 'string') { + throw serializableSource[0]; + } + + function sourceForArgument(defaultValue: unknown) { + const schema = buildSchema(` + scalar ArgumentScalar + + type Query { + echo(value: ArgumentScalar): String + } + `); + const scalar = assertScalarType(schema.getType('ArgumentScalar')); + scalar.coerceInputLiteral = () => defaultValue; + return generateExecution({ + schema, + document: parse('{ echo(value: "literal") }'), + }); + } + + const customPrototype = Object.create({ inherited: true }) as { + value?: string; + }; + customPrototype.value = 'custom'; + const nonStaticSource = sourceForArgument(customPrototype); + if (typeof nonStaticSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(nonStaticSource).to.have.lengthOf(1); + expect(nonStaticSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + }); + + it('covers generated partial constant argument objects', async () => { + const schema = buildSchema(` + type Query { + check(first: Int, second: Int): Boolean! + } + `); + const document = parse('{ check(first: 1) }'); + const rootValue = { + check(args: { first?: number; second?: number }) { + expect(Object.getPrototypeOf(args)).to.equal(null); + expect(Object.hasOwn(args, 'first')).to.equal(true); + expect(Object.hasOwn(args, 'second')).to.equal(false); + return args.first === 1 && args.second === undefined; + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).to.contain('Object.freeze((() =>'); + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('reports located errors from specialized root execution', async () => { + const schema = buildSchema(` + type Query { + value(input: Int): Int + } + `); + const document = parse('{ value(input: 1) }'); + const rootValue = { + value() { + throw new Error('broken'); + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const result = await Promise.resolve(generated.execute({ rootValue })); + expect(result.data).to.deep.equal({ value: null }); + expect(result.errors).to.have.lengthOf(1); + expect(result.errors?.[0]?.message).to.equal('broken'); + expect(result.errors?.[0]?.path).to.deep.equal(['value']); + expect(result.errors?.[0]?.locations).to.deep.equal([ + { line: 1, column: 3 }, + ]); + }); + + it('generates specialized nested object selection sets', async () => { + const schema = buildSchema(` + type Query { + user: User + } + + type User { + tags: [String] + } + `); + const document = parse(` + { + user { + ...UserFields + ... on User { + name + } + } + } + + fragment UserFields on User { + id + } + `); + const rootValue = { user: { id: null, name: 'Ada' } }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + expectJSON( + await Promise.resolve(generated.execute({ rootValue })), + ).toDeepEqual(await Promise.resolve(compiled.execute({ rootValue }))); + }); + + it('generates specialized conditional fields with merged field nodes', async () => { + const schema = buildSchema(` + type Query { + item: Item! + } + + type Item { + value: Int + other: Int + } + `); + const document = parse(` + query GeneratedExecution($flag: Boolean!) { + item { + ...ItemFields + ... on Item { + value @include(if: $flag) + other @skip(if: $flag) + } + } + } + + fragment ItemFields on Item { + value @include(if: $flag) + other @skip(if: $flag) + } + `); + const item = { + value( + _args: unknown, + _contextValue: unknown, + info: { fieldNodes: ReadonlyArray }, + ) { + return info.fieldNodes.length; + }, + other( + _args: unknown, + _contextValue: unknown, + info: { fieldNodes: ReadonlyArray }, + ) { + return info.fieldNodes.length; + }, + }; + const rootValue = { item }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const results = await Promise.all( + [true, false].map(async (flag) => { + const runtimeArgs = { + rootValue, + variableValues: { flag }, + }; + return { + compiledResult: await Promise.resolve(compiled.execute(runtimeArgs)), + generatedResult: await Promise.resolve( + generated.execute(runtimeArgs), + ), + }; + }), + ); + + for (const { compiledResult, generatedResult } of results) { + expectJSON(generatedResult).toDeepEqual(compiledResult); + } + }); + + it('keeps skipped Object prototype response names absent', async () => { + const schema = buildSchema(` + type Query { + item: Item! + } + + type Item { + value: String + } + `); + const document = parse(` + query GeneratedExecution($include: Boolean!) { + item { + value + toString: value @include(if: $include) + } + } + `); + const rootValue = { item: { value: 'Ada' } }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const skippedResult = await Promise.resolve( + generated.execute({ + rootValue, + variableValues: { include: false }, + }), + ); + expectJSON(skippedResult).toDeepEqual( + await Promise.resolve( + compiled.execute({ + rootValue, + variableValues: { include: false }, + }), + ), + ); + const skippedItem = ( + skippedResult.data as { item: { [key: string]: unknown } } + ).item; + expect(Object.getPrototypeOf(skippedItem)).to.equal(null); + expect(Object.hasOwn(skippedItem, 'toString')).to.equal(false); + expect(skippedItem.toString).to.equal(undefined); + + const includedResult = await Promise.resolve( + generated.execute({ + rootValue, + variableValues: { include: true }, + }), + ); + const includedItem = ( + includedResult.data as { item: { [key: string]: unknown } } + ).item; + expect(Object.getPrototypeOf(includedItem)).to.equal(null); + expect(Object.hasOwn(includedItem, 'toString')).to.equal(true); + expect(includedItem.toString).to.equal('Ada'); + }); + + it('keeps generated response maps null-prototype for ordinary field names', async () => { + const schema = buildSchema(` + type Query { + item: Item + items: [Item] + } + + type Item { + id: ID + name: String + nested: Nested + } + + type Nested { + label: String + } + `); + const document = parse(` + { + item { + id + name + nested { + label + } + } + items { + id + name + nested { + label + } + } + } + `); + const rootValue = { + item: { id: '1', name: 'Ada', nested: { label: 'primary' } }, + items: [{ id: '2', name: 'Grace', nested: { label: 'secondary' } }], + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('keeps generated response maps null-prototype', async () => { + const schema = buildSchema(` + type Item { + label: String + child: Item + } + + type Query { + value: String + slow: String + fast: String + item: Item + items: [Item] + } + `); + const document = parse(` + { + __proto__: value + slow + fast + item { + label + child { + label + } + } + items { + label + } + } + `); + const rootValue = { + value: 'own proto', + slow: Promise.resolve('slow'), + fast: 'fast', + item: { label: 'item', child: { label: 'child' } }, + items: [{ label: 'list item' }], + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const result = await Promise.resolve(generated.execute({ rootValue })); + const data = result.data as { [key: string]: unknown }; + expect(Object.getPrototypeOf(data)).to.equal(null); + expect(Object.hasOwn(data, '__proto__')).to.equal(true); + expect(Object.getOwnPropertyDescriptor(data, '__proto__')?.value).to.equal( + 'own proto', + ); + expect(Object.keys(data)).to.deep.equal([ + '__proto__', + 'slow', + 'fast', + 'item', + 'items', + ]); + const item = data.item as { child: object }; + expect(Object.getPrototypeOf(item)).to.equal(null); + expect(Object.getPrototypeOf(item.child)).to.equal(null); + const items = data.items as ReadonlyArray; + expect(Object.getPrototypeOf(items[0])).to.equal(null); + }); + + it('keeps generated argument maps null-prototype for Object prototype names', async () => { + const schema = buildSchema(` + type Query { + check(toString: ID): Boolean! + } + `); + const document = parse(` + query GeneratedExecution($id: ID!) { + omitted: check + constant: check(toString: "constant") + runtime: check(toString: $id) + } + `); + const rootValue = { + check(args: { [key: string]: unknown }) { + return ( + Object.getPrototypeOf(args) === null && + (Object.hasOwn(args, 'toString') + ? typeof args.toString === 'string' + : args.toString === undefined) + ); + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).to.contain('Object.create(null)'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue, + variableValues: { id: 'runtime' }, + }), + ), + ).toDeepEqual({ + data: { + omitted: true, + constant: true, + runtime: true, + }, + }); + }); + + it('keeps generated argument maps null-prototype for ordinary argument names', async () => { + const schema = buildSchema(` + input NestedInput { + value: String + } + + input CheckInput { + label: String + nested: NestedInput + } + + type Query { + check(id: ID!, count: Int!, input: CheckInput): Boolean! + } + `); + const document = parse(` + query GeneratedExecution($id: ID!, $count: Int!, $input: CheckInput) { + constant: check( + id: "constant" + count: 1 + input: { label: "constant", nested: { value: "nested" } } + ) + runtime: check(id: $id, count: $count, input: $input) + } + `); + const rootValue = { + check(args: { input?: { nested?: object } }) { + return ( + Object.getPrototypeOf(args) === null && + (args.input === undefined || + (Object.getPrototypeOf(args.input) === null && + (args.input.nested === undefined || + Object.getPrototypeOf(args.input.nested) === null))) + ); + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue, + variableValues: { + count: 2, + id: 'runtime', + input: { label: 'runtime', nested: { value: 'nested' } }, + }, + }), + ), + ).toDeepEqual({ + data: { + constant: true, + runtime: true, + }, + }); + }); + + it('keeps empty generated argument maps null-prototype', async () => { + const schema = buildSchema(` + type Query { + check: Boolean! + } + `); + const document = parse('{ check }'); + const rootValue = { + check(args: object) { + return Object.getPrototypeOf(args) === null; + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + expectJSON( + await Promise.resolve(generated.execute({ rootValue })), + ).toDeepEqual({ + data: { + check: true, + }, + }); + }); + + it('keeps generated variable maps null-prototype', async () => { + const schema = buildSchema(` + type Query { + check(value: String): Boolean! + } + `); + const document = parse(` + query GeneratedExecution($value: String = "default") { + check(value: $value) + } + `); + const rootValue = { + check( + _args: unknown, + _context: unknown, + info: { variableValues: { coerced: object; sources: object } }, + ) { + return ( + Object.getPrototypeOf(info.variableValues.coerced) === null && + Object.getPrototypeOf(info.variableValues.sources) === null + ); + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue, + variableValues: { value: 'runtime' }, + }), + ), + ).toDeepEqual({ + data: { + check: true, + }, + }); + expectJSON( + await Promise.resolve(generated.execute({ rootValue })), + ).toDeepEqual({ + data: { + check: true, + }, + }); + }); + + it('generates specialized object list completion', async () => { + const schema = buildSchema(` + type Query { + users: [User!]! + } + + type User { + id: ID! + name: String + } + `); + const document = parse(` + { + users { + tags + } + } + `); + const users = [{ tags: ['math', 'language'] }, { tags: ['compiler'] }]; + const rootValue = { users: new Set(users) }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue }; + expectJSON( + await Promise.resolve(generated.execute(runtimeArgs)), + ).toDeepEqual(await Promise.resolve(compiled.execute(runtimeArgs))); + }); + + it('covers generated empty child selection sets', async () => { + const schema = buildSchema(` + interface Node { + id: ID! + } + + type Item { + id: ID! + } + + type User implements Node { + id: ID! + } + + type Query { + item: Item + requiredItem: Item! + node: Node + items: [Item] + nodes: [Node] + } + `); + const document = parse(` + { + item { + id @skip(if: true) + } + requiredItem { + id @skip(if: true) + } + node { + ... on User { + id @skip(if: true) + } + } + items { + id @skip(if: true) + } + nodes { + ... on User { + id @skip(if: true) + } + } + } + `); + const rootValue = { + item: { id: '1' }, + requiredItem: { id: '2' }, + node: { __typename: 'User', id: '3' }, + items: [{ id: '4' }], + nodes: [{ __typename: 'User', id: '5' }], + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('reports generated object list item child errors at the item path', async () => { + const schema = buildSchema(` + type Query { + items: [Item] + } + + type Item { + id: ID! + } + `); + const document = parse('{ items { id } }'); + const rootValue = { + items: [ + { + get id() { + throw new Error('bad id'); + }, + }, + ], + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('generates specialized serial mutation execution', async () => { + const schema = buildSchema(` + type Query { + noop: Int + } + + type Mutation { + first: Int + second: Int + } + `); + const document = parse(` + mutation { + first + second + } + `); + const order: Array = []; + const rootValue = { + async first() { + order.push('first:start'); + await Promise.resolve(); + order.push('first:end'); + return 1; + }, + second() { + order.push('second'); + return 2; + }, + }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const generatedResult = await Promise.resolve( + generated.execute({ rootValue }), + ); + expectJSON(generatedResult).toDeepEqual( + await Promise.resolve(compiled.execute({ rootValue })), + ); + expect(order).to.deep.equal([ + 'first:start', + 'first:end', + 'second', + 'first:start', + 'first:end', + 'second', + ]); + }); + + it('generates specialized abstract object completion', async () => { + const schema = buildSchema(` + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String + } + + type Post implements Node { + id: ID! + title: String + } + + type Profile { + name: String + } + + type Query { + node: Node + results: [Node!]! + profile: Profile + profiles: [Profile!]! + } + `); + const userType = assertObjectType(schema.getType('User')); + const postType = assertObjectType(schema.getType('Post')); + const profileType = assertObjectType(schema.getType('Profile')); + userType.isTypeOf = (value) => + Promise.resolve( + typeof value === 'object' && + value !== null && + '__typename' in value && + value.__typename === 'User', + ); + postType.isTypeOf = (value) => + Promise.resolve( + typeof value === 'object' && + value !== null && + '__typename' in value && + value.__typename === 'Post', + ); + profileType.isTypeOf = (value) => + Promise.resolve( + typeof value === 'object' && value !== null && 'name' in value, + ); + const document = parse(` + { + node { + __typename + id + ... on User { + name + } + ... on Post { + title + } + } + results { + __typename + id + } + profile { + name + } + profiles { + name + } + } + `); + const rootValue = { + node: { __typename: 'User', id: '1', name: 'Ada' }, + results: [ + { __typename: 'User', id: '1', name: 'Ada' }, + { __typename: 'Post', id: '2', title: 'Notes' }, + ], + profile: { name: 'Ada' }, + profiles: [{ name: 'Ada' }, { name: 'Grace' }], + }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue }; + expectJSON( + await Promise.resolve(generated.execute(runtimeArgs)), + ).toDeepEqual(await Promise.resolve(compiled.execute(runtimeArgs))); + }); + + it('keeps custom scalar variables on the planned variable path', async () => { + const schema = buildSchema(` + scalar Odd + + type Query { + echo(value: Odd!): Odd! + } + `); + const oddType = assertScalarType(schema.getType('Odd')); + oddType.coerceInputValue = (value) => { + if (typeof value !== 'number' || value % 2 === 0) { + throw new Error('Expected odd integer.'); + } + return value; + }; + oddType.coerceOutputValue = oddType.coerceInputValue; + const document = parse(` + query GeneratedExecution($value: Odd!) { + echo(value: $value) + } + `); + const rootValue = { + echo({ value }: { value: number }) { + return value; + }, + }; + + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue, variableValues: { value: 7 } }; + expectJSON( + await Promise.resolve(generated.execute(runtimeArgs)), + ).toDeepEqual(await Promise.resolve(compiled.execute(runtimeArgs))); + }); + + it('covers built-in scalar variables on the planned variable path', async () => { + const schema = buildSchema(` + type Query { + requiredArgs( + bool: Boolean! + float: Float! + id: ID! + int: Int! + string: String! + ): String! + optionalArg(id: ID): String! + } + `); + const document = parse(` + query GeneratedExecution( + $bool: Boolean! + $float: Float! + $id: ID! + $int: Int! + $string: String! + $optionalId: ID + ) { + requiredArgs( + bool: $bool + float: $float + id: $id + int: $int + string: $string + ) + optionalArg(id: $optionalId) + } + `); + const rootValue = { + requiredArgs(args: { + bool: boolean; + float: number; + id: string; + int: number; + string: string; + }) { + expect(Object.getPrototypeOf(args)).to.equal(null); + return [ + String(args.bool), + String(args.float), + args.id, + String(args.int), + args.string, + ].join(':'); + }, + optionalArg(args: { id?: string | null }) { + expect(Object.getPrototypeOf(args)).to.equal(null); + return String(args.id); + }, + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: { + bool: true, + float: 1.25, + id: 123, + int: 7, + optionalId: null, + string: 'value', + }, + }); + }); + + it('covers generated scalar and list completion variants', async () => { + const schema = buildSchema(` + scalar Odd + + type Builtins { + bool: Boolean! + float: Float! + id: ID! + int: Int! + string: String! + } + + type Thing { + id: ID! + value: String + } + + type Query { + builtins: Builtins! + odd: Odd! + ints: [Int]! + requiredInts: [Int!] + things: [Thing]! + asyncThings: [Thing!]! + } + `); + const oddType = assertScalarType(schema.getType('Odd')); + oddType.coerceOutputValue = (value) => { + if (typeof value !== 'number' || value % 2 === 0) { + throw new Error('Expected odd integer.'); + } + return value; + }; + const document = parse(` + { + builtins { + bool + float + id + int + string + } + odd + ints + requiredInts + things { + id + value + } + asyncThings { + id + value + } + } + `); + const rootValue = { + builtins: { + bool: true, + float: 1.5, + id: 123, + int: 7, + string: 'value', + }, + odd: 9, + ints: new Set([1, null, 3]), + requiredInts: [1, null, 3], + things: [{ id: '1', value: 'one' }, null, { id: '3', value: 'three' }], + asyncThings() { + return asyncThings(); + }, + }; + async function* asyncThings() { + yield { id: 'a', value: 'A' }; + yield Promise.resolve({ id: 'b', value: 'B' }); + } + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('covers generated abstract completion without isTypeOf', async () => { + const schema = buildSchema(` + interface Result { + id: ID! + } + + type Book implements Result { + id: ID! + title: String + } + + type Movie implements Result { + id: ID! + title: String + } + + type Query { + result: Result + results: [Result!]! + } + `); + const document = parse(` + { + result { + __typename + id + ... on Book { + title + } + ... on Movie { + title + } + } + results { + __typename + id + ... on Book { + title + } + ... on Movie { + title + } + } + } + `); + const rootValue = { + result: { __typename: 'Book', id: '1', title: 'Compiler Notes' }, + results: [ + { __typename: 'Book', id: '1', title: 'Compiler Notes' }, + { __typename: 'Movie', id: '2', title: 'Runtime' }, + ], + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('covers generated abstract field plan merging', async () => { + const schema = buildSchema(` + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String + } + + type Query { + node: Node + } + `); + const document = parse(` + { + node { + ... on User { + id + } + } + node { + ... on User { + name + } + } + } + `); + const rootValue = { + node: { + __typename: 'User', + id: '1', + name: 'Ada', + }, + }; + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + }); + }); + + it('covers generated introspection meta fields', async () => { + const schema = buildSchema(` + type Query { + value: String + } + `); + const document = parse(` + { + __schema { + queryType { + name + } + } + __type(name: "Query") { + name + } + } + `); + + await expectGeneratedExecutionMatchesCompiled({ schema, document }); + }); + + it('covers generated complex input values and compiled variable values', async () => { + const schema = buildSchema(` + input NestedInput { + limit: Int = 3 + marker: String = "default-marker" + } + + input FilterInput { + tags: [String!]! + nested: NestedInput = {} + alias: String = "default-alias" + } + + type Query { + search(filter: FilterInput!): String! + literal(filter: FilterInput!): String! + } + `); + const document = parse(` + query GeneratedExecution($filter: FilterInput!, $tag: String! = "literal") { + search(filter: $filter) + literal(filter: { tags: [$tag], nested: { limit: 2 } }) + } + `); + const rootValue = { + search({ filter }: { filter: FilterInputValue }) { + return formatFilter(filter); + }, + literal(args: { filter: FilterInputValue }) { + const { filter } = args; + return [ + Object.getPrototypeOf(args) === null, + Object.getPrototypeOf(filter) === null, + Object.getPrototypeOf(filter.nested) === null, + formatFilter(filter), + ].join(':'); + }, + }; + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('compileVariableValues'); + + await expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues: { + filter: { + tags: ['runtime'], + nested: { marker: 'runtime-marker' }, + }, + }, + }); + }); + + it('covers generated static defer execution groups', async () => { + const schema = buildSchema(` + type Hero { + id: ID! + name: String + } + + type Query { + hero: Hero + } + `); + const document = parse(` + { + hero { + id + ...HeroName @defer(if: true, label: "HeroName") + } + } + + fragment HeroName on Hero { + name + } + `); + const rootValue = { + hero: { + id: '1', + name: 'Ada', + }, + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('deferPreplannedExecutionGroup'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally({ rootValue }), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally({ rootValue }), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); + + it('covers generated static defer below abstract selections', async () => { + const schema = buildSchema(` + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + } + + type Query { + node: Node + } + `); + const document = parse(` + { + node { + ... on User { + ... @defer(label: "UserId") { + id + } + } + } + } + `); + const rootValue = { + node: { + __typename: 'User', + id: '1', + }, + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally({ rootValue }), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally({ rootValue }), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); + + it('covers generated stream item completion', async () => { + const schema = buildSchema(` + type Item { + id: ID! + label: String + } + + type Query { + items: [Item!]! + } + `); + const document = parse(` + { + items @stream(initialCount: 1, label: "lateItems") { + id + label + } + } + `); + const rootValue = { + items: [ + { id: '1', label: 'one' }, + Promise.resolve({ id: '2', label: 'two' }), + { id: '3', label: 'three' }, + ], + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('handleStream'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue }; + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally(runtimeArgs), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally(runtimeArgs), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); + + it('covers generated nullable object stream completion', async () => { + const schema = buildSchema(` + type Item { + id: ID! + label: String + } + + type Query { + syncItems: [Item] + asyncItems: [Item] + } + `); + const document = parse(` + { + syncItems @stream(initialCount: 1, label: "syncNullableItems") { + id + label + } + asyncItems @stream(initialCount: 1, label: "asyncNullableItems") { + id + label + } + } + `); + const rootValue = { + syncItems: [ + { id: '1', label: 'one' }, + Promise.resolve({ id: '2', label: 'two' }), + null, + ], + async asyncItems() { + await Promise.resolve(); + return (async function* asyncItems() { + await Promise.resolve(); + yield { id: '3', label: 'three' }; + yield { id: '4', label: 'four' }; + })(); + }, + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('readAsyncListInitial'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue }; + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally(runtimeArgs), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally(runtimeArgs), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); + + it('covers generated leaf stream item completion', async () => { + const schema = buildSchema(` + type Query { + values: [String]! + } + `); + const document = parse(` + { + values @stream(initialCount: 1, label: "lateValues") + } + `); + const rootValue = { + values: ['one', Promise.resolve('two'), 'three'], + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('StreamItem'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue }; + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally(runtimeArgs), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally(runtimeArgs), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); + + it('covers generated conditional stream directives', async () => { + const schema = buildSchema(` + type Query { + values: [String]! + } + `); + const document = parse(` + query GeneratedExecution($enabled: Boolean!) { + disabled: values @stream(if: false, initialCount: 1) + conditional: values @stream( + if: $enabled + initialCount: 1 + label: "conditionalValues" + ) + } + `); + const rootValue = { + values: ['one', 'two', 'three'], + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('coerced["enabled"] !== false'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue, variableValues: { enabled: true } }; + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally(runtimeArgs), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally(runtimeArgs), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); + + it('covers generated async stream completion for abstract lists', async () => { + const schema = buildSchema(` + interface Result { + id: ID! + } + + type Book implements Result { + id: ID! + title: String + } + + type Movie implements Result { + id: ID! + title: String + } + + type Query { + results: [Result!]! + } + `); + const document = parse(` + { + results @stream(initialCount: 1, label: "lateResults") { + __typename + id + ... on Book { + title + } + ... on Movie { + title + } + } + } + `); + const rootValue = { + async *results() { + yield { __typename: 'Book', id: '1', title: 'Compiler Notes' }; + yield Promise.resolve({ + __typename: 'Movie', + id: '2', + title: 'Runtime', + }); + }, + }; + + const compiled = compileExecution({ schema, document }); + assert('experimentalExecuteIncrementally' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + expect(generatedSource).to.contain('readAsyncListInitial'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('experimentalExecuteIncrementally' in generated)) { + throw generated[0]; + } + + const compiledResult = await Promise.resolve( + compiled.experimentalExecuteIncrementally({ rootValue }), + ); + const generatedResult = await Promise.resolve( + generated.experimentalExecuteIncrementally({ rootValue }), + ); + assert('initialResult' in compiledResult); + assert('initialResult' in generatedResult); + expectJSON(generatedResult.initialResult).toDeepEqual( + compiledResult.initialResult, + ); + expectResponseDataMapsNullPrototype(generatedResult.initialResult.data); + + const generatedSubsequentResults = await collectAsyncIterable( + generatedResult.subsequentResults, + ); + expectJSON(generatedSubsequentResults).toDeepEqual( + await collectAsyncIterable(compiledResult.subsequentResults), + ); + for (const subsequentResult of generatedSubsequentResults) { + expectIncrementalDataMapsNullPrototype(subsequentResult); + } + }); +}); + +interface FilterInputValue { + tags: ReadonlyArray; + nested: { + limit: number; + marker: string; + }; + alias: string; +} + +describe('generateSubscription', () => { + it('generates a specialized compiled subscription module', async () => { + const schema = buildSchema(` + type Query { + noop: String + } + + type Subscription { + message(prefix: String!): String + } + `); + const subscriptionType = schema.getSubscriptionType(); + assert(subscriptionType != null); + subscriptionType.getFields().message.subscribe = + async function* generatedMessageEvents( + _source: unknown, + args: { prefix: string }, + ) { + await Promise.resolve(); + yield { message: `${args.prefix}:one` }; + }; + const document = parse(` + subscription GeneratedSubscription($enabled: Boolean!, $prefix: String!) { + message(prefix: $prefix) @include(if: $enabled) + } + `); + + const compiled = compileSubscription({ schema, document }); + assert('subscribe' in compiled); + + const generatedSource = generateSubscription({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).to.contain('if (coerced["enabled"] === true)'); + + const unconditionalSource = generateSubscription({ + schema, + document: parse(` + subscription GeneratedSubscription($prefix: String!) { + message(prefix: $prefix) + } + `), + }); + if (typeof unconditionalSource !== 'string') { + throw unconditionalSource[0]; + } + + const generatedModule = + await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledSubscription({ schema }); + if (!('subscribe' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { + variableValues: { enabled: true, prefix: 'event' }, + }; + const compiledFirst = await firstSubscriptionResult( + compiled.subscribe(runtimeArgs), + ); + const generatedFirst = await firstSubscriptionResult( + generated.subscribe(runtimeArgs), + ); + expectJSON(generatedFirst).toDeepEqual(compiledFirst); + expectResponseDataMapsNullPrototype( + (generatedFirst as { data?: unknown }).data, + ); + }); + + it('reports subscriptions outside the static generation boundary', () => { + const schema = buildSchema(` + type Query { + noop: String + } + + type Subscription { + message: String + } + `); + const document = parse( + ` + subscription { + ...Message(enabled: true) + } + + fragment Message($enabled: Boolean!) on Subscription { + message @include(if: $enabled) + } + `, + { experimentalFragmentArguments: true }, + ); + + const generatedSource = generateSubscription({ schema, document }); + if (typeof generatedSource === 'string') { + throw new Error('Expected generation to fail.'); + } + expect(generatedSource).to.have.lengthOf(1); + expect(generatedSource[0]?.message).to.equal( + 'Operation cannot be fully represented as static generated source.', + ); + }); +}); + +interface GeneratedExecutionModule { + createCompiledExecution: ( + args: unknown, + ) => ReadonlyArray | CompiledExecution; +} + +interface GeneratedSubscriptionModule { + createCompiledSubscription: ( + args: unknown, + ) => ReadonlyArray | CompiledSubscription; +} + +async function expectGeneratedExecutionMatchesCompiled({ + schema, + document, + rootValue, + variableValues, +}: { + schema: ReturnType; + document: ReturnType; + rootValue?: unknown; + variableValues?: { readonly [key: string]: unknown }; +}): Promise { + const compiled = compileExecution({ schema, document }); + assert('execute' in compiled); + + const generatedSource = generateExecution({ schema, document }); + if (typeof generatedSource !== 'string') { + throw generatedSource[0]; + } + expect(generatedSource).not.to.contain('return compileExecution'); + + const generatedModule = await importGeneratedModule(generatedSource); + const generated = generatedModule.createCompiledExecution({ schema }); + if (!('execute' in generated)) { + throw generated[0]; + } + + const runtimeArgs = { rootValue, variableValues }; + const generatedResult = await Promise.resolve(generated.execute(runtimeArgs)); + expectJSON(generatedResult).toDeepEqual( + await Promise.resolve(compiled.execute(runtimeArgs)), + ); + expectResponseDataMapsNullPrototype(generatedResult.data); +} + +function expectResponseDataMapsNullPrototype(value: unknown): void { + if (value == null) { + return; + } + + if (Array.isArray(value)) { + for (const item of value) { + expectResponseDataMapsNullPrototype(item); + } + return; + } + + if (typeof value !== 'object') { + return; + } + + expect(Object.getPrototypeOf(value)).to.equal(null); + for (const nestedValue of Object.values(value)) { + expectResponseDataMapsNullPrototype(nestedValue); + } +} + +function expectIncrementalDataMapsNullPrototype(value: unknown): void { + if (value == null || typeof value !== 'object') { + return; + } + + const result = value as { + incremental?: ReadonlyArray<{ + data?: unknown; + items?: ReadonlyArray; + }>; + }; + for (const incremental of result.incremental ?? []) { + expectResponseDataMapsNullPrototype(incremental.data); + expectResponseDataMapsNullPrototype(incremental.items); + } +} + +function formatFilter(filter: FilterInputValue): string { + return [ + filter.tags.join(','), + filter.nested.limit, + filter.nested.marker, + filter.alias, + ].join(':'); +} + +async function collectAsyncIterable( + iterable: AsyncIterable, +): Promise> { + const values = []; + for await (const value of iterable) { + values.push(value); + } + return values; +} + +async function importGeneratedModule( + source: string, +): Promise { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-js-generated-'), + ); + const tmpFile = path.join(tmpDir, 'generated.mjs'); + const srcRootURL = new URL('../../../', import.meta.url); + const localSource = source + .replaceAll("from 'graphql/", `from '${srcRootURL.href}`) + .replaceAll(".js';", ".ts';"); + + fs.writeFileSync(tmpFile, localSource); + try { + return (await import(url.pathToFileURL(tmpFile).href)) as T; + } finally { + fs.rmSync(tmpDir, { force: true, recursive: true }); + } +} + +async function firstSubscriptionResult( + result: ReturnType, +): Promise { + const stream = await Promise.resolve(result); + assert(Symbol.asyncIterator in Object(stream)); + const iterator = stream as AsyncGenerator; + const next = await iterator.next(); + await iterator.return?.(); + return next.value; +} diff --git a/src/execution/generate/__tests__/generated-coverage-test.ts b/src/execution/generate/__tests__/generated-coverage-test.ts new file mode 100644 index 0000000000..68d7c90d71 --- /dev/null +++ b/src/execution/generate/__tests__/generated-coverage-test.ts @@ -0,0 +1,547 @@ +import path from 'node:path'; +import { before, describe, it } from 'node:test'; +import { setImmediate } from 'node:timers/promises'; +import { pathToFileURL } from 'node:url'; + +import { assert, expect } from 'chai'; + +import { expectJSON } from '../../../__testUtils__/expectJSON.ts'; + +import { writeGeneratedExecutionCoverageFixtures } from '../../../../resources/generate-execution-coverage-fixtures.ts'; + +import type { CompiledExecution } from '../../compile/index.ts'; +import type { ExecutionResult } from '../../Executor.ts'; + +import type { RootStringCoverageSchemaMode } from './generatedCoverageFixtures.ts'; +import { createRootStringCoverageSchema } from './generatedCoverageFixtures.ts'; + +interface GeneratedExecutionModule { + createCompiledExecution: (args: unknown) => CompiledExecution; +} + +let generatedCoverageFixtureDir: string; + +describe('generated execution coverage fixtures', () => { + before(() => { + generatedCoverageFixtureDir = writeGeneratedExecutionCoverageFixtures(); + }); + + it('generates coverage fixture modules', () => { + expect(generatedCoverageFixtureDir).to.be.a('string'); + }); + + it('exercises root execution runtime compatibility branches', async () => { + await expectRootStringCreateFailure({ schemaMode: 'noQuery' }); + await expectRootStringCreateFailure({ schemaMode: 'renamedRoot' }); + await expectRootStringCreateFailure({ schemaMode: 'missingField' }); + await expectRootStringCreateFailure({ schemaMode: 'customResolver' }); + await expectRootStringCreateFailure({ schemaMode: 'customString' }); + await expectRootStringCreateFailure({ + args: { + fieldResolver: () => undefined, + }, + }); + }); + + it('exercises root execution entrypoints and validation arg variants', async () => { + const generated = await createRootStringCoverageExecution(); + expectJSON(await Promise.resolve(generated.execute())).toDeepEqual({ + data: { value: null }, + }); + expectJSON( + await Promise.resolve( + generated.experimentalExecuteIncrementally({ + rootValue: { value: 'incremental' }, + }), + ), + ).toDeepEqual({ data: { value: 'incremental' } }); + expectJSON( + await Promise.resolve( + generated.executeIgnoringIncremental({ + rootValue: { value: 'ignore' }, + variableValues: { unused: true }, + }), + ), + ).toDeepEqual({ data: { value: 'ignore' } }); + + const hookCalls: Array = []; + const hooked = await createRootStringCoverageExecution({ + hooks: { + asyncWorkFinished() { + hookCalls.push('asyncWorkFinished'); + }, + }, + hideSuggestions: true, + enableBatchResolvers: true, + }); + expectJSON( + await Promise.resolve( + hooked.execute({ + rootValue: { value: 'hooked' }, + }), + ), + ).toDeepEqual({ data: { value: 'hooked' } }); + + expectJSON( + await Promise.resolve( + hooked.execute({ + rootValue: { + value() { + return Promise.resolve('hooked async'); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: 'hooked async' } }); + await setImmediate(); + expect(hookCalls.join(',')).to.equal('asyncWorkFinished,asyncWorkFinished'); + }); + + it('exercises root execution scalar fast paths', async () => { + const generated = await createRootStringCoverageExecution(); + const cases: ReadonlyArray<{ + expected: string | null; + value: unknown; + }> = [ + { value: 'text', expected: 'text' }, + { value: false, expected: 'false' }, + { value: true, expected: 'true' }, + { value: 3, expected: '3' }, + { value: 4n, expected: '4' }, + { value: null, expected: null }, + { value: undefined, expected: null }, + ]; + + const results = await Promise.all( + cases.map(async (testCase) => ({ + result: await executeRootStringValue(generated, testCase.value), + testCase, + })), + ); + + for (const { result, testCase } of results) { + expectJSON(result).toDeepEqual({ data: { value: testCase.expected } }); + } + }); + + it('exercises root execution scalar completion branches', async () => { + const generated = await createRootStringCoverageExecution(); + const cases: ReadonlyArray<{ + expected?: string; + expectedData?: unknown; + message?: string; + value: unknown; + }> = [ + { value: { valueOf: () => 'object-string' }, expected: 'object-string' }, + { value: { valueOf: () => true }, expected: 'true' }, + { value: { valueOf: () => false }, expected: 'false' }, + { value: { valueOf: () => 5 }, expected: '5' }, + { value: { valueOf: () => 6n }, expected: '6' }, + { + value: { valueOf: () => Object.create(null), toJSON: () => 'json' }, + expected: 'json', + }, + { + value: { valueOf: () => Object.create(null), toJSON: () => true }, + expected: 'true', + }, + { + value: { valueOf: () => Object.create(null), toJSON: () => false }, + expected: 'false', + }, + { + value: { valueOf: () => Object.create(null), toJSON: () => 7 }, + expected: '7', + }, + { + value: { valueOf: () => Object.create(null), toJSON: () => 8n }, + expected: '8', + }, + { + value: { + get then() { + throw new Error('then lookup failed'); + }, + }, + message: 'then lookup failed', + expectedData: null, + }, + { + value: Number.NaN, + message: 'String cannot represent value: NaN', + }, + { + value: { valueOf: () => Object.create(null) }, + message: + 'String cannot represent value: { valueOf: [function valueOf] }', + }, + ]; + + const results = await Promise.all( + cases.map(async (testCase) => ({ + result: await executeRootStringValue(generated, testCase.value), + testCase, + })), + ); + + for (const { result, testCase } of results) { + if (testCase.message === undefined) { + expectJSON(result).toDeepEqual({ + data: { value: testCase.expected }, + }); + } else { + expect(result.errors?.[0]?.message).to.equal(testCase.message); + expectJSON(result.data).toDeepEqual( + Object.hasOwn(testCase, 'expectedData') + ? testCase.expectedData + : { value: null }, + ); + } + } + }); + + it('exercises root execution resolver function branches', async () => { + const generated = await createRootStringCoverageExecution(); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value( + _args: unknown, + contextValue: unknown, + info: { fieldName: string }, + ) { + expect(contextValue).to.deep.equal({ marker: 'context' }); + return `${info.fieldName}:sync`; + }, + }, + contextValue: { marker: 'context' }, + }), + ), + ).toDeepEqual({ data: { value: 'value:sync' } }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve('async'); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: 'async' } }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve(true); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: 'true' } }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve(false); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: 'false' } }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve(9); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: '9' } }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve(10n); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: '10' } }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve(null); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: null } }); + + const resolvedError = await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve(new Error('async error value')); + }, + }, + }), + ); + expect(resolvedError.errors?.[0]?.message).to.equal('async error value'); + expectJSON(resolvedError.data).toDeepEqual({ value: null }); + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve({ valueOf: () => 'async object' }); + }, + }, + }), + ), + ).toDeepEqual({ data: { value: 'async object' } }); + + const asyncCoercionError = await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve({ valueOf: () => Object.create(null) }); + }, + }, + }), + ); + expect(asyncCoercionError.errors?.[0]?.message).to.equal( + 'String cannot represent value: { valueOf: [function valueOf] }', + ); + expectJSON(asyncCoercionError.data).toDeepEqual({ value: null }); + + const rejected = await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.reject(new Error('async failed')); + }, + }, + }), + ); + expect(rejected.errors?.[0]?.message).to.equal('async failed'); + expectJSON(rejected.data).toDeepEqual({ value: null }); + + const thrown = await Promise.resolve( + generated.execute({ + rootValue: { + value() { + throw new Error('sync failed'); + }, + }, + }), + ); + expect(thrown.errors?.[0]?.message).to.equal('sync failed'); + expectJSON(thrown.data).toDeepEqual({ value: null }); + + const errorValue = await Promise.resolve( + generated.execute({ + rootValue: { value: new Error('error value') }, + }), + ); + expect(errorValue.errors?.[0]?.message).to.equal('error value'); + expectJSON(errorValue.data).toDeepEqual({ value: null }); + }); + + it('exercises root execution abort and async lifecycle branches', async () => { + const generated = await createRootStringCoverageExecution(); + const calls: Array = []; + let abortListener: (() => void) | undefined; + const passiveAbortSignal = { + throwIfAborted() { + calls.push('throwIfAborted'); + }, + addEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + abortListener = listener; + calls.push('addEventListener'); + }, + removeEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + expect(listener).to.equal(abortListener); + calls.push('removeEventListener'); + }, + } as unknown as AbortSignal; + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value() { + return Promise.resolve('signal'); + }, + }, + abortSignal: passiveAbortSignal, + }), + ), + ).toDeepEqual({ data: { value: 'signal' } }); + expect(calls).to.deep.equal([ + 'throwIfAborted', + 'addEventListener', + 'removeEventListener', + ]); + + const abortReason = new Error('external abort'); + const immediateAbortSignal = { + reason: abortReason, + throwIfAborted() { + calls.push('immediateThrowIfAborted'); + }, + addEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + calls.push('immediateAddEventListener'); + listener(); + }, + removeEventListener(type: string) { + expect(type).to.equal('abort'); + calls.push('immediateRemoveEventListener'); + }, + } as unknown as AbortSignal; + + try { + await Promise.resolve( + generated.execute({ + rootValue: { value: 'aborted' }, + abortSignal: immediateAbortSignal, + }), + ); + throw new Error('Expected generated execution to abort.'); + } catch (error) { + expect(error).to.have.property('message', 'external abort'); + } + + const alreadyAbortedSignal = { + throwIfAborted() { + throw new Error('already aborted'); + }, + } as unknown as AbortSignal; + try { + await Promise.resolve( + generated.execute({ + rootValue: { value: 'not reached' }, + abortSignal: alreadyAbortedSignal, + }), + ); + throw new Error('Expected generated execution to stop before running.'); + } catch (error) { + expect(error).to.have.property('message', 'already aborted'); + } + + expectJSON( + await Promise.resolve( + generated.execute({ + rootValue: { + value( + _args: unknown, + _contextValue: unknown, + info: { getAbortSignal: () => AbortSignal | undefined }, + ) { + info.getAbortSignal(); + return 'resolver signal'; + }, + }, + }), + ), + ).toDeepEqual({ data: { value: 'resolver signal' } }); + + const catchingAbortSignal = { + throwIfAborted() { + calls.push('catchingThrowIfAborted'); + }, + addEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + abortListener = listener; + calls.push('catchingAddEventListener'); + }, + removeEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + expect(listener).to.equal(abortListener); + calls.push('catchingRemoveEventListener'); + }, + } as AbortSignal; + const caught = await Promise.resolve( + generated.execute({ + rootValue: { + value: { + get then() { + throw new Error('catch with listener'); + }, + }, + }, + abortSignal: catchingAbortSignal, + }), + ); + expect(caught.errors?.[0]?.message).to.equal('catch with listener'); + expect(caught.data).to.equal(null); + + await setImmediate(); + }); +}); + +async function createRootStringCoverageExecution( + args: unknown = {}, +): Promise { + const module = await importGeneratedCoverageFixture('root-string.mjs'); + return module.createCompiledExecution({ + schema: createRootStringCoverageSchema(), + ...assertObject(args), + }); +} + +async function expectRootStringCreateFailure(args: { + args?: unknown; + schemaMode?: RootStringCoverageSchemaMode; +}): Promise { + const module = await importGeneratedCoverageFixture('root-string.mjs'); + expect(() => + module.createCompiledExecution({ + schema: createRootStringCoverageSchema(args.schemaMode), + ...assertObject(args.args ?? {}), + }), + ).to.throw('Generated execution is incompatible'); +} + +async function executeRootStringValue( + generated: CompiledExecution, + value: unknown, +): Promise { + return Promise.resolve( + generated.execute({ + rootValue: { value }, + }), + ); +} + +async function importGeneratedCoverageFixture( + filename: string, +): Promise { + return import( + pathToFileURL(path.join(generatedCoverageFixtureDir, filename)).href + ); +} + +function assertObject(value: unknown): { [key: string]: unknown } { + assert(value !== null && typeof value === 'object'); + return value as { [key: string]: unknown }; +} diff --git a/src/execution/generate/__tests__/generated-fixtures-test.ts b/src/execution/generate/__tests__/generated-fixtures-test.ts new file mode 100644 index 0000000000..dd01f38431 --- /dev/null +++ b/src/execution/generate/__tests__/generated-fixtures-test.ts @@ -0,0 +1,1165 @@ +import path from 'node:path'; +import { before, describe, it } from 'node:test'; +import { setImmediate } from 'node:timers/promises'; +import { pathToFileURL } from 'node:url'; + +import { assert, expect } from 'chai'; + +import { expectJSON } from '../../../__testUtils__/expectJSON.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +import { writeGeneratedExecutionFixtures } from '../../../../resources/generate-execution-fixtures.ts'; + +import type { + CompiledExecution, + CompiledSubscription, +} from '../../compile/index.ts'; +import { validateSubscriptionArgs } from '../../execute.ts'; + +import type { + QueryFixtureContext, + SubscriptionFixtureContext, +} from './generatedFixtureSchemas.ts'; +import { + createKitchenSinkFixtureSchema, + createQueryFixtureRootValue, + subscriptionFixtureDocument, +} from './generatedFixtureSchemas.ts'; + +interface GeneratedExecutionModule { + createCompiledExecution: ( + args: unknown, + ) => ReadonlyArray | CompiledExecution; +} + +interface GeneratedSubscriptionModule { + createCompiledSubscription: ( + args: unknown, + ) => ReadonlyArray | CompiledSubscription; +} + +let generatedFixtureDir: string; + +describe('generated execution kitchen sink fixtures', () => { + before(() => { + generatedFixtureDir = writeGeneratedExecutionFixtures(); + }); + + it('generates kitchen sink execution fixture modules', () => { + expect(generatedFixtureDir).to.be.a('string'); + }); + + it('executes the query fixture through synchronous specialized paths', async () => { + const generated = await createQueryFixtureExecution(); + const result = await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + contextValue: { + count: { valueOf: () => 8 }, + ratio: { valueOf: () => 2.5 }, + }, + }), + ); + + expectJSON(result).toDeepEqual({ + data: { + typename: 'Query', + node: { + __typename: 'Item', + id: 'q1', + label: 'item:q1', + count: 8, + ratio: 2.5, + active: true, + tags: ['generated', 'q1'], + computed: 'computed:q1', + maybeError: 'ok', + optional: 'computed:q1', + child: { name: 'child:q1' }, + }, + item: { + id: 'q1', + label: 'item:q1:mark:4', + count: 8, + tags: ['generated', 'q1'], + }, + defaultItem: { + id: 'root', + label: 'item:root', + tags: ['generated', 'root'], + child: { name: 'child:root' }, + }, + scalarBox: { + bool: true, + float: 3.5, + id: '123', + int: 6, + odd: 9, + string: '42', + }, + list: [ + { id: 'a', label: 'item:a' }, + { id: 'b', label: 'item:b' }, + ], + }, + }); + expectNullPrototypeData(result.data); + }); + + it('executes the query fixture through async and error paths', async () => { + const generated = await createQueryFixtureExecution(); + const result = await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + contextValue: { + asyncLeaf: true, + asyncRoot: true, + asyncType: true, + throwMaybe: true, + }, + }), + ); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'fixture field error', + locations: [{ line: 20, column: 9 }], + path: ['node', 'maybeError'], + }, + ], + data: { + typename: 'Query', + node: { + __typename: 'Item', + id: 'q1', + label: 'item:q1', + count: 7, + ratio: 1.25, + active: true, + tags: ['generated', 'q1'], + computed: 'computed:q1', + optional: 'computed:q1', + child: { name: 'child:q1' }, + maybeError: null, + }, + item: { + id: 'q1', + label: 'item:q1:mark:4', + count: 7, + tags: ['generated', 'q1'], + }, + defaultItem: { + id: 'root', + label: 'item:root', + tags: ['generated', 'root'], + child: { name: 'child:root' }, + }, + scalarBox: { + bool: true, + float: 3.5, + id: '123', + int: 6, + odd: 9, + string: '42', + }, + list: [ + { id: 'a', label: 'item:a' }, + { id: 'b', label: 'item:b' }, + ], + }, + }); + expectNullPrototypeData(result.data); + }); + + it('reports generated query fixture variable and coercion errors', async () => { + const generated = await createQueryFixtureExecution(); + + const invalidVariableResult = await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: { + ...queryVariables(), + id: null, + }, + }), + ); + expect(invalidVariableResult.data).to.equal(undefined); + expect(invalidVariableResult.errors).to.have.lengthOf(1); + + const invalidScalarResult = await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + contextValue: { odd: 10 }, + }), + ); + expectJSON(invalidScalarResult).toDeepEqual({ + errors: [ + { + message: 'Expected odd integer.', + locations: [{ line: 46, column: 7 }], + path: ['scalarBox', 'odd'], + }, + ], + data: { + typename: 'Query', + node: { + __typename: 'Item', + id: 'q1', + label: 'item:q1', + count: 7, + ratio: 1.25, + active: true, + tags: ['generated', 'q1'], + computed: 'computed:q1', + maybeError: 'ok', + optional: 'computed:q1', + child: { name: 'child:q1' }, + }, + item: { + id: 'q1', + label: 'item:q1:mark:4', + count: 7, + tags: ['generated', 'q1'], + }, + defaultItem: { + id: 'root', + label: 'item:root', + tags: ['generated', 'root'], + child: { name: 'child:root' }, + }, + scalarBox: { + bool: true, + float: 3.5, + id: '123', + int: 6, + string: '42', + odd: null, + }, + list: [ + { id: 'a', label: 'item:a' }, + { id: 'b', label: 'item:b' }, + ], + }, + }); + }); + + it('executes generated query variable defaults and entrypoint validation branches', async () => { + const generated = await createQueryFixtureExecution(); + const defaultedResult = await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: { + id: 'q1', + includeOptional: false, + skipMaybe: true, + }, + }), + ); + const defaultedData = getResultData(defaultedResult); + expectJSON(defaultedData.item).toDeepEqual({ + id: 'q1', + label: 'item:q1:none:3', + count: 7, + tags: ['generated', 'q1'], + }); + assertObject(defaultedData.node); + expect(defaultedData.node).not.to.have.property('maybeError'); + expect(defaultedData.node).not.to.have.property('optional'); + + const invalidVariables = { + ...queryVariables(), + id: null, + }; + const incrementalResult = await Promise.resolve( + generated.experimentalExecuteIncrementally({ + rootValue: createQueryFixtureRootValue(), + variableValues: invalidVariables, + }), + ); + assert(!('initialResult' in incrementalResult)); + expect(incrementalResult.errors).to.have.lengthOf(1); + + const ignoringIncrementalResult = await Promise.resolve( + generated.executeIgnoringIncremental({ + rootValue: createQueryFixtureRootValue(), + variableValues: invalidVariables, + }), + ); + assert(!('initialResult' in ignoringIncrementalResult)); + expect(ignoringIncrementalResult.errors).to.have.lengthOf(1); + + const maxErrorsResult = await Promise.resolve( + generated.execute({ + variableValues: {}, + options: { maxCoercionErrors: 1 }, + }), + ); + expect(maxErrorsResult.errors).to.have.lengthOf(2); + expect(maxErrorsResult.errors?.[1]?.message).to.equal( + 'Too many errors processing variables, error limit reached. Execution aborted.', + ); + }); + + it('executes generated query default resolver scalar variants', async () => { + const result = await executeQueryFixture({ + contextValue: { + active: 0, + child: { name: true }, + count: { valueOf: () => true }, + id: 12, + label: false, + ratio: '2.5', + tags: [true, 3], + }, + }); + const data = getResultData(result); + assertObject(data.node); + + expectJSON(data.node).toDeepEqual({ + __typename: 'Item', + id: '12', + label: 'false', + count: 1, + ratio: 2.5, + active: false, + tags: ['true', '3'], + computed: 'computed:q1', + maybeError: 'ok', + optional: 'computed:q1', + child: { name: 'true' }, + }); + }); + + it('coerces generated built-in scalar object variants', async () => { + const result = await executeQueryFixture({ + contextValue: { + bool: { valueOf: () => 0 }, + float: { toJSON: () => '4.25' }, + int: { valueOf: () => '8' }, + scalarId: { valueOf: () => 456 }, + string: { toJSON: () => 99 }, + }, + }); + + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: false, + float: 4.25, + id: '456', + int: 8, + odd: 9, + string: '99', + }); + }); + + it('coerces generated built-in scalar object boolean and bigint variants', async () => { + const result = await executeQueryFixture({ + contextValue: { + bool: { valueOf: () => 1n }, + float: { valueOf: () => true }, + int: { valueOf: () => false }, + scalarId: { toJSON: () => 456n }, + string: { valueOf: () => true }, + }, + }); + + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: true, + float: 1, + id: '456', + int: 0, + odd: 9, + string: 'true', + }); + }); + + it('coerces generated built-in scalar object string and bigint fallbacks', async () => { + const result = await executeQueryFixture({ + contextValue: { + bool: { toJSON: () => false }, + float: { toJSON: () => 8n }, + int: { toJSON: () => 8n }, + scalarId: { valueOf: () => 'custom-id' }, + string: { toJSON: () => 99n }, + }, + }); + + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: false, + float: 8, + id: 'custom-id', + int: 8, + odd: 9, + string: '99', + }); + }); + + it('coerces generated built-in scalar bigint variants', async () => { + const result = await executeQueryFixture({ + contextValue: { + bool: 1n, + float: 8n, + int: 8n, + scalarId: 456n, + string: 99n, + }, + }); + + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: true, + float: 8, + id: '456', + int: 8, + odd: 9, + string: '99', + }); + }); + + it('completes generated async built-in scalar fields', async () => { + const result = await executeQueryFixture({ + contextValue: { + bool: Promise.resolve(0), + float: Promise.resolve('4.25'), + int: Promise.resolve('8'), + odd: Promise.resolve(9), + scalarId: Promise.resolve(456n), + string: Promise.resolve(false), + }, + }); + + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: false, + float: 4.25, + id: '456', + int: 8, + odd: 9, + string: 'false', + }); + }); + + it('reports generated async scalar rejection errors', async () => { + const result = await executeQueryFixture({ + contextValue: { + string: Promise.reject(new Error('string failed')), + }, + }); + + expect(result.errors).to.have.lengthOf(1); + expect(result.errors?.[0]?.message).to.equal('string failed'); + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: true, + float: 3.5, + id: '123', + int: 6, + odd: 9, + string: null, + }); + }); + + it('reports generated built-in scalar object coercion errors', async () => { + const result = await executeQueryFixture({ + contextValue: { int: Object.create(null) as unknown }, + }); + + expect(result.errors).to.have.lengthOf(1); + expect(result.errors?.[0]?.message).to.equal( + 'Int cannot represent non-integer value: {}', + ); + expectJSON(getResultData(result).scalarBox).toDeepEqual({ + bool: true, + float: 3.5, + id: '123', + int: null, + odd: 9, + string: '42', + }); + }); + + it('reports generated built-in scalar primitive coercion errors', async () => { + const cases: ReadonlyArray<{ + contextValue: QueryFixtureContext; + fieldName: keyof NonNullable>; + message?: string; + messageIncludes?: string; + }> = [ + { + contextValue: { bool: Number.NaN }, + fieldName: 'bool', + message: 'Boolean cannot represent a non boolean value: NaN', + }, + { + contextValue: { bool: Object.create(null) as unknown }, + fieldName: 'bool', + message: 'Boolean cannot represent a non boolean value: {}', + }, + { + contextValue: { float: Number.NaN }, + fieldName: 'float', + message: 'Float cannot represent non numeric value: NaN', + }, + { + contextValue: { float: Object.create(null) as unknown }, + fieldName: 'float', + message: 'Float cannot represent non numeric value: {}', + }, + { + contextValue: { float: '' }, + fieldName: 'float', + message: 'Float cannot represent non numeric value: ""', + }, + { + contextValue: { float: 'abc' }, + fieldName: 'float', + message: 'Float cannot represent non numeric value: "abc"', + }, + { + contextValue: { float: 2n ** 1024n }, + fieldName: 'float', + messageIncludes: '(value is too large)', + }, + { + contextValue: { float: 9007199254740993n }, + fieldName: 'float', + messageIncludes: '(value would lose precision)', + }, + { + contextValue: { scalarId: Object.create(null) as unknown }, + fieldName: 'id', + message: 'ID cannot represent value: {}', + }, + { + contextValue: { scalarId: 1.5 }, + fieldName: 'id', + message: 'ID cannot represent value: 1.5', + }, + { + contextValue: { int: 1.5 }, + fieldName: 'int', + message: 'Int cannot represent non-integer value: 1.5', + }, + { + contextValue: { int: 2147483648 }, + fieldName: 'int', + message: + 'Int cannot represent non 32-bit signed integer value: 2147483648', + }, + { + contextValue: { int: '' }, + fieldName: 'int', + message: 'Int cannot represent non-integer value: ""', + }, + { + contextValue: { int: '1.5' }, + fieldName: 'int', + message: 'Int cannot represent non-integer value: "1.5"', + }, + { + contextValue: { int: '2147483648' }, + fieldName: 'int', + message: + 'Int cannot represent non 32-bit signed integer value: "2147483648"', + }, + { + contextValue: { int: 2147483648n }, + fieldName: 'int', + message: + 'Int cannot represent non 32-bit signed integer value: 2147483648', + }, + { + contextValue: { string: Object.create(null) as unknown }, + fieldName: 'string', + message: 'String cannot represent value: {}', + }, + { + contextValue: { string: Number.NaN }, + fieldName: 'string', + message: 'String cannot represent value: NaN', + }, + ]; + + const results = await Promise.all( + cases.map(async (testCase) => ({ + result: await executeQueryFixture({ + contextValue: testCase.contextValue, + }), + testCase, + })), + ); + + for (const { result, testCase } of results) { + expect(result.errors, testCase.fieldName).to.have.lengthOf(1); + if (testCase.message !== undefined) { + expect(result.errors?.[0]?.message).to.equal(testCase.message); + } + if (testCase.messageIncludes !== undefined) { + expect(result.errors?.[0]?.message).to.include( + testCase.messageIncludes, + ); + } + expect(scalarBoxFixture(result)[testCase.fieldName]).to.equal(null); + } + }); + + it('exposes the generated query fixture through every execution entrypoint', async () => { + const generated = await createQueryFixtureExecution(); + const executeIgnoringIncrementalResult = await Promise.resolve( + generated.executeIgnoringIncremental({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + }), + ); + const incrementalResult = await Promise.resolve( + generated.experimentalExecuteIncrementally({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + }), + ); + + expectJSON(executeIgnoringIncrementalResult).toDeepEqual( + await executeQueryFixture(), + ); + expectJSON(incrementalResult).toDeepEqual(await executeQueryFixture()); + }); + + it('runs generated query hooks and removes external abort listeners', async () => { + const calls: Array = []; + let abortListener: (() => void) | undefined; + const abortSignal = { + throwIfAborted() { + calls.push('throwIfAborted'); + }, + addEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + abortListener = listener; + calls.push('addEventListener'); + }, + removeEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + expect(listener).to.equal(abortListener); + calls.push('removeEventListener'); + }, + } as AbortSignal; + const generated = await createQueryFixtureExecution({ + hooks: { + asyncWorkFinished() { + calls.push('asyncWorkFinished'); + }, + }, + }); + + const invalidVariablesResult = await Promise.resolve( + generated.execute({ + variableValues: { + ...queryVariables(), + id: null, + }, + }), + ); + expect(invalidVariablesResult.errors).to.have.lengthOf(1); + + const result = await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + contextValue: { asyncRoot: true }, + abortSignal, + }), + ); + + expect(result.errors).to.equal(undefined); + await setImmediate(); + expect(calls.join(',')).to.equal( + 'throwIfAborted,addEventListener,removeEventListener,asyncWorkFinished', + ); + }); + + it('responds to generated query external abort signals', async () => { + const calls: Array = []; + let abortListener: (() => void) | undefined; + const abortSignal = { + reason: new Error('stop'), + throwIfAborted() { + calls.push('throwIfAborted'); + }, + addEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + abortListener = listener; + calls.push('addEventListener'); + listener(); + }, + removeEventListener(type: string, listener: () => void) { + expect(type).to.equal('abort'); + expect(listener).to.equal(abortListener); + calls.push('removeEventListener'); + }, + } as AbortSignal; + const generated = await createQueryFixtureExecution(); + + try { + await Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: queryVariables(), + contextValue: {}, + abortSignal, + }), + ); + throw new Error('Expected generated execution to abort.'); + } catch (error) { + expect((error as Error).message).to.equal('stop'); + expect((error as { abortedResult?: unknown }).abortedResult).to.not.equal( + undefined, + ); + } + + expect(calls).to.deep.equal([ + 'throwIfAborted', + 'addEventListener', + 'removeEventListener', + ]); + }); + + it('reports generated query runtime schema incompatibility', async () => { + const module = + await importFixtureModule('query.mjs'); + expect(() => + module.createCompiledExecution({ + schema: buildSchema('type Query { other: String }'), + }), + ).to.throw( + 'Generated execution is incompatible with the provided runtime arguments.', + ); + }); + + it('executes the incremental fixture from generated source', async () => { + const module = + await importFixtureModule('incremental.mjs'); + const generated = module.createCompiledExecution({ + schema: createKitchenSinkFixtureSchema(), + }); + assert('experimentalExecuteIncrementally' in generated); + + const result = await Promise.resolve( + generated.experimentalExecuteIncrementally({ + contextValue: { asyncDeferred: true }, + }), + ); + assert('initialResult' in result); + expectJSON(result.initialResult).toDeepEqual({ + data: { + immediate: 'first', + streamItems: [{ id: '1', label: 'one' }], + }, + pending: [ + { id: '0', path: [], label: 'deferred' }, + { id: '1', path: ['streamItems'], label: 'streamed' }, + ], + hasNext: true, + }); + expectNullPrototypeData(result.initialResult.data); + + const subsequentResults = await collectAsyncIterable( + result.subsequentResults, + ); + expectJSON(subsequentResults).toDeepEqual([ + { + hasNext: false, + incremental: [ + { + id: '1', + items: [{ id: '2', label: 'two' }], + }, + { + id: '0', + data: { deferred: 'later' }, + }, + ], + completed: [{ id: '1' }, { id: '0' }], + }, + ]); + }); + + it('executes incremental fixture with hooks and abort signal', async () => { + const module = + await importFixtureModule('incremental.mjs'); + const calls: Array = []; + const generated = module.createCompiledExecution({ + schema: createKitchenSinkFixtureSchema(), + hooks: { + asyncWorkFinished() { + calls.push('asyncWorkFinished'); + }, + }, + }); + assert('experimentalExecuteIncrementally' in generated); + + const result = await Promise.resolve( + generated.experimentalExecuteIncrementally({ + abortSignal: new AbortController().signal, + contextValue: { asyncDeferred: true }, + }), + ); + assert('initialResult' in result); + expectJSON(result.initialResult.data).toDeepEqual({ + immediate: 'first', + streamItems: [{ id: '1', label: 'one' }], + }); + await collectAsyncIterable(result.subsequentResults); + expect(calls).to.deep.equal(['asyncWorkFinished']); + }); + + it('executes incremental fixture non-incremental entrypoints', async () => { + const module = + await importFixtureModule('incremental.mjs'); + const generated = module.createCompiledExecution({ + schema: createKitchenSinkFixtureSchema(), + }); + assert('execute' in generated); + const executionArgs = { contextValue: { asyncDeferred: true } }; + + try { + await Promise.resolve(generated.execute(executionArgs)); + throw new Error('Expected execute to reject incremental delivery.'); + } catch (error) { + expectJSON( + (error as { abortedResult?: unknown }).abortedResult, + ).toDeepEqual({ + errors: [ + { + message: + 'Executing this GraphQL operation would unexpectedly produce multiple payloads (due to @defer or @stream directive)', + }, + ], + data: null, + }); + } + + const result = await Promise.resolve( + generated.executeIgnoringIncremental(executionArgs), + ); + expectJSON(result).toDeepEqual({ + data: { + immediate: 'first', + deferred: 'later', + streamItems: [ + { id: '1', label: 'one' }, + { id: '2', label: 'two' }, + ], + }, + }); + assert('data' in result); + expectNullPrototypeData(result.data); + }); + + it('reports generated incremental runtime schema incompatibility', async () => { + const module = + await importFixtureModule('incremental.mjs'); + expect(() => + module.createCompiledExecution({ + schema: buildSchema('type Query { other: String }'), + }), + ).to.throw( + 'Generated execution is incompatible with the provided runtime arguments.', + ); + }); + + it('executes the subscription fixture from generated source', async () => { + const module = + await importFixtureModule( + 'subscription.mjs', + ); + const generated = module.createCompiledSubscription({ + schema: createKitchenSinkFixtureSchema(), + }); + assert('subscribe' in generated); + + const result = await Promise.resolve( + generated.subscribe({ variableValues: { enabled: true } }), + ); + assertAsyncIterable(result); + const values = await collectAsyncIterable(result); + expectJSON(values).toDeepEqual([ + { data: { event: { id: '1', label: 'first' } } }, + { data: { event: { id: '2', label: 'second' } } }, + ]); + for (const value of values) { + expectNullPrototypeData((value as { data?: unknown }).data); + } + }); + + it('executes subscription fixture execution entrypoints and empty streams', async () => { + const module = + await importFixtureModule( + 'subscription.mjs', + ); + const generated = module.createCompiledSubscription({ + schema: createKitchenSinkFixtureSchema(), + }); + assert('subscribe' in generated); + + const executionArgs = { + rootValue: { event: { id: 'root', label: 'event' } }, + variableValues: { enabled: true }, + }; + const expected = { data: { event: { id: 'root', label: 'event' } } }; + expectJSON( + await Promise.resolve(generated.execute(executionArgs)), + ).toDeepEqual(expected); + expectJSON( + await Promise.resolve( + generated.executeIgnoringIncremental(executionArgs), + ), + ).toDeepEqual(expected); + expectJSON( + await Promise.resolve( + generated.experimentalExecuteIncrementally(executionArgs), + ), + ).toDeepEqual(expected); + + const emptyStream = await Promise.resolve( + generated.subscribe({ variableValues: { enabled: false } }), + ); + assertAsyncIterable(emptyStream); + expectJSON(await collectAsyncIterable(emptyStream)).toDeepEqual([]); + + const invalidVariables = await Promise.resolve( + generated.subscribe({ variableValues: { enabled: null } }), + ); + assert(!isAsyncIterableValue(invalidVariables)); + expect(invalidVariables.errors).to.have.lengthOf(1); + }); + + it('executes subscription helper paths for promised streams and abort-aware mapping', async () => { + const generated = await createSubscriptionFixtureExecution(); + const validatedExecutionArgs = validateSubscriptionArgs({ + schema: createKitchenSinkFixtureSchema(), + document: subscriptionFixtureDocument, + variableValues: { enabled: true }, + contextValue: { + eventId: 2, + eventLabel: false, + resolveEventAsync: true, + subscribeMode: 'promise', + }, + abortSignal: new AbortController().signal, + }); + assert('operation' in validatedExecutionArgs); + + const sourceEventStream = await Promise.resolve( + generated.createSourceEventStream(validatedExecutionArgs), + ); + assertAsyncIterable(sourceEventStream); + + const responseStream = generated.mapSourceToResponseEvent( + validatedExecutionArgs, + sourceEventStream, + ); + assertAsyncIterable(responseStream); + expectJSON(await collectAsyncIterable(responseStream)).toDeepEqual([ + { data: { event: { id: '2', label: 'false' } } }, + { data: { event: { id: '2', label: 'false' } } }, + ]); + }); + + it('reports generated subscription source failures', async () => { + const generated = await createSubscriptionFixtureExecution(); + const cases: ReadonlyArray<{ + contextValue: SubscriptionFixtureContext; + messageIncludes: string; + }> = [ + { + contextValue: { subscribeMode: 'reject' }, + messageIncludes: 'subscription source rejected', + }, + { + contextValue: { subscribeMode: 'throw' }, + messageIncludes: 'subscription source failed', + }, + { + contextValue: { subscribeMode: 'error' }, + messageIncludes: 'subscription source error', + }, + { + contextValue: { subscribeMode: 'nonIterable' }, + messageIncludes: 'Subscription field must return Async Iterable.', + }, + ]; + + const results = await Promise.all( + cases.map(async (testCase) => ({ + result: await Promise.resolve( + generated.subscribe({ + variableValues: { enabled: true }, + contextValue: testCase.contextValue, + }), + ), + testCase, + })), + ); + + for (const { result, testCase } of results) { + assert(!isAsyncIterableValue(result)); + expect(result.errors?.[0]?.message).to.include(testCase.messageIncludes); + } + + expect(() => generated.createSourceEventStream({} as never)).to.throw( + 'Passing ExecutionArgs to createSourceEventStream() was removed in graphql-js@17.0.0', + ); + }); + + it('reports generated subscription runtime schema incompatibility', async () => { + const module = + await importFixtureModule( + 'subscription.mjs', + ); + expect(() => + module.createCompiledSubscription({ + schema: buildSchema(` + type Query { noop: String } + type Subscription { other: String } + `), + }), + ).to.throw( + 'Generated subscription is incompatible with the provided runtime arguments.', + ); + }); +}); + +async function createQueryFixtureExecution( + args: { + hooks?: unknown; + } = {}, +): Promise { + const module = + await importFixtureModule('query.mjs'); + const generated = module.createCompiledExecution({ + schema: createKitchenSinkFixtureSchema(), + ...args, + }); + assert('execute' in generated); + return generated; +} + +async function createSubscriptionFixtureExecution(): Promise { + const module = + await importFixtureModule('subscription.mjs'); + const generated = module.createCompiledSubscription({ + schema: createKitchenSinkFixtureSchema(), + }); + assert('subscribe' in generated); + return generated; +} + +async function executeQueryFixture( + args: { + contextValue?: QueryFixtureContext; + variableValues?: { [key: string]: unknown }; + } = {}, +): Promise>> { + const generated = await createQueryFixtureExecution(); + return Promise.resolve( + generated.execute({ + rootValue: createQueryFixtureRootValue(), + variableValues: args.variableValues ?? queryVariables(), + contextValue: args.contextValue, + }), + ); +} + +function queryVariables(): { [key: string]: unknown } { + return { + id: 'q1', + includeOptional: true, + input: { marker: 'mark' }, + skipMaybe: false, + value: 4, + }; +} + +function getResultData( + result: Awaited>, +): { [key: string]: unknown; scalarBox?: unknown } { + assert(result.data != null && typeof result.data === 'object'); + return result.data; +} + +function scalarBoxFixture( + result: Awaited>, +): { + bool?: unknown; + float?: unknown; + id?: unknown; + int?: unknown; + odd?: unknown; + string?: unknown; +} { + const { scalarBox } = getResultData(result); + assert(scalarBox != null && typeof scalarBox === 'object'); + return scalarBox; +} + +async function importFixtureModule(filename: string): Promise { + return (await import( + pathToFileURL(path.join(generatedFixtureDir, filename)).href + )) as T; +} + +async function collectAsyncIterable( + iterable: AsyncIterable, +): Promise> { + const values = []; + for await (const value of iterable) { + values.push(value); + } + return values; +} + +function assertAsyncIterable( + value: unknown, +): asserts value is AsyncIterable { + assert( + value != null && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as Partial>)[ + Symbol.asyncIterator + ] === 'function', + ); +} + +function assertObject(value: unknown): asserts value is { + [key: string]: unknown; +} { + assert(value != null && typeof value === 'object'); +} + +function isAsyncIterableValue(value: unknown): value is AsyncIterable { + return ( + value != null && + (typeof value === 'object' || typeof value === 'function') && + typeof (value as Partial>)[Symbol.asyncIterator] === + 'function' + ); +} + +function expectNullPrototypeData(value: unknown): void { + if (value == null) { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + expectNullPrototypeData(item); + } + return; + } + if (typeof value !== 'object') { + return; + } + expect(Object.getPrototypeOf(value)).to.equal(null); + for (const childValue of Object.values(value)) { + expectNullPrototypeData(childValue); + } +} diff --git a/src/execution/generate/__tests__/generatedCoverageFixtures.ts b/src/execution/generate/__tests__/generatedCoverageFixtures.ts new file mode 100644 index 0000000000..9d2b753cab --- /dev/null +++ b/src/execution/generate/__tests__/generatedCoverageFixtures.ts @@ -0,0 +1,57 @@ +import type { DocumentNode } from '../../../language/ast.ts'; +import { parse } from '../../../language/parser.ts'; + +import { + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, +} from '../../../type/index.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +export const rootStringCoverageDocument: DocumentNode = parse('{ value }'); + +export type RootStringCoverageSchemaMode = + | 'customResolver' + | 'customString' + | 'missingField' + | 'noQuery' + | 'renamedRoot' + | 'standard'; + +export function createRootStringCoverageSchema( + mode: RootStringCoverageSchemaMode = 'standard', +): GraphQLSchema { + switch (mode) { + case 'customResolver': { + const schema = buildSchema('type Query { value: String }'); + const valueField = schema.getQueryType()?.getFields().value; + if (valueField === undefined) { + throw new Error('Expected root-string coverage schema field.'); + } + valueField.resolve = () => 'runtime-resolver'; + return schema; + } + case 'customString': { + const CustomString = new GraphQLScalarType({ + name: 'CustomString', + coerceOutputValue: (value) => value, + }); + const Query = new GraphQLObjectType({ + name: 'Query', + fields: { + value: { type: CustomString }, + }, + }); + return new GraphQLSchema({ query: Query }); + } + case 'missingField': + return buildSchema('type Query { other: String }'); + case 'noQuery': + return new GraphQLSchema({}); + case 'renamedRoot': + return buildSchema('schema { query: Root } type Root { value: String }'); + case 'standard': + return buildSchema('type Query { value: String }'); + } +} diff --git a/src/execution/generate/__tests__/generatedFixtureSchemas.ts b/src/execution/generate/__tests__/generatedFixtureSchemas.ts new file mode 100644 index 0000000000..37555061fe --- /dev/null +++ b/src/execution/generate/__tests__/generatedFixtureSchemas.ts @@ -0,0 +1,364 @@ +import type { DocumentNode } from '../../../language/ast.ts'; +import { parse } from '../../../language/parser.ts'; + +import type { GraphQLSchema } from '../../../type/index.ts'; +import { + assertInterfaceType, + assertObjectType, + assertScalarType, +} from '../../../type/index.ts'; + +import { buildSchema } from '../../../utilities/buildASTSchema.ts'; + +export const queryFixtureDocument: DocumentNode = parse(` + query GeneratedFixture( + $id: ID! + $includeOptional: Boolean! + $skipMaybe: Boolean! + $value: Int = 3 + $input: FixtureInput + ) { + typename: __typename + node(id: $id) { + __typename + id + label + ... on Item { + count + ratio + active + tags + computed + maybeError @skip(if: $skipMaybe) + optional: computed @include(if: $includeOptional) + child { + name + } + } + } + item(id: $id, input: $input, values: [$value, 2]) { + id + label + count + tags + } + defaultItem { + id + label + tags + child { + name + } + } + scalarBox { + bool + float + id + int + odd + string + } + list { + id + label + } + } +`); + +export const incrementalFixtureDocument: DocumentNode = parse(` + query GeneratedIncrementalFixture { + immediate + ... @defer(label: "deferred") { + deferred + } + streamItems @stream(initialCount: 1, label: "streamed") { + id + label + } + } +`); + +export const subscriptionFixtureDocument: DocumentNode = parse(` + subscription GeneratedSubscriptionFixture($enabled: Boolean!) { + event(enabled: $enabled) { + id + label + } + } +`); + +export interface QueryFixtureContext { + active?: unknown; + asyncLeaf?: boolean; + asyncRoot?: boolean; + asyncType?: boolean; + bool?: unknown; + child?: unknown; + count?: unknown; + float?: unknown; + id?: unknown; + int?: unknown; + label?: unknown; + list?: unknown; + odd?: unknown; + ratio?: unknown; + scalarId?: unknown; + string?: unknown; + tags?: unknown; + throwMaybe?: boolean; +} + +export interface SubscriptionFixtureContext { + eventId?: unknown; + eventLabel?: unknown; + resolveEventAsync?: boolean; + subscribeMode?: 'error' | 'nonIterable' | 'promise' | 'reject' | 'throw'; +} + +export function createKitchenSinkFixtureSchema(): GraphQLSchema { + const schema = buildSchema(` + scalar Odd + + interface Node { + id: ID! + label: String + } + + input FixtureInput { + marker: String + } + + type Child { + name: String + } + + type Item implements Node { + id: ID! + label: String + count: Int + ratio: Float + active: Boolean + tags: [String] + computed: String + maybeError: String + child: Child + } + + type ScalarBox { + bool: Boolean + float: Float + id: ID + int: Int + odd: Odd + string: String + } + + type Event { + id: ID + label: String + } + + type Query { + node(id: ID!): Node + item(id: ID!, input: FixtureInput, values: [Int]): Item + defaultItem: Item + scalarBox: ScalarBox + list: [Item] + immediate: String + deferred: String + streamItems: [Item] + } + + type Subscription { + event(enabled: Boolean): Event + } + `); + + const oddType = assertScalarType(schema.getType('Odd')); + oddType.coerceOutputValue = (value) => { + if (typeof value === 'number' && value % 2 === 1) { + return value; + } + throw new Error('Expected odd integer.'); + }; + + const nodeType = assertInterfaceType(schema.getType('Node')); + nodeType.resolveType = (_value, contextValue) => + toQueryFixtureContext(contextValue).asyncType === true + ? Promise.resolve('Item') + : 'Item'; + + const queryType = assertObjectType(schema.getType('Query')); + const queryFields = queryType.getFields(); + queryFields.node.resolve = (_source, args, contextValue) => { + const context = toQueryFixtureContext(contextValue); + const item = createItem(args.id, context); + return context.asyncRoot === true ? Promise.resolve(item) : item; + }; + queryFields.item.resolve = (_source, args, contextValue) => { + const context = toQueryFixtureContext(contextValue); + const item = createItem(args.id, context); + item.label = `${item.label}:${args.input?.marker ?? 'none'}:${String( + args.values?.[0], + )}`; + return context.asyncRoot === true ? Promise.resolve(item) : item; + }; + queryFields.scalarBox.resolve = (_source, _args, contextValue) => + createScalarBox(toQueryFixtureContext(contextValue)); + queryFields.list.resolve = (_source, _args, contextValue) => { + const context = toQueryFixtureContext(contextValue); + return context.list ?? [createItem('a', context), createItem('b', context)]; + }; + queryFields.immediate.resolve = () => 'first'; + queryFields.deferred.resolve = (_source, _args, contextValue) => + (contextValue as IncrementalFixtureContext).asyncDeferred === true + ? Promise.resolve('later') + : 'later'; + queryFields.streamItems.resolve = () => [ + { id: '1', label: 'one' }, + { id: '2', label: 'two' }, + ]; + + const subscriptionType = assertObjectType(schema.getSubscriptionType()); + const subscriptionFields = subscriptionType.getFields(); + subscriptionFields.event.subscribe = (_source, args, contextValue) => { + if (args.enabled !== true) { + return createEmptyEventStream(); + } + const context = toSubscriptionFixtureContext(contextValue); + if (context.subscribeMode === 'throw') { + throw new Error('subscription source failed'); + } + if (context.subscribeMode === 'reject') { + return Promise.reject(new Error('subscription source rejected')); + } + if (context.subscribeMode === 'error') { + return new Error('subscription source error'); + } + if (context.subscribeMode === 'nonIterable') { + return { event: createEvent(context, 'not-iterable') }; + } + + const stream = createEventStream(context); + return context.subscribeMode === 'promise' + ? Promise.resolve(stream) + : stream; + }; + subscriptionFields.event.resolve = (source, _args, contextValue) => { + const event = (source as { event: unknown }).event; + return toSubscriptionFixtureContext(contextValue).resolveEventAsync === true + ? Promise.resolve(event) + : event; + }; + + return schema; +} + +function toQueryFixtureContext(contextValue: unknown): QueryFixtureContext { + return (contextValue as QueryFixtureContext | undefined) ?? {}; +} + +function toSubscriptionFixtureContext( + contextValue: unknown, +): SubscriptionFixtureContext { + return (contextValue as SubscriptionFixtureContext | undefined) ?? {}; +} + +async function* createEmptyEventStream(): AsyncGenerator { + await Promise.resolve(); + for (const event of [] as Array) { + yield event; + } +} + +async function* createEventStream( + context: SubscriptionFixtureContext, +): AsyncGenerator<{ event: ReturnType }> { + await Promise.resolve(); + yield { event: createEvent(context, '1', 'first') }; + yield { event: createEvent(context, '2', 'second') }; +} + +function createEvent( + context: SubscriptionFixtureContext, + id: unknown, + label: unknown = id, +): { + id: unknown; + label: unknown; +} { + return { + id: context.eventId ?? id, + label: context.eventLabel ?? label, + }; +} + +export function createQueryFixtureRootValue(): unknown { + return { + defaultItem: createItem('root', {}), + }; +} + +export interface IncrementalFixtureContext { + asyncDeferred?: boolean; +} + +function createScalarBox(context: QueryFixtureContext): { + active?: never; + bool: unknown; + float: unknown; + id: unknown; + int: unknown; + odd: unknown; + string: unknown; +} { + return { + bool: context.bool ?? true, + float: context.float ?? 3.5, + id: context.scalarId ?? 123, + int: context.int ?? 6, + odd: context.odd ?? 9, + string: context.string ?? 42, + }; +} + +function createItem( + id: unknown, + context: QueryFixtureContext, +): { + __typename: string; + active: unknown; + child: unknown; + computed: ( + args: unknown, + contextValue: QueryFixtureContext, + info: { fieldName: string }, + ) => Promise | string; + count: unknown; + id: unknown; + label: unknown; + maybeError: () => string; + ratio: unknown; + tags: unknown; +} { + return { + __typename: 'Item', + active: context.active ?? true, + child: context.child ?? { name: `child:${String(id)}` }, + computed: (_args, contextValue, info) => { + const value = `${info.fieldName}:${String(id)}`; + return contextValue.asyncLeaf === true ? Promise.resolve(value) : value; + }, + count: context.count ?? 7, + id: context.id ?? id, + label: context.label ?? `item:${String(id)}`, + maybeError: () => { + if (context.throwMaybe === true) { + throw new Error('fixture field error'); + } + return 'ok'; + }, + ratio: context.ratio ?? 1.25, + tags: context.tags ?? ['generated', String(id)], + }; +} diff --git a/src/execution/generate/generate.ts b/src/execution/generate/generate.ts new file mode 100644 index 0000000000..ab6ab5fcb6 --- /dev/null +++ b/src/execution/generate/generate.ts @@ -0,0 +1,9140 @@ +import { invariant } from '../../jsutils/invariant.ts'; + +import { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { + DirectiveNode, + FieldNode, + FragmentDefinitionNode, + InlineFragmentNode, + ObjectValueNode, + SelectionSetNode, + ValueNode, + VariableDefinitionNode, +} from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; +import { isSubscriptionOperationDefinitionNode } from '../../language/predicates.ts'; +import { print } from '../../language/printer.ts'; + +import type { + GraphQLField, + GraphQLInputType, + GraphQLObjectType, + GraphQLOutputType, +} from '../../type/definition.ts'; +import { + isAbstractType, + isEnumType, + isInputObjectType, + isInputType, + isLeafType, + isListType, + isNonNullType, + isObjectType, + isRequiredInputField, + isScalarType, +} from '../../type/definition.ts'; +import { + GraphQLBoolean, + GraphQLFloat, + GraphQLID, + GraphQLInt, + GraphQLString, +} from '../../type/scalars.ts'; +import type { GraphQLSchema } from '../../type/schema.ts'; + +import { + coerceDefaultValue, + coerceInputLiteral, +} from '../../utilities/coerceInputValue.ts'; +import { typeFromAST } from '../../utilities/typeFromAST.ts'; + +import type { BareVariableArgumentValueEntry } from '../compile/compileArgumentValues.ts'; +import { compileArgumentValues } from '../compile/compileArgumentValues.ts'; +import { + compileExecutionState, + isExecutionErrors, +} from '../compile/compileExecutionState.ts'; +import type { CompileExecutionArgs } from '../ExecutionArgs.ts'; + +/** + * Generates an ECMAScript module source string for a compiled execution + * operation. + * + * The generated module exports `createCompiledExecution(args)`. Call that + * factory with the schema and any resolver or hook functions that cannot be + * serialized into source. The returned value has the same API as + * `compileExecution(args)`. + * @param args - Static execution arguments to generate from. + * @returns A generated module source string, or validation errors. + * @example + * ```ts + * const source = generateExecution({ schema, document }); + * ``` + * @category Execution + */ +export function generateExecution( + args: CompileExecutionArgs, +): ReadonlyArray | string { + const compiledExecution = compileExecutionState(args); + if (isExecutionErrors(compiledExecution)) { + return compiledExecution; + } + const operationPlan = getOperationPlan(args, compiledExecution); + if (operationPlan === undefined) { + return [nonStaticGeneratedExecutionError()]; + } + return generateExecutionSource( + args, + compiledExecution, + compiledExecution.variableDefinitions.map( + (variableDefinition) => + variableDefinition.type.kind === Kind.NON_NULL_TYPE, + ), + getVariableValuesPlan(compiledExecution), + operationPlan, + ); +} + +/** + * Generates an ECMAScript module source string for a compiled subscription + * operation. + * + * The generated module exports `createCompiledSubscription(args)`. Call that + * factory with the schema and any resolver or hook functions that cannot be + * serialized into source. The returned value has the same API as + * `compileSubscription(args)`. + * @param args - Static execution arguments to generate from. + * @returns A generated module source string, or validation errors. + * @example + * ```ts + * const source = generateSubscription({ schema, document }); + * ``` + * @category Execution + */ +export function generateSubscription( + args: CompileExecutionArgs, +): ReadonlyArray | string { + const compiledExecution = compileExecutionState(args); + if (isExecutionErrors(compiledExecution)) { + return compiledExecution; + } + if (!isSubscriptionOperationDefinitionNode(compiledExecution.operation)) { + return [new GraphQLError('Expected subscription operation.')]; + } + const operationPlan = getOperationPlan(args, compiledExecution); + if (operationPlan === undefined) { + return [nonStaticGeneratedExecutionError()]; + } + return generateSubscriptionSource( + args, + compiledExecution, + compiledExecution.variableDefinitions.map( + (variableDefinition) => + variableDefinition.type.kind === Kind.NON_NULL_TYPE, + ), + getVariableValuesPlan(compiledExecution), + operationPlan, + ); +} + +function nonStaticGeneratedExecutionError(): GraphQLError { + return new GraphQLError( + 'Operation cannot be fully represented as static generated source.', + ); +} + +function generateExecutionSource( + args: CompileExecutionArgs, + compiledExecution: Exclude< + ReturnType, + ReadonlyArray + >, + requiredVariableDefinitions: ReadonlyArray, + variableValuesPlan: VariableValuesPlan | undefined, + operationPlan: OperationPlan, +): string { + const variableDefinitionCount = compiledExecution.variableDefinitions.length; + return `${importsForGeneratedExecution( + variableDefinitionCount, + variableValuesPlan, + false, + operationPlan, + )} + +${generatedOutputScalarHelpersSource(operationPlan)} +${staticDocumentSource(args)} +${staticCompiledExecutionSource(args, compiledExecution)} + +export function createCompiledExecution(args) { + return createOperation(createGeneratedCompiledExecution(args)); +} + +export default createCompiledExecution; + +function createOperation(compiledExecution) { +${variableValueSetup(variableDefinitionCount, variableValuesPlan)} +${validatedExecutionArgsSetup(operationPlan)} +${operationSetup(operationPlan, false)} + + if (executeGeneratedRootFields === undefined) { + throw new Error( + 'Generated execution is incompatible with the provided runtime arguments.', + ); + } + +${generatedExecutionEntrypointFunctions(variableDefinitionCount)} + +${getValidatedExecutionArgsFunction(variableDefinitionCount, operationPlan)} + +${getVariableValuesFunction( + variableDefinitionCount, + requiredVariableDefinitions, + variableValuesPlan, +)} + + return { + execute, + experimentalExecuteIncrementally, + executeIgnoringIncremental, + }; +} + +${generatedArgumentFunctions(operationPlan)} + +${generatedFieldBindingFunctions(operationPlan)} +`; +} + +function generateSubscriptionSource( + args: CompileExecutionArgs, + compiledExecution: Exclude< + ReturnType, + ReadonlyArray + >, + requiredVariableDefinitions: ReadonlyArray, + variableValuesPlan: VariableValuesPlan | undefined, + operationPlan: OperationPlan, +): string { + const variableDefinitionCount = compiledExecution.variableDefinitions.length; + return `${importsForGeneratedExecution( + variableDefinitionCount, + variableValuesPlan, + true, + operationPlan, + )} + +${generatedOutputScalarHelpersSource(operationPlan)} +${staticDocumentSource(args)} +${staticCompiledExecutionSource(args, compiledExecution)} + +export function createCompiledSubscription(args) { + return createOperation(createGeneratedCompiledExecution(args)); +} + +export default createCompiledSubscription; + +function createOperation(compiledExecution) { +${variableValueSetup(variableDefinitionCount, variableValuesPlan)} +${validatedExecutionArgsSetup(operationPlan)} +${operationSetup(operationPlan, true)} + const generatedSubscription = createGeneratedSubscription( + compiledExecution, + getValidatedExecutionArgs, + executeGeneratedRootFields, + executeGeneratedSubscriptionSource, + ); + + if ( + executeGeneratedRootFields === undefined || + generatedSubscription === undefined + ) { + throw new Error( + 'Generated subscription is incompatible with the provided runtime arguments.', + ); + } + +${generatedExecutionEntrypointFunctions(variableDefinitionCount)} + +${getValidatedExecutionArgsFunction(variableDefinitionCount, operationPlan)} + +${getVariableValuesFunction( + variableDefinitionCount, + requiredVariableDefinitions, + variableValuesPlan, +)} + + return { + execute, + experimentalExecuteIncrementally, + executeIgnoringIncremental, + ...generatedSubscription, + }; +} + +${generatedArgumentFunctions(operationPlan)} + +${generatedFieldBindingFunctions(operationPlan)} + +${generatedSubscriptionSourceFieldFunction(operationPlan)} +`; +} + +function generatedExecutionEntrypointFunctions( + variableDefinitionCount: number, +): string { + const validationErrorCheck = + variableDefinitionCount === 0 + ? '' + : ` if (Array.isArray(validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } +`; + const entrypoint = ( + functionName: string, + mode: string, + ) => ` function ${functionName}(args = emptyRuntimeArgs) { + const validatedExecutionArgs = getValidatedExecutionArgs(args); +${validationErrorCheck} return executeGeneratedRootWithValidatedArgs( + validatedExecutionArgs, + ${toJavaScript(mode)}, + executeGeneratedRootFields, + ); + }`; + + return [ + entrypoint('execute', 'throw'), + entrypoint('experimentalExecuteIncrementally', 'incremental'), + entrypoint('executeIgnoringIncremental', 'ignore'), + ].join('\n\n'); +} + +function importsForGeneratedExecution( + variableDefinitionCount: number, + variableValuesPlan: VariableValuesPlan | undefined, + includeSubscription: boolean, + operationPlan: OperationPlan, +): string { + const includeListHelpers = hasGeneratedListFields(operationPlan); + const includeMetaFieldDefs = hasGeneratedMetaFields(operationPlan); + const includeOutputScalarHelpers = + hasGeneratedBuiltinOutputScalars(operationPlan); + const scalarImports = getGeneratedBuiltinScalarImportNames( + operationPlan, + variableValuesPlan, + ); + const includeScalarImports = scalarImports.length !== 0; + const executionArgsImports = + variableDefinitionCount === 0 + ? '' + : `import { EMPTY_VARIABLE_VALUES } from 'graphql/execution/ExecutionArgs.js'; +`; + const graphQLErrorImport = + variableDefinitionCount === 0 && + !includeSubscription && + !includeOutputScalarHelpers + ? '' + : ` +import { GraphQLError } from 'graphql/error/GraphQLError.js';`; + const variableImports = + variableDefinitionCount === 0 + ? '' + : variableValuesPlan === undefined + ? ` +import { printPathArray } from 'graphql/jsutils/printPathArray.js'; +import { compileVariableValues } from 'graphql/execution/compile/compileVariableValues.js'; +import { validateDefaultInput } from 'graphql/type/validate.js'; +import { validateInputValue } from 'graphql/utilities/validateInputValue.js';` + : ` +import { printPathArray } from 'graphql/jsutils/printPathArray.js'; +import { validateInputValue } from 'graphql/utilities/validateInputValue.js';`; + const typeImports = + variableValuesPlan?.variables.some((variable) => variable.required) === true + ? ` +import { GraphQLNonNull } from 'graphql/type/definition.js';` + : ''; + const subscriptionImports = includeSubscription + ? ` +import { pathToArray } from 'graphql/jsutils/Path.js'; +import { locatedError } from 'graphql/error/locatedError.js'; +import { cancellablePromise } from 'graphql/execution/cancellablePromise.js'; +import { createSharedExecutionContext } from 'graphql/execution/createSharedExecutionContext.js'; +import { mapAsyncIterable } from 'graphql/execution/mapAsyncIterable.js';` + : ''; + const asyncIterableImport = + includeSubscription || includeListHelpers + ? `import { isAsyncIterable } from 'graphql/jsutils/isAsyncIterable.js'; +` + : ''; + const listHelperImports = includeListHelpers + ? `import { isIterableObject } from 'graphql/jsutils/isIterableObject.js'; +import { collectIteratorPromises } from 'graphql/execution/collectIteratorPromises.js'; +import { returnIteratorCatchingErrors } from 'graphql/execution/returnIteratorCatchingErrors.js'; +` + : ''; + const metaFieldDefImports = includeMetaFieldDefs + ? ` +import { SchemaMetaFieldDef, TypeMetaFieldDef } from 'graphql/type/introspection.js';` + : ''; + const outputScalarImports = includeScalarImports + ? ` +import { ${scalarImports + .map((scalarName) => `GraphQL${scalarName}`) + .join(', ')} } from 'graphql/type/scalars.js';` + : ''; + const rootCanNull = operationPlan.fields.some( + (field) => field.completedNonNull, + ); + return `${asyncIterableImport}${listHelperImports}import { inspect } from 'graphql/jsutils/inspect.js'; +import { ensureGraphQLError } from 'graphql/error/ensureGraphQLError.js'; +import { defaultFieldResolver, defaultTypeResolver } from 'graphql/execution/execute.js'; +${executionArgsImports}import { parse } from 'graphql/language/parser.js'; +import { CompiledExecutor, CompiledExecutionRunner } from 'graphql/execution/compile/CompiledExecutor.js';${variableImports} +${graphQLErrorImport}${subscriptionImports}${typeImports}${metaFieldDefImports}${outputScalarImports} + +const emptyGeneratedArgumentValues = Object.freeze(Object.create(null)); + +${generatedRootExecutionHelperSource( + rootCanNull, + operationPlan.deferUsages.length !== 0, + hasGeneratedIncrementalFields(operationPlan), +)} + +${ + includeSubscription + ? `function createGeneratedSubscription( + compiledExecution, + getValidatedExecutionArgs, + executeGeneratedRootFields, + executeGeneratedSubscriptionSource, +) { + if (compiledExecution.operation.operation !== 'subscription') { + return undefined; + } + + if ( + executeGeneratedRootFields === undefined || + executeGeneratedSubscriptionSource === undefined + ) { + return undefined; + } + + function executeSubscriptionEvent(validatedExecutionArgs) { + return executeGeneratedRootWithValidatedArgs( + validatedExecutionArgs, + 'throw', + executeGeneratedRootFields, + ); + } + + function createSourceEventStream(validatedExecutionArgs) { + 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 = + executeGeneratedSubscriptionSource(validatedExecutionArgs); + if (eventStream != null && typeof eventStream.then === 'function') { + return Promise.resolve(eventStream).then(undefined, (error) => ({ + errors: [ensureGraphQLError(error)], + })); + } + return eventStream; + } catch (error) { + return { errors: [ensureGraphQLError(error)] }; + } + } + + function mapSourceToResponseEvent( + validatedExecutionArgs, + sourceEventStream, + rootSelectionSetExecutor = executeSubscriptionEvent, + ) { + function mapFn(payload) { + const perEventExecutionArgs = { + ...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 subscribe(args = {}) { + const validatedExecutionArgs = getValidatedExecutionArgs(args); + if (Array.isArray(validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } + + const resultOrStream = createSourceEventStream(validatedExecutionArgs); + if ( + resultOrStream != null && + typeof resultOrStream.then === 'function' + ) { + return Promise.resolve(resultOrStream).then((resolvedResultOrStream) => + isAsyncIterable(resolvedResultOrStream) + ? mapSourceToResponseEvent( + validatedExecutionArgs, + resolvedResultOrStream, + executeSubscriptionEvent, + ) + : resolvedResultOrStream, + ); + } + + return isAsyncIterable(resultOrStream) + ? mapSourceToResponseEvent( + validatedExecutionArgs, + resultOrStream, + executeSubscriptionEvent, + ) + : resultOrStream; + } + + return { + executeSubscriptionEvent, + createSourceEventStream, + mapSourceToResponseEvent, + subscribe, + }; +} + +function assertGeneratedEventStream(result) { + if (result instanceof Error) { + throw result; + } + + if (!isAsyncIterable(result)) { + throw new GraphQLError( + 'Subscription field must return Async Iterable. ' + + \`Received: \${inspect(result)}.\`, + ); + } + + return result; +} +` + : '' +} + +`; +} + +function generatedRootExecutionHelperSource( + rootCanNull: boolean, + includeDeliveryGroupMap: boolean, + includeIncrementalFinish: boolean, +): string { + const rootObjectSource = generatedResultObjectSource(); + const deliveryGroupMapArgument = includeDeliveryGroupMap + ? ' undefined,\n' + : ''; + const finishSource = includeIncrementalFinish + ? { + async: ( + _dataExpression: string, + rootBoxExpression: string, + ) => `return executor.finishAsyncRootExecution( + completed, + ${rootBoxExpression}, + removeExternalAbortListener, + );`, + catch: `return executor.finish(executor.buildResponse(null));`, + sync: (dataExpression: string) => + `return executor.finish(executor.buildResponse(${dataExpression}));`, + helper: '', + } + : { + async: (dataExpression: string, rootBoxExpression: string) => `if ( + validatedExecutionArgs.hooks?.asyncWorkFinished === undefined && + externalAbortSignal === undefined + ) { + return completed.then( + () => finishGeneratedExecution(executor, ${dataExpression}), + /* node:coverage ignore next 4 */ + (error) => { + executor.collectedErrors.add(ensureGraphQLError(error), undefined); + return finishGeneratedExecution(executor, null); + }, + ); + } + return executor.finishAsyncRootExecution( + completed, + ${rootBoxExpression}, + removeExternalAbortListener, + );`, + catch: `return finishGeneratedExecution(executor, null);`, + sync: (dataExpression: string) => + `return finishGeneratedExecution(executor, ${dataExpression});`, + helper: ` +function finishGeneratedExecution(executor, data) { + if (executor.validatedExecutionArgs.hooks?.asyncWorkFinished === undefined) { + if (executor.resolverAbortController === undefined) { + executor._resolverAbortFinished = true; + } else { + executor.abortResolverSignal(); + } + } else { + executor.finishSharedExecution(); + } + const errors = executor._collectedErrors?._errors; + const result = + errors === undefined || errors.length === 0 ? { data } : { errors, data }; + if (executor.aborted) { + throw executor.createAbortedExecutionError(result); + } + executor.aborted = true; + return result; +} +`, + }; + if (!rootCanNull) { + return `function executeGeneratedRootWithValidatedArgs( + validatedExecutionArgs, + mode, + executeGeneratedRootFields, +) { + const executor = new CompiledExecutor(validatedExecutionArgs, mode); + const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; + let removeExternalAbortListener; + if (externalAbortSignal) { + externalAbortSignal.throwIfAborted(); + const onExternalAbort = () => { + executor.abort(externalAbortSignal.reason); + }; + removeExternalAbortListener = () => + externalAbortSignal.removeEventListener('abort', onExternalAbort); + externalAbortSignal.addEventListener('abort', onExternalAbort); + } + + let data; + try { + data = ${rootObjectSource}; + const runner = new CompiledExecutionRunner(executor); + executeGeneratedRootFields( + executor, + runner, + validatedExecutionArgs.rootValue, + data, + undefined, +${deliveryGroupMapArgument.trimEnd()} + ); + const completed = runner.runUntilNulled(undefined); + if (completed !== undefined) { + ${finishSource.async('data', '{ data }')} + } + removeExternalAbortListener?.(); + } catch (error) { + removeExternalAbortListener?.(); + executor.collectedErrors.add(ensureGraphQLError(error), undefined); + ${finishSource.catch} + } + ${finishSource.sync('data')} +}${finishSource.helper}`; + } + + return `function executeGeneratedRootWithValidatedArgs( + validatedExecutionArgs, + mode, + executeGeneratedRootFields, +) { + const executor = new CompiledExecutor(validatedExecutionArgs, mode); + const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; + let removeExternalAbortListener; + if (externalAbortSignal) { + externalAbortSignal.throwIfAborted(); + const onExternalAbort = () => { + executor.abort(externalAbortSignal.reason); + }; + removeExternalAbortListener = () => + externalAbortSignal.removeEventListener('abort', onExternalAbort); + externalAbortSignal.addEventListener('abort', onExternalAbort); + } + + let rootBox; + try { + const data = ${rootObjectSource}; + rootBox = { data }; + const runner = new CompiledExecutionRunner(executor); + executeGeneratedRootFields( + executor, + runner, + validatedExecutionArgs.rootValue, + data, + { + container: rootBox, + key: 'data', + path: undefined, + }, +${deliveryGroupMapArgument.trimEnd()} + ); + const completed = runner.runUntilNulled(undefined); + if (completed !== undefined) { + ${finishSource.async('rootBox.data', 'rootBox')} + } + removeExternalAbortListener?.(); + } catch (error) { + removeExternalAbortListener?.(); + executor.collectedErrors.add(ensureGraphQLError(error), undefined); + ${finishSource.catch} + } + ${finishSource.sync('rootBox.data')} +}${finishSource.helper}`; +} + +function variableValueSetup( + variableDefinitionCount: number, + variableValuesPlan: VariableValuesPlan | undefined, +): string { + if (variableDefinitionCount === 0) { + return ` const emptyVariableValues = { + sources: ${generatedNullPrototypeObjectSource()}, + coerced: ${generatedNullPrototypeObjectSource()}, + };`; + } + + if (variableValuesPlan !== undefined) { + return ` const generatedVariableEntries = createGeneratedVariableEntries( + compiledExecution, + ); + if (generatedVariableEntries === undefined) { + throw new Error( + 'Generated execution is incompatible with the provided runtime arguments.', + ); + } +${variableValuesPlan.variables + .map( + (variable) => + ` const ${generatedVariableSignatureName( + variable, + )} = generatedVariableEntries[${String(variable.entryIndex)}].signature;`, + ) + .join('\n')}`; + } + + return ` let compiledVariableValues; + function getCompiledVariableValues() { + compiledVariableValues ??= compileVariableValues( + compiledExecution.schema, + compiledExecution.variableDefinitions, + compiledExecution.hideSuggestions, + ); + return compiledVariableValues; + }`; +} + +function validatedExecutionArgsSetup(operationPlan: OperationPlan): string { + const typeResolverSetup = hasGeneratedAbstractFields(operationPlan) + ? ` const staticSchema = compiledExecution.schema; + const staticTypeResolver = + compiledExecution.typeResolver ?? defaultTypeResolver; +` + : ` const staticSchema = compiledExecution.schema; +`; + const earlyExecutionSetup = hasGeneratedIncrementalFields(operationPlan) + ? ` const staticEnableEarlyExecution = compiledExecution.enableEarlyExecution; +` + : ''; + + return ` const emptyRuntimeArgs = Object.freeze({}); +${typeResolverSetup} const staticDocument = compiledExecution.document; + const staticOperation = compiledExecution.operation; + const staticHideSuggestions = compiledExecution.hideSuggestions; + const staticErrorPropagation = compiledExecution.errorPropagation; +${earlyExecutionSetup} const staticHooks = compiledExecution.hooks; + const staticNeedsHookValidatedArgs = + staticHooks?.asyncWorkFinished !== undefined;`; +} + +function getValidatedExecutionArgsFunction( + variableDefinitionCount: number, + operationPlan: OperationPlan, +): string { + const variableValuesSetup = + variableDefinitionCount === 0 + ? ` const variableValues = emptyVariableValues;` + : ` const variableValues = getVariableValues(args); + if (variableValues.coerced === undefined) { + return variableValues; + } +`; + + const includeTypeResolver = hasGeneratedAbstractFields(operationPlan); + const includeEarlyExecution = hasGeneratedIncrementalFields(operationPlan); + const optionalProperties = [ + 'schema: staticSchema', + 'document: staticDocument', + 'operation: staticOperation', + includeTypeResolver ? 'typeResolver: staticTypeResolver' : undefined, + includeEarlyExecution + ? 'enableEarlyExecution: staticEnableEarlyExecution' + : undefined, + ].filter((property): property is string => property !== undefined); + const optionalPropertiesSource = `${optionalProperties + .map((property) => ` ${property},`) + .join('\n')}\n`; + + const validatedArgsPropertiesSource = (includeHookProperties: boolean) => { + const staticPropertiesSource = includeHookProperties + ? ` schema: compiledExecution.schema, + document: compiledExecution.document, + operation: compiledExecution.operation, + typeResolver: compiledExecution.typeResolver ?? defaultTypeResolver, + enableEarlyExecution: compiledExecution.enableEarlyExecution, +` + : optionalPropertiesSource; + return `${staticPropertiesSource} rootValue: args.rootValue, + contextValue: args.contextValue, + variableValues, + rawVariableValues: args.variableValues, + errorPropagation: staticErrorPropagation, + externalAbortSignal: args.abortSignal ?? undefined, + hooks: staticHooks,`; + }; + + if (operationPlan.operationType !== 'subscription') { + return ` const getValidatedExecutionArgs = staticNeedsHookValidatedArgs + ? getValidatedExecutionArgsWithHookProperties + : getValidatedExecutionArgsWithoutHookProperties; + + function getValidatedExecutionArgsWithHookProperties(args = emptyRuntimeArgs) { +${variableValuesSetup} + return { +${validatedArgsPropertiesSource(true)} + }; + } + + function getValidatedExecutionArgsWithoutHookProperties(args = emptyRuntimeArgs) { +${variableValuesSetup} + return { +${validatedArgsPropertiesSource(false)} + }; + }`; + } + + return ` function getValidatedExecutionArgs(args = emptyRuntimeArgs) { +${variableValuesSetup} + return { +${validatedArgsPropertiesSource(true)} + }; + }`; +} + +interface OperationPlan { + deferUsages: ReadonlyArray; + fields: ReadonlyArray; + operationType: 'query' | 'mutation' | 'subscription'; + rootTypeName: string; + serialRoot: boolean; +} + +interface DeferUsagePlan { + index: number; + label?: string; + parentIndex?: number; + selectionSetPath: ReadonlyArray; +} + +interface FieldPlan { + argumentPlan: ArgumentPlan; + childTypeName?: string; + childrenCanNullParent?: boolean; + completedItemNonNull: boolean; + completedNonNull: boolean; + deferUsageIndexes: Array; + errorPropagation: boolean; + fieldName: string; + fieldNodeAccessors: Array; + inclusionCondition: string | undefined; + builtinScalarName?: BuiltInScalarVariablePlan['scalarName']; + outputKind: + | 'leaf' + | 'leafList' + | 'object' + | 'objectList' + | 'abstract' + | 'abstractList'; + objectTypeHasIsTypeOf?: boolean; + parentTypeName: string; + possibleFields?: Array; + resolveMode: 'customDefault' | 'default' | 'field'; + responseName: string; + fields?: Array; + streamPlan?: StreamPlan; +} + +type NonLeafFieldPlan = FieldPlan & { + outputKind: Exclude; +}; + +interface PossibleFieldsPlan { + fields: Array; + typeName: string; +} + +interface StreamPlan { + condition?: string; + initialCount: number; + label?: string; +} + +interface ArgumentPlan { + isConstant: boolean; + key: string; + objectSource: string; + returnExpression?: string; + statements: ReadonlyArray; +} + +interface GeneratedObjectProperty { + name: string; + value: string; +} + +interface VariableValuesPlan { + variables: ReadonlyArray; +} + +type VariablePlan = BuiltInScalarVariablePlan | CoercerVariablePlan; + +interface BuiltInScalarVariablePlan { + defaultValueSource?: string; + entryIndex: number; + kind: 'builtinScalar'; + name: string; + required: boolean; + scalarName: 'Boolean' | 'Float' | 'ID' | 'Int' | 'String'; + typeName: string; +} + +interface CoercerVariablePlan { + defaultValueSource?: string; + entryIndex: number; + kind: 'compiledCoercer'; + name: string; + required: boolean; + typeName: string; +} + +type InclusionPlan = + | { kind: 'include'; condition?: string } + | { kind: 'skip' } + | { kind: 'dynamic' }; + +type StaticDeferPlan = + | { kind: 'defer'; label?: string } + | { kind: 'dynamic' } + | { kind: 'disabled' } + | undefined; + +type ObjectOutputPlan = + | { + kind: 'object' | 'objectList'; + objectType: GraphQLObjectType; + } + | { + kind: 'abstract' | 'abstractList'; + possibleTypes: ReadonlyArray; + }; + +function getOperationPlan( + args: CompileExecutionArgs, + compiledExecution: Exclude< + ReturnType, + ReadonlyArray + >, +): OperationPlan | undefined { + const rootType = compiledExecution.schema.getRootType( + compiledExecution.operation.operation, + ); + if ( + rootType == null || + (compiledExecution.operation.operation !== 'query' && + compiledExecution.operation.operation !== 'mutation' && + compiledExecution.operation.operation !== 'subscription') + ) { + return undefined; + } + + const variableAvailability = getVariableAvailability( + compiledExecution.variableDefinitions, + ); + const deferUsages: Array = []; + const fields = planSelectionSet({ + deferUsageIndex: undefined, + deferUsages, + errorPropagation: compiledExecution.errorPropagation, + hasFallbackFieldResolver: args.fieldResolver != null, + hideSuggestions: compiledExecution.hideSuggestions, + schema: compiledExecution.schema, + fragments: compiledExecution.fragmentDefinitions, + inclusionCondition: undefined, + operationType: compiledExecution.operation.operation, + parentDeferUsageIndex: undefined, + parentType: rootType, + selectionSet: compiledExecution.operation.selectionSet, + selectionSetAccessor: 'staticOperation.selectionSet', + selectionSetPath: [], + skipUnknownFields: compiledExecution.operation.operation !== 'subscription', + variableAvailability, + visitedFragmentNames: new Set(), + }); + if (fields === undefined) { + return undefined; + } + if ( + deferUsages.some((deferUsage) => deferUsage.parentIndex !== undefined) || + (deferUsages.length > 0 && hasComplexGeneratedDeferShape(fields)) + ) { + return undefined; + } + return { + deferUsages, + fields, + operationType: compiledExecution.operation.operation, + rootTypeName: rootType.name, + serialRoot: compiledExecution.operation.operation === 'mutation', + }; +} + +function hasComplexGeneratedDeferShape( + fields: ReadonlyArray, +): boolean { + return fields.some((field) => { + if (field.deferUsageIndexes.length > 1) { + return true; + } + if ( + field.fields !== undefined && + hasComplexGeneratedDeferShape(field.fields) + ) { + return true; + } + return ( + field.possibleFields?.some((possibleField) => + hasComplexGeneratedDeferShape(possibleField.fields), + ) === true + ); + }); +} + +interface SelectionSetPlanContext { + deferUsageIndex: number | undefined; + deferUsages: Array; + errorPropagation: boolean; + schema: GraphQLSchema; + fragments: { readonly [fragmentName: string]: FragmentDefinitionNode }; + hasFallbackFieldResolver: boolean; + hideSuggestions: boolean; + inclusionCondition: string | undefined; + operationType: 'query' | 'mutation' | 'subscription'; + parentDeferUsageIndex: number | undefined; + parentType: GraphQLObjectType; + selectionSet: SelectionSetNode; + selectionSetAccessor: string; + selectionSetPath: ReadonlyArray; + skipUnknownFields: boolean; + variableAvailability: VariableAvailability; + visitedFragmentNames: Set; +} + +interface VariableAvailability { + alwaysDefined: ReadonlySet; + alwaysNonNull: ReadonlySet; +} + +function getVariableAvailability( + variableDefinitions: ReadonlyArray, +): VariableAvailability { + const alwaysDefinedVariables = new Set(); + const alwaysNonNullVariables = new Set(); + for (const variableDefinition of variableDefinitions) { + if (variableDefinition.type.kind === Kind.NON_NULL_TYPE) { + alwaysDefinedVariables.add(variableDefinition.variable.name.value); + alwaysNonNullVariables.add(variableDefinition.variable.name.value); + continue; + } + if (variableDefinition.defaultValue !== undefined) { + alwaysDefinedVariables.add(variableDefinition.variable.name.value); + } + } + return { + alwaysDefined: alwaysDefinedVariables, + alwaysNonNull: alwaysNonNullVariables, + }; +} + +function getVariableValuesPlan( + compiledExecution: Exclude< + ReturnType, + ReadonlyArray + >, +): VariableValuesPlan | undefined { + const variableDefinitions = compiledExecution.variableDefinitions; + if (variableDefinitions.length === 0) { + return undefined; + } + + const variables: Array = []; + for ( + let entryIndex = 0; + entryIndex < variableDefinitions.length; + entryIndex++ + ) { + const variableDefinition = variableDefinitions[entryIndex]; + const type = typeFromAST(compiledExecution.schema, variableDefinition.type); + if (type === undefined || !isInputType(type)) { + return undefined; + } + + const required = isNonNullType(type); + const nullableType = required ? type.ofType : type; + const builtinScalarName = getBuiltinScalarName(nullableType); + let defaultValueSource: string | undefined; + if (variableDefinition.defaultValue !== undefined) { + const defaultValue = coerceInputLiteral( + variableDefinition.defaultValue, + type, + ); + if (defaultValue === undefined) { + return undefined; + } + defaultValueSource = toSerializableJavaScript(defaultValue); + if (defaultValueSource === undefined) { + return undefined; + } + } + + const name = variableDefinition.variable.name.value; + if (builtinScalarName !== undefined) { + variables.push({ + entryIndex, + kind: 'builtinScalar', + name, + required, + scalarName: builtinScalarName, + typeName: builtinScalarName, + ...(defaultValueSource === undefined ? {} : { defaultValueSource }), + }); + continue; + } + + if (!isScalarType(nullableType) && !isEnumType(nullableType)) { + return undefined; + } + + const typeName = nullableType.name; + variables.push({ + entryIndex, + kind: 'compiledCoercer', + name, + required, + typeName, + ...(defaultValueSource === undefined ? {} : { defaultValueSource }), + }); + } + + return { variables }; +} + +function getBuiltinScalarName( + type: unknown, +): BuiltInScalarVariablePlan['scalarName'] | undefined { + switch (type) { + case GraphQLBoolean: + return 'Boolean'; + case GraphQLFloat: + return 'Float'; + case GraphQLID: + return 'ID'; + case GraphQLInt: + return 'Int'; + case GraphQLString: + return 'String'; + default: + return undefined; + } +} + +function getArgumentPlan( + fieldDef: GraphQLField, + fieldNode: FieldNode, + fieldNodeAccessor: string, + hideSuggestions: boolean, + variableAvailability: VariableAvailability, +): ArgumentPlan | undefined { + const compiledArgumentValues = compileArgumentValues( + fieldDef, + fieldNode, + hideSuggestions, + undefined, + ); + if (compiledArgumentValues.entries.length === 0) { + return { + isConstant: true, + key: 'constant:', + objectSource: generatedNullPrototypeObjectSource(), + statements: [], + }; + } + + const statements: Array = []; + const alwaysAssignedArgumentNames = new Set(); + const properties: Array = []; + let isConstant = true; + for (const entry of compiledArgumentValues.entries) { + switch (entry.kind) { + case 'constant': { + const valueSource = toSerializableJavaScript(entry.value); + if (valueSource === undefined) { + return undefined; + } + statements.push( + ` ${argumentPropertyAssignment(entry.name)} = ${valueSource};`, + ); + alwaysAssignedArgumentNames.add(entry.name); + properties.push({ name: entry.name, value: valueSource }); + break; + } + case 'bareVariable': { + isConstant = false; + const variableNameSource = toJavaScript(entry.variableName); + const coercedVariableSource = generatedObjectAssignmentSource( + 'coerced', + entry.variableName, + ); + if (entry.isNonNull) { + if (variableAvailability.alwaysNonNull.has(entry.variableName)) { + statements.push( + ` ${argumentPropertyAssignment( + entry.name, + )} = ${coercedVariableSource};`, + ); + alwaysAssignedArgumentNames.add(entry.name); + properties.push({ + name: entry.name, + value: coercedVariableSource, + }); + break; + } + + const valueNodeAccessor = variableArgumentValueNodeAccessor( + fieldNode, + fieldNodeAccessor, + entry.name, + ); + const valueName = `value${statements.length}`; + let missingValueSource: string; + if (entry.defaultValue === undefined) { + missingValueSource = ` throw ${generatedInvalidArgumentVariableValueSource( + entry, + valueNodeAccessor, + 'missing', + )};`; + } else { + const defaultValueSource = toSerializableJavaScript( + entry.defaultValue, + ); + if (defaultValueSource === undefined) { + return undefined; + } + missingValueSource = ` ${argumentPropertyAssignment( + entry.name, + )} = ${defaultValueSource};`; + } + statements.push(` if (${variableNameSource} in coerced) { + const ${valueName} = ${coercedVariableSource}; + if (${valueName} == null) { + throw ${generatedInvalidArgumentVariableValueSource( + entry, + valueNodeAccessor, + 'null', + )}; + } + ${argumentPropertyAssignment(entry.name)} = ${valueName}; + } else { +${missingValueSource} + }`); + alwaysAssignedArgumentNames.add(entry.name); + break; + } + + if (variableAvailability.alwaysDefined.has(entry.variableName)) { + statements.push( + ` ${argumentPropertyAssignment( + entry.name, + )} = ${coercedVariableSource};`, + ); + alwaysAssignedArgumentNames.add(entry.name); + properties.push({ + name: entry.name, + value: coercedVariableSource, + }); + break; + } + + if (entry.defaultValue !== undefined) { + const defaultValueSource = toSerializableJavaScript( + entry.defaultValue, + ); + if (defaultValueSource === undefined) { + return undefined; + } + statements.push(` if (${variableNameSource} in coerced) { + ${argumentPropertyAssignment(entry.name)} = ${coercedVariableSource}; + } else { + ${argumentPropertyAssignment(entry.name)} = ${defaultValueSource}; + }`); + alwaysAssignedArgumentNames.add(entry.name); + const valueSource = `${variableNameSource} in coerced ? ${coercedVariableSource} : ${defaultValueSource}`; + properties.push({ name: entry.name, value: valueSource }); + break; + } + + statements.push(` if (${variableNameSource} in coerced) { + ${argumentPropertyAssignment(entry.name)} = ${coercedVariableSource}; + }`); + break; + } + case 'embeddedVariable': { + isConstant = false; + const valueExpression = inputValueExpression( + entry.valueNode, + entry.argDef.type, + variableAvailability, + ); + if (valueExpression === undefined) { + return undefined; + } + statements.push( + ` ${argumentPropertyAssignment(entry.name)} = ${valueExpression};`, + ); + alwaysAssignedArgumentNames.add(entry.name); + properties.push({ name: entry.name, value: valueExpression }); + break; + } + case 'invalidLiteral': + case 'invalidDefault': + case 'missing': + return undefined; + } + } + + const objectSource = isConstant + ? generatedArgumentObjectSource(fieldDef, alwaysAssignedArgumentNames) + : generatedNullPrototypeObjectSource(); + const returnExpression = + !isConstant || properties.length !== fieldDef.args.length + ? undefined + : generatedNullPrototypeObjectSource(properties); + return { + isConstant, + key: `${isConstant ? 'constant' : 'runtime'}:${ + returnExpression ?? objectSource + }:${statements.join('\n')}`, + objectSource, + ...(returnExpression === undefined ? {} : { returnExpression }), + statements, + }; +} + +function variableArgumentValueNodeAccessor( + fieldNode: FieldNode, + fieldNodeAccessor: string, + argumentName: string, +): string { + const argumentIndex = fieldNode.arguments?.findIndex( + (argumentNode) => argumentNode.name.value === argumentName, + ); + invariant(argumentIndex !== undefined && argumentIndex !== -1); + return `${fieldNodeAccessor}.arguments[${String(argumentIndex)}].value`; +} + +function generatedInvalidArgumentVariableValueSource( + entry: BareVariableArgumentValueEntry, + valueNodeAccessor: string, + reason: 'missing' | 'null', +): string { + const variableName = entry.variableName; + const type = String(entry.argDef.type); + const reasonMessage = + reason === 'missing' + ? `Expected variable "$${variableName}" provided to type "${type}" to provide a runtime value.` + : `Expected variable "$${variableName}" provided to non-null type "${type}" not to be null.`; + return `new GraphQLError(${toJavaScript( + `Argument "${entry.argDef}" has invalid value: ${reasonMessage}`, + )}, { nodes: ${valueNodeAccessor} })`; +} + +function generatedArgumentObjectSource( + fieldDef: GraphQLField, + alwaysAssignedArgumentNames: ReadonlySet, +): string { + const properties = fieldDef.args + .filter((arg) => alwaysAssignedArgumentNames.has(arg.name)) + .map((arg) => ({ name: arg.name, value: 'undefined' })); + return generatedNullPrototypeObjectSource(properties); +} + +function generatedNullPrototypeObjectSource( + properties: ReadonlyArray = [], +): string { + if (properties.length === 0) { + return 'Object.create(null)'; + } + + const assignments = properties.map( + (property) => + `${generatedObjectAssignmentSource('object', property.name)} = ${ + property.value + };`, + ); + return `(() => { + const object = Object.create(null); + ${assignments.join('\n ')} + return object; +})()`; +} + +function generatedObjectAssignmentSource( + objectName: string, + propertyName: string, +): string { + return /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u.test(propertyName) + ? `${objectName}.${propertyName}` + : `${objectName}[${toJavaScript(propertyName)}]`; +} + +function argumentPropertyAssignment(argumentName: string): string { + return generatedObjectAssignmentSource('args', argumentName); +} + +function inputValueExpression( + valueNode: ValueNode, + type: GraphQLInputType, + variableAvailability: VariableAvailability, +): string | undefined { + if (!inputLiteralContainsVariable(valueNode)) { + const coerced = coerceInputLiteral(valueNode, type); + return coerced === undefined + ? undefined + : toSerializableJavaScript(coerced); + } + + const isRequired = isNonNullType(type); + const nullableType = isRequired ? type.ofType : type; + + if (valueNode.kind === Kind.VARIABLE) { + return (isRequired + ? variableAvailability.alwaysNonNull + : variableAvailability.alwaysDefined + ).has(valueNode.name.value) + ? `coerced[${toJavaScript(valueNode.name.value)}]` + : undefined; + } + + if (valueNode.kind === Kind.LIST) { + if (!isListType(nullableType)) { + return undefined; + } + const itemType = nullableType.ofType; + const itemExpressions = []; + for (const itemNode of valueNode.values) { + const itemExpression = inputValueExpression( + itemNode, + itemType, + variableAvailability, + ); + if (itemExpression === undefined) { + return undefined; + } + itemExpressions.push(itemExpression); + } + return `[${itemExpressions.join(', ')}]`; + } + + const objectValueNode = valueNode as ObjectValueNode; + + if (!isInputObjectType(nullableType) || nullableType.isOneOf) { + return undefined; + } + + const fields = nullableType.getFields(); + const providedFieldNames = new Set(); + const fieldStatements: Array = []; + for (const fieldNode of objectValueNode.fields) { + const fieldName = fieldNode.name.value; + const field = fields[fieldName]; + if (field === undefined) { + return undefined; + } + providedFieldNames.add(fieldName); + const fieldExpression = inputValueExpression( + fieldNode.value, + field.type, + variableAvailability, + ); + if (fieldExpression === undefined) { + return undefined; + } + fieldStatements.push( + `${generatedObjectAssignmentSource( + 'object', + fieldName, + )} = ${fieldExpression};`, + ); + } + + for (const field of Object.values(fields)) { + if ( + !providedFieldNames.has(field.name) && + (field.default !== undefined || field.defaultValue !== undefined) + ) { + const defaultValue = coerceDefaultValue(field); + if (defaultValue !== undefined) { + const defaultValueSource = toSerializableJavaScript(defaultValue); + if (defaultValueSource === undefined) { + return undefined; + } + fieldStatements.push( + `${generatedObjectAssignmentSource( + 'object', + field.name, + )} = ${defaultValueSource};`, + ); + } + } else if ( + !providedFieldNames.has(field.name) && + isRequiredInputField(field) + ) { + return undefined; + } + } + + const objectSource = generatedNullPrototypeObjectSource(); + return `(() => { + const object = ${objectSource}; + ${fieldStatements.join('\n ')} + return object; + })()`; +} + +function inputLiteralContainsVariable(valueNode: ValueNode): boolean { + switch (valueNode.kind) { + case Kind.VARIABLE: + return true; + case Kind.LIST: + return valueNode.values.some(inputLiteralContainsVariable); + case Kind.OBJECT: + return valueNode.fields.some((fieldNode) => + inputLiteralContainsVariable(fieldNode.value), + ); + case Kind.NULL: + case Kind.INT: + case Kind.FLOAT: + case Kind.STRING: + case Kind.BOOLEAN: + case Kind.ENUM: + return false; + } +} + +function getStaticInclusionPlan( + directives: ReadonlyArray | undefined, + variableAvailability: VariableAvailability, +): InclusionPlan { + if (directives === undefined || directives.length === 0) { + return { kind: 'include' }; + } + + const conditions: Array = []; + for (const directive of directives) { + const directiveName = directive.name.value; + if (directiveName !== 'skip' && directiveName !== 'include') { + if (directiveName === 'stream') { + continue; + } + continue; + } + + const ifArgument = directive.arguments?.find( + (argument) => argument.name.value === 'if', + ); + if (ifArgument === undefined) { + return { kind: 'dynamic' }; + } + + const ifExpression = directiveIfExpression( + ifArgument.value, + variableAvailability, + ); + if (ifExpression === undefined) { + return { kind: 'dynamic' }; + } + + if (directiveName === 'skip') { + if (ifExpression === true) { + return { kind: 'skip' }; + } + if (ifExpression !== false) { + conditions.push(`${ifExpression} !== true`); + } + continue; + } + + if (ifExpression === false) { + return { kind: 'skip' }; + } + if (ifExpression !== true) { + conditions.push(`${ifExpression} === true`); + } + } + + return conditions.length === 0 + ? { kind: 'include' } + : { kind: 'include', condition: conditions.join(' && ') }; +} + +function getStaticDeferPlan( + directives: ReadonlyArray | undefined, +): StaticDeferPlan { + const deferDirective = directives?.find( + (directive) => directive.name.value === 'defer', + ); + if (deferDirective === undefined) { + return; + } + + let label: string | undefined; + for (const argument of deferDirective.arguments ?? []) { + switch (argument.name.value) { + case 'if': + if (argument.value.kind === Kind.BOOLEAN) { + if (!argument.value.value) { + return { kind: 'disabled' }; + } + break; + } + return { kind: 'dynamic' }; + case 'label': + if (argument.value.kind === Kind.STRING) { + label = argument.value.value; + } else if (argument.value.kind !== Kind.NULL) { + return { kind: 'dynamic' }; + } + break; + } + } + + return { + kind: 'defer', + ...(label === undefined ? {} : { label }), + }; +} + +function getStaticStreamPlan( + directives: ReadonlyArray | undefined, +): StreamPlan | 'dynamic' | undefined { + const streamDirective = directives?.find( + (directive) => directive.name.value === 'stream', + ); + if (streamDirective === undefined) { + return; + } + + let condition: string | undefined; + let initialCount = 0; + let label: string | undefined; + for (const argument of streamDirective.arguments ?? []) { + switch (argument.name.value) { + case 'if': { + const ifExpression = incrementalDirectiveIfExpression(argument.value); + if (ifExpression === undefined) { + return 'dynamic'; + } + if (ifExpression === false) { + return; + } + if (ifExpression !== true) { + condition = ifExpression; + } + break; + } + case 'initialCount': + if (argument.value.kind !== Kind.INT) { + return 'dynamic'; + } + initialCount = Number(argument.value.value); + break; + case 'label': + if (argument.value.kind === Kind.STRING) { + label = argument.value.value; + } else if (argument.value.kind !== Kind.NULL) { + return 'dynamic'; + } + break; + } + } + + return { + initialCount, + ...(condition === undefined ? {} : { condition }), + ...(label === undefined ? {} : { label }), + }; +} + +function incrementalDirectiveIfExpression( + valueNode: ValueNode, +): boolean | string | undefined { + switch (valueNode.kind) { + case Kind.BOOLEAN: + return valueNode.value; + case Kind.VARIABLE: + return `coerced[${toJavaScript(valueNode.name.value)}] !== false`; + case Kind.NULL: + case Kind.INT: + case Kind.FLOAT: + case Kind.STRING: + case Kind.ENUM: + case Kind.LIST: + case Kind.OBJECT: + return undefined; + } +} + +function directiveIfExpression( + valueNode: ValueNode, + variableAvailability: VariableAvailability, +): boolean | string | undefined { + switch (valueNode.kind) { + case Kind.BOOLEAN: + return valueNode.value; + case Kind.VARIABLE: + return variableAvailability.alwaysNonNull.has(valueNode.name.value) + ? `coerced[${toJavaScript(valueNode.name.value)}]` + : undefined; + case Kind.NULL: + case Kind.INT: + case Kind.FLOAT: + case Kind.STRING: + case Kind.ENUM: + case Kind.LIST: + case Kind.OBJECT: + return undefined; + } +} + +function combineInclusionConditions( + left: string | undefined, + right: string | undefined, +): string | undefined { + if (left === undefined) { + return right; + } + if (right === undefined) { + return left; + } + return `${left} && ${right}`; +} + +function addDeferUsagePlan( + deferUsages: Array, + selectionSetPath: ReadonlyArray, + parentIndex: number | undefined, + deferPlan: Extract, +): number { + const index = deferUsages.length; + deferUsages.push({ + index, + selectionSetPath, + ...(parentIndex === undefined ? {} : { parentIndex }), + ...(deferPlan.label === undefined ? {} : { label: deferPlan.label }), + }); + return index; +} + +function planSelectionSet({ + deferUsageIndex, + deferUsages, + errorPropagation, + schema, + fragments, + hasFallbackFieldResolver, + hideSuggestions, + inclusionCondition, + operationType, + parentDeferUsageIndex, + parentType, + selectionSet, + selectionSetAccessor, + selectionSetPath, + skipUnknownFields, + variableAvailability, + visitedFragmentNames, +}: SelectionSetPlanContext): Array | undefined { + const fieldsByResponseName = new Map(); + const selections = selectionSet.selections; + for ( + let selectionIndex = 0; + selectionIndex < selections.length; + selectionIndex++ + ) { + const selection = selections[selectionIndex]; + if (selection.kind === 'FragmentSpread') { + if ((selection.arguments?.length ?? 0) !== 0) { + return undefined; + } + const fragmentInclusionPlan = getStaticInclusionPlan( + selection.directives, + variableAvailability, + ); + if (fragmentInclusionPlan.kind === 'dynamic') { + return undefined; + } + if (fragmentInclusionPlan.kind === 'skip') { + continue; + } + const fragmentDeferPlan = getStaticDeferPlan(selection.directives); + if (fragmentDeferPlan?.kind === 'dynamic') { + return undefined; + } + const nextDeferUsageIndex = + fragmentDeferPlan?.kind === 'defer' + ? addDeferUsagePlan( + deferUsages, + selectionSetPath, + parentDeferUsageIndex, + fragmentDeferPlan, + ) + : deferUsageIndex; + const fragmentName = selection.name.value; + const nextInclusionCondition = combineInclusionConditions( + inclusionCondition, + fragmentInclusionPlan.condition, + ); + const visitedFragmentKey = + nextInclusionCondition === undefined + ? `${fragmentName}:${String(nextDeferUsageIndex)}` + : `${fragmentName}:${nextInclusionCondition}:${String( + nextDeferUsageIndex, + )}`; + if (visitedFragmentNames.has(visitedFragmentKey)) { + continue; + } + const fragment = fragments[fragmentName]; + if (fragment === undefined) { + continue; + } + if ((fragment.variableDefinitions?.length ?? 0) !== 0) { + return undefined; + } + if (!fragmentConditionApplies(schema, parentType, fragment)) { + continue; + } + visitedFragmentNames.add(visitedFragmentKey); + const fragmentFields = planSelectionSet({ + deferUsageIndex: nextDeferUsageIndex, + deferUsages, + errorPropagation, + schema, + fragments, + hasFallbackFieldResolver, + hideSuggestions, + inclusionCondition: nextInclusionCondition, + operationType, + parentDeferUsageIndex: nextDeferUsageIndex ?? parentDeferUsageIndex, + parentType, + selectionSet: fragment.selectionSet, + selectionSetAccessor: `staticFragmentDefinitions[${toJavaScript( + fragmentName, + )}].selectionSet`, + selectionSetPath, + skipUnknownFields, + variableAvailability, + visitedFragmentNames, + }); + if ( + fragmentFields === undefined || + !mergeFieldPlans(fieldsByResponseName, fragmentFields) + ) { + return undefined; + } + continue; + } + + if (selection.kind === 'InlineFragment') { + const fragmentInclusionPlan = getStaticInclusionPlan( + selection.directives, + variableAvailability, + ); + if (fragmentInclusionPlan.kind === 'dynamic') { + return undefined; + } + if (fragmentInclusionPlan.kind === 'skip') { + continue; + } + const fragmentDeferPlan = getStaticDeferPlan(selection.directives); + if (fragmentDeferPlan?.kind === 'dynamic') { + return undefined; + } + if (!fragmentConditionApplies(schema, parentType, selection)) { + continue; + } + const nextDeferUsageIndex = + fragmentDeferPlan?.kind === 'defer' + ? addDeferUsagePlan( + deferUsages, + selectionSetPath, + parentDeferUsageIndex, + fragmentDeferPlan, + ) + : deferUsageIndex; + const fragmentFields = planSelectionSet({ + deferUsageIndex: nextDeferUsageIndex, + deferUsages, + errorPropagation, + schema, + fragments, + hasFallbackFieldResolver, + hideSuggestions, + inclusionCondition: combineInclusionConditions( + inclusionCondition, + fragmentInclusionPlan.condition, + ), + operationType, + parentDeferUsageIndex: nextDeferUsageIndex ?? parentDeferUsageIndex, + parentType, + selectionSet: selection.selectionSet, + selectionSetAccessor: `${selectionSetAccessor}.selections[${selectionIndex}].selectionSet`, + selectionSetPath, + skipUnknownFields, + variableAvailability, + visitedFragmentNames, + }); + if ( + fragmentFields === undefined || + !mergeFieldPlans(fieldsByResponseName, fragmentFields) + ) { + return undefined; + } + continue; + } + + const inclusionPlan = getStaticInclusionPlan( + selection.directives, + variableAvailability, + ); + if (inclusionPlan.kind === 'dynamic') { + return undefined; + } + if (inclusionPlan.kind === 'skip') { + continue; + } + + const fieldDef = schema.getField(parentType, selection.name.value); + if (fieldDef === undefined) { + if (skipUnknownFields) { + continue; + } + return undefined; + } + const streamPlan = getStaticStreamPlan(selection.directives); + if ( + streamPlan === 'dynamic' || + (operationType === 'subscription' && streamPlan !== undefined) + ) { + return undefined; + } + const argumentPlan = getArgumentPlan( + fieldDef, + selection, + `${selectionSetAccessor}.selections[${selectionIndex}]`, + hideSuggestions, + variableAvailability, + ); + if (argumentPlan === undefined) { + return undefined; + } + const resolveMode = + fieldDef.resolve === undefined + ? hasFallbackFieldResolver + ? 'customDefault' + : 'default' + : 'field'; + + if (selection.selectionSet === undefined) { + const outputKind = getLeafOutputKind(fieldDef.type); + if (outputKind === undefined) { + return undefined; + } + const builtinScalarName = getBuiltinLeafScalarName( + fieldDef.type, + outputKind, + ); + if (streamPlan !== undefined && outputKind !== 'leafList') { + return undefined; + } + if ( + !mergeFieldPlan(fieldsByResponseName, { + argumentPlan, + completedItemNonNull: getCompletedItemNonNull(fieldDef.type), + completedNonNull: isNonNullType(fieldDef.type), + deferUsageIndexes: [deferUsageIndex], + errorPropagation, + fieldName: selection.name.value, + fieldNodeAccessors: [ + `${selectionSetAccessor}.selections[${selectionIndex}]`, + ], + inclusionCondition: combineInclusionConditions( + inclusionCondition, + inclusionPlan.condition, + ), + ...(builtinScalarName === undefined ? {} : { builtinScalarName }), + outputKind, + parentTypeName: parentType.name, + resolveMode, + responseName: selection.alias?.value ?? selection.name.value, + ...(streamPlan === undefined ? {} : { streamPlan }), + }) + ) { + return undefined; + } + continue; + } + + const objectOutput = getObjectOutputPlan(schema, fieldDef.type); + if (objectOutput === undefined) { + return undefined; + } + if ( + streamPlan !== undefined && + objectOutput.kind !== 'objectList' && + objectOutput.kind !== 'abstractList' + ) { + return undefined; + } + let childFields: Array | undefined; + let possibleFields: Array | undefined; + let childTypeName: string | undefined; + let objectTypeHasIsTypeOf: boolean | undefined; + const childParentDeferUsageIndex = deferUsageIndex ?? parentDeferUsageIndex; + switch (objectOutput.kind) { + case 'object': + case 'objectList': + objectTypeHasIsTypeOf = objectOutput.objectType.isTypeOf !== undefined; + childFields = planSelectionSet({ + deferUsageIndex: undefined, + deferUsages, + errorPropagation, + schema, + fragments, + hasFallbackFieldResolver, + hideSuggestions, + inclusionCondition: undefined, + operationType, + parentDeferUsageIndex: childParentDeferUsageIndex, + parentType: objectOutput.objectType, + selectionSet: selection.selectionSet, + selectionSetAccessor: `${selectionSetAccessor}.selections[${selectionIndex}].selectionSet`, + selectionSetPath: [...selectionSetPath, selectionIndex], + skipUnknownFields: true, + variableAvailability, + visitedFragmentNames: new Set(), + }); + if (childFields === undefined) { + return undefined; + } + childTypeName = objectOutput.objectType.name; + break; + case 'abstract': + case 'abstractList': + possibleFields = []; + for ( + let possibleIndex = 0; + possibleIndex < objectOutput.possibleTypes.length; + possibleIndex++ + ) { + const possibleType = objectOutput.possibleTypes[possibleIndex]; + const fieldsForType = planSelectionSet({ + deferUsageIndex: undefined, + deferUsages, + errorPropagation, + schema, + fragments, + hasFallbackFieldResolver, + hideSuggestions, + inclusionCondition: undefined, + operationType, + parentDeferUsageIndex: childParentDeferUsageIndex, + parentType: possibleType, + selectionSet: selection.selectionSet, + selectionSetAccessor: `${selectionSetAccessor}.selections[${selectionIndex}].selectionSet`, + selectionSetPath: [ + ...selectionSetPath, + selectionIndex, + possibleIndex, + ], + skipUnknownFields: true, + variableAvailability, + visitedFragmentNames: new Set(), + }); + if (fieldsForType === undefined) { + return undefined; + } + possibleFields.push({ + fields: fieldsForType, + typeName: possibleType.name, + }); + } + } + if ( + !mergeFieldPlan(fieldsByResponseName, { + argumentPlan, + ...((childFields !== undefined && + selectionSetCanNullParent(childFields)) || + possibleFields?.some((possibleField) => + selectionSetCanNullParent(possibleField.fields), + ) === true + ? { childrenCanNullParent: true } + : {}), + completedItemNonNull: getCompletedItemNonNull(fieldDef.type), + completedNonNull: isNonNullType(fieldDef.type), + deferUsageIndexes: [deferUsageIndex], + errorPropagation, + fieldName: selection.name.value, + fieldNodeAccessors: [ + `${selectionSetAccessor}.selections[${selectionIndex}]`, + ], + inclusionCondition: combineInclusionConditions( + inclusionCondition, + inclusionPlan.condition, + ), + outputKind: objectOutput.kind, + parentTypeName: parentType.name, + resolveMode, + responseName: selection.alias?.value ?? selection.name.value, + ...(streamPlan === undefined ? {} : { streamPlan }), + ...(childFields === undefined ? {} : { fields: childFields }), + ...(childTypeName === undefined ? {} : { childTypeName }), + ...(objectTypeHasIsTypeOf === undefined + ? {} + : { objectTypeHasIsTypeOf }), + ...(possibleFields === undefined ? {} : { possibleFields }), + }) + ) { + return undefined; + } + } + + return [...fieldsByResponseName.values()]; +} + +function selectionSetCanNullParent(fields: ReadonlyArray): boolean { + return fields.some( + (field) => + field.errorPropagation && + field.completedNonNull && + !isGeneratedTypenameField(field), + ); +} + +function mergeFieldPlans( + fieldsByResponseName: Map, + fields: ReadonlyArray, +): boolean { + for (const field of fields) { + if (!mergeFieldPlan(fieldsByResponseName, field)) { + return false; + } + } + return true; +} + +function mergeFieldPlan( + fieldsByResponseName: Map, + field: FieldPlan, +): boolean { + const existingField = fieldsByResponseName.get(field.responseName); + if (existingField === undefined) { + fieldsByResponseName.set(field.responseName, field); + return true; + } + + if (existingField.fieldName !== field.fieldName) { + return false; + } + + if (!canMergeFieldPlans(existingField, field)) { + return false; + } + + existingField.fieldNodeAccessors.push(...field.fieldNodeAccessors); + existingField.deferUsageIndexes.push(...field.deferUsageIndexes); + if (existingField.fields !== undefined && field.fields !== undefined) { + const fieldsByChildResponseName = new Map( + existingField.fields.map((childField) => [ + childField.responseName, + childField, + ]), + ); + if (!mergeFieldPlans(fieldsByChildResponseName, field.fields)) { + return false; + } + existingField.fields = [...fieldsByChildResponseName.values()]; + } + if ( + existingField.possibleFields !== undefined && + field.possibleFields !== undefined + ) { + for ( + let possibleIndex = 0; + possibleIndex < existingField.possibleFields.length; + possibleIndex++ + ) { + const existingPossibleField = existingField.possibleFields[possibleIndex]; + const possibleField = field.possibleFields[possibleIndex]; + const fieldsByChildResponseName = new Map( + existingPossibleField.fields.map((childField) => [ + childField.responseName, + childField, + ]), + ); + if (!mergeFieldPlans(fieldsByChildResponseName, possibleField.fields)) { + return false; + } + existingPossibleField.fields = [...fieldsByChildResponseName.values()]; + } + } + if ( + (existingField.fields !== undefined && + selectionSetCanNullParent(existingField.fields)) || + existingField.possibleFields?.some((possibleField) => + selectionSetCanNullParent(possibleField.fields), + ) === true + ) { + existingField.childrenCanNullParent = true; + } else { + delete existingField.childrenCanNullParent; + } + return true; +} + +function canMergeFieldPlans(left: FieldPlan, right: FieldPlan): boolean { + return ( + left.argumentPlan.key === right.argumentPlan.key && + left.childTypeName === right.childTypeName && + left.completedItemNonNull === right.completedItemNonNull && + left.completedNonNull === right.completedNonNull && + left.errorPropagation === right.errorPropagation && + left.inclusionCondition === right.inclusionCondition && + left.builtinScalarName === right.builtinScalarName && + left.objectTypeHasIsTypeOf === right.objectTypeHasIsTypeOf && + left.outputKind === right.outputKind && + left.parentTypeName === right.parentTypeName && + left.resolveMode === right.resolveMode && + haveSameStreamPlan(left.streamPlan, right.streamPlan) && + (left.fields === undefined) === (right.fields === undefined) && + haveSamePossibleFieldTypes(left, right) + ); +} + +function haveSameStreamPlan( + left: StreamPlan | undefined, + right: StreamPlan | undefined, +): boolean { + return ( + left?.condition === right?.condition && + left?.initialCount === right?.initialCount && + left?.label === right?.label + ); +} + +function haveSamePossibleFieldTypes( + left: FieldPlan, + right: FieldPlan, +): boolean { + if (left.possibleFields === undefined) { + return true; + } + const rightPossibleFields = right.possibleFields as Array; + return ( + left.possibleFields.length === rightPossibleFields.length && + left.possibleFields.every( + (possibleField, index) => + possibleField.typeName === rightPossibleFields[index].typeName, + ) + ); +} + +function fragmentConditionApplies( + schema: GraphQLSchema, + parentType: GraphQLObjectType, + fragment: FragmentDefinitionNode | InlineFragmentNode, +): boolean { + const typeCondition = fragment.typeCondition; + if (typeCondition === undefined) { + return true; + } + + const conditionalType = typeFromAST(schema, typeCondition); + if (conditionalType === parentType) { + return true; + } + return isAbstractType(conditionalType) + ? schema.isSubType(conditionalType, parentType) + : false; +} + +function getLeafOutputKind( + type: GraphQLOutputType, +): 'leaf' | 'leafList' | undefined { + let nullableType = type; + if (isNonNullType(nullableType)) { + nullableType = nullableType.ofType; + } + if (isLeafType(nullableType)) { + return 'leaf'; + } + if (!isListType(nullableType)) { + return undefined; + } + + let itemType = nullableType.ofType; + if (isNonNullType(itemType)) { + itemType = itemType.ofType; + } + return isLeafType(itemType) ? 'leafList' : undefined; +} + +function getBuiltinLeafScalarName( + type: GraphQLOutputType, + outputKind: 'leaf' | 'leafList', +): BuiltInScalarVariablePlan['scalarName'] | undefined { + let nullableType = type; + if (isNonNullType(nullableType)) { + nullableType = nullableType.ofType; + } + if (outputKind === 'leaf') { + return getBuiltinScalarName(nullableType); + } + + invariant(isListType(nullableType)); + let itemType = nullableType.ofType; + if (isNonNullType(itemType)) { + itemType = itemType.ofType; + } + return getBuiltinScalarName(itemType); +} + +function getCompletedItemNonNull(type: GraphQLOutputType): boolean { + let nullableType = type; + if (isNonNullType(nullableType)) { + nullableType = nullableType.ofType; + } + return isListType(nullableType) && isNonNullType(nullableType.ofType); +} + +function getObjectOutputPlan( + schema: GraphQLSchema, + type: GraphQLOutputType, +): ObjectOutputPlan | undefined { + let nullableType = type; + if (isNonNullType(nullableType)) { + nullableType = nullableType.ofType; + } + if (isObjectType(nullableType)) { + return { kind: 'object', objectType: nullableType }; + } + if (isAbstractType(nullableType)) { + return { + kind: 'abstract', + possibleTypes: schema.getPossibleTypes(nullableType), + }; + } + if (!isListType(nullableType)) { + return undefined; + } + let itemType = nullableType.ofType; + if (isNonNullType(itemType)) { + itemType = itemType.ofType; + } + if (isObjectType(itemType)) { + return { kind: 'objectList', objectType: itemType }; + } + if (isAbstractType(itemType)) { + return { + kind: 'abstractList', + possibleTypes: schema.getPossibleTypes(itemType), + }; + } + return undefined; +} + +function operationSetup( + operationPlan: OperationPlan, + includeSubscriptionSource: boolean, +): string { + const subscriptionSourceSetup = + includeSubscriptionSource && operationPlan.operationType === 'subscription' + ? ` const executeGeneratedSubscriptionSource = + generatedRootType == null + ? undefined + : bindGeneratedSubscriptionSource( + compiledExecution, + generatedRootType, + undefined, + ); +` + : ''; + + return ` const generatedRootType = compiledExecution.schema.getRootType( + ${toJavaScript(operationPlan.operationType)}, + ); + const executeGeneratedRootFields = + generatedRootType == null + ? undefined + : bindGeneratedRootFields( + compiledExecution, + generatedRootType, + undefined, + ); +${subscriptionSourceSetup}`; +} + +function fieldBindingFunctionName( + selectionSetPath: ReadonlyArray, + parentTypeName: string, +): string { + return selectionSetPath.length === 0 + ? 'bindGeneratedRootFields' + : `bindGenerated${toIdentifierPart( + parentTypeName, + )}Fields${selectionSetPath.join('_')}`; +} + +function abstractFieldBindingFunctionName( + fieldPath: ReadonlyArray, + fieldName: string, +): string { + return `bindGenerated${toIdentifierPart( + fieldName, + )}AbstractFields${fieldPath.join('_')}`; +} + +function executeFunctionName( + selectionSetPath: ReadonlyArray, + parentTypeName: string, +): string { + return selectionSetPath.length === 0 + ? `executeGenerated${toIdentifierPart(parentTypeName)}RootFields` + : `executeGenerated${toIdentifierPart( + parentTypeName, + )}SelectionSet${selectionSetPath.join('_')}`; +} + +function abstractExecuteFunctionName( + fieldPath: ReadonlyArray, + fieldName: string, +): string { + return `executeGenerated${toIdentifierPart( + fieldName, + )}AbstractSelectionSet${fieldPath.join('_')}`; +} + +function argumentFunctionName(fieldPath: ReadonlyArray): string { + return `getGeneratedArgumentValues${fieldPath.join('_')}`; +} + +function generatedArgumentFunctions(operationPlan: OperationPlan): string { + const chunks: Array = []; + emitArgumentFunctions([], operationPlan.fields); + return chunks.join('\n\n'); + + function emitArgumentFunctions( + selectionSetPath: ReadonlyArray, + fields: ReadonlyArray, + ): void { + fields.forEach((field, index) => { + const fieldPath = [...selectionSetPath, index]; + if (requiresGeneratedArgumentFunction(field.argumentPlan)) { + chunks.push( + generatedArgumentFunctionSource( + argumentFunctionName(fieldPath), + field.argumentPlan, + ), + ); + } + if (field.fields !== undefined) { + emitArgumentFunctions(fieldPath, field.fields); + } + if (field.possibleFields !== undefined) { + field.possibleFields.forEach((possibleField, possibleIndex) => { + emitArgumentFunctions( + [...fieldPath, possibleIndex], + possibleField.fields, + ); + }); + } + }); + } +} + +function requiresGeneratedArgumentFunction( + argumentPlan: ArgumentPlan, +): boolean { + return argumentPlan.isConstant && hasGeneratedArgumentValues(argumentPlan); +} + +function hasGeneratedArgumentValues(argumentPlan: ArgumentPlan): boolean { + return ( + argumentPlan.statements.length !== 0 || + argumentPlan.returnExpression !== undefined || + argumentPlan.objectSource !== 'Object.create(null)' + ); +} + +function hasGeneratedMetaFields(operationPlan: OperationPlan): boolean { + return operationPlan.fields.some(hasGeneratedMetaField); +} + +function hasGeneratedMetaField(field: FieldPlan): boolean { + return ( + isGeneratedMetaFieldDefName(field.fieldName) || + field.fields?.some(hasGeneratedMetaField) === true || + field.possibleFields?.some((possibleField) => + possibleField.fields.some(hasGeneratedMetaField), + ) === true + ); +} + +function hasGeneratedListFields(operationPlan: OperationPlan): boolean { + return operationPlan.fields.some(hasGeneratedListField); +} + +function hasGeneratedListField(field: FieldPlan): boolean { + return ( + field.outputKind === 'leafList' || + field.outputKind === 'objectList' || + field.outputKind === 'abstractList' || + field.fields?.some(hasGeneratedListField) === true || + field.possibleFields?.some((possibleField) => + possibleField.fields.some(hasGeneratedListField), + ) === true + ); +} + +function hasGeneratedAbstractFields(operationPlan: OperationPlan): boolean { + return operationPlan.fields.some(hasGeneratedAbstractField); +} + +function hasGeneratedAbstractField(field: FieldPlan): boolean { + return ( + field.outputKind === 'abstract' || + field.outputKind === 'abstractList' || + field.fields?.some(hasGeneratedAbstractField) === true + ); +} + +function hasGeneratedIncrementalFields(operationPlan: OperationPlan): boolean { + return ( + operationPlan.deferUsages.length !== 0 || + operationPlan.fields.some(hasGeneratedStreamField) + ); +} + +function hasGeneratedStreamField(field: FieldPlan): boolean { + return ( + field.streamPlan !== undefined || + field.fields?.some(hasGeneratedStreamField) === true || + field.possibleFields?.some((possibleField) => + possibleField.fields.some(hasGeneratedStreamField), + ) === true + ); +} + +function hasGeneratedBuiltinOutputScalars( + operationPlan: OperationPlan, +): boolean { + return getGeneratedBuiltinOutputScalarNames(operationPlan).size !== 0; +} + +function getGeneratedBuiltinScalarImportNames( + operationPlan: OperationPlan, + variableValuesPlan: VariableValuesPlan | undefined, +): ReadonlyArray { + const scalarNames = new Set( + getGeneratedBuiltinOutputScalarNames(operationPlan), + ); + if (variableValuesPlan !== undefined) { + for (const variable of variableValuesPlan.variables) { + if (variable.kind === 'builtinScalar') { + scalarNames.add(variable.scalarName); + } + } + } + return (['Boolean', 'Float', 'ID', 'Int', 'String'] as const).filter( + (scalarName) => scalarNames.has(scalarName), + ); +} + +function getGeneratedBuiltinOutputScalarNames( + operationPlan: OperationPlan, +): ReadonlySet { + const scalarNames = new Set(); + for (const field of operationPlan.fields) { + collectGeneratedBuiltinOutputScalarNames(field, scalarNames); + } + return scalarNames; +} + +function collectGeneratedBuiltinOutputScalarNames( + field: FieldPlan, + scalarNames: Set, +): void { + if ( + (field.outputKind === 'leaf' || field.outputKind === 'leafList') && + field.builtinScalarName !== undefined + ) { + scalarNames.add(field.builtinScalarName); + } + if (field.fields !== undefined) { + for (const childField of field.fields) { + collectGeneratedBuiltinOutputScalarNames(childField, scalarNames); + } + } + if (field.possibleFields !== undefined) { + for (const possibleFields of field.possibleFields) { + for (const possibleField of possibleFields.fields) { + collectGeneratedBuiltinOutputScalarNames(possibleField, scalarNames); + } + } + } +} + +function generatedOutputScalarHelpersSource( + operationPlan: OperationPlan, +): string { + const scalarNames = getGeneratedBuiltinOutputScalarNames(operationPlan); + if (scalarNames.size === 0) { + return ''; + } + + return ` +function isGeneratedObjectLike(value) { + return ( + (typeof value === 'object' && value !== null) || + typeof value === 'function' + ); +} + +function coerceGeneratedOutputValueObject(outputValue) { + if (isGeneratedObjectLike(outputValue)) { + if (typeof outputValue.valueOf === 'function') { + const valueOfResult = outputValue.valueOf(); + if (!isGeneratedObjectLike(valueOfResult)) { + return valueOfResult; + } + } + if (typeof outputValue.toJSON === 'function') { + return outputValue.toJSON(); + } + } + return outputValue; +} + +${ + scalarNames.has('Int') + ? ` +function coerceGeneratedIntOutputValue(outputValue) { + if (typeof outputValue === 'number') { + return coerceGeneratedIntFromNumber(outputValue); + } + if (typeof outputValue === 'string') { + return coerceGeneratedIntFromString(outputValue); + } + if (typeof outputValue === 'bigint') { + return coerceGeneratedIntFromBigInt(outputValue); + } + const coercedValue = coerceGeneratedOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'number') { + return coerceGeneratedIntFromNumber(coercedValue); + } + if (typeof coercedValue === 'boolean') { + return coercedValue ? 1 : 0; + } + if (typeof coercedValue === 'string') { + return coerceGeneratedIntFromString(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return coerceGeneratedIntFromBigInt(coercedValue); + } + } + throw new GraphQLError( + \`Int cannot represent non-integer value: \${inspect(coercedValue)}\`, + ); +} + +function coerceGeneratedIntFromNumber(value) { + if (!Number.isInteger(value)) { + throw new GraphQLError( + \`Int cannot represent non-integer value: \${inspect(value)}\`, + ); + } + if (value > 2147483647 || value < -2147483648) { + throw new GraphQLError( + \`Int cannot represent non 32-bit signed integer value: \${inspect(value)}\`, + ); + } + return value; +} + +function coerceGeneratedIntFromString(value) { + if (value === '') { + throw new GraphQLError( + \`Int cannot represent non-integer value: \${inspect(value)}\`, + ); + } + const num = Number(value); + if (!Number.isInteger(num)) { + throw new GraphQLError( + \`Int cannot represent non-integer value: \${inspect(value)}\`, + ); + } + if (num > 2147483647 || num < -2147483648) { + throw new GraphQLError( + \`Int cannot represent non 32-bit signed integer value: \${inspect(value)}\`, + ); + } + return num; +} + +function coerceGeneratedIntFromBigInt(value) { + if (value > 2147483647 || value < -2147483648) { + throw new GraphQLError( + \`Int cannot represent non 32-bit signed integer value: \${String(value)}\`, + ); + } + return Number(value); +} +` + : '' +} +${ + scalarNames.has('Float') + ? ` +function coerceGeneratedFloatOutputValue(outputValue) { + if (typeof outputValue === 'number') { + return coerceGeneratedFloatFromNumber(outputValue); + } + if (typeof outputValue === 'string') { + return coerceGeneratedFloatFromString(outputValue); + } + if (typeof outputValue === 'bigint') { + return coerceGeneratedFloatFromBigInt(outputValue); + } + const coercedValue = coerceGeneratedOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'number') { + return coerceGeneratedFloatFromNumber(coercedValue); + } + if (typeof coercedValue === 'boolean') { + return coercedValue ? 1 : 0; + } + if (typeof coercedValue === 'string') { + return coerceGeneratedFloatFromString(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return coerceGeneratedFloatFromBigInt(coercedValue); + } + } + throw new GraphQLError( + \`Float cannot represent non numeric value: \${inspect(coercedValue)}\`, + ); +} + +function coerceGeneratedFloatFromNumber(value) { + if (!Number.isFinite(value)) { + throw new GraphQLError( + \`Float cannot represent non numeric value: \${inspect(value)}\`, + ); + } + return value; +} + +function coerceGeneratedFloatFromString(value) { + if (value === '') { + throw new GraphQLError( + \`Float cannot represent non numeric value: \${inspect(value)}\`, + ); + } + const num = Number(value); + if (!Number.isFinite(num)) { + throw new GraphQLError( + \`Float cannot represent non numeric value: \${inspect(value)}\`, + ); + } + return num; +} + +function coerceGeneratedFloatFromBigInt(value) { + const num = Number(value); + if (!Number.isFinite(num)) { + throw new GraphQLError( + \`Float cannot represent non numeric value: \${inspect(value)} (value is too large)\`, + ); + } + if (BigInt(num) !== value) { + throw new GraphQLError( + \`Float cannot represent non numeric value: \${inspect(value)} (value would lose precision)\`, + ); + } + return num; +} +` + : '' +} +${ + scalarNames.has('String') + ? ` +function coerceGeneratedStringOutputValue(outputValue) { + if (typeof outputValue === 'number') { + return coerceGeneratedStringFromNumber(outputValue); + } + const coercedValue = coerceGeneratedOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'string') { + return coercedValue; + } + if (typeof coercedValue === 'boolean') { + return coercedValue ? 'true' : 'false'; + } + if (typeof coercedValue === 'number') { + return coerceGeneratedStringFromNumber(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return String(coercedValue); + } + } + throw new GraphQLError( + \`String cannot represent value: \${inspect(outputValue)}\`, + ); +} + +function coerceGeneratedStringFromNumber(value) { + if (!Number.isFinite(value)) { + throw new GraphQLError(\`String cannot represent value: \${inspect(value)}\`); + } + return String(value); +} +` + : '' +} +${ + scalarNames.has('Boolean') + ? ` +function coerceGeneratedBooleanOutputValue(outputValue) { + if (typeof outputValue === 'number') { + return coerceGeneratedBooleanFromNumber(outputValue); + } + const coercedValue = coerceGeneratedOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'boolean') { + return coercedValue; + } + if (typeof coercedValue === 'number') { + return coerceGeneratedBooleanFromNumber(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return coercedValue !== 0n; + } + } + throw new GraphQLError( + \`Boolean cannot represent a non boolean value: \${inspect(coercedValue)}\`, + ); +} + +function coerceGeneratedBooleanFromNumber(value) { + if (!Number.isFinite(value)) { + throw new GraphQLError( + \`Boolean cannot represent a non boolean value: \${inspect(value)}\`, + ); + } + return value !== 0; +} +` + : '' +} +${ + scalarNames.has('ID') + ? ` +function coerceGeneratedIDOutputValue(outputValue) { + if (typeof outputValue === 'number') { + return coerceGeneratedIDFromNumber(outputValue); + } + const coercedValue = coerceGeneratedOutputValueObject(outputValue); + if (coercedValue !== outputValue) { + if (typeof coercedValue === 'string') { + return coercedValue; + } + if (typeof coercedValue === 'number') { + return coerceGeneratedIDFromNumber(coercedValue); + } + if (typeof coercedValue === 'bigint') { + return String(coercedValue); + } + } + throw new GraphQLError(\`ID cannot represent value: \${inspect(outputValue)}\`); +} + +function coerceGeneratedIDFromNumber(value) { + if (!Number.isInteger(value)) { + throw new GraphQLError(\`ID cannot represent value: \${inspect(value)}\`); + } + return String(value); +} +` + : '' +} +`; +} + +function isGeneratedMetaFieldName(fieldName: string): boolean { + return ( + fieldName === '__schema' || + fieldName === '__type' || + fieldName === '__typename' + ); +} + +function isGeneratedMetaFieldDefName(fieldName: string): boolean { + return fieldName === '__schema' || fieldName === '__type'; +} + +function generatedArgumentFunctionSource( + functionName: string, + argumentPlan: ArgumentPlan, +): string { + invariant( + argumentPlan.isConstant, + 'Expected generated argument functions only for constant arguments.', + ); + const valueName = `${functionName}Value`; + if (argumentPlan.returnExpression !== undefined) { + return `const ${valueName} = Object.freeze(${argumentPlan.returnExpression}); + +function ${functionName}() { + return ${valueName}; +}`; + } + return `const ${valueName} = Object.freeze((() => { + const args = ${argumentPlan.objectSource}; +${argumentPlan.statements.join('\n')} + return args; +})()); + +function ${functionName}() { + return ${valueName}; +}`; +} + +interface GeneratedArgumentValuesSource { + setup: string; + expression: string; +} + +function generatedArgumentValuesSource( + fieldPath: ReadonlyArray, + argumentPlan: ArgumentPlan, + variableValuesExpression: string, +): GeneratedArgumentValuesSource { + if (!hasGeneratedArgumentValues(argumentPlan)) { + return { setup: '', expression: 'emptyGeneratedArgumentValues' }; + } + + if (argumentPlan.isConstant) { + return { setup: '', expression: `${argumentFunctionName(fieldPath)}()` }; + } + + return { + setup: ` const coerced = ${variableValuesExpression}.coerced; + const args = ${argumentPlan.objectSource}; +${argumentPlan.statements + .map((statement) => indentGeneratedSource(statement, ' ')) + .join('\n')}`, + expression: 'args', + }; +} + +function generatedFieldBindingFunctions(operationPlan: OperationPlan): string { + const plan = operationPlan; + const chunks: Array = []; + emitBindingFunction( + [], + plan.rootTypeName, + plan.fields, + plan.serialRoot, + false, + ); + const source = chunks.join('\n\n'); + return plan.deferUsages.length === 0 + ? stripGeneratedDeliveryGroupMapSource(source) + : source; + + function emitBindingFunction( + selectionSetPath: ReadonlyArray, + parentTypeName: string, + fields: ReadonlyArray, + serial: boolean, + needsDynamicPathFactory: boolean, + ): void { + const functionName = fieldBindingFunctionName( + selectionSetPath, + parentTypeName, + ); + const selectionSetName = executeFunctionName( + selectionSetPath, + parentTypeName, + ); + const localDeferUsages = plan.deferUsages.filter((deferUsage) => + haveSameSelectionSetPath(deferUsage.selectionSetPath, selectionSetPath), + ); + const conditionNames = getGeneratedConditionNames(fields); + const fieldBindingResults = fields.map((field, index) => { + const childPath = [...selectionSetPath, index]; + let childFunctionName = 'undefined'; + let childExecutorName: string | undefined; + const childNeedsDynamicPathFactory = + field.outputKind === 'objectList' || + (needsDynamicPathFactory && field.outputKind === 'object'); + const childUsesOnlyDynamicPath = field.outputKind === 'objectList'; + if (field.fields !== undefined) { + const childTypeName = field.childTypeName; + invariant( + childTypeName !== undefined, + 'Expected an object field plan to include a child type name.', + ); + childFunctionName = fieldBindingFunctionName(childPath, childTypeName); + emitBindingFunction( + childPath, + childTypeName, + field.fields, + false, + childNeedsDynamicPathFactory, + ); + childExecutorName = `children${index}`; + } + if (field.possibleFields !== undefined) { + childFunctionName = abstractFieldBindingFunctionName( + childPath, + field.fieldName, + ); + emitAbstractBindingFunction(childPath, field); + childExecutorName = `children${index}`; + } + const executeName = `${selectionSetName}${toIdentifierPart( + field.fieldName, + )}Field${index}`; + const completeName = `${executeName}Complete`; + const sharedDefaultLeafName = `${selectionSetName}DefaultLeafField`; + const useSharedDefaultLeaf = isGeneratedSharedDefaultLeafField(field); + const sourceObjectArgument = canUseSelectionSetSourceObject(field) + ? 'sourceObject' + : 'undefined'; + const call = + field.outputKind === 'leaf' && isGeneratedTypenameField(field) + ? generatedTypenameFieldSource(field) + : useSharedDefaultLeaf + ? generatedSharedDefaultLeafFieldCallSource({ + executeName: sharedDefaultLeafName, + fieldIndex: index, + fieldPlan: field, + sourceObjectName: sourceObjectArgument, + }) + : field.outputKind === 'leaf' + ? ` ${executeName}( + executor, + runner, + source, + ${sourceObjectArgument}, + target, + parentNullTarget, + path${index}, + );` + : ` ${executeName}( + executor, + runner, + source, + ${sourceObjectArgument}, + target, + parentNullTarget, + deliveryGroupMap, + );`; + const dynamicCall = + field.outputKind === 'leaf' && isGeneratedTypenameField(field) + ? generatedTypenameFieldSource(field) + : useSharedDefaultLeaf + ? generatedSharedDefaultLeafFieldCallSource({ + executeName: sharedDefaultLeafName, + fieldIndex: index, + fieldPlan: field, + sourceObjectName: sourceObjectArgument, + }) + : field.outputKind === 'leaf' + ? ` ${executeName}( + executor, + runner, + source, + ${sourceObjectArgument}, + target, + parentNullTarget, + path${index}, + );` + : ` ${executeName}( + executor, + runner, + source, + ${sourceObjectArgument}, + target, + parentNullTarget, + deliveryGroupMap, + path${index}, + );`; + const fieldSource = useSharedDefaultLeaf + ? '' + : field.outputKind === 'leaf' + ? isGeneratedTypenameField(field) + ? '' + : `${generatedLeafFieldFunctionSource({ + completeName, + executeName, + fieldIndex: index, + fieldPath: childPath, + fieldPlan: field, + sourceObjectName: canUseSelectionSetSourceObject(field) + ? 'sourceObject' + : undefined, + })} + +${generatedLeafFieldCompletionFunctionSource(completeName, field, index)}` + : generatedFieldFunctionSource({ + childSelectionSetName: childExecutorName, + completeName, + executeName, + fieldIndex: index, + fieldPath: childPath, + fieldPlan: field as NonLeafFieldPlan, + sourceObjectName: canUseSelectionSetSourceObject(field) + ? 'sourceObject' + : undefined, + }); + return { + declarations: generatedFieldBindingDeclarationSource( + field, + index, + childFunctionName, + childNeedsDynamicPathFactory, + childUsesOnlyDynamicPath, + ), + call: withInclusionCondition( + call, + getGeneratedConditionExpression( + field.inclusionCondition, + conditionNames, + ), + ), + dynamicCall: withInclusionCondition( + dynamicCall, + getGeneratedConditionExpression( + field.inclusionCondition, + conditionNames, + ), + ), + source: fieldSource, + usesSharedDefaultLeaf: useSharedDefaultLeaf, + }; + }); + const conditionSetup = generatedConditionSetup(conditionNames); + const sourceObjectSetup = generatedSourceObjectSetup(fields); + const fieldCalls = fieldBindingResults.map((result) => result.call); + const dynamicFieldCalls = fieldBindingResults.map( + (result) => result.dynamicCall, + ); + const fieldSources = fieldBindingResults + .map((result) => result.source) + .join('\n\n'); + const sharedDefaultLeafSource = fieldBindingResults.some( + (result) => result.usesSharedDefaultLeaf, + ) + ? generatedSharedDefaultLeafFieldFunctionSource( + `${selectionSetName}DefaultLeafField`, + ) + : ''; + const selectionSetSource = generatedSelectionSetFunction({ + conditionSetup: `${conditionSetup}${sourceObjectSetup}`, + fields, + fieldCalls, + localDeferUsages, + selectionSetName, + serial, + }); + const dynamicSelectionSetSource = needsDynamicPathFactory + ? `function ${selectionSetName}WithParentPath( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + parentPath, +) { +${generatedFieldPathDeclarationsSource(fields)} +${indentGeneratedSource( + generatedSelectionSetBody({ + conditionSetup: `${conditionSetup}${sourceObjectSetup}`, + fields, + fieldCalls: dynamicFieldCalls, + localDeferUsages, + selectionSetName: `${selectionSetName}WithParentPath`, + serial, + }), + ' ', +)} +} + +${selectionSetName}.withParentPath = ${selectionSetName}WithParentPath;` + : ''; + chunks.push(`function ${functionName}( + compiledExecution, + parentType, + parentPath, +) { + if (parentType.name !== ${toJavaScript(parentTypeName)}) { + return undefined; + } + const schema = compiledExecution.schema; + const parentFields = parentType.getFields(); +${fieldBindingResults.map((result) => result.declarations).join('\n')} +${indentGeneratedSource(selectionSetSource, ' ')} +${indentGeneratedSource(sharedDefaultLeafSource, ' ')} +${indentGeneratedSource(fieldSources, ' ')} +${indentGeneratedSource(dynamicSelectionSetSource, ' ')} + return ${selectionSetName}; +}`); + } + + function emitAbstractBindingFunction( + fieldPath: ReadonlyArray, + field: FieldPlan, + ): void { + invariant(field.possibleFields !== undefined); + const declarations = field.possibleFields.map( + (possibleField, possibleIndex) => { + const possiblePath = [...fieldPath, possibleIndex]; + const functionName = fieldBindingFunctionName( + possiblePath, + possibleField.typeName, + ); + emitBindingFunction( + possiblePath, + possibleField.typeName, + possibleField.fields, + false, + false, + ); + const typeName = `type${possibleIndex}`; + const executeName = `execute${possibleIndex}`; + return ` const ${typeName} = typeMap[${toJavaScript( + possibleField.typeName, + )}]; + if (${typeName} == null) { + return undefined; + } + const ${executeName} = ${functionName}( + compiledExecution, + ${typeName}, + parentPath, + ); + if (${executeName} === undefined) { + return undefined; + }`; + }, + ); + const cases = field.possibleFields.map( + (possibleField, possibleIndex) => + ` case ${toJavaScript(possibleField.typeName)}: + return execute${possibleIndex}( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + );`, + ); + chunks.push(`function ${abstractFieldBindingFunctionName(fieldPath, field.fieldName)}( + compiledExecution, + abstractType, + parentPath, +) { + const typeMap = compiledExecution.schema.getTypeMap(); +${declarations.join('\n')} + return function ${abstractExecuteFunctionName(fieldPath, field.fieldName)}( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + runtimeType, + ) { + switch (runtimeType.name) { +${cases.join('\n')} + } + }; +}`); + } +} + +function stripGeneratedDeliveryGroupMapSource(source: string): string { + return source + .replace(/\n[ \t]*deliveryGroupMap,\n/g, '\n') + .replace( + /\n([ \t]*)undefined,\n\1(fieldPath|itemPath|runtimeType|path\d+),/g, + '\n$1$2,', + ); +} + +function haveSameSelectionSetPath( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function generatedFieldBindingDeclarationSource( + field: FieldPlan, + index: number, + childFunctionName: string, + childNeedsDynamicPathFactory: boolean, + childUsesOnlyDynamicPath: boolean, +): string { + if (isGeneratedTypenameField(field)) { + return ''; + } + + const fieldDefName = `fieldDef${index}`; + const returnTypeName = `returnType${index}`; + const fieldNodesName = `fieldNodes${index}`; + const fieldDetailsListName = `fieldDetailsList${index}`; + const pathName = `path${index}`; + const childrenName = `children${index}`; + const childTypeExpression = + field.outputKind === 'object' || field.outputKind === 'objectList' + ? `objectType${index}` + : `abstractType${index}`; + const childrenSource = + childFunctionName === 'undefined' + ? '' + : childNeedsDynamicPathFactory + ? childUsesOnlyDynamicPath + ? ` const ${childrenName}Template = ${childFunctionName}( + compiledExecution, + ${childTypeExpression}, + ${pathName}, + ); + if (${childrenName}Template === undefined) { + return undefined; + } + const ${childrenName} = ${childrenName}Template.withParentPath; +` + : ` const ${childrenName}Template = ${childFunctionName}( + compiledExecution, + ${childTypeExpression}, + ${pathName}, + ); + if (${childrenName}Template === undefined) { + return undefined; + } + function ${childrenName}( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + runtimeParentPath, + ) { + if (runtimeParentPath === undefined || runtimeParentPath === ${pathName}) { + ${childrenName}Template( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + ); + return; + } + ${childrenName}Template.withParentPath( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + runtimeParentPath, + ); + } +` + : ` const ${childrenName} = ${childFunctionName}( + compiledExecution, + ${childTypeExpression}, + ${pathName}, + ); + if (${childrenName} === undefined) { + return undefined; + } +`; + + return `${generatedFieldDefSource(field, fieldDefName)} + if (${fieldDefName} === undefined) { + return undefined; + } +${generatedResolveModeGuardSource(fieldDefName, field.resolveMode)} + const ${returnTypeName} = ${fieldDefName}.type; +${generatedResolveFnBindingSource(field, index, fieldDefName)} +${generatedOutputTypeBindingsSource(field, index, returnTypeName)} + const ${pathName} = { + prev: parentPath, + key: ${toJavaScript(field.responseName)}, + typename: ${toJavaScript(field.parentTypeName)}, + }; + const ${fieldNodesName} = [${field.fieldNodeAccessors.join(', ')}]; + const ${fieldDetailsListName} = [ +${field.fieldNodeAccessors + .map((_fieldNodeAccessor, fieldNodeIndex) => + generatedFieldDetailsSource(fieldNodesName, fieldNodeIndex), + ) + .join(',\n')} + ]; +${childrenSource}`; +} + +function generatedFieldPathDeclarationsSource( + fields: ReadonlyArray, +): string { + return fields + .map((field, index) => { + if (isGeneratedTypenameField(field)) { + return ''; + } + return ` const path${index} = { + prev: parentPath, + key: ${toJavaScript(field.responseName)}, + typename: ${toJavaScript(field.parentTypeName)}, + };`; + }) + .filter((source) => source.length > 0) + .join('\n'); +} + +function generatedFieldDefSource( + field: FieldPlan, + fieldDefName: string, +): string { + switch (field.fieldName) { + case '__schema': + return ` const ${fieldDefName} = SchemaMetaFieldDef;`; + case '__type': + return ` const ${fieldDefName} = TypeMetaFieldDef;`; + default: + return ` const ${fieldDefName} = parentFields[${toJavaScript( + field.fieldName, + )}];`; + } +} + +function generatedResolveModeGuardSource( + fieldDefName: string, + resolveMode: FieldPlan['resolveMode'], +): string { + switch (resolveMode) { + case 'default': + return ` if (${fieldDefName}.resolve !== undefined || compiledExecution.fieldResolver != null) { + return undefined; + }`; + case 'field': + return ` if (${fieldDefName}.resolve === undefined) { + return undefined; + }`; + case 'customDefault': + return ` if (${fieldDefName}.resolve !== undefined || compiledExecution.fieldResolver == null) { + return undefined; + }`; + } +} + +function generatedResolveFnBindingSource( + field: FieldPlan, + index: number, + fieldDefName: string, +): string { + switch (field.resolveMode) { + case 'default': + return ''; + case 'field': + return ` const resolveFn${index} = ${fieldDefName}.resolve;`; + case 'customDefault': + return ` const resolveFn${index} = compiledExecution.fieldResolver;`; + } +} + +function generatedFieldDetailsSource( + fieldNodesName: string, + fieldNodeIndex: number, +): string { + return ` { + node: ${fieldNodesName}[${String(fieldNodeIndex)}], + }`; +} + +function generatedOutputTypeBindingsSource( + field: FieldPlan, + index: number, + returnType: string, +): string { + const nullableReturnType = field.completedNonNull + ? `${returnType}.ofType` + : returnType; + + if ( + field.outputKind === 'leafList' || + field.outputKind === 'objectList' || + field.outputKind === 'abstractList' + ) { + const listType = nullableReturnType; + const itemType = `${listType}.ofType`; + const nullableItemType = field.completedItemNonNull + ? `${itemType}.ofType` + : itemType; + const itemTypeSource = ` const itemType${index} = ${itemType};`; + if (field.outputKind === 'leafList') { + return `${itemTypeSource} + const leafType${index} = ${nullableItemType}; +${generatedOutputCoerceBindingSource(index, field, `leafType${index}`)};`; + } + if (field.outputKind === 'objectList') { + return `${itemTypeSource} + const objectType${index} = ${nullableItemType}; +${generatedIsTypeOfGuardSource(field, `objectType${index}`)}`; + } + return `${itemTypeSource} + const abstractType${index} = ${nullableItemType};`; + } + + if (field.outputKind === 'leaf') { + return ` const leafType${index} = ${nullableReturnType}; +${generatedOutputCoerceBindingSource(index, field, `leafType${index}`)};`; + } + if (field.outputKind === 'object') { + return ` const objectType${index} = ${nullableReturnType}; +${generatedIsTypeOfGuardSource(field, `objectType${index}`)}`; + } + return ` const abstractType${index} = ${nullableReturnType};`; +} + +function generatedOutputCoerceBindingSource( + index: number, + field: FieldPlan, + leafTypeName: string, +): string { + const helperName = generatedBuiltinOutputCoerceFunctionName( + field.builtinScalarName, + ); + if (helperName === undefined) { + return ` const coerceOutputValue${index} = ${leafTypeName}.coerceOutputValue.bind(${leafTypeName})`; + } + const specifiedScalarName = `GraphQL${field.builtinScalarName}`; + return ` if (${leafTypeName}.coerceOutputValue !== ${specifiedScalarName}.coerceOutputValue) { + return undefined; + } + const coerceOutputValue${index} = ${helperName}`; +} + +function generatedBuiltinOutputCoerceFunctionName( + scalarName: string | undefined, +): string | undefined { + switch (scalarName) { + case 'Boolean': + return 'coerceGeneratedBooleanOutputValue'; + case 'Float': + return 'coerceGeneratedFloatOutputValue'; + case 'ID': + return 'coerceGeneratedIDOutputValue'; + case 'Int': + return 'coerceGeneratedIntOutputValue'; + case 'String': + return 'coerceGeneratedStringOutputValue'; + default: + return undefined; + } +} + +function generatedIsTypeOfGuardSource( + field: FieldPlan, + objectTypeName: string, +): string { + return field.objectTypeHasIsTypeOf + ? ` if (${objectTypeName}.isTypeOf === undefined) { + return undefined; + }` + : ` if (${objectTypeName}.isTypeOf !== undefined) { + return undefined; + }`; +} + +function generatedSubscriptionSourceFieldFunction( + operationPlan: OperationPlan, +): string { + invariant(operationPlan.operationType === 'subscription'); + const declarations = operationPlan.fields + .map((field, index) => + generatedSubscriptionSourceFieldDeclarationSource(field, index), + ) + .join('\n'); + const conditionSetup = operationPlan.fields.some( + (field) => field.inclusionCondition !== undefined, + ) + ? ` const coerced = validatedExecutionArgs.variableValues.coerced;\n` + : ''; + const fieldChecks = operationPlan.fields.map((field, index) => { + const returnSource = ` return executeGeneratedSubscriptionSourceField${index}( + validatedExecutionArgs, + );`; + if (field.inclusionCondition === undefined) { + return returnSource; + } + return ` if (${field.inclusionCondition}) { +${indentGeneratedSource(returnSource, ' ')} + }`; + }); + const fieldFunctions = operationPlan.fields + .map((field, index) => generatedSubscriptionSourceFieldSource(field, index)) + .join('\n\n'); + const noSelectedFieldSource = operationPlan.fields.every( + (field) => field.inclusionCondition !== undefined, + ) + ? `\n throw new GraphQLError('Subscription operation must select a field.');` + : ''; + + return `function bindGeneratedSubscriptionSource( + compiledExecution, + parentType, + parentPath, +) { + if (parentType.name !== ${toJavaScript(operationPlan.rootTypeName)}) { + return undefined; + } + const schema = compiledExecution.schema; + const parentFields = parentType.getFields(); +${declarations} + + function executeGeneratedSubscriptionSource(validatedExecutionArgs) { +${conditionSetup}${fieldChecks.join('\n')}${noSelectedFieldSource} + } + +${indentGeneratedSource(fieldFunctions, ' ')} + + return executeGeneratedSubscriptionSource; +}`; +} + +function generatedSubscriptionSourceFieldSource( + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + const argumentValues = generatedArgumentValuesSource( + [fieldIndex], + fieldPlan.argumentPlan, + 'validatedExecutionArgs.variableValues', + ); + return `function executeGeneratedSubscriptionSourceField${fieldIndex}( + validatedExecutionArgs, +) { +${generatedSubscriptionSourceFieldAliasSource(fieldIndex)} + const { + rootValue, + contextValue, + externalAbortSignal, + } = validatedExecutionArgs; + const sharedExecutionContext = + createSharedExecutionContext(externalAbortSignal); +${argumentValues.setup === '' ? '' : `${argumentValues.setup}\n`} + const info = ${generatedResolveInfoObjectSource({ + fieldName: toJavaScript(fieldPlan.fieldName), + fieldNodes: 'fieldNodes', + helpersName: 'sharedExecutionContext', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + + try { + const result = subscribeFn${fieldIndex}( + rootValue, + ${argumentValues.expression}, + contextValue, + info, + ); + + if (result != null && typeof result.then === 'function') { + const promisedResult = Promise.resolve(result); + const promise = externalAbortSignal + ? cancellablePromise(promisedResult, externalAbortSignal) + : promisedResult; + return promise + .then(assertGeneratedEventStream) + .then(undefined, (error) => { + throw locatedError( + error, + fieldNodes, + pathToArray(fieldPath), + ); + }); + } + return assertGeneratedEventStream(result); + } catch (error) { + throw locatedError(error, fieldNodes, pathToArray(fieldPath)); + } +}`; +} + +function generatedSubscriptionSourceFieldDeclarationSource( + field: FieldPlan, + index: number, +): string { + const fieldDefName = `fieldDef${index}`; + const returnTypeName = `returnType${index}`; + const fieldNodesName = `fieldNodes${index}`; + const pathName = `path${index}`; + + return `${generatedFieldDefSource(field, fieldDefName)} + if (${fieldDefName} === undefined) { + return undefined; + } + const ${returnTypeName} = ${fieldDefName}.type; + const subscribeFn${index} = + ${fieldDefName}.subscribe ?? + (compiledExecution.subscribeFieldResolver ?? defaultFieldResolver); + const ${pathName} = { + prev: parentPath, + key: ${toJavaScript(field.responseName)}, + typename: ${toJavaScript(field.parentTypeName)}, + }; + const ${fieldNodesName} = [${field.fieldNodeAccessors.join(', ')}];`; +} + +function generatedSubscriptionSourceFieldAliasSource( + fieldIndex: number, +): string { + return ` const fieldNodes = fieldNodes${fieldIndex}; + const returnType = returnType${fieldIndex}; + const fieldPath = path${fieldIndex};`; +} + +function isGeneratedTypenameField(field: FieldPlan): boolean { + return field.fieldName === '__typename'; +} + +function generatedResultObjectSource(): string { + return generatedNullPrototypeObjectSource(); +} + +function generatedAbstractResultObjectSource(): string { + return generatedNullPrototypeObjectSource(); +} + +interface GeneratedSelectionSetSourceContext { + conditionSetup: string; + fields: ReadonlyArray; + fieldCalls: ReadonlyArray; + localDeferUsages: ReadonlyArray; + selectionSetName: string; + serial: boolean; +} + +function generatedSelectionSetFunction({ + conditionSetup, + fields, + fieldCalls, + localDeferUsages, + selectionSetName, + serial, +}: GeneratedSelectionSetSourceContext): string { + return `function ${selectionSetName}( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, +) { +${generatedSelectionSetBody({ + conditionSetup, + fields, + fieldCalls, + localDeferUsages, + selectionSetName, + serial, +})} +}`; +} + +function generatedSelectionSetBody({ + conditionSetup, + fields, + fieldCalls, + localDeferUsages, + selectionSetName, + serial, +}: GeneratedSelectionSetSourceContext): string { + const deferGroups = generatedDeferFieldGroups(fields, fieldCalls); + const localDeferUsageIndexes = new Set( + localDeferUsages.map((deferUsage) => deferUsage.index), + ); + if (deferGroups.length === 0 && localDeferUsages.length === 0) { + return serial + ? generatedSerialSelectionSetBody(conditionSetup, fieldCalls) + : generatedParallelSelectionSetBody(conditionSetup, fieldCalls); + } + + const currentFieldCalls = fieldCalls.filter((_call, index) => { + const usageIndexes = generatedFilteredDeferUsageIndexes(fields[index]); + return usageIndexes.length === 0; + }); + const allFieldsBody = serial + ? generatedSerialSelectionSetBody('', fieldCalls) + : generatedParallelSelectionSetBody('', fieldCalls); + const currentFieldsBody = serial + ? generatedSerialSelectionSetBody('', currentFieldCalls) + : generatedParallelSelectionSetBody('', currentFieldCalls); + const localDeferSetup = generatedLocalDeferUsageSetupSource(localDeferUsages); + const groupSources = deferGroups + .map((group, groupIndex) => + generatedDeferGroupSource(selectionSetName, group, groupIndex), + ) + .join('\n'); + + invariant(localDeferUsages.length > 0); + const localDeliverySetup = `${localDeferSetup} const generatedDelivery = executor.getNewDeliveryGroupMap( + [${localDeferUsages + .map((deferUsage) => `deferUsage${String(deferUsage.index)}`) + .join(', ')}], + deliveryGroupMap, + parentPath, + ); + deliveryGroupMap = generatedDelivery.newDeliveryGroupMap; + executor.groups.push(...generatedDelivery.newDeliveryGroups); +`; + + return `${conditionSetup}${groupSources} + if (executor.mode === 'ignore') { +${indentGeneratedSource(allFieldsBody, ' ')} + return; + } + if (executor.mode === 'throw') { + executor.throwUnexpectedIncremental(); + } +${localDeliverySetup}${currentFieldsBody}${deferGroups + .map((group, groupIndex) => { + const setName = `deferUsageSet${groupIndex}`; + return ` const ${setName} = new Set([${group.usageIndexes + .map((usageIndex) => + generatedDeferUsageReferenceSource( + usageIndex, + localDeferUsageIndexes, + ), + ) + .join(', ')}]); + if (executor.isCurrentDeferUsageSet(${setName})) { + ${selectionSetName}DeferGroup${groupIndex}( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + ); + } else { + executor.deferPreplannedExecutionGroup( + ${setName}, + deliveryGroupMap, + parentPath, + source, + ${selectionSetName}DeferGroup${groupIndex}, + ); + }`; + }) + .join('\n')}`; +} + +function generatedParallelSelectionSetBody( + conditionSetup: string, + fieldCalls: ReadonlyArray, +): string { + return `${conditionSetup}${fieldCalls.join('\n')}`; +} + +interface GeneratedDeferFieldGroup { + calls: ReadonlyArray; + usageIndexes: ReadonlyArray; +} + +function generatedDeferFieldGroups( + fields: ReadonlyArray, + fieldCalls: ReadonlyArray, +): ReadonlyArray { + const groupsByKey = new Map< + string, + { calls: Array; usageIndexes: ReadonlyArray } + >(); + fields.forEach((field, index) => { + const usageIndexes = generatedFilteredDeferUsageIndexes(field); + if (usageIndexes.length === 0) { + return; + } + const key = usageIndexes.join(','); + let group = groupsByKey.get(key); + if (group === undefined) { + group = { calls: [], usageIndexes }; + groupsByKey.set(key, group); + } + group.calls.push(fieldCalls[index]); + }); + return Array.from(groupsByKey.values()); +} + +function generatedFilteredDeferUsageIndexes( + field: FieldPlan, +): ReadonlyArray { + const usageIndexes: Array = []; + for (const usageIndex of field.deferUsageIndexes) { + if (usageIndex === undefined) { + return []; + } + if (!usageIndexes.includes(usageIndex)) { + usageIndexes.push(usageIndex); + } + } + return usageIndexes; +} + +function generatedLocalDeferUsageSetupSource( + localDeferUsages: ReadonlyArray, +): string { + return localDeferUsages + .map( + (deferUsage) => ` const deferUsage${String(deferUsage.index)} = { + label: ${toJavaScript(deferUsage.label)}, + parentDeferUsage: undefined, + }; +`, + ) + .join(''); +} + +function generatedDeferUsageReferenceSource( + usageIndex: number, + localDeferUsageIndexes: ReadonlySet, +): string { + invariant(localDeferUsageIndexes.has(usageIndex)); + return `deferUsage${String(usageIndex)}`; +} + +function generatedDeferGroupSource( + selectionSetName: string, + group: GeneratedDeferFieldGroup, + groupIndex: number, +): string { + return ` function ${selectionSetName}DeferGroup${groupIndex}( + executor, + runner, + source, + target, + parentNullTarget, + deliveryGroupMap, + ) { +${indentGeneratedSource(generatedParallelSelectionSetBody('', group.calls), ' ')} + } +`; +} + +function generatedSerialSelectionSetBody( + conditionSetup: string, + fieldCalls: ReadonlyArray, +): string { + const cases = fieldCalls.map( + (call, index) => ` case ${String(index)}: + runner.runWhenDrained(runNext); +${indentGeneratedSource(call, ' ')} + return;`, + ); + return `${conditionSetup} let index = 0; + const runNext = () => { + switch (index++) { +${cases.join('\n')} + } + }; + runNext();`; +} + +function withInclusionCondition( + source: string, + condition: string | undefined, +): string { + if (condition === undefined) { + return source; + } + + return ` if (${condition}) { +${indentGeneratedSource(source, ' ')} + }`; +} + +function getGeneratedConditionNames( + fields: ReadonlyArray, +): ReadonlyMap { + const conditionNames = new Map(); + for (const field of fields) { + const condition = field.inclusionCondition; + if (condition !== undefined && !conditionNames.has(condition)) { + conditionNames.set(condition, `condition${conditionNames.size}`); + } + } + return conditionNames; +} + +function getGeneratedConditionExpression( + condition: string | undefined, + conditionNames: ReadonlyMap, +): string | undefined { + return condition === undefined ? undefined : conditionNames.get(condition); +} + +function generatedConditionSetup( + conditionNames: ReadonlyMap, +): string { + if (conditionNames.size === 0) { + return ''; + } + + const conditions = Array.from( + conditionNames, + ([condition, name]) => ` const ${name} = ${condition};`, + ); + return ` const coerced = executor.validatedExecutionArgs.variableValues.coerced; +${conditions.join('\n')} +`; +} + +function generatedSourceObjectSetup(fields: ReadonlyArray): string { + return fields.some(canUseSelectionSetSourceObject) + ? ` const sourceObject = + (typeof source === 'object' && source !== null) || + typeof source === 'function' + ? source + : undefined; +` + : ''; +} + +function canUseSelectionSetSourceObject(field: FieldPlan): boolean { + return field.resolveMode === 'default' && !isGeneratedTypenameField(field); +} + +function indentGeneratedSource(source: string, indentation: string): string { + return source + .split('\n') + .map((line) => (line.length === 0 ? line : `${indentation}${line}`)) + .join('\n'); +} + +interface SimplePromiseSourceContext { + promiseExpression: string; + resolvedName?: string; + onResolveSource: string; + onRejectSource: string; +} + +function generatedSimplePromiseSource({ + promiseExpression, + resolvedName = 'resolved', + onResolveSource, + onRejectSource, +}: SimplePromiseSourceContext): string { + return `runner._pending++; +try { + ${promiseExpression}.then( + (${resolvedName}) => { + if (runner._settled) { + runner._pending--; + return; + } + try { +${indentGeneratedSource(onResolveSource, ' ')} + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + (rawError) => { + if (runner._settled) { + runner._pending--; + return; + } + try { +${indentGeneratedSource(onRejectSource, ' ')} + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + ); +} catch (rawError) { + runner._pending--; +${indentGeneratedSource(onRejectSource, ' ')} +}`; +} + +interface ResolveInfoSourceContext { + fieldName: string; + fieldNodes: string; + helpersName?: 'executor' | 'sharedExecutionContext'; + parentType: string; + path: string; + returnType: string; +} + +function generatedResolveInfoObjectSource({ + fieldName, + fieldNodes, + helpersName = 'executor', + parentType, + path, + returnType, +}: ResolveInfoSourceContext): string { + const getAbortSignal = + helpersName === 'executor' + ? 'executor.getAbortSignal' + : 'sharedExecutionContext.getAbortSignal'; + const getAsyncHelpers = + helpersName === 'executor' + ? 'executor.getAsyncHelpers' + : 'sharedExecutionContext.getAsyncHelpers'; + return `{ + fieldName: ${fieldName}, + fieldNodes: ${fieldNodes}, + returnType: ${returnType}, + parentType: ${parentType}, + path: ${path}, + schema, + fragments: staticFragmentDefinitions, + rootValue: validatedExecutionArgs.rootValue, + operation: staticOperation, + variableValues: validatedExecutionArgs.variableValues, + getAbortSignal: ${getAbortSignal}, + getAsyncHelpers: ${getAsyncHelpers}, +}`; +} + +interface FieldFunctionSourceContext { + childSelectionSetName: string | undefined; + completeName: string; + executeName: string; + fieldIndex: number; + fieldPath: ReadonlyArray; + fieldPlan: NonLeafFieldPlan; + sourceObjectName?: string | undefined; +} + +interface FieldAliasOptions { + abstractType?: boolean; + fieldDetailsList?: boolean; + fieldNodes?: boolean; + fieldPath?: boolean; + itemType?: boolean; + objectType?: boolean; + returnType?: boolean; +} + +function generatedFieldAliasSource( + fieldIndex: number, + _fieldPlan: FieldPlan, + options: FieldAliasOptions, +): string { + const aliases: Array = []; + if (options.fieldNodes === true) { + aliases.push(`const fieldNodes = fieldNodes${fieldIndex};`); + } + if (options.fieldDetailsList === true) { + aliases.push(`const fieldDetailsList = fieldDetailsList${fieldIndex};`); + } + if (options.returnType === true) { + aliases.push(`const returnType = returnType${fieldIndex};`); + } + if (options.fieldPath === true) { + aliases.push(`const fieldPath = path${fieldIndex};`); + } + if (options.itemType === true) { + aliases.push(`const itemType = itemType${fieldIndex};`); + } + if (options.objectType === true) { + aliases.push(`const objectType = objectType${fieldIndex};`); + } + if (options.abstractType === true) { + aliases.push(`const abstractType = abstractType${fieldIndex};`); + } + return aliases.map((alias) => ` ${alias}`).join('\n'); +} + +function completionAliasOptions( + fieldPlan: NonLeafFieldPlan, +): FieldAliasOptions { + switch (fieldPlan.outputKind) { + case 'object': + return { + fieldDetailsList: true, + fieldNodes: true, + fieldPath: true, + objectType: true, + returnType: true, + }; + case 'abstract': + return { + abstractType: true, + fieldDetailsList: true, + fieldNodes: true, + fieldPath: true, + returnType: true, + }; + case 'leafList': + case 'objectList': + case 'abstractList': + return { + fieldDetailsList: true, + fieldNodes: fieldPlan.streamPlan !== undefined, + fieldPath: true, + itemType: fieldPlan.streamPlan !== undefined, + returnType: true, + }; + } +} + +function completionAliasOptionsWithoutFieldPath( + fieldPlan: NonLeafFieldPlan, +): FieldAliasOptions { + return { + ...completionAliasOptions(fieldPlan), + fieldPath: false, + }; +} + +function generatedFieldFunctionSource({ + childSelectionSetName, + completeName, + executeName, + fieldIndex, + fieldPath, + fieldPlan, + sourceObjectName, +}: FieldFunctionSourceContext): string { + const responseNameExpression = toJavaScript(fieldPlan.responseName); + const fieldNameExpression = toJavaScript(fieldPlan.fieldName); + const fieldDetailsList = `fieldDetailsList${fieldIndex}`; + const staticFieldPathName = `path${fieldIndex}`; + const fieldPathName = 'fieldPath'; + const returnType = `returnType${fieldIndex}`; + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + if (canUseNullableObjectFieldWithoutSuccessTarget(fieldPlan)) { + return generatedNullableObjectFieldFunctionSource({ + childSelectionSetName, + completeName, + executeName, + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + responseNameExpression, + returnType, + sourceObjectName, + staticFieldPathName, + }); + } + if (canUseNullableObjectListFieldWithoutSuccessTarget(fieldPlan)) { + return generatedNullableObjectListFieldFunctionSource({ + childSelectionSetName, + completeName, + executeName, + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + responseNameExpression, + returnType, + sourceObjectName, + staticFieldPathName, + }); + } + + return `function ${executeName}( + executor, + runner, + source, + sourceObject, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath = ${staticFieldPathName}, +) { + const fieldTarget = { + container: target, + key: ${responseNameExpression}, + path: ${fieldPathName}, + }; + const nullTarget = ${ + fieldPlan.completedNonNull ? 'parentNullTarget' : 'fieldTarget' + }; + let result; + try { +${generatedSpecializedResolveFieldSource({ + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + returnType, + sourceObjectName, +})} + } catch (rawError) { + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + nullTarget, + ); + return; + } + + if (result != null && typeof result.then === 'function') { + ${targetField} = undefined; + runner._pending++; + try { + result.then( + (resolved) => { + if (runner._settled) { + runner._pending--; + return; + } + try { + ${completeName}( + executor, + runner, + resolved, + target, + fieldTarget, + nullTarget, + deliveryGroupMap, + fieldPath, + ); + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + (rawError) => { + if (runner._settled) { + runner._pending--; + return; + } + try { + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + nullTarget, + ); + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + ); + } catch (rawError) { + runner._pending--; + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + nullTarget, + ); + } + return; + } + + ${completeName}( + executor, + runner, + result, + target, + fieldTarget, + nullTarget, + deliveryGroupMap, + fieldPath, + ); +} + +function ${completeName}( + executor, + runner, + result, + target, + fieldTarget, + nullTarget, + deliveryGroupMap, + fieldPath, +) { +${generatedFieldAliasSource( + fieldIndex, + fieldPlan, + completionAliasOptionsWithoutFieldPath(fieldPlan), +)} +${generatedCompletionSource( + completeName, + childSelectionSetName, + fieldNameExpression, + fieldPlan, +)} +} + +${generatedCompletionHelperFunctions( + completeName, + childSelectionSetName, + fieldPlan, + fieldIndex, +)}`; +} + +function canUseNullableObjectFieldWithoutSuccessTarget( + fieldPlan: FieldPlan, +): boolean { + return ( + fieldPlan.outputKind === 'object' && + !fieldPlan.completedNonNull && + fieldPlan.childrenCanNullParent !== true && + fieldPlan.objectTypeHasIsTypeOf !== true + ); +} + +function canUseNullableObjectListFieldWithoutSuccessTarget( + fieldPlan: FieldPlan, +): boolean { + return ( + fieldPlan.outputKind === 'objectList' && + !fieldPlan.completedNonNull && + !fieldPlan.completedItemNonNull + ); +} + +interface NullableObjectFieldFunctionSourceContext extends SpecializedResolveFieldSourceContext { + childSelectionSetName: string | undefined; + completeName: string; + executeName: string; + responseNameExpression: string; + staticFieldPathName: string; +} + +function generatedNullableObjectFieldFunctionSource({ + childSelectionSetName, + completeName, + executeName, + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + responseNameExpression, + returnType, + sourceObjectName, + staticFieldPathName, +}: NullableObjectFieldFunctionSourceContext): string { + const executeChildFields = getGeneratedChildSelectionSetName( + childSelectionSetName, + ); + const fieldDetailsList = `fieldDetailsList${fieldIndex}`; + const resultObjectSource = generatedResultObjectSource(); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const createFieldTarget = `const fieldTarget = { + container: target, + key: ${responseNameExpression}, + path: ${fieldPathName}, + };`; + return `function ${executeName}( + executor, + runner, + source, + sourceObject, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath = ${staticFieldPathName}, +) { + let result; + try { +${generatedSpecializedResolveFieldSource({ + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + returnType, + sourceObjectName, +})} + } catch (rawError) { + ${createFieldTarget} + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + return; + } + + if (result != null && typeof result.then === 'function') { + ${targetField} = undefined; + runner._pending++; + try { + result.then( + (resolved) => { + if (runner._settled) { + runner._pending--; + return; + } + try { + ${completeName}( + executor, + runner, + resolved, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath, + ); + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + (rawError) => { + if (runner._settled) { + runner._pending--; + return; + } + try { + ${createFieldTarget} + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + ); + } catch (rawError) { + runner._pending--; + ${createFieldTarget} + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + } + return; + } + + if (result instanceof Error) { + ${createFieldTarget} + executor.handleCompletionError( + result, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + return; + } + + if (result == null) { + ${targetField} = null; + return; + } + + const data = ${resultObjectSource}; + ${targetField} = data; + ${executeChildFields}( + executor, + runner, + result, + data, + parentNullTarget, + deliveryGroupMap, + ${fieldPathName}, + ); +} + +function ${completeName}( + executor, + runner, + result, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath, +) { + if (result instanceof Error) { + ${createFieldTarget} + executor.handleCompletionError( + result, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + return; + } + + if (result == null) { + ${targetField} = null; + return; + } + + const data = ${resultObjectSource}; + ${targetField} = data; + ${executeChildFields}( + executor, + runner, + result, + data, + parentNullTarget, + deliveryGroupMap, + ${fieldPathName}, + ); +}`; +} + +function generatedNullableObjectListFieldFunctionSource({ + childSelectionSetName, + completeName, + executeName, + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + responseNameExpression, + returnType, + sourceObjectName, + staticFieldPathName, +}: NullableObjectFieldFunctionSourceContext): string { + const fieldDetailsList = `fieldDetailsList${fieldIndex}`; + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const createFieldTarget = `const fieldTarget = { + container: target, + key: ${responseNameExpression}, + path: ${fieldPathName}, + };`; + return `function ${executeName}( + executor, + runner, + source, + sourceObject, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath = ${staticFieldPathName}, +) { + let result; + try { +${generatedSpecializedResolveFieldSource({ + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPlan, + returnType, + sourceObjectName, +})} + } catch (rawError) { + ${createFieldTarget} + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + return; + } + + if (result != null && typeof result.then === 'function') { + ${targetField} = undefined; + runner._pending++; + try { + result.then( + (resolved) => { + if (runner._settled) { + runner._pending--; + return; + } + try { + ${completeName}( + executor, + runner, + resolved, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath, + ); + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + (rawError) => { + if (runner._settled) { + runner._pending--; + return; + } + try { + ${createFieldTarget} + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + runner._pending--; + runner._drainIfReady(); + } catch (error) { + runner._pending--; + runner._fail(error); + } + }, + ); + } catch (rawError) { + runner._pending--; + ${createFieldTarget} + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPathName}, + fieldTarget, + fieldTarget, + ); + } + return; + } + + ${completeName}( + executor, + runner, + result, + target, + parentNullTarget, + deliveryGroupMap, + fieldPath, + ); +} + +function ${completeName}( + executor, + runner, + result, + target, + parentNullTarget, + deliveryGroupMap, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, completionAliasOptions(fieldPlan as NonLeafFieldPlan))} +${generatedNullableObjectListCompletionSource(completeName, fieldPlan)} +} + +${generatedCompletionHelperFunctions( + completeName, + childSelectionSetName, + fieldPlan, + fieldIndex, +)}`; +} + +function generatedNullFieldCompletionSource(fieldPlan: FieldPlan): string { + if (!fieldPlan.completedNonNull || !fieldPlan.errorPropagation) { + return `${generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + )} = null;`; + } + return `executor.handleCompletionError( + new Error( + \`Cannot return null for non-nullable field ${fieldPlan.parentTypeName}.${fieldPlan.fieldName}.\`, + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + );`; +} + +function generatedListItemNullTargetSource( + fieldPlan: FieldPlan, + itemTargetName: string, + parentNullTargetName: string, +): string { + return fieldPlan.completedItemNonNull ? parentNullTargetName : itemTargetName; +} + +interface NullListItemCompletionSourceContext { + completedResultsName: string; + fieldPlan: FieldPlan; + fieldDetailsListName: string; + indexName: string; + itemNullTargetName: string; + itemPathName: string; + itemTargetName: string; + itemTypeName: string; +} + +function generatedNullListItemCompletionSource({ + completedResultsName, + fieldPlan, + fieldDetailsListName, + indexName, + itemNullTargetName, + itemPathName, + itemTargetName, + itemTypeName, +}: NullListItemCompletionSourceContext): string { + if (!fieldPlan.completedItemNonNull || !fieldPlan.errorPropagation) { + return `${completedResultsName}[${indexName}] = null;`; + } + return `executor.handleCompletionError( + new Error( + \`Cannot return null for non-nullable field ${fieldPlan.parentTypeName}.${fieldPlan.fieldName}.\`, + ), + ${itemTypeName}, + ${fieldDetailsListName}, + ${itemPathName}, + ${itemTargetName}, + ${itemNullTargetName}, + );`; +} + +function generatedStreamUsageSetupSource( + fieldPlan: FieldPlan, + createFieldTargetSource: string, + nullTargetName = 'nullTarget', +): string { + const streamPlan = fieldPlan.streamPlan; + invariant(streamPlan !== undefined); + const conditionSetup = + streamPlan.condition === undefined + ? '' + : ` const coerced = validatedExecutionArgs.variableValues.coerced;\n`; + const streamUsageAssignment = `streamUsage = { + initialCount: ${String(streamPlan.initialCount)}, + label: ${toJavaScript(streamPlan.label)}, + fieldDetailsList, + };`; + const streamUsageSource = + streamPlan.condition === undefined + ? ` if (executor.mode !== 'ignore') { + ${streamUsageAssignment} + }` + : ` if (executor.mode !== 'ignore' && ${streamPlan.condition}) { + ${streamUsageAssignment} + }`; + + return ` const validatedExecutionArgs = executor.validatedExecutionArgs; +${conditionSetup} let streamUsage; + try { +${streamUsageSource} + if (streamUsage !== undefined && streamUsage.initialCount < 0) { + throw new Error('initialCount must be a positive integer'); + } + } catch (rawError) { + ${createFieldTargetSource} + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + ${nullTargetName}, + ); + return; + } + const streamInfo = + streamUsage === undefined + ? undefined + : ${generatedResolveInfoObjectSource({ + fieldName: toJavaScript(fieldPlan.fieldName), + fieldNodes: 'fieldNodes', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + `; +} + +function generatedStreamItemCompleterArgument( + completeName: string, + indentation: string, +): string { + return `, +${indentation}${completeName}StreamItem`; +} + +function generatedTypenameFieldSource(fieldPlan: FieldPlan): string { + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + return ` ${targetField} = ${toJavaScript(fieldPlan.parentTypeName)};`; +} + +function isGeneratedSharedDefaultLeafField(fieldPlan: FieldPlan): boolean { + return ( + fieldPlan.outputKind === 'leaf' && + fieldPlan.resolveMode === 'default' && + fieldPlan.builtinScalarName === 'Int' && + !isGeneratedTypenameField(fieldPlan) && + !hasGeneratedArgumentValues(fieldPlan.argumentPlan) + ); +} + +interface SharedDefaultLeafFieldCallSourceContext { + executeName: string; + fieldIndex: number; + fieldPlan: FieldPlan; + sourceObjectName: string; +} + +function generatedSharedDefaultLeafFieldCallSource({ + executeName, + fieldIndex, + fieldPlan, + sourceObjectName, +}: SharedDefaultLeafFieldCallSourceContext): string { + return ` ${executeName}( + executor, + runner, + ${sourceObjectName}, + target, + parentNullTarget, + path${String(fieldIndex)}, + ${toJavaScript(fieldPlan.responseName)}, + ${toJavaScript(fieldPlan.fieldName)}, + fieldNodes${String(fieldIndex)}, + fieldDetailsList${String(fieldIndex)}, + returnType${String(fieldIndex)}, + leafType${String(fieldIndex)}, + coerceOutputValue${String(fieldIndex)}, + ${String(fieldPlan.completedNonNull)}, + );`; +} + +function generatedSharedDefaultLeafFieldFunctionSource( + executeName: string, +): string { + return `function ${executeName}( + executor, + runner, + sourceObject, + target, + parentNullTarget, + fieldPath, + responseName, + fieldName, + fieldNodes, + fieldDetailsList, + returnType, + leafType, + coerceOutputValue, + completedNonNull, +) { + let result; + try { + if (sourceObject === undefined) { + result = undefined; + } else { + const property = sourceObject[fieldName]; + if (typeof property !== 'function') { + result = property; + } else { + const validatedExecutionArgs = executor.validatedExecutionArgs; + const info = ${generatedResolveInfoObjectSource({ + fieldName: 'fieldName', + fieldNodes: 'fieldNodes', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + result = property.call( + sourceObject, + emptyGeneratedArgumentValues, + validatedExecutionArgs.contextValue, + info, + ); + } + } + } catch (rawError) { + executor.handleLeafFieldError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + return; + } + + if (result != null && typeof result.then === 'function') { + target[responseName] = undefined; + runner.awaitValue( + result, + (resolved) => { + ${executeName}Complete( + executor, + resolved, + target, + parentNullTarget, + fieldPath, + responseName, + fieldName, + fieldDetailsList, + returnType, + leafType, + coerceOutputValue, + completedNonNull, + ); + }, + (rawError) => { + executor.handleLeafFieldError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + }, + fieldPath, + ); + return; + } + + ${executeName}Complete( + executor, + result, + target, + parentNullTarget, + fieldPath, + responseName, + fieldName, + fieldDetailsList, + returnType, + leafType, + coerceOutputValue, + completedNonNull, + ); +} + +function ${executeName}Complete( + executor, + result, + target, + parentNullTarget, + fieldPath, + responseName, + fieldName, + fieldDetailsList, + returnType, + leafType, + coerceOutputValue, + completedNonNull, +) { + if (result == null) { + if (completedNonNull && executor.validatedExecutionArgs.errorPropagation) { + executor.handleLeafFieldError( + new Error( + \`Cannot return null for non-nullable field \${parentType.name}.\${fieldName}.\`, + ), + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + } else { + target[responseName] = null; + } + return; + } + + if (result instanceof Error) { + executor.handleLeafFieldError( + result, + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + return; + } + + try { + const coerced = 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) { + executor.handleLeafFieldError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + target, + responseName, + parentNullTarget, + ); + } +}`; +} + +interface LeafFieldFunctionSourceContext { + completeName: string; + executeName: string; + fieldIndex: number; + fieldPath: ReadonlyArray; + fieldPlan: FieldPlan; + sourceObjectName?: string | undefined; +} + +function generatedLeafFieldFunctionSource({ + completeName, + executeName, + fieldIndex, + fieldPath, + fieldPlan, + sourceObjectName, +}: LeafFieldFunctionSourceContext): string { + const fieldNameExpression = toJavaScript(fieldPlan.fieldName); + const fieldDetailsList = `fieldDetailsList${fieldIndex}`; + const returnType = `returnType${fieldIndex}`; + const responseNameExpression = toJavaScript(fieldPlan.responseName); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + return `function ${executeName}( + executor, + runner, + source, + sourceObject, + target, + parentNullTarget, + fieldPath, +) { + let result; + try { +${generatedSpecializedResolveFieldSource({ + fieldIndex, + fieldPlan, + fieldNameExpression, + fieldPath, + fieldPathName: 'fieldPath', + fieldPathSource: 'fieldPath', + returnType, + sourceObjectName, +})} + } catch (rawError) { + executor.handleLeafFieldError( + rawError, + ${returnType}, + ${fieldDetailsList}, + fieldPath, + target, + ${responseNameExpression}, + parentNullTarget, + ); + return; + } + + if (result != null && typeof result.then === 'function') { + ${completeName}( + executor, + runner, + result, + target, + parentNullTarget, + fieldPath, + ); + return; + } + +${generatedBuiltinScalarCompletionSource({ + assignmentSource: (coerced) => `${targetField} = ${coerced};`, + exitStatement: 'return;', + fieldPlan, + valueName: 'result', +})} + ${completeName}( + executor, + runner, + result, + target, + parentNullTarget, + fieldPath, + ); +}`; +} + +function generatedLeafFieldCompletionFunctionSource( + completeName: string, + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + const fieldDetailsList = `fieldDetailsList${fieldIndex}`; + const returnType = `returnType${fieldIndex}`; + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const responseNameExpression = toJavaScript(fieldPlan.responseName); + return `function ${completeName}( + executor, + runner, + result, + target, + parentNullTarget, + fieldPath, +) { + if (result != null && typeof result.then === 'function') { + ${targetField} = undefined; + runner.awaitValue( + result, + (resolved) => { + const result = resolved; +${indentGeneratedSource( + generatedInlineLeafCompletionSource({ + responseNameExpression, + fieldPlan, + fieldIndex, + exitStatement: 'return;', + fieldPath: 'fieldPath', + }), + ' ', +)} + }, + (rawError) => { + const fieldTarget = { + container: target, + key: ${responseNameExpression}, + path: fieldPath, + }; + executor.handleCompletionError( + rawError, + ${returnType}, + ${fieldDetailsList}, + fieldPath, + fieldTarget, + ${fieldPlan.completedNonNull ? 'parentNullTarget' : 'fieldTarget'}, + ); + }, + fieldPath, + ); + return; + } + +${indentGeneratedSource( + generatedInlineLeafCompletionSource({ + responseNameExpression, + fieldPlan, + fieldIndex, + includeBuiltinScalarShortcuts: false, + fieldPath: 'fieldPath', + }), + ' ', +)} +}`; +} + +interface SpecializedResolveFieldSourceContext { + fieldIndex: number; + fieldNameExpression: string; + fieldPath: ReadonlyArray; + fieldPathName: string; + fieldPathSource?: string; + fieldPlan: FieldPlan; + returnType: string; + sourceObjectName?: string | undefined; +} + +function generatedSpecializedResolveFieldSource({ + fieldIndex, + fieldNameExpression, + fieldPath, + fieldPathName, + fieldPathSource = fieldPathName, + fieldPlan, + returnType, + sourceObjectName, +}: SpecializedResolveFieldSourceContext): string { + const fieldNodes = `fieldNodes${fieldIndex}`; + const resolveFn = `resolveFn${fieldIndex}`; + const sourceField = generatedObjectAssignmentSource( + sourceObjectName ?? 'object', + fieldPlan.fieldName, + ); + const argumentValues = generatedArgumentValuesSource( + fieldPath, + fieldPlan.argumentPlan, + 'validatedExecutionArgs.variableValues', + ); + switch (fieldPlan.resolveMode) { + case 'default': + invariant( + sourceObjectName !== undefined, + 'Expected default resolver fields to use a precomputed source object.', + ); + return ` if (${sourceObjectName} === undefined) { + result = undefined; + } else { + const property = ${sourceField}; + if (typeof property !== 'function') { + result = property; + } else { + const validatedExecutionArgs = executor.validatedExecutionArgs; +${argumentValues.setup === '' ? '' : `${argumentValues.setup}\n`} + const info = ${generatedResolveInfoObjectSource({ + fieldName: fieldNameExpression, + fieldNodes, + parentType: 'parentType', + path: fieldPathSource, + returnType, + })}; + result = property.call( + ${sourceObjectName}, + ${argumentValues.expression}, + validatedExecutionArgs.contextValue, + info, + ); + } + }`; + case 'field': + case 'customDefault': + return ` const validatedExecutionArgs = executor.validatedExecutionArgs; +${argumentValues.setup === '' ? '' : `${argumentValues.setup}\n`} + const info = ${generatedResolveInfoObjectSource({ + fieldName: fieldNameExpression, + fieldNodes, + parentType: 'parentType', + path: fieldPathSource, + returnType, + })}; + result = ${resolveFn}( + source, + ${argumentValues.expression}, + validatedExecutionArgs.contextValue, + info, + );`; + } +} + +interface InlineLeafCompletionSourceContext { + responseNameExpression: string; + fieldPlan: FieldPlan; + fieldIndex: number; + exitStatement?: string; + fieldPath?: string; + includeBuiltinScalarShortcuts?: boolean; +} + +function generatedInlineLeafCompletionSource({ + responseNameExpression, + fieldPlan, + fieldIndex, + exitStatement = 'return;', + fieldPath = `path${fieldIndex}`, + includeBuiltinScalarShortcuts = true, +}: InlineLeafCompletionSourceContext): string { + const coerceOutputValue = `coerceOutputValue${fieldIndex}`; + const fieldDetailsList = `fieldDetailsList${fieldIndex}`; + const returnType = `returnType${fieldIndex}`; + const leafType = `leafType${fieldIndex}`; + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const nullCompletionSource = + fieldPlan.completedNonNull && fieldPlan.errorPropagation + ? `executor.handleLeafFieldError( + new Error( + \`Cannot return null for non-nullable field ${fieldPlan.parentTypeName}.${fieldPlan.fieldName}.\`, + ), + ${returnType}, + ${fieldDetailsList}, + ${fieldPath}, + target, + ${responseNameExpression}, + parentNullTarget, + );` + : `${targetField} = null;`; + const coercedNullCheckSource = + fieldPlan.builtinScalarName === undefined + ? ` if (coerced == null) { + throw new Error( + \`Expected \\\`\${inspect(${leafType})}.coerceOutputValue(\${inspect( + result, + )})\\\` to return non-nullable value, returned: \${inspect(coerced)}\`, + ); + } +` + : ''; + return `${ + includeBuiltinScalarShortcuts + ? generatedBuiltinScalarCompletionSource({ + assignmentSource: (coerced) => `${targetField} = ${coerced};`, + exitStatement, + fieldPlan, + valueName: 'result', + }) + : '' + } if (result == null) { +${indentGeneratedSource(nullCompletionSource, ' ')} + ${exitStatement} + } + + if (result instanceof Error) { + executor.handleLeafFieldError( + result, + ${returnType}, + ${fieldDetailsList}, + ${fieldPath}, + target, + ${responseNameExpression}, + parentNullTarget, + ); + ${exitStatement} + } + + try { + const coerced = ${coerceOutputValue}(result); +${coercedNullCheckSource} ${targetField} = coerced; + } catch (rawError) { + executor.handleLeafFieldError( + rawError, + ${returnType}, + ${fieldDetailsList}, + ${fieldPath}, + target, + ${responseNameExpression}, + parentNullTarget, + ); + }`; +} + +function getGeneratedChildSelectionSetName( + childSelectionSetName: string | undefined, +): string { + invariant( + childSelectionSetName !== undefined, + 'Expected a non-leaf field plan to include a child selection set.', + ); + return childSelectionSetName; +} + +function generatedCompletionSource( + completeName: string, + childSelectionSetName: string | undefined, + fieldNameExpression: string, + fieldPlan: NonLeafFieldPlan, +): string { + if (fieldPlan.outputKind === 'object') { + const executeChildFields = getGeneratedChildSelectionSetName( + childSelectionSetName, + ); + const resultObjectSource = generatedResultObjectSource(); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const nullCompletionSource = generatedNullFieldCompletionSource(fieldPlan); + const isTypeOfSource = + fieldPlan.objectTypeHasIsTypeOf === true + ? ` const validatedExecutionArgs = executor.validatedExecutionArgs; + const info = ${generatedResolveInfoObjectSource({ + fieldName: fieldNameExpression, + fieldNodes: 'fieldNodes', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + let isTypeOf; + try { + isTypeOf = objectType.isTypeOf( + result, + validatedExecutionArgs.contextValue, + info, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (isTypeOf != null && typeof isTypeOf.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'isTypeOf', + resolvedName: 'resolvedIsTypeOf', + onResolveSource: `if (resolvedIsTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + objectType, + result, + fieldDetailsList, + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); +} else { + const data = ${resultObjectSource}; + ${targetField} = data; + ${executeChildFields}( + executor, + runner, + result, + data, + nullTarget, + deliveryGroupMap, + fieldPath, + ); +}`, + onRejectSource: `executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, +);`, + }), + ' ', +)} + return; + } + + if (isTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + objectType, + result, + fieldDetailsList, + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + +` + : ''; + return ` if (result instanceof Error) { + executor.handleCompletionError( + result, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullCompletionSource, ' ')} + return; + } + +${isTypeOfSource} + const data = ${resultObjectSource}; + ${targetField} = data; + ${executeChildFields}( + executor, + runner, + result, + data, + nullTarget, + deliveryGroupMap, + fieldPath, + );`; + } + + if (fieldPlan.outputKind === 'abstract') { + return generatedAbstractCompletionSource(completeName, fieldPlan); + } + + if (fieldPlan.outputKind === 'objectList') { + return generatedObjectListCompletionSource(completeName, fieldPlan); + } + + if (fieldPlan.outputKind === 'abstractList') { + return generatedAbstractListCompletionSource(completeName, fieldPlan); + } + + return generatedLeafListCompletionSource(completeName, fieldPlan); +} + +function generatedCompletionHelperFunctions( + completeName: string, + childSelectionSetName: string | undefined, + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + if (fieldPlan.outputKind === 'abstract') { + return generatedAbstractCompletionHelperFunctions( + completeName, + childSelectionSetName, + fieldIndex, + fieldPlan, + ); + } + if (fieldPlan.outputKind === 'leafList') { + return `${generatedLeafListCompletionHelperFunctions( + completeName, + fieldPlan, + fieldIndex, + )}${generatedStreamItemCompletionHelperFunction( + completeName, + fieldPlan, + fieldIndex, + )}`; + } + if (fieldPlan.outputKind === 'objectList') { + return `${generatedObjectListCompletionHelperFunctions( + completeName, + childSelectionSetName, + fieldPlan, + fieldIndex, + )}${generatedStreamItemCompletionHelperFunction( + completeName, + fieldPlan, + fieldIndex, + )}`; + } + if (fieldPlan.outputKind === 'abstractList') { + return `${generatedAbstractListCompletionHelperFunctions( + completeName, + childSelectionSetName, + fieldPlan, + fieldIndex, + )}${generatedStreamItemCompletionHelperFunction( + completeName, + fieldPlan, + fieldIndex, + )}`; + } + return ''; +} + +function generatedStreamItemCompletionHelperFunction( + completeName: string, + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + if (fieldPlan.streamPlan === undefined) { + return ''; + } + + const itemTypeName = `itemType${fieldIndex}`; + const completedResult = `completedResults[0]`; + const buildResultSource = `const completed = runner.runUntilNulled(itemPath); + if (completed !== undefined) { + return completed.then(() => + executor.buildStreamItemResult(${completedResult}, itemPath, ${itemTypeName}), + ); + } + return executor.buildStreamItemResult(${completedResult}, itemPath, ${itemTypeName});`; + + if (fieldPlan.outputKind === 'leafList') { + return ` + +function ${completeName}StreamItem(executor, itemPath, item, _index) { + const completedResults = []; + const runner = new CompiledExecutionRunner(executor); + const itemTarget = { + container: completedResults, + key: 0, + path: itemPath, + }; + if (item != null && typeof item.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'item', + onResolveSource: `${completeName}LeafListItem( + executor, + resolved, + completedResults, + 0, + itemTarget, + itemPath, +);`, + onRejectSource: `${completeName}LeafListItemError( + executor, + rawError, + completedResults, + 0, + itemTarget, + itemPath, +);`, + }), + ' ', +)} + } else { + ${completeName}LeafListItem( + executor, + item, + completedResults, + 0, + itemTarget, + itemPath, + ); + } + ${buildResultSource} +}`; + } + + const itemFunction = + fieldPlan.outputKind === 'objectList' + ? `${completeName}ObjectListItem` + : `${completeName}AbstractListItem`; + return ` + +function ${completeName}StreamItem(executor, itemPath, item, _index) { + const completedResults = []; + const runner = new CompiledExecutionRunner(executor); + const itemTarget = { + container: completedResults, + key: 0, + path: itemPath, + }; + ${itemFunction}( + executor, + runner, + item, + completedResults, + 0, + itemTarget, + itemPath, + ); + ${buildResultSource} +}`; +} + +function generatedAbstractCompletionSource( + completeName: string, + fieldPlan: FieldPlan, +): string { + const nullCompletionSource = generatedNullFieldCompletionSource(fieldPlan); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + return ` if (result instanceof Error) { + executor.handleCompletionError( + result, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullCompletionSource, ' ')} + return; + } + + const validatedExecutionArgs = executor.validatedExecutionArgs; + const info = ${generatedResolveInfoObjectSource({ + fieldName: toJavaScript(fieldPlan.fieldName), + fieldNodes: 'fieldNodes', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + const resolveTypeFn = + abstractType.resolveType ?? + validatedExecutionArgs.typeResolver; + let runtimeTypeName; + try { + runtimeTypeName = resolveTypeFn( + result, + validatedExecutionArgs.contextValue, + info, + abstractType, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (runtimeTypeName != null && typeof runtimeTypeName.then === 'function') { + ${targetField} = undefined; +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'runtimeTypeName', + resolvedName: 'resolvedRuntimeTypeName', + onResolveSource: `${completeName}RuntimeType( + executor, + runner, + result, + fieldTarget, + nullTarget, + deliveryGroupMap, + resolvedRuntimeTypeName, + info, + );`, + onRejectSource: `executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, +);`, + }), + ' ', +)} + return; + } + + ${completeName}RuntimeType( + executor, + runner, + result, + fieldTarget, + nullTarget, + deliveryGroupMap, + runtimeTypeName, + info, + );`; +} + +function generatedAbstractCompletionHelperFunctions( + completeName: string, + childSelectionSetName: string | undefined, + fieldIndex: number, + fieldPlan: FieldPlan, +): string { + const executeChildFields = getGeneratedChildSelectionSetName( + childSelectionSetName, + ); + const resultObjectSource = generatedAbstractResultObjectSource(); + return `function ${completeName}RuntimeType( + executor, + runner, + result, + fieldTarget, + nullTarget, + deliveryGroupMap, + runtimeTypeName, + info, + ) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + abstractType: true, + fieldDetailsList: true, + fieldPath: true, + returnType: true, +})} + let runtimeType; + try { + runtimeType = executor.ensureValidRuntimeType( + runtimeTypeName, + abstractType, + fieldDetailsList, + info, + result, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + ${completeName}RuntimeObject( + executor, + runner, + result, + fieldTarget, + nullTarget, + deliveryGroupMap, + runtimeType, + info, + ); +} + +function ${completeName}RuntimeObject( + executor, + runner, + result, + fieldTarget, + nullTarget, + deliveryGroupMap, + runtimeType, + info, + ) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + fieldPath: true, + returnType: true, +})} + if (runtimeType.isTypeOf !== undefined) { + let isTypeOf; + try { + isTypeOf = runtimeType.isTypeOf( + result, + executor.validatedExecutionArgs.contextValue, + info, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (isTypeOf != null && typeof isTypeOf.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'isTypeOf', + resolvedName: 'resolvedIsTypeOf', + onResolveSource: `if (resolvedIsTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + runtimeType, + result, + fieldDetailsList, + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); +} else { + const data = ${resultObjectSource}; + fieldTarget.container[fieldTarget.key] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + nullTarget, + deliveryGroupMap, + runtimeType, + ); +}`, + onRejectSource: `executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, +);`, + }), + ' ', +)} + return; + } + + if (isTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + runtimeType, + result, + fieldDetailsList, + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + } + + const data = ${resultObjectSource}; + fieldTarget.container[fieldTarget.key] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + nullTarget, + deliveryGroupMap, + runtimeType, + ); +}`; +} + +function generatedLeafListCompletionHelperFunctions( + completeName: string, + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + const coerceOutputValueName = `coerceOutputValue${fieldIndex}`; + const fieldDetailsListName = `fieldDetailsList${fieldIndex}`; + const fieldPathName = `path${fieldIndex}`; + const itemTypeName = `itemType${fieldIndex}`; + const leafTypeName = `leafType${fieldIndex}`; + return `async function ${completeName}AsyncLeafListItems( + executor, + runner, + items, + completedResults, + nullTarget, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { fieldPath: true })} + const iterator = items[Symbol.asyncIterator](); + let iteration; + let index = 0; + + try { + while (true) { + iteration = await iterator.next(); + if (executor.aborted || iteration.done) { + break; + } + + ${completeName}LeafListItems( + executor, + runner, + [iteration.value], + completedResults, + nullTarget, + index, + ); + runner.drain(); + if (executor.collectedErrors.hasNulledPosition(fieldPath)) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + return; + } + + index++; + } + } catch (error) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + throw error; + } + + if (executor.aborted) { + if (iteration?.done !== true) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + } + throw new Error('Aborted!'); + } +} + +function ${completeName}LeafListItems( + executor, + runner, + values, + completedResults, + nullTarget, + offset, +) { + + const end = offset + values.length; + if (completedResults.length < end) { + completedResults.length = end; + } + for (let index = 0; index < values.length; index++) { + const completedIndex = offset + index; + const value = values[index]; + if (value != null && typeof value.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'value', + onResolveSource: `${completeName}LeafListItem( + executor, + resolved, + completedResults, + completedIndex, + nullTarget, +);`, + onRejectSource: `${completeName}LeafListItemError( + executor, + rawError, + completedResults, + completedIndex, + nullTarget, +);`, + }), + ' ', +)} + continue; + } + + ${indentGeneratedSource( + generatedBuiltinScalarCompletionSource({ + assignmentSource: (coerced) => + `completedResults[completedIndex] = ${coerced};`, + exitStatement: 'continue;', + fieldPlan, + valueName: 'value', + }), + ' ', + )} + ${completeName}LeafListItem( + executor, + value, + completedResults, + completedIndex, + nullTarget, + ); + } +} + +function ${completeName}LeafListItemError( + executor, + rawError, + completedResults, + index, + nullTarget, + providedItemPath, +) { + const itemPath = + providedItemPath ?? { prev: ${fieldPathName}, key: index, typename: undefined }; + const itemTarget = { + container: completedResults, + key: index, + path: itemPath, + }; + executor.handleCompletionError( + rawError, + ${itemTypeName}, + ${fieldDetailsListName}, + itemPath, + itemTarget, + ${generatedListItemNullTargetSource(fieldPlan, 'itemTarget', 'nullTarget')}, + ); +} + +function ${completeName}LeafListItem( + executor, + value, + completedResults, + index, + nullTarget, + providedItemPath, +) { +${generatedBuiltinScalarCompletionSource({ + assignmentSource: (coerced) => `completedResults[index] = ${coerced};`, + exitStatement: 'return;', + fieldPlan, + valueName: 'value', +})} if (value instanceof Error) { + ${completeName}LeafListItemError( + executor, + value, + completedResults, + index, + nullTarget, + providedItemPath, + ); + return; + } + + if (value == null) { +${indentGeneratedSource( + fieldPlan.completedItemNonNull + ? `const itemPath = + providedItemPath ?? { prev: ${fieldPathName}, key: index, typename: undefined }; +const itemTarget = { + container: completedResults, + key: index, + path: itemPath, +}; +${generatedNullListItemCompletionSource({ + completedResultsName: 'completedResults', + fieldPlan, + fieldDetailsListName, + indexName: 'index', + itemNullTargetName: 'nullTarget', + itemPathName: 'itemPath', + itemTargetName: 'itemTarget', + itemTypeName, +})}` + : generatedNullListItemCompletionSource({ + completedResultsName: 'completedResults', + fieldPlan, + fieldDetailsListName, + indexName: 'index', + itemNullTargetName: 'nullTarget', + itemPathName: 'itemPath', + itemTargetName: 'itemTarget', + itemTypeName, + }), + ' ', +)} + return; + } + + try { + const coerced = ${coerceOutputValueName}(value); + if (coerced == null) { + throw new Error( + \`Expected \\\`\${inspect(${leafTypeName})}.coerceOutputValue(\${inspect( + value, + )})\\\` to return non-nullable value, returned: \${inspect(coerced)}\`, + ); + } + completedResults[index] = coerced; + } catch (rawError) { + ${completeName}LeafListItemError( + executor, + rawError, + completedResults, + index, + nullTarget, + providedItemPath, + ); + } +}`; +} + +interface BuiltinScalarCompletionSourceContext { + assignmentSource: (coerced: string) => string; + exitStatement: string; + fieldPlan: FieldPlan; + valueName: string; +} + +function generatedBuiltinScalarCompletionSource({ + assignmentSource, + exitStatement, + fieldPlan, + valueName, +}: BuiltinScalarCompletionSourceContext): string { + const helperName = generatedBuiltinOutputCoerceFunctionName( + fieldPlan.builtinScalarName, + ); + if (helperName === undefined) { + return ''; + } + + const assignment = (coerced: string) => + `${assignmentSource(coerced)} + ${exitStatement}`; + + const scalarName = + fieldPlan.builtinScalarName as BuiltInScalarVariablePlan['scalarName']; + switch (scalarName) { + case 'Boolean': + return ` if (typeof ${valueName} === 'boolean') { + ${assignment(valueName)} + } + if (typeof ${valueName} === 'number' && Number.isFinite(${valueName})) { + ${assignment(`${valueName} !== 0`)} + } + if (typeof ${valueName} === 'bigint') { + ${assignment(`${valueName} !== 0n`)} + } + +`; + case 'Float': + return ` if (typeof ${valueName} === 'number' && Number.isFinite(${valueName})) { + ${assignment(valueName)} + } + if (typeof ${valueName} === 'boolean') { + ${assignment(`${valueName} ? 1 : 0`)} + } + if (typeof ${valueName} === 'string' && ${valueName} !== '') { + const coerced = Number(${valueName}); + if (Number.isFinite(coerced)) { + ${assignment('coerced')} + } + } + if (typeof ${valueName} === 'bigint') { + const coerced = Number(${valueName}); + if (Number.isFinite(coerced) && BigInt(coerced) === ${valueName}) { + ${assignment('coerced')} + } + } + +`; + case 'ID': + return ` if (typeof ${valueName} === 'string') { + ${assignment(valueName)} + } + if (typeof ${valueName} === 'number' && Number.isInteger(${valueName})) { + ${assignment(`String(${valueName})`)} + } + if (typeof ${valueName} === 'bigint') { + ${assignment(`String(${valueName})`)} + } + +`; + case 'Int': + return ` if ( + typeof ${valueName} === 'number' && + Number.isInteger(${valueName}) && + ${valueName} <= 2147483647 && + ${valueName} >= -2147483648 + ) { + ${assignment(valueName)} + } + if (typeof ${valueName} === 'boolean') { + ${assignment(`${valueName} ? 1 : 0`)} + } + if (typeof ${valueName} === 'string' && ${valueName} !== '') { + const coerced = Number(${valueName}); + if ( + Number.isInteger(coerced) && + coerced <= 2147483647 && + coerced >= -2147483648 + ) { + ${assignment('coerced')} + } + } + if ( + typeof ${valueName} === 'bigint' && + ${valueName} <= 2147483647 && + ${valueName} >= -2147483648 + ) { + ${assignment(`Number(${valueName})`)} + } + +`; + case 'String': + return ` if (typeof ${valueName} === 'string') { + ${assignment(valueName)} + } + if (typeof ${valueName} === 'boolean') { + ${assignment(`${valueName} ? 'true' : 'false'`)} + } + if (typeof ${valueName} === 'number' && Number.isFinite(${valueName})) { + ${assignment(`String(${valueName})`)} + } + if (typeof ${valueName} === 'bigint') { + ${assignment(`String(${valueName})`)} + } + +`; + } +} + +function generatedObjectListCompletionSource( + completeName: string, + fieldPlan: FieldPlan, +): string { + const nullCompletionSource = generatedNullFieldCompletionSource(fieldPlan); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const streamSetupSource = + fieldPlan.streamPlan === undefined + ? '' + : generatedStreamUsageSetupSource(fieldPlan, ''); + const arrayCondition = + fieldPlan.streamPlan === undefined + ? 'Array.isArray(result)' + : 'streamUsage === undefined && Array.isArray(result)'; + const streamIteratorBreakSource = + fieldPlan.streamPlan === undefined + ? '' + : ` if ( + streamUsage?.initialCount === index && + executor.handleStream( + index, + fieldPath, + { handle: iterator }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ) + ) { + break; + } +`; + return ` if (result instanceof Error) { + executor.handleCompletionError( + result, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullCompletionSource, ' ')} + return; + } + +${streamSetupSource} + if (${arrayCondition}) { + const completedResults = new Array(result.length); + ${targetField} = completedResults; + ${completeName}ObjectListItems( + executor, + runner, + result, + completedResults, + nullTarget, + 0, + ); + return; + } + + const completedResults = []; + ${targetField} = completedResults; + if (isAsyncIterable(result)) { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: + fieldPlan.streamPlan === undefined + ? `${completeName}AsyncObjectListItems( + executor, + runner, + result, + completedResults, + nullTarget, +)` + : `executor.readAsyncListInitial( + result, + streamUsage, + fieldPath, + fieldDetailsList, +)`, + onResolveSource: + fieldPlan.streamPlan === undefined + ? '' + : `${completeName}ObjectListItems( + executor, + runner, + resolved.values, + completedResults, + nullTarget, + 0, +); +if (!resolved.done) { + executor.handleStream( + resolved.nextIndex, + fieldPath, + { handle: resolved.iterator, isAsync: true }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ); + }`, + onRejectSource: `executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, +);`, + }), + ' ', +)} + return; + } + + if (!isIterableObject(result)) { + executor.handleCompletionError( + new Error( + 'Expected Iterable, but did not find one for field "${fieldPlan.parentTypeName}.${fieldPlan.fieldName}".', + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + const values = []; + const iterator = result[Symbol.iterator](); + let index = 0; + try { + while (true) { +${streamIteratorBreakSource} const iteration = iterator.next(); + if (iteration.done) { + break; + } + values.push(iteration.value); + index++; + } + } catch (rawError) { + executor.sharedExecutionContext.asyncWorkTracker.addValues( + collectIteratorPromises(iterator), + ); + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + ${completeName}ObjectListItems( + executor, + runner, + values, + completedResults, + nullTarget, + 0, + );`; +} + +function generatedNullableObjectListCompletionSource( + completeName: string, + fieldPlan: FieldPlan, +): string { + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const createFieldTarget = `const fieldTarget = { + container: target, + key: ${toJavaScript(fieldPlan.responseName)}, + path: fieldPath, + };`; + const streamSetupSource = + fieldPlan.streamPlan === undefined + ? '' + : generatedStreamUsageSetupSource( + fieldPlan, + createFieldTarget, + 'fieldTarget', + ); + const arrayCondition = + fieldPlan.streamPlan === undefined + ? 'Array.isArray(result)' + : 'streamUsage === undefined && Array.isArray(result)'; + const streamIteratorBreakSource = + fieldPlan.streamPlan === undefined + ? '' + : ` if ( + streamUsage?.initialCount === index && + executor.handleStream( + index, + fieldPath, + { handle: iterator }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ) + ) { + break; + } +`; + return ` if (result instanceof Error) { + ${createFieldTarget} + executor.handleCompletionError( + result, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + fieldTarget, + ); + return; + } + + if (result == null) { + ${targetField} = null; + return; + } + +${streamSetupSource} + if (${arrayCondition}) { + const completedResults = new Array(result.length); + ${targetField} = completedResults; + ${completeName}ObjectListItems( + executor, + runner, + result, + completedResults, + undefined, + 0, + ); + return; + } + + const completedResults = []; + ${targetField} = completedResults; + if (isAsyncIterable(result)) { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: + fieldPlan.streamPlan === undefined + ? `${completeName}AsyncObjectListItems( + executor, + runner, + result, + completedResults, + undefined, +)` + : `executor.readAsyncListInitial( + result, + streamUsage, + fieldPath, + fieldDetailsList, +)`, + onResolveSource: + fieldPlan.streamPlan === undefined + ? '' + : `${completeName}ObjectListItems( + executor, + runner, + resolved.values, + completedResults, + undefined, + 0, +); +if (!resolved.done) { + executor.handleStream( + resolved.nextIndex, + fieldPath, + { handle: resolved.iterator, isAsync: true }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ); + }`, + onRejectSource: `${createFieldTarget} +executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + fieldTarget, +);`, + }), + ' ', +)} + return; + } + + if (!isIterableObject(result)) { + ${createFieldTarget} + executor.handleCompletionError( + new Error( + 'Expected Iterable, but did not find one for field "${fieldPlan.parentTypeName}.${fieldPlan.fieldName}".', + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + fieldTarget, + ); + return; + } + + const values = []; + const iterator = result[Symbol.iterator](); + let index = 0; + try { + while (true) { +${streamIteratorBreakSource} const iteration = iterator.next(); + if (iteration.done) { + break; + } + values.push(iteration.value); + index++; + } + } catch (rawError) { + executor.sharedExecutionContext.asyncWorkTracker.addValues( + collectIteratorPromises(iterator), + ); + ${createFieldTarget} + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + fieldTarget, + ); + return; + } + + ${completeName}ObjectListItems( + executor, + runner, + values, + completedResults, + undefined, + 0, + );`; +} + +function generatedObjectListCompletionHelperFunctions( + completeName: string, + childSelectionSetName: string | undefined, + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + const executeChildFields = getGeneratedChildSelectionSetName( + childSelectionSetName, + ); + const resultObjectSource = generatedResultObjectSource(); + const isTypeOfSource = + fieldPlan.objectTypeHasIsTypeOf === true + ? ` const validatedExecutionArgs = executor.validatedExecutionArgs; + const info = ${generatedResolveInfoObjectSource({ + fieldName: toJavaScript(fieldPlan.fieldName), + fieldNodes: 'fieldNodes', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + let isTypeOf; + try { + isTypeOf = objectType.isTypeOf( + result, + validatedExecutionArgs.contextValue, + info, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (isTypeOf != null && typeof isTypeOf.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'isTypeOf', + resolvedName: 'resolvedIsTypeOf', + onResolveSource: `if (resolvedIsTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + objectType, + result, + fieldDetailsList, + ), + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); +} else { + const data = ${resultObjectSource}; + completedResults[index] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + itemPath, + ); +}`, + onRejectSource: `executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, +);`, + }), + ' ', +)} + return; + } + + if (isTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + objectType, + result, + fieldDetailsList, + ), + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + +` + : ''; + const objectListItemFunctions = generatedObjectListItemFunctionsSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, + isTypeOfSource, + }); + const objectListItemFastPath = generatedObjectListItemFastPathSource({ + executeChildFields, + fieldIndex, + fieldPlan, + }); + return `async function ${completeName}AsyncObjectListItems( + executor, + runner, + items, + completedResults, + nullTarget, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { fieldPath: true })} + const iterator = items[Symbol.asyncIterator](); + let iteration; + let index = 0; + try { + while (true) { + iteration = await iterator.next(); + if (executor.aborted || iteration.done) { + break; + } + ${completeName}ObjectListItem( + executor, + runner, + iteration.value, + completedResults, + index, + nullTarget, + ); + runner.drain(); + if (executor.collectedErrors.hasNulledPosition(fieldPath)) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + return; + } + index++; + } + } catch (error) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + throw error; + } + + if (executor.aborted) { + if (iteration?.done !== true) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + } + throw new Error('Aborted!'); + } +} + +function ${completeName}ObjectListItems( + executor, + runner, + values, + completedResults, + nullTarget, + offset, +) { + + const end = offset + values.length; + if (completedResults.length < end) { + completedResults.length = end; + } + for (let index = 0; index < values.length; index++) { + const completedIndex = offset + index; + const result = values[index]; + ${indentGeneratedSource(objectListItemFastPath, ' ')} + ${completeName}ObjectListItem( + executor, + runner, + result, + completedResults, + completedIndex, + nullTarget, + ); + } +} + +${objectListItemFunctions}`; +} + +interface ObjectListItemFunctionsSourceContext { + completeName: string; + executeChildFields: string; + fieldIndex: number; + fieldPlan: FieldPlan; + isTypeOfSource: string; +} + +function generatedObjectListItemFastPathSource({ + executeChildFields, + fieldIndex, + fieldPlan, +}: Omit< + ObjectListItemFunctionsSourceContext, + 'completeName' | 'isTypeOfSource' +>): string { + if ( + fieldPlan.objectTypeHasIsTypeOf === true || + fieldPlan.fields?.some(hasImmediateDefaultResolvedLeafField) !== true + ) { + return ''; + } + + const resultObjectSource = generatedResultObjectSource(); + const fieldPathName = `path${fieldIndex}`; + const itemTargetSource = + fieldPlan.completedItemNonNull || fieldPlan.childrenCanNullParent === true + ? `const itemTarget = { + container: completedResults, + key: completedIndex, + path: itemPath, +}; +const itemNullTarget = ${generatedListItemNullTargetSource( + fieldPlan, + 'itemTarget', + 'nullTarget', + )};` + : 'const itemNullTarget = nullTarget;'; + + return `if ( + result != null && + typeof result.then !== 'function' && + !(result instanceof Error) +) { + const itemPath = { prev: ${fieldPathName}, key: completedIndex, typename: undefined }; + ${itemTargetSource} + const data = ${resultObjectSource}; + completedResults[completedIndex] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + itemPath, + ); + continue; +}`; +} + +const leafFieldOutputKinds = new Set([ + 'leaf', + 'leafList', +]); + +function hasImmediateDefaultResolvedLeafField(field: FieldPlan): boolean { + return ( + field.resolveMode === 'default' && + !isGeneratedMetaFieldName(field.fieldName) && + leafFieldOutputKinds.has(field.outputKind) + ); +} + +function generatedObjectListItemFunctionsSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, + isTypeOfSource, +}: ObjectListItemFunctionsSourceContext): string { + if ( + fieldPlan.completedItemNonNull || + fieldPlan.childrenCanNullParent === true || + fieldPlan.objectTypeHasIsTypeOf === true + ) { + return generatedObjectListItemFunctionsWithTargetSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, + isTypeOfSource, + }); + } + + return generatedNullableObjectListItemFunctionsSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, + }); +} + +function generatedObjectListItemFunctionsWithTargetSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, + isTypeOfSource, +}: ObjectListItemFunctionsSourceContext): string { + if (fieldPlan.objectTypeHasIsTypeOf !== true) { + return generatedObjectListItemFunctionsWithTargetWithoutIsTypeOfSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, + }); + } + + const resultObjectSource = generatedResultObjectSource(); + const syncCompletionSource = `${completeName}ResolvedObjectListItem( + executor, + runner, + result, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, + );`; + return `function ${completeName}ObjectListItem( + executor, + runner, + result, + completedResults, + index, + nullTarget, + providedItemPath, + ) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + fieldPath: true, + itemType: true, +})} + const itemPath = + providedItemPath ?? { prev: fieldPath, key: index, typename: undefined }; + const itemTarget = { + container: completedResults, + key: index, + path: itemPath, + }; + const itemNullTarget = ${generatedListItemNullTargetSource( + fieldPlan, + 'itemTarget', + 'nullTarget', + )}; + if (result != null && typeof result.then === 'function') { + completedResults[index] = undefined; +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'result', + onResolveSource: `${completeName}ResolvedObjectListItem( + executor, + runner, + resolved, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, +);`, + onRejectSource: `executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, +);`, + }), + ' ', +)} + return; + } + +${indentGeneratedSource(syncCompletionSource, ' ')} +} + +function ${completeName}ResolvedObjectListItem( + executor, + runner, + result, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + fieldNodes: true, + fieldPath: true, + itemType: true, + objectType: true, + returnType: true, +})} + if (result instanceof Error) { + executor.handleCompletionError( + result, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource( + generatedNullListItemCompletionSource({ + completedResultsName: 'completedResults', + fieldPlan, + fieldDetailsListName: 'fieldDetailsList', + indexName: 'index', + itemNullTargetName: 'itemNullTarget', + itemPathName: 'itemPath', + itemTargetName: 'itemTarget', + itemTypeName: 'itemType', + }), + ' ', +)} + return; + } + +${isTypeOfSource} + const data = ${resultObjectSource}; + completedResults[index] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + itemPath, + ); +}`; +} + +function generatedObjectListItemFunctionsWithTargetWithoutIsTypeOfSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, +}: Omit): string { + const resultObjectSource = generatedResultObjectSource(); + const fieldDetailsListName = `fieldDetailsList${fieldIndex}`; + const fieldPathName = `path${fieldIndex}`; + const itemTypeName = `itemType${fieldIndex}`; + const nullListItemCompletionSource = generatedNullListItemCompletionSource({ + completedResultsName: 'completedResults', + fieldPlan, + fieldDetailsListName, + indexName: 'index', + itemNullTargetName: 'itemNullTarget', + itemPathName: 'itemPath', + itemTargetName: 'itemTarget', + itemTypeName, + }); + return `function ${completeName}ObjectListItem( + executor, + runner, + result, +\t completedResults, +\t index, +\t nullTarget, +\t providedItemPath, +\t) { + const itemPath = + providedItemPath ?? { prev: ${fieldPathName}, key: index, typename: undefined }; + const itemTarget = { + container: completedResults, + key: index, + path: itemPath, + }; + const itemNullTarget = ${generatedListItemNullTargetSource( + fieldPlan, + 'itemTarget', + 'nullTarget', + )}; + if (result != null && typeof result.then === 'function') { + completedResults[index] = undefined; +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'result', + onResolveSource: `${completeName}ResolvedObjectListItem( + executor, + runner, + resolved, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, +);`, + onRejectSource: `executor.handleCompletionError( + rawError, + ${itemTypeName}, + ${fieldDetailsListName}, + itemPath, + itemTarget, + itemNullTarget, +);`, + }), + ' ', +)} + return; + } + + if (result instanceof Error) { + executor.handleCompletionError( + result, + ${itemTypeName}, + ${fieldDetailsListName}, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullListItemCompletionSource, ' ')} + return; + } + + const data = ${resultObjectSource}; + completedResults[index] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + itemPath, + ); +} + +function ${completeName}ResolvedObjectListItem( + executor, + runner, + result, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, +) { + if (result instanceof Error) { + executor.handleCompletionError( + result, + ${itemTypeName}, + ${fieldDetailsListName}, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullListItemCompletionSource, ' ')} + return; + } + + const data = ${resultObjectSource}; + completedResults[index] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + itemPath, + ); +}`; +} + +function generatedNullableObjectListItemFunctionsSource({ + completeName, + executeChildFields, + fieldIndex, + fieldPlan, +}: Omit): string { + const resultObjectSource = generatedResultObjectSource(); + return `function ${completeName}ObjectListItem( + executor, + runner, + result, + completedResults, + index, + nullTarget, + providedItemPath, + ) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + fieldPath: true, + itemType: true, +})} + const itemPath = + providedItemPath ?? { prev: fieldPath, key: index, typename: undefined }; + if (result != null && typeof result.then === 'function') { + completedResults[index] = undefined; +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'result', + onResolveSource: `${completeName}ResolvedObjectListItem( + executor, + runner, + resolved, + completedResults, + index, + nullTarget, + itemPath, + );`, + onRejectSource: `${completeName}ObjectListItemError( + executor, + rawError, + completedResults, + index, + itemPath, + nullTarget, + );`, + }), + ' ', +)} + return; + } + + if (result instanceof Error) { + ${completeName}ObjectListItemError( + executor, + result, + completedResults, + index, + itemPath, + nullTarget, + ); + return; + } + + if (result == null) { + completedResults[index] = null; + return; + } + + const data = ${resultObjectSource}; + completedResults[index] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + nullTarget, + undefined, + itemPath, + ); +} + +function ${completeName}ObjectListItemError( + executor, + rawError, + completedResults, + index, + itemPath, + nullTarget, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + itemType: true, +})} + const itemTarget = { + container: completedResults, + key: index, + path: itemPath, + }; + executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + nullTarget, + ); +} + +function ${completeName}ResolvedObjectListItem( + executor, + runner, + result, + completedResults, + index, + nullTarget, + itemPath, + ) { + if (result instanceof Error) { + ${completeName}ObjectListItemError( + executor, + result, + completedResults, + index, + itemPath, + nullTarget, + ); + return; + } + + if (result == null) { + completedResults[index] = null; + return; + } + + const data = ${resultObjectSource}; + completedResults[index] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + nullTarget, + undefined, + itemPath, + ); +}`; +} + +function generatedAbstractListCompletionSource( + completeName: string, + fieldPlan: FieldPlan, +): string { + const nullCompletionSource = generatedNullFieldCompletionSource(fieldPlan); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const streamSetupSource = + fieldPlan.streamPlan === undefined + ? '' + : generatedStreamUsageSetupSource(fieldPlan, ''); + const arrayCondition = + fieldPlan.streamPlan === undefined + ? 'Array.isArray(result)' + : 'streamUsage === undefined && Array.isArray(result)'; + const streamIteratorBreakSource = + fieldPlan.streamPlan === undefined + ? '' + : ` if ( + streamUsage?.initialCount === index && + executor.handleStream( + index, + fieldPath, + { handle: iterator }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ) + ) { + break; + } +`; + return ` if (result instanceof Error) { + executor.handleCompletionError( + result, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullCompletionSource, ' ')} + return; + } + +${streamSetupSource} + if (${arrayCondition}) { + const completedResults = new Array(result.length); + ${targetField} = completedResults; + ${completeName}AbstractListItems( + executor, + runner, + result, + completedResults, + nullTarget, + 0, + ); + return; + } + + const completedResults = []; + ${targetField} = completedResults; + if (isAsyncIterable(result)) { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: + fieldPlan.streamPlan === undefined + ? `${completeName}AsyncAbstractListItems( + executor, + runner, + result, + completedResults, + nullTarget, +)` + : `executor.readAsyncListInitial( + result, + streamUsage, + fieldPath, + fieldDetailsList, +)`, + onResolveSource: + fieldPlan.streamPlan === undefined + ? '' + : `${completeName}AbstractListItems( + executor, + runner, + resolved.values, + completedResults, + nullTarget, + 0, +); +if (!resolved.done) { + executor.handleStream( + resolved.nextIndex, + fieldPath, + { handle: resolved.iterator, isAsync: true }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ); + }`, + onRejectSource: `executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, +);`, + }), + ' ', +)} + return; + } + + if (!isIterableObject(result)) { + executor.handleCompletionError( + new Error( + 'Expected Iterable, but did not find one for field "${fieldPlan.parentTypeName}.${fieldPlan.fieldName}".', + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + const values = []; + const iterator = result[Symbol.iterator](); + let index = 0; + try { + while (true) { +${streamIteratorBreakSource} const iteration = iterator.next(); + if (iteration.done) { + break; + } + values.push(iteration.value); + index++; + } + } catch (rawError) { + executor.sharedExecutionContext.asyncWorkTracker.addValues( + collectIteratorPromises(iterator), + ); + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + ${completeName}AbstractListItems( + executor, + runner, + values, + completedResults, + nullTarget, + 0, + );`; +} + +function generatedAbstractListCompletionHelperFunctions( + completeName: string, + childSelectionSetName: string | undefined, + fieldPlan: FieldPlan, + fieldIndex: number, +): string { + const executeChildFields = getGeneratedChildSelectionSetName( + childSelectionSetName, + ); + const resultObjectSource = generatedAbstractResultObjectSource(); + return `async function ${completeName}AsyncAbstractListItems( + executor, + runner, + items, + completedResults, + nullTarget, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { fieldPath: true })} + const iterator = items[Symbol.asyncIterator](); + let iteration; + let index = 0; + try { + while (true) { + iteration = await iterator.next(); + if (executor.aborted || iteration.done) { + break; + } + ${completeName}AbstractListItem( + executor, + runner, + iteration.value, + completedResults, + index, + nullTarget, + ); + runner.drain(); + if (executor.collectedErrors.hasNulledPosition(fieldPath)) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + return; + } + index++; + } + } catch (error) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + throw error; + } + + if (executor.aborted) { + if (iteration?.done !== true) { + executor.sharedExecutionContext.asyncWorkTracker.add( + returnIteratorCatchingErrors(iterator), + ); + } + throw new Error('Aborted!'); + } +} + +function ${completeName}AbstractListItems( + executor, + runner, + values, + completedResults, + nullTarget, + offset, +) { + + const end = offset + values.length; + if (completedResults.length < end) { + completedResults.length = end; + } + for (let index = 0; index < values.length; index++) { + ${completeName}AbstractListItem( + executor, + runner, + values[index], + completedResults, + offset + index, + nullTarget, + ); + } +} + +function ${completeName}AbstractListItem( + executor, + runner, + result, + completedResults, + index, + nullTarget, + providedItemPath, + ) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + fieldPath: true, + itemType: true, +})} + const itemPath = + providedItemPath ?? { prev: fieldPath, key: index, typename: undefined }; + const itemTarget = { + container: completedResults, + key: index, + path: itemPath, + }; + const itemNullTarget = ${generatedListItemNullTargetSource( + fieldPlan, + 'itemTarget', + 'nullTarget', + )}; + if (result != null && typeof result.then === 'function') { + completedResults[index] = undefined; +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'result', + onResolveSource: `${completeName}ResolvedAbstractListItem( + executor, + runner, + resolved, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, +);`, + onRejectSource: `executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, +);`, + }), + ' ', +)} + return; + } + + ${completeName}ResolvedAbstractListItem( + executor, + runner, + result, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, + ); +} + +function ${completeName}ResolvedAbstractListItem( + executor, + runner, + result, + completedResults, + index, + itemPath, + itemTarget, + itemNullTarget, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + abstractType: true, + fieldDetailsList: true, + fieldNodes: true, + fieldPath: true, + itemType: true, + returnType: true, +})} + if (result instanceof Error) { + executor.handleCompletionError( + result, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource( + generatedNullListItemCompletionSource({ + completedResultsName: 'completedResults', + fieldPlan, + fieldDetailsListName: 'fieldDetailsList', + indexName: 'index', + itemNullTargetName: 'itemNullTarget', + itemPathName: 'itemPath', + itemTargetName: 'itemTarget', + itemTypeName: 'itemType', + }), + ' ', +)} + return; + } + + const validatedExecutionArgs = executor.validatedExecutionArgs; + const info = ${generatedResolveInfoObjectSource({ + fieldName: toJavaScript(fieldPlan.fieldName), + fieldNodes: 'fieldNodes', + parentType: 'parentType', + path: 'fieldPath', + returnType: 'returnType', + })}; + const resolveTypeFn = + abstractType.resolveType ?? + validatedExecutionArgs.typeResolver; + let runtimeTypeName; + try { + runtimeTypeName = resolveTypeFn( + result, + validatedExecutionArgs.contextValue, + info, + abstractType, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (runtimeTypeName != null && typeof runtimeTypeName.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'runtimeTypeName', + resolvedName: 'resolvedRuntimeTypeName', + onResolveSource: `${completeName}AbstractListItemRuntimeType( + executor, + runner, + result, + itemPath, + itemTarget, + itemNullTarget, + resolvedRuntimeTypeName, + info, +);`, + onRejectSource: `executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, +);`, + }), + ' ', +)} + return; + } + + ${completeName}AbstractListItemRuntimeType( + executor, + runner, + result, + itemPath, + itemTarget, + itemNullTarget, + runtimeTypeName, + info, + ); +} + +function ${completeName}AbstractListItemRuntimeType( + executor, + runner, + result, + itemPath, + itemTarget, + itemNullTarget, + runtimeTypeName, + info, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + abstractType: true, + fieldDetailsList: true, + itemType: true, +})} + let runtimeType; + try { + runtimeType = executor.ensureValidRuntimeType( + runtimeTypeName, + abstractType, + fieldDetailsList, + info, + result, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + ${completeName}AbstractListItemRuntimeObject( + executor, + runner, + result, + itemPath, + itemTarget, + itemNullTarget, + runtimeType, + info, + ); +} + +function ${completeName}AbstractListItemRuntimeObject( + executor, + runner, + result, + itemPath, + itemTarget, + itemNullTarget, + runtimeType, + info, +) { +${generatedFieldAliasSource(fieldIndex, fieldPlan, { + fieldDetailsList: true, + itemType: true, +})} + if (runtimeType.isTypeOf !== undefined) { + let isTypeOf; + try { + isTypeOf = runtimeType.isTypeOf( + result, + executor.validatedExecutionArgs.contextValue, + info, + ); + } catch (rawError) { + executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + + if (isTypeOf != null && typeof isTypeOf.then === 'function') { +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: 'isTypeOf', + resolvedName: 'resolvedIsTypeOf', + onResolveSource: `if (resolvedIsTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + runtimeType, + result, + fieldDetailsList, + ), + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); +} else { + const data = ${resultObjectSource}; + itemTarget.container[itemTarget.key] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + runtimeType, + ); +}`, + onRejectSource: `executor.handleCompletionError( + rawError, + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, +);`, + }), + ' ', +)} + return; + } + + if (isTypeOf !== true) { + executor.handleCompletionError( + executor.invalidReturnTypeError( + runtimeType, + result, + fieldDetailsList, + ), + itemType, + fieldDetailsList, + itemPath, + itemTarget, + itemNullTarget, + ); + return; + } + } + + const data = ${resultObjectSource}; + itemTarget.container[itemTarget.key] = data; + ${executeChildFields}( + executor, + runner, + result, + data, + itemNullTarget, + undefined, + runtimeType, + ); +}`; +} + +function generatedLeafListCompletionSource( + completeName: string, + fieldPlan: FieldPlan, +): string { + const nullCompletionSource = generatedNullFieldCompletionSource(fieldPlan); + const targetField = generatedObjectAssignmentSource( + 'target', + fieldPlan.responseName, + ); + const streamSetupSource = + fieldPlan.streamPlan === undefined + ? '' + : generatedStreamUsageSetupSource(fieldPlan, ''); + const arrayCondition = + fieldPlan.streamPlan === undefined + ? 'Array.isArray(result)' + : 'streamUsage === undefined && Array.isArray(result)'; + const streamIteratorBreakSource = + fieldPlan.streamPlan === undefined + ? '' + : ` if ( + streamUsage?.initialCount === index && + executor.handleStream( + index, + fieldPath, + { handle: iterator }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ) + ) { + break; + } +`; + return ` if (result instanceof Error) { + executor.handleCompletionError( + result, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + if (result == null) { +${indentGeneratedSource(nullCompletionSource, ' ')} + return; + } + +${streamSetupSource} + if (${arrayCondition}) { + const completedResults = new Array(result.length); + ${targetField} = completedResults; + ${completeName}LeafListItems( + executor, + runner, + result, + completedResults, + nullTarget, + 0, + ); + return; + } + + if (isAsyncIterable(result)) { + const completedResults = []; + ${targetField} = completedResults; +${indentGeneratedSource( + generatedSimplePromiseSource({ + promiseExpression: + fieldPlan.streamPlan === undefined + ? `${completeName}AsyncLeafListItems( + executor, + runner, + result, + completedResults, + nullTarget, +)` + : `executor.readAsyncListInitial( + result, + streamUsage, + fieldPath, + fieldDetailsList, +)`, + onResolveSource: + fieldPlan.streamPlan === undefined + ? '' + : `${completeName}LeafListItems( + executor, + runner, + resolved.values, + completedResults, + nullTarget, + 0, +); +if (!resolved.done) { + executor.handleStream( + resolved.nextIndex, + fieldPath, + { handle: resolved.iterator, isAsync: true }, + streamUsage, + streamInfo, + itemType${generatedStreamItemCompleterArgument(completeName, ' ')}, + ); + }`, + onRejectSource: `executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, +);`, + }), + ' ', +)} + return; + } + + if (!isIterableObject(result)) { + executor.handleCompletionError( + new Error( + 'Expected Iterable, but did not find one for field "${fieldPlan.parentTypeName}.${fieldPlan.fieldName}".', + ), + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + const completedResults = []; + ${targetField} = completedResults; + const values = []; + const iterator = result[Symbol.iterator](); + let index = 0; + try { + while (true) { +${streamIteratorBreakSource} const iteration = iterator.next(); + if (iteration.done) { + break; + } + values.push(iteration.value); + index++; + } + } catch (rawError) { + executor.sharedExecutionContext.asyncWorkTracker.addValues( + collectIteratorPromises(iterator), + ); + executor.handleCompletionError( + rawError, + returnType, + fieldDetailsList, + fieldPath, + fieldTarget, + nullTarget, + ); + return; + } + + ${completeName}LeafListItems( + executor, + runner, + values, + completedResults, + nullTarget, + 0, + );`; +} + +function getVariableValuesFunction( + variableDefinitionCount: number, + requiredVariableDefinitions: ReadonlyArray, + variableValuesPlan: VariableValuesPlan | undefined, +): string { + if (variableDefinitionCount === 0) { + return ''; + } + + if (variableValuesPlan !== undefined) { + return getPlannedVariableValuesFunction(variableValuesPlan); + } + + const variableValuesObjectSource = generatedNullPrototypeObjectSource(); + + return ` function getVariableValues(args) { + const rawVariableValues = args.variableValues ?? EMPTY_VARIABLE_VALUES; + const maxCoercionErrors = args.options?.maxCoercionErrors ?? 50; + return getGeneratedVariableValues(rawVariableValues, maxCoercionErrors); + } + + function getGeneratedVariableValues(inputs, maxErrors) { + const errors = []; + const onError = (error) => { + if (errors.length >= maxErrors) { + throw new GraphQLError( + 'Too many errors processing variables, error limit reached. Execution aborted.', + ); + } + errors.push(error); + }; + const sources = ${variableValuesObjectSource}; + const coerced = ${variableValuesObjectSource}; + const compiledVariableValues = getCompiledVariableValues(); + + try { +${Array.from({ length: variableDefinitionCount }, (_value, index) => + generatedVariableValueSource(index, requiredVariableDefinitions[index]), +).join('\n')} + if (errors.length === 0) { + return { sources, coerced }; + } + } catch (error) { + errors.push(ensureGraphQLError(error)); + } + + return errors; + } + + function useGeneratedVariableDefaultValue( + entry, + coerced, + onError, + hideSuggestions, + ) { + if (entry.defaultError === undefined) { + coerced[entry.signature.name] = entry.defaultValue; + return; + } + + const defaultInput = entry.signature.default; + 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 reportGeneratedInvalidVariableValue( + entry, + value, + onError, + hideSuggestions, + ) { + 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 getPlannedVariableValuesFunction( + variableValuesPlan: VariableValuesPlan, +): string { + const variableValuesObjectSource = generatedNullPrototypeObjectSource(); + return ` function getVariableValues(args) { + const inputs = args.variableValues ?? EMPTY_VARIABLE_VALUES; +${generatedProvidedVariableValuesWithoutErrorReportingSource(variableValuesPlan)} + return getVariableValuesGeneral(args, inputs); + } + + function getVariableValuesGeneral(args, inputs) { + validateWithoutErrorReporting: { + const coerced = ${variableValuesObjectSource}; +${variableValuesPlan.variables + .map((variable) => + generatedVariableShortcutValueSource( + variable, + 'break validateWithoutErrorReporting;', + ), + ) + .join('\n')} + const sources = ${variableValuesObjectSource}; +${variableValuesPlan.variables + .map(generatedVariableSourceValueSource) + .join('\n')} + return { sources, coerced }; + } + + const maxErrors = args.options?.maxCoercionErrors ?? 50; + const errors = []; + const onError = (error) => { + if (errors.length >= maxErrors) { + throw new GraphQLError( + 'Too many errors processing variables, error limit reached. Execution aborted.', + ); + } + errors.push(error); + }; + const coerced = ${variableValuesObjectSource}; + + try { +${variableValuesPlan.variables + .map(generatedVariableValueSourceFromPlan) + .join('\n')} + if (errors.length === 0) { + const sources = ${variableValuesObjectSource}; +${variableValuesPlan.variables + .map(generatedVariableSourceValueSource) + .join('\n')} + return { sources, coerced }; + } + } catch (error) { + errors.push(ensureGraphQLError(error)); + } + + return errors; + } + +${generatedVariableEntriesFunction(variableValuesPlan)} + + function reportGeneratedInvalidVariableValue(entry, value, onError) { + 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 }, + ), + ); + }, + staticHideSuggestions, + ); + }`; +} + +function generatedVariableEntriesFunction( + variableValuesPlan: VariableValuesPlan, +): string { + const typeDeclarations = variableValuesPlan.variables + .map((variable) => { + const typeName = `type${variable.entryIndex}`; + const namedTypeName = getVariableNamedTypeName(variable); + const signatureType = variable.required + ? `new GraphQLNonNull(${typeName})` + : typeName; + const builtinCoerceGuard = + variable.kind === 'builtinScalar' + ? ` || ${typeName}.coerceInputValue !== GraphQL${variable.scalarName}.coerceInputValue` + : ''; + const valueCoercer = + variable.kind === 'compiledCoercer' + ? `, + valueCoercer(inputValue) { + try { + return ${typeName}.coerceInputValue(inputValue); + } catch (_error) { + return undefined; + } + }` + : ''; + return ` const ${typeName} = compiledExecution.schema.getType(${toJavaScript( + namedTypeName, + )}); + if (${typeName} == null || ${typeName}.name !== ${toJavaScript( + namedTypeName, + )} || typeof ${typeName}.coerceInputValue !== 'function'${builtinCoerceGuard}) { + return undefined; + } + const entry${variable.entryIndex} = { + node: staticVariableDefinitions[${String(variable.entryIndex)}], + signature: { + name: ${toJavaScript(variable.name)}, + type: ${signatureType}, + default: + staticVariableDefinitions[${String(variable.entryIndex)}] + .defaultValue === undefined + ? undefined + : { + literal: + staticVariableDefinitions[${String(variable.entryIndex)}] + .defaultValue, + }, + }${valueCoercer}, + };`; + }) + .join('\n'); + const entries = variableValuesPlan.variables + .map((variable) => `entry${variable.entryIndex}`) + .join(', '); + + return ` function createGeneratedVariableEntries(compiledExecution) { +${typeDeclarations} + return [${entries}]; + }`; +} + +function getVariableNamedTypeName(variable: VariablePlan): string { + return variable.typeName; +} + +function generatedProvidedVariableValuesWithoutErrorReportingSource( + variableValuesPlan: VariableValuesPlan, +): string { + const variables = variableValuesPlan.variables; + if (!variables.every(isBuiltInScalarVariablePlan)) { + return ''; + } + const variableValuesObjectSource = generatedNullPrototypeObjectSource(); + + const valueDeclarations = variables + .map((variable) => { + const valueName = `providedValue${variable.entryIndex}`; + return ` let ${valueName};`; + }) + .join('\n'); + const conditions = variables + .map((variable) => + generatedProvidedVariableValueCondition( + variable, + `providedValue${variable.entryIndex}`, + 'inputs', + ), + ) + .join(' &&\n '); + const coercedAssignments = variables + .map((variable) => { + const valueName = `providedValue${variable.entryIndex}`; + return ` ${generatedObjectAssignmentSource( + 'coerced', + variable.name, + )} = ${generatedProvidedVariableCoercedExpression(variable, valueName)};`; + }) + .join('\n'); + const sourceProperties = variables + .map((variable) => { + const valueName = `providedValue${variable.entryIndex}`; + return ` ${generatedObjectAssignmentSource('sources', variable.name)} = { + signature: ${generatedVariableSignatureName(variable)}, + value: ${valueName}, + };`; + }) + .join('\n'); + + return `${valueDeclarations} + if ( + ${conditions} + ) { + const coerced = ${variableValuesObjectSource}; +${coercedAssignments} + const sources = ${variableValuesObjectSource}; +${sourceProperties} + return { sources, coerced }; + } +`; +} + +function isBuiltInScalarVariablePlan( + variable: VariablePlan, +): variable is BuiltInScalarVariablePlan { + return variable.kind === 'builtinScalar'; +} + +function generatedProvidedVariableValueCondition( + variable: BuiltInScalarVariablePlan, + valueName: string, + inputsName: string, +): string { + const name = toJavaScript(variable.name); + const inputValueSource = generatedObjectAssignmentSource( + inputsName, + variable.name, + ); + const providedCondition = `Object.hasOwn(${inputsName}, ${name}) && (${valueName} = ${inputValueSource}) !== undefined`; + const validCondition = generatedProvidedVariableNonNullCondition( + variable, + valueName, + ); + if (variable.required) { + return `${providedCondition} && ${valueName} !== null && (${validCondition})`; + } + return `${providedCondition} && (${valueName} === null || ${validCondition})`; +} + +function generatedProvidedVariableNonNullCondition( + variable: BuiltInScalarVariablePlan, + valueName: string, +): string { + switch (variable.scalarName) { + case 'Boolean': + return `typeof ${valueName} === 'boolean'`; + case 'Float': + return `typeof ${valueName} === 'number' && Number.isFinite(${valueName})`; + case 'ID': + return `typeof ${valueName} === 'string' || + (typeof ${valueName} === 'number' && Number.isInteger(${valueName}))`; + case 'Int': + return `typeof ${valueName} === 'number' && + Number.isInteger(${valueName}) && + ${valueName} <= 2147483647 && + ${valueName} >= -2147483648`; + case 'String': + return `typeof ${valueName} === 'string'`; + } +} + +function generatedProvidedVariableCoercedExpression( + variable: BuiltInScalarVariablePlan, + valueName: string, +): string { + if (variable.scalarName !== 'ID') { + return valueName; + } + return `${valueName} === null ? null : String(${valueName})`; +} + +function generatedVariableValueSource( + index: number, + isRequired: boolean, +): string { + const entryName = `entry${index}`; + const signatureName = `signature${index}`; + const valueName = `value${index}`; + const coercedName = `coercedValue${index}`; + const missingRequiredSource = isRequired + ? ` } else { + reportGeneratedInvalidVariableValue( + ${entryName}, + ${valueName}, + onError, + compiledVariableValues.hideSuggestions, + );` + : ''; + return ` const ${entryName} = compiledVariableValues.entries[${String(index)}]; + if (${entryName}.kind === 'invalid') { + onError(${entryName}.error); + } else { + const ${signatureName} = ${entryName}.signature; + const ${valueName} = Object.hasOwn(inputs, ${signatureName}.name) + ? inputs[${signatureName}.name] + : undefined; + if (${valueName} === undefined) { + sources[${signatureName}.name] = { signature: ${signatureName} }; + if (${signatureName}.default !== undefined) { + useGeneratedVariableDefaultValue( + ${entryName}, + coerced, + onError, + compiledVariableValues.hideSuggestions, + ); +${missingRequiredSource} + } + } else { + sources[${signatureName}.name] = { + signature: ${signatureName}, + value: ${valueName}, + }; + const ${coercedName} = ${entryName}.valueCoercer(${valueName}); + if (${coercedName} !== undefined) { + coerced[${signatureName}.name] = ${coercedName}; + } else { + reportGeneratedInvalidVariableValue( + ${entryName}, + ${valueName}, + onError, + compiledVariableValues.hideSuggestions, + ); + } + } + }`; +} + +function generatedVariableShortcutValueSource( + variable: VariablePlan, + invalidValueStatement = 'return undefined;', +): string { + const entryName = `generatedVariableEntries[${String(variable.entryIndex)}]`; + const name = toJavaScript(variable.name); + const coercedProperty = generatedObjectAssignmentSource( + 'coerced', + variable.name, + ); + const inputProperty = generatedObjectAssignmentSource( + 'inputs', + variable.name, + ); + const hasValueName = `hasValue${variable.entryIndex}`; + const valueName = `value${variable.entryIndex}`; + const missingValueSource = + variable.defaultValueSource === undefined + ? variable.required + ? ` ${invalidValueStatement}` + : '' + : ` ${coercedProperty} = ${variable.defaultValueSource};`; + const nullValueSource = variable.required + ? ` ${invalidValueStatement}` + : ` ${coercedProperty} = null;`; + + if (variable.kind === 'compiledCoercer') { + const coercedValueName = `coercedValue${variable.entryIndex}`; + return ` const ${hasValueName} = Object.hasOwn(inputs, ${name}); + const ${valueName} = ${hasValueName} ? ${inputProperty} : undefined; + if (${valueName} === undefined) { +${missingValueSource} + } else if (${valueName} === null) { +${nullValueSource} + } else { + const ${coercedValueName} = ${entryName}.valueCoercer(${valueName}); + if (${coercedValueName} === undefined) { + ${invalidValueStatement} + } + ${coercedProperty} = ${coercedValueName}; + }`; + } + + return ` const ${hasValueName} = Object.hasOwn(inputs, ${name}); + const ${valueName} = ${hasValueName} ? ${inputProperty} : undefined; + if (${valueName} === undefined) { +${missingValueSource} + } else if (${valueName} === null) { +${nullValueSource} + } else if (${generatedVariableInvalidCondition(variable, valueName)}) { + ${invalidValueStatement} + } else { + ${coercedProperty} = ${generatedVariableCoercedExpression( + variable, + valueName, + )}; + }`; +} + +function generatedVariableValueSourceFromPlan(variable: VariablePlan): string { + const entryName = `generatedVariableEntries[${String(variable.entryIndex)}]`; + const name = toJavaScript(variable.name); + const coercedProperty = generatedObjectAssignmentSource( + 'coerced', + variable.name, + ); + const inputProperty = generatedObjectAssignmentSource( + 'inputs', + variable.name, + ); + const hasValueName = `hasValue${variable.entryIndex}`; + const valueName = `value${variable.entryIndex}`; + const missingValueSource = + variable.defaultValueSource === undefined + ? variable.required + ? ` reportGeneratedInvalidVariableValue( + ${entryName}, + ${valueName}, + onError, + );` + : '' + : ` ${coercedProperty} = ${variable.defaultValueSource};`; + const nullValueSource = variable.required + ? ` reportGeneratedInvalidVariableValue( + ${entryName}, + ${valueName}, + onError, + );` + : ` ${coercedProperty} = null;`; + + if (variable.kind === 'compiledCoercer') { + const coercedValueName = `coercedValue${variable.entryIndex}`; + return ` const ${hasValueName} = Object.hasOwn(inputs, ${name}); + const ${valueName} = ${hasValueName} ? ${inputProperty} : undefined; + if (${valueName} === undefined) { +${missingValueSource} + } else if (${valueName} === null) { +${nullValueSource} + } else { + const ${coercedValueName} = ${entryName}.valueCoercer(${valueName}); + if (${coercedValueName} === undefined) { + reportGeneratedInvalidVariableValue( + ${entryName}, + ${valueName}, + onError, + ); + } else { + ${coercedProperty} = ${coercedValueName}; + } + }`; + } + + const invalidCondition = generatedVariableInvalidCondition( + variable, + valueName, + ); + return ` const ${hasValueName} = Object.hasOwn(inputs, ${name}); + const ${valueName} = ${hasValueName} ? ${inputProperty} : undefined; + if (${valueName} === undefined) { +${missingValueSource} + } else if (${valueName} === null) { +${nullValueSource} + } else if (${invalidCondition}) { + reportGeneratedInvalidVariableValue( + ${entryName}, + ${valueName}, + onError, + ); + } else { + ${coercedProperty} = ${generatedVariableCoercedExpression( + variable, + valueName, + )}; + }`; +} + +function generatedVariableSourceValueSource(variable: VariablePlan): string { + const hasValueName = `hasValue${variable.entryIndex}`; + const valueName = `value${variable.entryIndex}`; + return ` ${generatedObjectAssignmentSource('sources', variable.name)} = + ${hasValueName} && ${valueName} !== undefined + ? { signature: ${generatedVariableSignatureName( + variable, + )}, value: ${valueName} } + : { signature: ${generatedVariableSignatureName(variable)} };`; +} + +function generatedVariableSignatureName(variable: VariablePlan): string { + return `generatedVariableSignature${variable.entryIndex}`; +} + +function generatedVariableInvalidCondition( + variable: BuiltInScalarVariablePlan, + valueName: string, +): string { + switch (variable.scalarName) { + case 'Boolean': + return `typeof ${valueName} !== 'boolean'`; + case 'Float': + return `typeof ${valueName} !== 'number' || !Number.isFinite(${valueName})`; + case 'ID': + return `typeof ${valueName} !== 'string' && + (typeof ${valueName} !== 'number' || + !Number.isInteger(${valueName}))`; + case 'Int': + return `typeof ${valueName} !== 'number' || + !Number.isInteger(${valueName}) || + ${valueName} > 2147483647 || + ${valueName} < -2147483648`; + case 'String': + return `typeof ${valueName} !== 'string'`; + } +} + +function generatedVariableCoercedExpression( + variable: BuiltInScalarVariablePlan, + valueName: string, +): string { + return variable.scalarName === 'ID' ? `String(${valueName})` : valueName; +} + +function staticDocumentSource(args: CompileExecutionArgs): string { + const documentSource = args.document.loc?.source.body ?? print(args.document); + return `const documentSource = ${toJavaScript(documentSource)}; +const document = parse(documentSource, ${parseOptions(args)}); +const staticExecutionArgs = { document }; +${optionalStaticArg('operationName', args.operationName)} +${optionalStaticArg('hideSuggestions', args.hideSuggestions)} +${optionalStaticArg('enableEarlyExecution', args.enableEarlyExecution)} +${optionalStaticArg('enableBatchResolvers', args.enableBatchResolvers)}`; +} + +function staticCompiledExecutionSource( + args: CompileExecutionArgs, + compiledExecution: Exclude< + ReturnType, + ReadonlyArray + >, +): string { + const operationIndex = args.document.definitions.indexOf( + compiledExecution.operation, + ); + const fragmentEntries = Object.entries( + compiledExecution.fragmentDefinitions, + ).map(([fragmentName, fragment]) => { + const fragmentIndex = args.document.definitions.indexOf(fragment); + invariant(fragmentIndex !== -1); + return { fragmentIndex, fragmentName }; + }); + invariant(operationIndex !== -1); + const fragmentDefinitionsSource = generatedNullPrototypeObjectSource(); + + return `const staticOperation = document.definitions[${String(operationIndex)}]; +const staticVariableDefinitions = staticOperation.variableDefinitions ?? []; +const staticFragmentDefinitions = (() => { + const definitions = ${fragmentDefinitionsSource}; +${fragmentEntries + .map( + ({ fragmentIndex, fragmentName }) => + ` definitions[${toJavaScript(fragmentName)}] = document.definitions[${String( + fragmentIndex, + )}];`, + ) + .join('\n')} + return definitions; +})(); + +function createGeneratedCompiledExecution(args) { + const executionArgs = { + ...args, + ...staticExecutionArgs, + }; + const hideSuggestions = executionArgs.hideSuggestions ?? false; + return { + schema: executionArgs.schema, + document: executionArgs.document, + fragmentDefinitions: staticFragmentDefinitions, + operation: staticOperation, + variableDefinitions: staticVariableDefinitions, + hideSuggestions, + errorPropagation: ${String(compiledExecution.errorPropagation)}, + fieldResolver: executionArgs.fieldResolver, + typeResolver: executionArgs.typeResolver, + subscribeFieldResolver: executionArgs.subscribeFieldResolver, + enableEarlyExecution: executionArgs.enableEarlyExecution === true, + enableBatchResolvers: executionArgs.enableBatchResolvers === true, + hooks: executionArgs.hooks ?? undefined, + }; +}`; +} + +function parseOptions(args: CompileExecutionArgs): string { + const options = [ + args.document.loc == null ? 'noLocation: true' : undefined, + 'experimentalFragmentArguments: true', + 'experimentalDirectivesOnDirectiveDefinitions: true', + ].filter((option): option is string => option !== undefined); + + return `{ ${options.join(', ')} }`; +} + +function optionalStaticArg(name: string, value: unknown): string { + return value === undefined + ? '' + : `staticExecutionArgs.${name} = ${toJavaScript(value)};`; +} + +function toIdentifierPart(value: string): string { + return value.replaceAll(/[^0-9A-Z_a-z]/g, '_'); +} + +type SerializableJavaScriptValue = + | null + | string + | boolean + | number + | ReadonlyArray + | { readonly [key: string]: SerializableJavaScriptValue }; + +type JavaScriptType = + | 'undefined' + | 'object' + | 'boolean' + | 'number' + | 'bigint' + | 'string' + | 'symbol' + | 'function'; + +function toSerializableJavaScript(value: unknown): string | undefined { + return isSerializableJavaScriptValue(value) + ? serializableJavaScriptValueSource(value) + : undefined; +} + +function isSerializableJavaScriptValue( + value: unknown, + seen = new Set(), +): value is SerializableJavaScriptValue { + if (value === null) { + return true; + } + + const valueType: JavaScriptType = typeof value; + switch (valueType) { + case 'string': + case 'boolean': + return true; + case 'number': + return Number.isFinite(value); + case 'object': { + const objectValue = value as object; + if (seen.has(objectValue)) { + return false; + } + seen.add(objectValue); + if (Array.isArray(value)) { + const isSerializable = value.every((item) => + isSerializableJavaScriptValue(item, seen), + ); + seen.delete(objectValue); + return isSerializable; + } + const prototype = Object.getPrototypeOf(objectValue); + if (prototype !== null && prototype !== Object.prototype) { + seen.delete(objectValue); + return false; + } + for (const item of Object.values(objectValue)) { + if (item === undefined || !isSerializableJavaScriptValue(item, seen)) { + seen.delete(objectValue); + return false; + } + } + seen.delete(objectValue); + return true; + } + case 'undefined': + case 'bigint': + case 'function': + case 'symbol': + return false; + } +} + +function serializableJavaScriptValueSource( + value: SerializableJavaScriptValue, + seen = new Set(), +): string { + if (value === null) { + return 'null'; + } + + if (typeof value !== 'object') { + return toJavaScript(value); + } + + seen.add(value); + if (Array.isArray(value)) { + const items = value.map((item) => + serializableJavaScriptValueSource(item, seen), + ); + seen.delete(value); + return `[${items.join(', ')}]`; + } + + const properties = Object.entries(value).map(([key, item]) => ({ + name: key, + value: serializableJavaScriptValueSource(item, seen), + })); + seen.delete(value); + return generatedNullPrototypeObjectSource(properties); +} + +function toJavaScript(value: unknown): string { + return JSON.stringify(value) ?? 'undefined'; +} diff --git a/src/execution/generate/index.ts b/src/execution/generate/index.ts new file mode 100644 index 0000000000..fde764f6d3 --- /dev/null +++ b/src/execution/generate/index.ts @@ -0,0 +1,6 @@ +/** + * Generate specialized source for GraphQL operations. + * @packageDocumentation + */ + +export { generateExecution, generateSubscription } from './generate.ts'; 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..daeff7373b 100644 --- a/src/execution/incremental/__tests__/defer-test.ts +++ b/src/execution/incremental/__tests__/defer-test.ts @@ -20,14 +20,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 type { - InitialIncrementalExecutionResult, - SubsequentIncrementalExecutionResult, -} from '../IncrementalExecutor.ts'; +import { + COMPARISON_COUNT, + completeDirectly, + completeExecution, + experimentalExecuteIncrementally, +} from '../../__tests__/executeTestUtils.ts'; const friendType = new GraphQLObjectType({ fields: { @@ -130,6 +128,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 +152,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 +370,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 +445,7 @@ describe('Execute: defer directive', () => { }, }, }, - true, + { enableEarlyExecution: true, compareCompiledExecution: false }, ); expectJSON(result).toDeepEqual([ @@ -1077,7 +1052,7 @@ describe('Execute: defer directive', () => { done: false, }); - expect(cResolverSpy.callCount).to.equal(1); + expect(cResolverSpy.callCount).to.equal(COMPARISON_COUNT); expect(eResolverSpy.callCount).to.equal(0); resolveSlowField('someField'); @@ -1104,7 +1079,7 @@ describe('Execute: defer directive', () => { done: false, }); - expect(eResolverSpy.callCount).to.equal(1); + expect(eResolverSpy.callCount).to.equal(COMPARISON_COUNT); const result4 = await iterator.next(); expectJSON(result4).toDeepEqual({ @@ -1199,7 +1174,7 @@ describe('Execute: defer directive', () => { resolveC(); - expect(cResolverSpy.callCount).to.equal(1); + expect(cResolverSpy.callCount).to.equal(COMPARISON_COUNT); expect(eResolverSpy.callCount).to.equal(0); const result3 = await iterator.next(); @@ -2149,7 +2124,7 @@ describe('Execute: defer directive', () => { someField: 'someField', }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2215,7 +2190,7 @@ describe('Execute: defer directive', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual({ data: { @@ -2253,7 +2228,7 @@ describe('Execute: defer directive', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual({ data: { @@ -2377,7 +2352,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 +2369,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 +2397,7 @@ describe('Execute: defer directive', () => { completed: [{ id: '0' }], hasNext: false, }, - }); + ]); const lateSide = { value: () => 'late value', @@ -2435,12 +2407,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 +2428,7 @@ describe('Execute: defer directive', () => { nonNullName: () => null, }, }, - true, + { enableEarlyExecution: true }, ); expectJSON(result).toDeepEqual([ { @@ -2533,7 +2499,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 +2514,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 +2539,7 @@ describe('Execute: defer directive', () => { completed: [{ id: '0' }], hasNext: false, }, - }); + ]); const lateSide = { value: () => 'late value', @@ -2585,12 +2549,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 +3090,7 @@ describe('Execute: defer directive', () => { `); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { todo: { @@ -3188,7 +3146,7 @@ describe('Execute: defer directive', () => { `); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { todo: async () => @@ -3223,7 +3181,7 @@ describe('Execute: defer directive', () => { } `); - const resultPromise = completeCancellation( + const resultPromise = complete( document, { todo: () => @@ -3234,7 +3192,10 @@ describe('Execute: defer directive', () => { Promise.resolve(() => expect.fail('Should not be called')), }), }, - abortController.signal, + { + compareCompiledExecution: false, + abortSignal: abortController.signal, + }, ); abortController.abort(); @@ -3250,7 +3211,7 @@ describe('Execute: defer directive', () => { const document = parse('{ scalarList ... @defer { slowScalarList } }'); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { scalarList: () => ['a'], @@ -3320,7 +3281,7 @@ describe('Execute: defer directive', () => { const sourceReturnSpy = spyOnMethod(asyncIterator, 'return'); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, enableEarlyExecution: true, abortSignal: abortController.signal, @@ -3349,7 +3310,7 @@ describe('Execute: defer directive', () => { abortController.abort(); await resolveOnNextTick(); - expect(sourceReturnSpy.callCount).to.equal(1); + expect(sourceReturnSpy.callCount).to.equal(COMPARISON_COUNT); await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); @@ -3382,7 +3343,7 @@ describe('Execute: defer directive', () => { promiseWithResolvers<{ id: string }>(); const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, enableEarlyExecution: true, @@ -3434,7 +3395,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..605acba6c2 100644 --- a/src/execution/incremental/__tests__/stream-test.ts +++ b/src/execution/incremental/__tests__/stream-test.ts @@ -22,9 +22,13 @@ 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 { + COMPARISON_COUNT, + completeDirectly, + completeExecution, + executeIncrementally, + experimentalExecuteIncrementally, +} from '../../__tests__/executeTestUtils.ts'; import type { InitialIncrementalExecutionResult, @@ -54,11 +58,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 +108,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 +151,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 +175,7 @@ async function completeAsync( numCalls: number, rootValue: unknown = {}, ) { - const result = await experimentalExecuteIncrementally({ + const result = await executeIncrementally({ schema, document, rootValue, @@ -239,7 +240,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 +529,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 +611,7 @@ describe('Execute: stream directive', () => { }, })), }, - true, + { enableEarlyExecution: true, compareCompiledExecution: false }, ); expectJSON(result).toDeepEqual([ { @@ -951,13 +962,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 +1041,7 @@ describe('Execute: stream directive', () => { yield await Promise.resolve(slowFriend(2)); }, }, - true, + { enableEarlyExecution: true, compareCompiledExecution: false }, ); expectJSON(result).toDeepEqual([ { @@ -1281,7 +1296,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 +1515,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 +1537,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 +1769,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 +1837,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 +2195,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 +2241,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 +2298,7 @@ describe('Execute: stream directive', () => { const result3 = await iterator.next(); expectJSON(result3).toDeepEqual({ done: true, value: undefined }); - assert(returnSpy.callCount === 1); + expect(returnCallCount).to.equal(COMPARISON_COUNT); }); it('Handles promises returned by completeValue after initialCount is reached', async () => { const document = parse(` @@ -2882,7 +2907,7 @@ describe('Execute: stream directive', () => { value: undefined, }); await returnPromise; - assert(returnSpy.callCount === 1); + assert(returnSpy.callCount === COMPARISON_COUNT); }); it('Awaits stream source async iterable return before iterator return settles', async () => { const { promise: returnCleanup, resolve: resolveReturnCleanup } = @@ -2946,7 +2971,7 @@ describe('Execute: stream directive', () => { ); await resolveOnNextTick(); - expect(returnSpy.callCount).to.equal(1); + expect(returnSpy.callCount).to.equal(COMPARISON_COUNT); expect(returnSettled).to.equal(false); resolveReturnCleanup(); @@ -2959,17 +2984,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 +3041,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 +3105,7 @@ describe('Execute: stream directive', () => { value: undefined, }); - assert(returnSpy.callCount === 1); + assert(returnSpy.callCount === COMPARISON_COUNT); }); it('Returns underlying async iterables when resource is disposed before source completion', async () => { const iterable = { @@ -3127,22 +3156,24 @@ describe('Execute: stream directive', () => { }, ); - assert(returnSpy.callCount === 1); + assert(returnSpy.callCount === COMPARISON_COUNT); }); 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 +3323,7 @@ describe('Execute: stream directive (cancellation)', () => { const resultPromise = (async () => { const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue: { todo: { @@ -3508,7 +3539,7 @@ describe('Execute: stream directive (cancellation)', () => { }; const result = await experimentalExecuteIncrementally({ - schema: cancelStreamSchema, + schema, document, rootValue: { todos: () => todosAsyncIterator, @@ -3638,7 +3669,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 +3682,7 @@ describe('Execute: stream directive (cancellation)', () => { await secondNextStarted; await expectPromise(iterator.return()).toResolve(); await expectPromise(nextPromise).toResolve(); - expect(sourceReturnSpy.callCount).to.equal(1); + expect(sourceReturnSpy.callCount).to.equal(COMPARISON_COUNT); const todo = { id: 'todo', @@ -3741,7 +3772,7 @@ describe('Execute: stream directive (cancellation)', () => { promiseWithResolvers(); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, rootValue: { @@ -3815,7 +3846,7 @@ describe('Execute: stream directive (cancellation)', () => { const sourceReturnSpy = spyOnMethod(asyncIterator, 'return'); const resultPromise = experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, abortSignal: abortController.signal, rootValue: { @@ -3839,7 +3870,7 @@ describe('Execute: stream directive (cancellation)', () => { abortController.abort(); await resolveOnNextTick(); - expect(sourceReturnSpy.callCount).to.equal(1); + expect(sourceReturnSpy.callCount).to.equal(COMPARISON_COUNT); await expectPromise(resultPromise).toRejectWith( 'This operation was aborted', ); @@ -3906,7 +3937,7 @@ describe('Execute: stream directive (cancellation)', () => { }; const result = await experimentalExecuteIncrementally({ - schema: cancellationSchema, + schema, document, rootValue, enableEarlyExecution: true, @@ -3962,7 +3993,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..f56287466e 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -22,18 +22,26 @@ export { validateExecutionArgs, validateSubscriptionArgs, } from './execute.ts'; +export { compileExecution, compileSubscription } from './compile/index.ts'; +export { generateExecution, generateSubscription } from './generate/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..449029338e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -384,6 +384,10 @@ export { getArgumentValues, getVariableValues, getDirectiveValues, + compileExecution, + compileSubscription, + generateExecution, + generateSubscription, subscribe, createSourceEventStream, mapSourceToResponseEvent, @@ -393,6 +397,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.', + ); }); });