diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 9dc4bc957a..7c415f8b8e 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -615,12 +615,16 @@ export class IncrementalPublisher { } this._introduce(subsequentResultRecord); + subsequentResultRecord.publish(); return; } if (subsequentResultRecord._pending.size === 0) { this._push(subsequentResultRecord); } else { + for (const deferredGroupedFieldSetRecord of subsequentResultRecord.deferredGroupedFieldSetRecords) { + deferredGroupedFieldSetRecord.publish(); + } this._introduce(subsequentResultRecord); } } @@ -701,33 +705,56 @@ function isStreamItemsRecord( export class InitialResultRecord { errors: Array; children: Set; + priority: number; + deferPriority: number; + published: true; constructor() { this.errors = []; this.children = new Set(); + this.priority = 0; + this.deferPriority = 0; + this.published = true; } } /** @internal */ export class DeferredGroupedFieldSetRecord { path: ReadonlyArray; + priority: number; + deferPriority: number; deferredFragmentRecords: ReadonlyArray; groupedFieldSet: GroupedFieldSet; shouldInitiateDefer: boolean; errors: Array; data: ObjMap | undefined; + published: true | Promise; + publish: () => void; sent: boolean; constructor(opts: { path: Path | undefined; + priority: number; + deferPriority: number; deferredFragmentRecords: ReadonlyArray; groupedFieldSet: GroupedFieldSet; shouldInitiateDefer: boolean; }) { this.path = pathToArray(opts.path); + this.priority = opts.priority; + this.deferPriority = opts.deferPriority; this.deferredFragmentRecords = opts.deferredFragmentRecords; this.groupedFieldSet = opts.groupedFieldSet; this.shouldInitiateDefer = opts.shouldInitiateDefer; this.errors = []; + // promiseWithResolvers uses void only as a generic type parameter + // see: https://typescript-eslint.io/rules/no-invalid-void-type/ + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + const { promise: published, resolve } = promiseWithResolvers(); + this.published = published; + this.publish = () => { + resolve(); + this.published = true; + }; this.sent = false; } } @@ -778,21 +805,42 @@ export class StreamItemsRecord { errors: Array; streamRecord: StreamRecord; path: ReadonlyArray; + priority: number; + deferPriority: number; items: Array; children: Set; isFinalRecord?: boolean; isCompletedAsyncIterator?: boolean; isCompleted: boolean; filtered: boolean; + published: true | Promise; + publish: () => void; + sent: boolean; - constructor(opts: { streamRecord: StreamRecord; path: Path | undefined }) { + constructor(opts: { + streamRecord: StreamRecord; + path: Path | undefined; + priority: number; + }) { this.streamRecord = opts.streamRecord; this.path = pathToArray(opts.path); + this.priority = opts.priority; + this.deferPriority = 0; this.children = new Set(); this.errors = []; this.isCompleted = false; this.filtered = false; this.items = []; + // promiseWithResolvers uses void only as a generic type parameter + // see: https://typescript-eslint.io/rules/no-invalid-void-type/ + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + const { promise: published, resolve } = promiseWithResolvers(); + this.published = published; + this.publish = () => { + resolve(); + this.published = true; + }; + this.sent = false; } } diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 33c310523b..d430142f7c 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -1,13 +1,17 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; import { expectPromise } from '../../__testUtils__/expectPromise.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; +import { isPromise } from '../../jsutils/isPromise.js'; + import type { DocumentNode } from '../../language/ast.js'; +import { Kind } from '../../language/kinds.js'; import { parse } from '../../language/parser.js'; +import type { FieldDetails } from '../../type/definition.js'; import { GraphQLList, GraphQLNonNull, @@ -216,6 +220,174 @@ describe('Execute: defer directive', () => { }, }); }); + it('Can provides correct info about deferred execution state when resolver could defer', async () => { + let fieldDetails: ReadonlyArray | undefined; + let deferPriority; + let published; + let resumed; + + const SomeType = new GraphQLObjectType({ + name: 'SomeType', + fields: { + someField: { + type: GraphQLString, + resolve: () => Promise.resolve('someField'), + }, + deferredField: { + type: GraphQLString, + resolve: async (_parent, _args, _context, info) => { + fieldDetails = info.fieldDetails; + deferPriority = info.deferPriority; + published = info.published; + await published; + resumed = true; + }, + }, + }, + }); + + const someSchema = new GraphQLSchema({ query: SomeType }); + + const document = parse(` + query { + someField + ... @defer { + deferredField + } + } + `); + + const operation = document.definitions[0]; + assert(operation.kind === Kind.OPERATION_DEFINITION); + const fragment = operation.selectionSet.selections[1]; + assert(fragment.kind === Kind.INLINE_FRAGMENT); + const field = fragment.selectionSet.selections[0]; + + const result = experimentalExecuteIncrementally({ + schema: someSchema, + document, + }); + + expect(fieldDetails).to.equal(undefined); + expect(deferPriority).to.equal(undefined); + expect(published).to.equal(undefined); + expect(resumed).to.equal(undefined); + + const initialPayload = await result; + assert('initialResult' in initialPayload); + const iterator = initialPayload.subsequentResults[Symbol.asyncIterator](); + await iterator.next(); + + assert(fieldDetails !== undefined); + expect(fieldDetails[0].node).to.equal(field); + expect(fieldDetails[0].target?.deferPriority).to.equal(1); + expect(deferPriority).to.equal(1); + expect(isPromise(published)).to.equal(true); + expect(resumed).to.equal(true); + }); + it('Can provides correct info about deferred execution state when deferred field is masked by non-deferred field', async () => { + let fieldDetails: ReadonlyArray | undefined; + let deferPriority; + let published; + + const SomeType = new GraphQLObjectType({ + name: 'SomeType', + fields: { + someField: { + type: GraphQLString, + resolve: (_parent, _args, _context, info) => { + fieldDetails = info.fieldDetails; + deferPriority = info.deferPriority; + published = info.published; + return 'someField'; + }, + }, + }, + }); + + const someSchema = new GraphQLSchema({ query: SomeType }); + + const document = parse(` + query { + someField + ... @defer { + someField + } + } + `); + + const operation = document.definitions[0]; + assert(operation.kind === Kind.OPERATION_DEFINITION); + const node1 = operation.selectionSet.selections[0]; + const fragment = operation.selectionSet.selections[1]; + assert(fragment.kind === Kind.INLINE_FRAGMENT); + const node2 = fragment.selectionSet.selections[0]; + + const result = experimentalExecuteIncrementally({ + schema: someSchema, + document, + }); + + const initialPayload = await result; + assert('initialResult' in initialPayload); + expect(initialPayload.initialResult).to.deep.equal({ + data: { + someField: 'someField', + }, + pending: [{ path: [] }], + hasNext: true, + }); + + assert(fieldDetails !== undefined); + expect(fieldDetails[0].node).to.equal(node1); + expect(fieldDetails[0].target).to.equal(undefined); + expect(fieldDetails[1].node).to.equal(node2); + expect(fieldDetails[1].target?.deferPriority).to.equal(1); + expect(deferPriority).to.equal(0); + expect(published).to.equal(true); + }); + it('Can provides correct info about deferred execution state when resolver need not defer', async () => { + let deferPriority; + let published; + const SomeType = new GraphQLObjectType({ + name: 'SomeType', + fields: { + deferredField: { + type: GraphQLString, + resolve: (_parent, _args, _context, info) => { + deferPriority = info.deferPriority; + published = info.published; + }, + }, + }, + }); + + const someSchema = new GraphQLSchema({ query: SomeType }); + + const document = parse(` + query { + ... @defer { + deferredField + } + } + `); + + const result = experimentalExecuteIncrementally({ + schema: someSchema, + document, + }); + + expect(deferPriority).to.equal(undefined); + expect(published).to.equal(undefined); + + const initialPayload = await result; + assert('initialResult' in initialPayload); + const iterator = initialPayload.subsequentResults[Symbol.asyncIterator](); + await iterator.next(); + + expect(deferPriority).to.equal(1); + expect(published).to.equal(true); + }); it('Does not disable defer with null if argument', async () => { const document = parse(` query HeroNameQuery($shouldDefer: Boolean) { diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index c29b4ae60d..9132ca36bd 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -9,6 +9,7 @@ import { inspect } from '../../jsutils/inspect.js'; import { Kind } from '../../language/kinds.js'; import { parse } from '../../language/parser.js'; +import type { GraphQLResolveInfo } from '../../type/definition.js'; import { GraphQLInterfaceType, GraphQLList, @@ -191,7 +192,7 @@ describe('Execute: Handles basic execution tasks', () => { }); it('provides info about current execution state', () => { - let resolvedInfo; + let resolvedInfo: GraphQLResolveInfo | undefined; const testType = new GraphQLObjectType({ name: 'Test', fields: { @@ -213,7 +214,7 @@ describe('Execute: Handles basic execution tasks', () => { expect(resolvedInfo).to.have.all.keys( 'fieldName', - 'fieldNodes', + 'fieldDetails', 'returnType', 'parentType', 'path', @@ -222,6 +223,9 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'priority', + 'deferPriority', + 'published', ); const operation = document.definitions[0]; @@ -234,14 +238,24 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, + priority: 0, + deferPriority: 0, + published: true, }); - const field = operation.selectionSet.selections[0]; expect(resolvedInfo).to.deep.include({ - fieldNodes: [field], path: { prev: undefined, key: 'result', typename: 'Test' }, variableValues: { var: 'abc' }, }); + + const fieldDetails = resolvedInfo?.fieldDetails; + assert(fieldDetails !== undefined); + + const field = operation.selectionSet.selections[0]; + expect(fieldDetails[0]).to.deep.include({ + node: field, + target: undefined, + }); }); it('populates path correctly with complex types', () => { diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index 43b36343eb..dc63ff803e 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -15,7 +15,12 @@ import type { import { OperationTypeNode } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; -import type { GraphQLObjectType } from '../type/definition.js'; +import type { + DeferUsage, + FieldDetails, + GraphQLObjectType, + Target, +} from '../type/definition.js'; import { isAbstractType } from '../type/definition.js'; import { GraphQLDeferDirective, @@ -28,24 +33,13 @@ import { typeFromAST } from '../utilities/typeFromAST.js'; import { getDirectiveValues } from './values.js'; -export interface DeferUsage { - label: string | undefined; - ancestors: ReadonlyArray; -} - export const NON_DEFERRED_TARGET_SET = new OrderedSet([ undefined, ]).freeze(); -export type Target = DeferUsage | undefined; export type TargetSet = ReadonlyOrderedSet; export type DeferUsageSet = ReadonlyOrderedSet; -export interface FieldDetails { - node: FieldNode; - target: Target; -} - export interface FieldGroup { fields: ReadonlyArray; targets: TargetSet; @@ -213,12 +207,19 @@ function collectFieldsImpl( let target: Target; if (!defer) { target = newTarget; + } else if (parentTarget === undefined) { + target = { + ...defer, + ancestors: [parentTarget], + deferPriority: 1, + }; + newDeferUsages.push(target); } else { - const ancestors = - parentTarget === undefined - ? [parentTarget] - : [parentTarget, ...parentTarget.ancestors]; - target = { ...defer, ancestors }; + target = { + ...defer, + ancestors: [parentTarget, ...parentTarget.ancestors], + deferPriority: parentTarget.deferPriority + 1, + }; newDeferUsages.push(target); } @@ -255,12 +256,19 @@ function collectFieldsImpl( if (!defer) { visitedFragmentNames.add(fragName); target = newTarget; + } else if (parentTarget === undefined) { + target = { + ...defer, + ancestors: [parentTarget], + deferPriority: 1, + }; + newDeferUsages.push(target); } else { - const ancestors = - parentTarget === undefined - ? [parentTarget] - : [parentTarget, ...parentTarget.ancestors]; - target = { ...defer, ancestors }; + target = { + ...defer, + ancestors: [parentTarget, ...parentTarget.ancestors], + deferPriority: parentTarget.deferPriority + 1, + }; newDeferUsages.push(target); } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 55bba806f5..1683aaffae 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -26,6 +26,7 @@ import { OperationTypeNode } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; import type { + DeferUsage, GraphQLAbstractType, GraphQLField, GraphQLFieldResolver, @@ -48,7 +49,6 @@ import type { GraphQLSchema } from '../type/schema.js'; import { assertValidSchema } from '../type/validate.js'; import type { - DeferUsage, DeferUsageSet, FieldGroup, GroupedFieldSet, @@ -419,6 +419,7 @@ function executeOperation( const newDeferredGroupedFieldSetRecords = addNewDeferredGroupedFieldSets( incrementalPublisher, newGroupedFieldSetDetails, + initialResultRecord, newDeferMap, path, ); @@ -606,6 +607,7 @@ function executeField( fieldGroup, parentType, path, + incrementalDataRecord, ); // Get the resolve function, regardless of if its result is normal or abrupt (error). @@ -691,12 +693,31 @@ export function buildResolveInfo( fieldGroup: FieldGroup, parentType: GraphQLObjectType, path: Path, + incrementalDataRecord?: IncrementalDataRecord | undefined, ): GraphQLResolveInfo { // The resolve function's optional fourth argument is a collection of // information about the current execution state. + if (incrementalDataRecord === undefined) { + return { + fieldName: fieldDef.name, + fieldDetails: fieldGroup.fields, + returnType: fieldDef.type, + parentType, + path, + schema: exeContext.schema, + fragments: exeContext.fragments, + rootValue: exeContext.rootValue, + operation: exeContext.operation, + variableValues: exeContext.variableValues, + priority: 0, + deferPriority: 0, + published: true, + }; + } + return { fieldName: fieldDef.name, - fieldNodes: toNodes(fieldGroup), + fieldDetails: fieldGroup.fields, returnType: fieldDef.type, parentType, path, @@ -705,6 +726,12 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + priority: incrementalDataRecord.priority, + deferPriority: incrementalDataRecord.deferPriority, + published: + incrementalDataRecord.published === true + ? true + : incrementalDataRecord.published, }; } @@ -1469,6 +1496,7 @@ function deferredFragmentRecordFromDeferUsage( function addNewDeferredGroupedFieldSets( incrementalPublisher: IncrementalPublisher, newGroupedFieldSetDetails: Map, + incrementalDataRecord: IncrementalDataRecord, deferMap: ReadonlyMap, path?: Path | undefined, ): ReadonlyArray { @@ -1483,12 +1511,23 @@ function addNewDeferredGroupedFieldSets( newGroupedFieldSetDeferUsages, deferMap, ); - const deferredGroupedFieldSetRecord = new DeferredGroupedFieldSetRecord({ - path, - deferredFragmentRecords, - groupedFieldSet, - shouldInitiateDefer, - }); + const deferredGroupedFieldSetRecord = shouldInitiateDefer + ? new DeferredGroupedFieldSetRecord({ + path, + priority: incrementalDataRecord.priority + 1, + deferPriority: incrementalDataRecord.deferPriority + 1, + deferredFragmentRecords, + groupedFieldSet, + shouldInitiateDefer: true, + }) + : new DeferredGroupedFieldSetRecord({ + path, + priority: incrementalDataRecord.priority, + deferPriority: incrementalDataRecord.deferPriority, + deferredFragmentRecords, + groupedFieldSet, + shouldInitiateDefer: false, + }); incrementalPublisher.reportNewDeferredGroupedFieldSetRecord( deferredGroupedFieldSetRecord, ); @@ -1533,6 +1572,7 @@ function collectAndExecuteSubfields( const newDeferredGroupedFieldSetRecords = addNewDeferredGroupedFieldSets( incrementalPublisher, newGroupedFieldSetDetails, + incrementalDataRecord, newDeferMap, path, ); @@ -1951,6 +1991,7 @@ function executeStreamField( const streamItemsRecord = new StreamItemsRecord({ streamRecord, path: itemPath, + priority: incrementalDataRecord.priority + 1, }); incrementalPublisher.reportNewStreamItemsRecord( streamItemsRecord, @@ -2143,6 +2184,7 @@ async function executeStreamAsyncIterator( const streamItemsRecord = new StreamItemsRecord({ streamRecord, path: itemPath, + priority: incrementalDataRecord.priority + 1, }); incrementalPublisher.reportNewStreamItemsRecord( streamItemsRecord, diff --git a/src/type/definition.ts b/src/type/definition.ts index 0ca4152bd2..1da73124eb 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -885,9 +885,22 @@ export type GraphQLFieldResolver< info: GraphQLResolveInfo, ) => TResult; +export interface DeferUsage { + label: string | undefined; + ancestors: ReadonlyArray; + deferPriority: number; +} + +export type Target = DeferUsage | undefined; + +export interface FieldDetails { + node: FieldNode; + target: Target; +} + export interface GraphQLResolveInfo { readonly fieldName: string; - readonly fieldNodes: ReadonlyArray; + readonly fieldDetails: ReadonlyArray; readonly returnType: GraphQLOutputType; readonly parentType: GraphQLObjectType; readonly path: Path; @@ -896,6 +909,9 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + readonly priority: number; + readonly deferPriority: number; + readonly published: true | Promise; } /**