diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 1c9d318522d..9c929832a74 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -8,8 +8,9 @@ import type { GraphQLFormattedError, } from '../error/GraphQLError.js'; +import type { DeferUsage } from '../type/definition.js'; + import type { - DeferUsage, DeferUsageSet, GroupedFieldSet, GroupedFieldSetDetails, @@ -303,12 +304,26 @@ export class IncrementalPublisher { newGroupedFieldSetDeferUsages, newDeferMap, ); - const deferredGroupedFieldSetRecord = new DeferredGroupedFieldSetRecord({ - path, - deferredFragmentRecords, - groupedFieldSet, - shouldInitiateDefer, - }); + const deferredGroupedFieldSetRecord = + incrementalDataRecord === undefined + ? new DeferredGroupedFieldSetRecord({ + path, + deferredFragmentRecords, + groupedFieldSet, + deferPriority: 1, + streamPriority: 0, + shouldInitiateDefer, + }) + : new DeferredGroupedFieldSetRecord({ + path, + deferredFragmentRecords, + groupedFieldSet, + deferPriority: shouldInitiateDefer + ? incrementalDataRecord.deferPriority + 1 + : incrementalDataRecord.deferPriority, + streamPriority: incrementalDataRecord.streamPriority, + shouldInitiateDefer, + }); for (const deferredFragmentRecord of deferredFragmentRecords) { deferredFragmentRecord._pending.add(deferredGroupedFieldSetRecord); deferredFragmentRecord.deferredGroupedFieldSetRecords.add( @@ -342,11 +357,22 @@ export class IncrementalPublisher { incrementalDataRecord: IncrementalDataRecord | undefined, ): StreamItemsRecord { const parents = getSubsequentResultRecords(incrementalDataRecord); - const streamItemsRecord = new StreamItemsRecord({ - streamRecord, - path, - parents, - }); + const streamItemsRecord = + incrementalDataRecord === undefined + ? new StreamItemsRecord({ + streamRecord, + path, + deferPriority: 0, + streamPriority: 1, + parents, + }) + : new StreamItemsRecord({ + streamRecord, + path, + deferPriority: incrementalDataRecord.deferPriority, + streamPriority: incrementalDataRecord.streamPriority + 1, + parents, + }); if (parents === undefined) { this._initialResult.children.add(streamItemsRecord); @@ -672,6 +698,7 @@ export class IncrementalPublisher { if (isStreamItemsRecord(subsequentResultRecord)) { this._introduce(subsequentResultRecord); + subsequentResultRecord.publish(); return; } @@ -679,6 +706,9 @@ export class IncrementalPublisher { subsequentResultRecord.isCompleted = true; this._push(subsequentResultRecord); } else { + for (const deferredGroupedFieldSetRecord of subsequentResultRecord.deferredGroupedFieldSetRecords) { + deferredGroupedFieldSetRecord.publish(); + } this._introduce(subsequentResultRecord); } } @@ -748,24 +778,41 @@ export class IncrementalPublisher { /** @internal */ export class DeferredGroupedFieldSetRecord { path: ReadonlyArray; + deferPriority: number; + streamPriority: number; deferredFragmentRecords: ReadonlyArray; groupedFieldSet: GroupedFieldSet; shouldInitiateDefer: boolean; errors: Array; data: ObjMap | undefined; + published: true | Promise; + publish: () => void; sent: boolean; constructor(opts: { path: Path | undefined; + deferPriority: number; + streamPriority: number; deferredFragmentRecords: ReadonlyArray; groupedFieldSet: GroupedFieldSet; shouldInitiateDefer: boolean; }) { this.path = pathToArray(opts.path); + this.deferPriority = opts.deferPriority; + this.streamPriority = opts.streamPriority; 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; } } @@ -822,26 +869,43 @@ export class StreamItemsRecord { errors: Array; streamRecord: StreamRecord; path: ReadonlyArray; + deferPriority: number; + streamPriority: number; items: Array; parents: ReadonlyArray | undefined; children: Set; isFinalRecord?: boolean; isCompletedAsyncIterator?: boolean; isCompleted: boolean; + published: true | Promise; + publish: () => void; sent: boolean; constructor(opts: { streamRecord: StreamRecord; path: Path | undefined; + deferPriority: number; + streamPriority: number; parents: ReadonlyArray | undefined; }) { this.streamRecord = opts.streamRecord; this.path = pathToArray(opts.path); + this.deferPriority = opts.deferPriority; + this.streamPriority = opts.streamPriority; this.parents = opts.parents; this.children = new Set(); this.errors = []; this.isCompleted = 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 1b7ec48b4ab..d75de60a162 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 { FieldGroup } from '../../type/definition.js'; import { GraphQLList, GraphQLNonNull, @@ -224,6 +228,177 @@ describe('Execute: defer directive', () => { }, }); }); + it('Can provides correct info about deferred execution state when resolver could defer', async () => { + let fieldGroup: FieldGroup | 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) => { + fieldGroup = info.fieldGroup; + 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(fieldGroup).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(fieldGroup !== undefined); + const fieldDetails = fieldGroup.fields[0]; + expect(fieldDetails.node).to.equal(field); + expect(fieldDetails.target?.priority).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 fieldGroup: FieldGroup | undefined; + let deferPriority; + let published; + + const SomeType = new GraphQLObjectType({ + name: 'SomeType', + fields: { + someField: { + type: GraphQLString, + resolve: (_parent, _args, _context, info) => { + fieldGroup = info.fieldGroup; + 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(fieldGroup !== undefined); + const fieldDetails1 = fieldGroup.fields[0]; + expect(fieldDetails1.node).to.equal(node1); + expect(fieldDetails1.target).to.equal(undefined); + const fieldDetails2 = fieldGroup.fields[1]; + expect(fieldDetails2.node).to.equal(node2); + expect(fieldDetails2.target?.priority).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 c29b4ae60dd..4b5b876bb59 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -5,10 +5,12 @@ import { expectJSON } from '../../__testUtils__/expectJSON.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import { inspect } from '../../jsutils/inspect.js'; +import { OrderedSet } from '../../jsutils/OrderedSet.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 +193,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 +215,7 @@ describe('Execute: Handles basic execution tasks', () => { expect(resolvedInfo).to.have.all.keys( 'fieldName', - 'fieldNodes', + 'fieldGroup', 'returnType', 'parentType', 'path', @@ -222,6 +224,9 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'deferPriority', + 'streamPriority', + 'published', ); const operation = document.definitions[0]; @@ -234,14 +239,26 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, + deferPriority: 0, + streamPriority: 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 fieldGroup = resolvedInfo?.fieldGroup; + expect(fieldGroup).to.deep.include({ + targets: new OrderedSet([undefined]).freeze(), + }); + + const field = operation.selectionSet.selections[0]; + expect(fieldGroup?.fields[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 43b36343ebc..797f56e0c09 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -15,7 +15,13 @@ 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, + FieldGroup, + GraphQLObjectType, + Target, +} from '../type/definition.js'; import { isAbstractType } from '../type/definition.js'; import { GraphQLDeferDirective, @@ -28,29 +34,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; +type TargetSet = ReadonlyOrderedSet; export type DeferUsageSet = ReadonlyOrderedSet; -export interface FieldDetails { - node: FieldNode; - target: Target; -} - -export interface FieldGroup { - fields: ReadonlyArray; - targets: TargetSet; -} - export type GroupedFieldSet = Map; export interface GroupedFieldSetDetails { @@ -213,12 +203,19 @@ function collectFieldsImpl( let target: Target; if (!defer) { target = newTarget; + } else if (parentTarget === undefined) { + target = { + ...defer, + ancestors: [parentTarget], + priority: 1, + }; + newDeferUsages.push(target); } else { - const ancestors = - parentTarget === undefined - ? [parentTarget] - : [parentTarget, ...parentTarget.ancestors]; - target = { ...defer, ancestors }; + target = { + ...defer, + ancestors: [parentTarget, ...parentTarget.ancestors], + priority: parentTarget.priority + 1, + }; newDeferUsages.push(target); } @@ -255,12 +252,19 @@ function collectFieldsImpl( if (!defer) { visitedFragmentNames.add(fragName); target = newTarget; + } else if (parentTarget === undefined) { + target = { + ...defer, + ancestors: [parentTarget], + priority: 1, + }; + newDeferUsages.push(target); } else { - const ancestors = - parentTarget === undefined - ? [parentTarget] - : [parentTarget, ...parentTarget.ancestors]; - target = { ...defer, ancestors }; + target = { + ...defer, + ancestors: [parentTarget, ...parentTarget.ancestors], + priority: parentTarget.priority + 1, + }; newDeferUsages.push(target); } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index f45a20bc9d5..7ff4be6eb7b 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -27,6 +27,8 @@ import { OperationTypeNode } from '../language/ast.js'; import { Kind } from '../language/kinds.js'; import type { + DeferUsage, + FieldGroup, GraphQLAbstractType, GraphQLField, GraphQLFieldResolver, @@ -48,11 +50,7 @@ import { GraphQLStreamDirective } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; import { assertValidSchema } from '../type/validate.js'; -import type { - DeferUsage, - FieldGroup, - GroupedFieldSet, -} from './collectFields.js'; +import type { GroupedFieldSet } from './collectFields.js'; import { collectFields, collectSubfields as _collectSubfields, @@ -725,6 +723,7 @@ function executeField( fieldGroup, parentType, path, + incrementalDataRecord, ); // Get the resolve function, regardless of if its result is normal or abrupt (error). @@ -810,12 +809,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, + fieldGroup, + returnType: fieldDef.type, + parentType, + path, + schema: exeContext.schema, + fragments: exeContext.fragments, + rootValue: exeContext.rootValue, + operation: exeContext.operation, + variableValues: exeContext.variableValues, + deferPriority: 0, + streamPriority: 0, + published: true, + }; + } + return { fieldName: fieldDef.name, - fieldNodes: toNodes(fieldGroup), + fieldGroup, returnType: fieldDef.type, parentType, path, @@ -824,6 +842,12 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + deferPriority: incrementalDataRecord.deferPriority, + streamPriority: incrementalDataRecord.streamPriority, + published: + incrementalDataRecord.published === true + ? true + : incrementalDataRecord.published, }; } diff --git a/src/type/definition.ts b/src/type/definition.ts index 25f4133a425..af47cf9c799 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -885,9 +885,27 @@ export type GraphQLFieldResolver< info: GraphQLResolveInfo, ) => TResult; +export interface DeferUsage { + label: string | undefined; + ancestors: ReadonlyArray; + priority: number; +} + +export type Target = DeferUsage | undefined; + +export interface FieldDetails { + node: FieldNode; + target: Target; +} + +export interface FieldGroup { + fields: ReadonlyArray; + targets: ReadonlySet; +} + export interface GraphQLResolveInfo { readonly fieldName: string; - readonly fieldNodes: ReadonlyArray; + readonly fieldGroup: FieldGroup; readonly returnType: GraphQLOutputType; readonly parentType: GraphQLObjectType; readonly path: Path; @@ -896,6 +914,9 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + readonly deferPriority: number; + readonly streamPriority: number; + readonly published: true | Promise; } /** diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index c0d10311031..1efc3818025 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -10,7 +10,8 @@ import type { import { Kind } from '../../language/kinds.js'; import type { ASTVisitor } from '../../language/visitor.js'; -import type { FieldGroup } from '../../execution/collectFields.js'; +import type { FieldGroup } from '../../type/definition.js'; + import { collectFields } from '../../execution/collectFields.js'; import type { ValidationContext } from '../ValidationContext.js';