diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index c99a9ed3353..d02cd3b8108 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, @@ -255,8 +256,11 @@ export class IncrementalPublisher { path, deferredFragmentRecords, groupedFieldSet, + deferPriority: incrementalDataRecord.deferPriority + 1, + streamPriority: incrementalDataRecord.streamPriority, shouldInitiateDefer, }); + for (const deferredFragmentRecord of deferredFragmentRecords) { deferredFragmentRecord._pending.add(deferredGroupedFieldSetRecord); deferredFragmentRecord.deferredGroupedFieldSetRecords.add( @@ -292,6 +296,8 @@ export class IncrementalPublisher { const streamItemsRecord = new StreamItemsRecord({ streamRecord, path, + deferPriority: 0, + streamPriority: incrementalDataRecord.streamPriority + 1, }); if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { @@ -714,12 +720,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); } } @@ -788,33 +798,56 @@ export class IncrementalPublisher { export class InitialResultRecord { errors: Array; children: Set; + deferPriority: number; + streamPriority: number; + published: true; constructor() { this.errors = []; this.children = new Set(); + this.deferPriority = 0; + this.streamPriority = 0; + this.published = true; } } /** @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; } } @@ -865,22 +898,42 @@ export class StreamItemsRecord { errors: Array; streamRecord: StreamRecord; path: ReadonlyArray; + deferPriority: number; + streamPriority: 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; + deferPriority: number; + streamPriority: number; + }) { this.streamRecord = opts.streamRecord; this.path = pathToArray(opts.path); + this.deferPriority = opts.deferPriority; + this.streamPriority = opts.streamPriority; 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 68a56ee5054..7e1cf596f42 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, @@ -226,6 +230,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?.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 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?.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..e79cf028e51 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', + 'deferPriority', + 'streamPriority', + 'published', ); const operation = document.definitions[0]; @@ -234,14 +238,24 @@ 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 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 43b36343ebc..dc2dd4be2c9 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], + 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 +256,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 62585b6f8c1..ca6d801cd3a 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, @@ -47,11 +48,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 { FieldGroup, GroupedFieldSet } from './collectFields.js'; import { collectFields, collectSubfields as _collectSubfields, @@ -598,6 +595,7 @@ function executeField( fieldGroup, parentType, path, + incrementalDataRecord, ); // Get the resolve function, regardless of if its result is normal or abrupt (error). @@ -683,12 +681,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, + deferPriority: 0, + streamPriority: 0, + published: true, + }; + } + return { fieldName: fieldDef.name, - fieldNodes: toNodes(fieldGroup), + fieldDetails: fieldGroup.fields, returnType: fieldDef.type, parentType, path, @@ -697,6 +714,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 0ca4152bd2f..2d212d9f46e 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; + priority: 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 deferPriority: number; + readonly streamPriority: number; + readonly published: true | Promise; } /**