diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 5cad95bbc3..9320945eeb 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -40,6 +40,66 @@ const friends = [ { name: 'C-3PO', id: 4 }, ]; +const deeperObject = new GraphQLObjectType({ + fields: { + foo: { type: GraphQLString, resolve: () => 'foo' }, + bar: { type: GraphQLString, resolve: () => 'bar' }, + baz: { type: GraphQLString, resolve: () => 'baz' }, + bak: { type: GraphQLString, resolve: () => 'bak' }, + }, + name: 'DeeperObject', +}); + +const nestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject, resolve: () => ({}) }, + }, + name: 'NestedObject', +}); + +const anotherNestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject, resolve: () => ({}) }, + }, + name: 'AnotherNestedObject', +}); + +const c = new GraphQLObjectType({ + fields: { + d: { type: GraphQLString, resolve: () => 'd' }, + }, + name: 'c', +}); + +const e = new GraphQLObjectType({ + fields: { + f: { type: GraphQLString, resolve: () => 'f' }, + }, + name: 'e', +}); + +const b = new GraphQLObjectType({ + fields: { + c: { type: c, resolve: () => ({}) }, + e: { type: e, resolve: () => ({}) }, + }, + name: 'b', +}); + +const a = new GraphQLObjectType({ + fields: { + b: { type: b, resolve: () => ({}) }, + }, + name: 'a', +}); + +const g = new GraphQLObjectType({ + fields: { + h: { type: GraphQLString, resolve: () => 'h' }, + }, + name: 'g', +}); + const heroType = new GraphQLObjectType({ fields: { id: { type: GraphQLID }, @@ -75,6 +135,8 @@ const heroType = new GraphQLObjectType({ yield await Promise.resolve(friends[0]); }, }, + nestedObject: { type: nestedObject, resolve: () => ({}) }, + anotherNestedObject: { type: anotherNestedObject, resolve: () => ({}) }, }, name: 'Hero', }); @@ -87,6 +149,8 @@ const query = new GraphQLObjectType({ type: heroType, resolve: () => hero, }, + a: { type: a, resolve: () => ({}) }, + g: { type: g, resolve: () => ({}) }, }, name: 'Query', }); @@ -206,7 +270,7 @@ describe('Execute: defer directive', () => { it('Can defer fragments on the top level Query field', async () => { const document = parse(` query HeroNameQuery { - ...QueryFragment @defer(label: "DeferQuery") + ...QueryFragment @defer } fragment QueryFragment on Query { hero { @@ -230,7 +294,6 @@ describe('Execute: defer directive', () => { }, }, path: [], - label: 'DeferQuery', }, ], hasNext: false, @@ -240,7 +303,7 @@ describe('Execute: defer directive', () => { it('Can defer fragments with errors on the top level Query field', async () => { const document = parse(` query HeroNameQuery { - ...QueryFragment @defer(label: "DeferQuery") + ...QueryFragment @defer } fragment QueryFragment on Query { hero { @@ -271,7 +334,6 @@ describe('Execute: defer directive', () => { }, ], path: [], - label: 'DeferQuery', }, ], hasNext: false, @@ -283,12 +345,12 @@ describe('Execute: defer directive', () => { query HeroNameQuery { hero { id - ...TopFragment @defer(label: "DeferTop") + ...TopFragment @defer } } fragment TopFragment on Hero { name - ...NestedFragment @defer(label: "DeferNested") + ...NestedFragment @defer } fragment NestedFragment on Hero { friends { @@ -309,19 +371,12 @@ describe('Execute: defer directive', () => { }, { incremental: [ - { - data: { - friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], - }, - path: ['hero'], - label: 'DeferNested', - }, { data: { name: 'Luke', + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], }, path: ['hero'], - label: 'DeferTop', }, ], hasNext: false, @@ -333,7 +388,7 @@ describe('Execute: defer directive', () => { query HeroNameQuery { hero { id - ...TopFragment @defer(label: "DeferTop") + ...TopFragment @defer ...TopFragment } } @@ -359,7 +414,6 @@ describe('Execute: defer directive', () => { name: 'Luke', }, path: ['hero'], - label: 'DeferTop', }, ], hasNext: false, @@ -372,7 +426,7 @@ describe('Execute: defer directive', () => { hero { id ...TopFragment - ...TopFragment @defer(label: "DeferTop") + ...TopFragment @defer } } fragment TopFragment on Hero { @@ -397,7 +451,6 @@ describe('Execute: defer directive', () => { name: 'Luke', }, path: ['hero'], - label: 'DeferTop', }, ], hasNext: false, @@ -410,7 +463,7 @@ describe('Execute: defer directive', () => { query HeroNameQuery { hero { id - ... on Hero @defer(label: "InlineDeferred") { + ... on Hero @defer { name } } @@ -423,14 +476,346 @@ describe('Execute: defer directive', () => { data: { hero: { id: '1' } }, hasNext: true, }, + { + incremental: [{ data: { name: 'Luke' }, path: ['hero'] }], + hasNext: false, + }, + ]); + }); + + it('Can deduplicate multiple defers on the same object', async () => { + const document = parse(` + query { + hero { + friends { + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + } + } + } + } + } + } + } + + fragment FriendFrag on Friend { + id + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { hero: { friends: [{}, {}, {}] } }, + hasNext: true, + }, { incremental: [ - { data: { name: 'Luke' }, path: ['hero'], label: 'InlineDeferred' }, + { data: { id: '2', name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: { id: '3', name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: { id: '4', name: 'C-3PO' }, path: ['hero', 'friends', 2] }, ], hasNext: false, }, ]); }); + + it('Does not deduplicate leaf fields present in the initial payload', async () => { + const document = parse(` + query { + hero { + nestedObject { + deeperObject { + foo + } + } + anotherNestedObject { + deeperObject { + foo + } + } + ... @defer { + nestedObject { + deeperObject { + bar + } + } + anotherNestedObject { + deeperObject { + foo + } + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + nestedObject: { + deeperObject: { + foo: 'foo', + }, + }, + anotherNestedObject: { + deeperObject: { + foo: 'foo', + }, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + nestedObject: { + deeperObject: { + bar: 'bar', + }, + }, + anotherNestedObject: { + deeperObject: { + foo: 'foo', + }, + }, + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate fields with deferred fragments at multiple levels', async () => { + const document = parse(` + query { + hero { + nestedObject { + deeperObject { + foo + } + } + ... @defer { + nestedObject { + deeperObject { + foo + bar + } + ... @defer { + deeperObject { + foo + bar + baz + ... @defer { + foo + bar + baz + bak + } + } + } + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + nestedObject: { + deeperObject: { + foo: 'foo', + }, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + bak: 'bak', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + { + data: { + deeperObject: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + }, + }, + path: ['hero', 'nestedObject'], + }, + { + data: { + nestedObject: { + deeperObject: { + foo: 'foo', + bar: 'bar', + }, + }, + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can combine multiple fields from deferred fragments from different branches occurring at the same level', async () => { + const document = parse(` + query { + hero { + nestedObject { + deeperObject { + ... @defer { + foo + } + } + } + ... @defer { + nestedObject { + deeperObject { + ... @defer { + foo + bar + } + } + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + nestedObject: { + deeperObject: {}, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + foo: 'foo', + bar: 'bar', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + { + data: { + nestedObject: { + deeperObject: {}, + }, + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can deduplicate fields with deferred fragments in different branches at multiple non-overlapping levels', async () => { + const document = parse(` + query { + a { + b { + c { + d + } + ... @defer { + e { + f + } + } + } + } + ... @defer { + a { + b { + e { + f + } + } + } + g { + h + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + a: { + b: { + c: { + d: 'd', + }, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + e: { + f: 'f', + }, + }, + path: ['a', 'b'], + }, + { + data: { + a: { + b: { + e: { + f: 'f', + }, + }, + }, + g: { + h: 'h', + }, + }, + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles errors thrown in deferred fragments', async () => { const document = parse(` query HeroNameQuery { diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 5e25dddb5f..0ec1de3ff0 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 type { Path } from '../../jsutils/Path.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,8 @@ describe('Execute: Handles basic execution tasks', () => { expect(resolvedInfo).to.have.all.keys( 'fieldName', - 'fieldNodes', + 'fieldGroup', + 'deferDepth', 'returnType', 'parentType', 'path', @@ -236,16 +239,22 @@ describe('Execute: Handles basic execution tasks', () => { operation, }); - const field = operation.selectionSet.selections[0]; + const fieldNode = operation.selectionSet.selections[0]; expect(resolvedInfo).to.deep.include({ - fieldNodes: [field], - path: { prev: undefined, key: 'result', typename: 'Test' }, + fieldGroup: [{ fieldNode, depth: 0, deferDepth: undefined }], + deferDepth: undefined, variableValues: { var: 'abc' }, }); + + expect(resolvedInfo?.path).to.deep.include({ + prev: undefined, + key: 'result', + typename: 'Test', + }); }); it('populates path correctly with complex types', () => { - let path; + let path: Path | undefined; const someObject = new GraphQLObjectType({ name: 'SomeObject', fields: { @@ -288,18 +297,20 @@ describe('Execute: Handles basic execution tasks', () => { executeSync({ schema, document, rootValue }); - expect(path).to.deep.equal({ + expect(path).to.deep.include({ key: 'l2', typename: 'SomeObject', - prev: { - key: 0, - typename: undefined, - prev: { - key: 'l1', - typename: 'SomeQuery', - prev: undefined, - }, - }, + }); + + expect(path?.prev).to.deep.include({ + key: 0, + typename: undefined, + }); + + expect(path?.prev?.prev).to.deep.include({ + key: 'l1', + typename: 'SomeQuery', + prev: undefined, }); }); diff --git a/src/execution/__tests__/mutations-test.ts b/src/execution/__tests__/mutations-test.ts index fa533c75ea..ce3b6c5fc7 100644 --- a/src/execution/__tests__/mutations-test.ts +++ b/src/execution/__tests__/mutations-test.ts @@ -206,7 +206,7 @@ describe('Execute: Handles mutation execution ordering', () => { const document = parse(` mutation M { first: promiseToChangeTheNumber(newNumber: 1) { - ...DeferFragment @defer(label: "defer-label") + ...DeferFragment @defer }, second: immediatelyChangeTheNumber(newNumber: 2) { theNumber @@ -242,7 +242,6 @@ describe('Execute: Handles mutation execution ordering', () => { { incremental: [ { - label: 'defer-label', path: ['first'], data: { promiseToGetTheNumber: 2, @@ -281,7 +280,7 @@ describe('Execute: Handles mutation execution ordering', () => { it('Mutation with @defer is not executed serially', async () => { const document = parse(` mutation M { - ...MutationFragment @defer(label: "defer-label") + ...MutationFragment @defer second: immediatelyChangeTheNumber(newNumber: 2) { theNumber } @@ -317,7 +316,6 @@ describe('Execute: Handles mutation execution ordering', () => { { incremental: [ { - label: 'defer-label', path: [], data: { first: { diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index cd9b9b3965..7a168269c9 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -210,9 +210,7 @@ describe('Execute: stream directive', () => { }); }); it('Returns label from stream directive', async () => { - const document = parse( - '{ scalarList @stream(initialCount: 1, label: "scalar-stream") }', - ); + const document = parse('{ scalarList @stream(initialCount: 1) }'); const result = await complete(document, { scalarList: () => ['apple', 'banana', 'coconut'], }); @@ -228,7 +226,6 @@ describe('Execute: stream directive', () => { { items: ['banana'], path: ['scalarList', 1], - label: 'scalar-stream', }, ], hasNext: true, @@ -238,7 +235,6 @@ describe('Execute: stream directive', () => { { items: ['coconut'], path: ['scalarList', 2], - label: 'scalar-stream', }, ], hasNext: false, @@ -1661,8 +1657,8 @@ describe('Execute: stream directive', () => { const document = parse(` query { - friendList @stream(initialCount: 1, label:"stream-label") { - ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + friendList @stream(initialCount: 1) { + ...NameFragment @defer id } } @@ -1705,12 +1701,10 @@ describe('Execute: stream directive', () => { { data: { name: 'Luke' }, path: ['friendList', 0], - label: 'DeferName', }, { items: [{ id: '2' }], path: ['friendList', 1], - label: 'stream-label', }, ], hasNext: true, @@ -1727,7 +1721,6 @@ describe('Execute: stream directive', () => { { data: { name: 'Han' }, path: ['friendList', 1], - label: 'DeferName', }, ], hasNext: false, @@ -1747,8 +1740,8 @@ describe('Execute: stream directive', () => { const document = parse(` query { - friendList @stream(initialCount: 1, label:"stream-label") { - ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + friendList @stream(initialCount: 1) { + ...NameFragment @defer id } } @@ -1791,12 +1784,10 @@ describe('Execute: stream directive', () => { { data: { name: 'Luke' }, path: ['friendList', 0], - label: 'DeferName', }, { items: [{ id: '2' }], path: ['friendList', 1], - label: 'stream-label', }, ], hasNext: true, @@ -1811,7 +1802,6 @@ describe('Execute: stream directive', () => { { data: { name: 'Han' }, path: ['friendList', 1], - label: 'DeferName', }, ], hasNext: true, diff --git a/src/execution/__tests__/sync-test.ts b/src/execution/__tests__/sync-test.ts index f5efa4097c..aa68dcf441 100644 --- a/src/execution/__tests__/sync-test.ts +++ b/src/execution/__tests__/sync-test.ts @@ -117,7 +117,7 @@ describe('Execute: synchronously when possible', () => { it('throws if encountering async iterable execution', () => { const doc = ` query Example { - ...deferFrag @defer(label: "deferLabel") + ...deferFrag @defer } fragment deferFrag on Query { syncField diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index 17468b791f..a953b38467 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -26,14 +26,29 @@ import { typeFromAST } from '../utilities/typeFromAST.js'; import { getDirectiveValues } from './values.js'; -export interface PatchFields { - label: string | undefined; - fields: Map>; -} +export type FieldGroup = ReadonlyArray; + +export type GroupedFieldSet = Map; -export interface FieldsAndPatches { - fields: Map>; - patches: Array; +/** + * A tagged field node includes metadata necessary to determine whether a field should + * be executed. + * + * A field's depth is equivalent to the number of fields between the given field and + * the operation root. For example, root fields have a depth of 0, their sub-fields + * have a depth of 1, and so on. Tagging fields with their depth is necessary only to + * compute a field's "defer depth". + * + * A field's defer depth is the depth of the closest containing defer directive , or + * undefined, if the field is not contained by a deferred fragment. + * + * Because deferred fragments at a given level are merged, the defer depth may be used + * as a unique id to tag the fields for inclusion within a given deferred payload. + */ +export interface TaggedFieldNode { + fieldNode: FieldNode; + depth: number; + deferDepth: number | undefined; } /** @@ -51,21 +66,27 @@ export function collectFields( variableValues: { [variable: string]: unknown }, runtimeType: GraphQLObjectType, operation: OperationDefinitionNode, -): FieldsAndPatches { - const fields = new AccumulatorMap(); - const patches: Array = []; - collectFieldsImpl( +): { + groupedFieldSet: GroupedFieldSet; + newDeferDepth: number | undefined; +} { + const groupedFieldSet = new AccumulatorMap(); + const newDeferDepth = collectFieldsImpl( schema, fragments, variableValues, operation, runtimeType, operation.selectionSet, - fields, - patches, + groupedFieldSet, new Set(), + 0, + undefined, ); - return { fields, patches }; + return { + groupedFieldSet, + newDeferDepth, + }; } /** @@ -85,33 +106,39 @@ export function collectSubfields( variableValues: { [variable: string]: unknown }, operation: OperationDefinitionNode, returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, -): FieldsAndPatches { - const subFieldNodes = new AccumulatorMap(); + fieldGroup: FieldGroup, +): { + groupedFieldSet: GroupedFieldSet; + newDeferDepth: number | undefined; +} { + const groupedFieldSet = new AccumulatorMap(); + let newDeferDepth: number | undefined; const visitedFragmentNames = new Set(); - const subPatches: Array = []; - const subFieldsAndPatches = { - fields: subFieldNodes, - patches: subPatches, - }; - - for (const node of fieldNodes) { - if (node.selectionSet) { - collectFieldsImpl( + for (const field of fieldGroup) { + if (field.fieldNode.selectionSet) { + const nestedNewDeferDepth = collectFieldsImpl( schema, fragments, variableValues, operation, returnType, - node.selectionSet, - subFieldNodes, - subPatches, + field.fieldNode.selectionSet, + groupedFieldSet, visitedFragmentNames, + fieldGroup[0].depth + 1, + field.deferDepth, ); + if (nestedNewDeferDepth !== undefined) { + newDeferDepth = nestedNewDeferDepth; + } } } - return subFieldsAndPatches; + + return { + groupedFieldSet, + newDeferDepth, + }; } // eslint-disable-next-line max-params @@ -122,17 +149,23 @@ function collectFieldsImpl( operation: OperationDefinitionNode, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - fields: AccumulatorMap, - patches: Array, + groupedFieldSet: AccumulatorMap, visitedFragmentNames: Set, -): void { + depth: number, + deferDepth: number | undefined, +): number | undefined { + let hasNewDefer = false; for (const selection of selectionSet.selections) { switch (selection.kind) { case Kind.FIELD: { if (!shouldIncludeNode(variableValues, selection)) { continue; } - fields.add(getFieldEntryKey(selection), selection); + groupedFieldSet.add(getFieldEntryKey(selection), { + fieldNode: selection, + depth, + deferDepth, + }); break; } case Kind.INLINE_FRAGMENT: { @@ -143,38 +176,23 @@ function collectFieldsImpl( continue; } - const defer = getDeferValues(operation, variableValues, selection); - - if (defer) { - const patchFields = new AccumulatorMap(); - collectFieldsImpl( - schema, - fragments, - variableValues, - operation, - runtimeType, - selection.selectionSet, - patchFields, - patches, - visitedFragmentNames, - ); - patches.push({ - label: defer.label, - fields: patchFields, - }); - } else { - collectFieldsImpl( - schema, - fragments, - variableValues, - operation, - runtimeType, - selection.selectionSet, - fields, - patches, - visitedFragmentNames, - ); - } + const defer = isFragmentDeferred(operation, variableValues, selection); + + const nestedHasNewDefer = collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + runtimeType, + selection.selectionSet, + groupedFieldSet, + visitedFragmentNames, + depth, + defer ? depth : deferDepth, + ); + + hasNewDefer ||= defer || nestedHasNewDefer !== undefined; + break; } case Kind.FRAGMENT_SPREAD: { @@ -184,7 +202,7 @@ function collectFieldsImpl( continue; } - const defer = getDeferValues(operation, variableValues, selection); + const defer = isFragmentDeferred(operation, variableValues, selection); if (visitedFragmentNames.has(fragName) && !defer) { continue; } @@ -201,60 +219,45 @@ function collectFieldsImpl( visitedFragmentNames.add(fragName); } - if (defer) { - const patchFields = new AccumulatorMap(); - collectFieldsImpl( - schema, - fragments, - variableValues, - operation, - runtimeType, - fragment.selectionSet, - patchFields, - patches, - visitedFragmentNames, - ); - patches.push({ - label: defer.label, - fields: patchFields, - }); - } else { - collectFieldsImpl( - schema, - fragments, - variableValues, - operation, - runtimeType, - fragment.selectionSet, - fields, - patches, - visitedFragmentNames, - ); - } + const nestedNewDeferDepth = collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + runtimeType, + fragment.selectionSet, + groupedFieldSet, + visitedFragmentNames, + depth, + defer ? depth : deferDepth, + ); + + hasNewDefer ||= defer || nestedNewDeferDepth !== undefined; + break; } } } + return hasNewDefer ? depth : undefined; } /** - * Returns an object containing the `@defer` arguments if a field should be - * deferred based on the experimental flag, defer directive present and - * not disabled by the "if" argument. + * Returns whether a fragment should be deferred based on the presence of a + * defer directive and whether it is disabled by the "if" argument. */ -function getDeferValues( +function isFragmentDeferred( operation: OperationDefinitionNode, variableValues: { [variable: string]: unknown }, node: FragmentSpreadNode | InlineFragmentNode, -): undefined | { label: string | undefined } { +): boolean { const defer = getDirectiveValues(GraphQLDeferDirective, node, variableValues); if (!defer) { - return; + return false; } if (defer.if === false) { - return; + return false; } invariant( @@ -262,9 +265,7 @@ function getDeferValues( '`@defer` directive not supported on subscription operations. Disable `@defer` by setting the `if` argument to `false`.', ); - return { - label: typeof defer.label === 'string' ? defer.label : undefined, - }; + return true; } /** diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 1bc6c4267b..38a73d1328 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -7,8 +7,8 @@ import { isPromise } from '../jsutils/isPromise.js'; import type { Maybe } from '../jsutils/Maybe.js'; import { memoize3 } from '../jsutils/memoize3.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; -import type { Path } from '../jsutils/Path.js'; -import { addPath, pathToArray } from '../jsutils/Path.js'; +import type { Path, PathFactory } from '../jsutils/Path.js'; +import { createPathFactory, pathToArray } from '../jsutils/Path.js'; import { promiseForObject } from '../jsutils/promiseForObject.js'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; import { promiseReduce } from '../jsutils/promiseReduce.js'; @@ -48,6 +48,7 @@ import { GraphQLStreamDirective } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; import { assertValidSchema } from '../type/validate.js'; +import type { FieldGroup, GroupedFieldSet } from './collectFields.js'; import { collectFields, collectSubfields as _collectSubfields, @@ -72,7 +73,7 @@ const collectSubfields = memoize3( ( exeContext: ExecutionContext, returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, ) => _collectSubfields( exeContext.schema, @@ -80,7 +81,7 @@ const collectSubfields = memoize3( exeContext.variableValues, exeContext.operation, returnType, - fieldNodes, + fieldGroup, ), ); @@ -120,8 +121,10 @@ export interface ExecutionContext { fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; + addPath: PathFactory; errors: Array; subsequentPayloads: Set; + branches: WeakMap>; } /** @@ -204,7 +207,6 @@ export interface IncrementalDeferResult< TExtensions = ObjMap, > extends ExecutionResult { path?: ReadonlyArray; - label?: string; } export interface FormattedIncrementalDeferResult< @@ -212,7 +214,6 @@ export interface FormattedIncrementalDeferResult< TExtensions = ObjMap, > extends FormattedExecutionResult { path?: ReadonlyArray; - label?: string; } export interface IncrementalStreamResult< @@ -222,7 +223,6 @@ export interface IncrementalStreamResult< errors?: ReadonlyArray; items?: TData | null; path?: ReadonlyArray; - label?: string; extensions?: TExtensions; } @@ -233,7 +233,6 @@ export interface FormattedIncrementalStreamResult< errors?: ReadonlyArray; items?: TData | null; path?: ReadonlyArray; - label?: string; extensions?: TExtensions; } @@ -503,7 +502,9 @@ export function buildExecutionContext( fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, + addPath: createPathFactory(), subsequentPayloads: new Set(), + branches: new WeakMap(), errors: [], }; } @@ -515,11 +516,30 @@ function buildPerEventExecutionContext( return { ...exeContext, rootValue: payload, + addPath: createPathFactory(), subsequentPayloads: new Set(), + branches: new WeakMap(), errors: [], }; } +function shouldBranch( + groupedFieldSet: GroupedFieldSet, + exeContext: ExecutionContext, + path: Path | undefined, +): boolean { + const set = exeContext.branches.get(groupedFieldSet); + if (set === undefined) { + exeContext.branches.set(groupedFieldSet, new Set([path])); + return true; + } + if (!set.has(path)) { + set.add(path); + return true; + } + return false; +} + /** * Implements the "Executing operations" section of the spec. */ @@ -536,7 +556,7 @@ function executeOperation( ); } - const { fields: rootFields, patches } = collectFields( + const { groupedFieldSet, newDeferDepth } = collectFields( schema, fragments, variableValues, @@ -548,7 +568,13 @@ function executeOperation( switch (operation.operation) { case OperationTypeNode.QUERY: - result = executeFields(exeContext, rootType, rootValue, path, rootFields); + result = executeFields( + exeContext, + rootType, + rootValue, + path, + groupedFieldSet, + ); break; case OperationTypeNode.MUTATION: result = executeFieldsSerially( @@ -556,23 +582,31 @@ function executeOperation( rootType, rootValue, path, - rootFields, + groupedFieldSet, ); break; case OperationTypeNode.SUBSCRIPTION: // TODO: deprecate `subscribe` and move all logic here // Temporary solution until we finish merging execute and subscribe together - result = executeFields(exeContext, rootType, rootValue, path, rootFields); + result = executeFields( + exeContext, + rootType, + rootValue, + path, + groupedFieldSet, + ); } - for (const patch of patches) { - const { label, fields: patchFields } = patch; + if ( + newDeferDepth !== undefined && + shouldBranch(groupedFieldSet, exeContext, path) + ) { executeDeferredFragment( exeContext, rootType, rootValue, - patchFields, - label, + groupedFieldSet, + newDeferDepth, path, ); } @@ -589,17 +623,21 @@ function executeFieldsSerially( parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - fields: Map>, + groupedFieldSet: GroupedFieldSet, ): PromiseOrValue> { return promiseReduce( - fields, - (results, [responseName, fieldNodes]) => { - const fieldPath = addPath(path, responseName, parentType.name); + groupedFieldSet, + (results, [responseName, fieldGroup]) => { + const fieldPath = exeContext.addPath(path, responseName, parentType.name); + + if (!shouldExecute(fieldGroup)) { + return results; + } const result = executeField( exeContext, parentType, sourceValue, - fieldNodes, + fieldGroup, fieldPath, ); if (result === undefined) { @@ -618,6 +656,15 @@ function executeFieldsSerially( ); } +function shouldExecute( + fieldGroup: FieldGroup, + deferDepth?: number | undefined, +): boolean { + return fieldGroup.some( + ({ deferDepth: fieldDeferDepth }) => fieldDeferDepth === deferDepth, + ); +} + /** * Implements the "Executing selection sets" section of the spec * for fields that may be executed in parallel. @@ -627,28 +674,31 @@ function executeFields( parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - fields: Map>, + groupedFieldSet: GroupedFieldSet, asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; try { - for (const [responseName, fieldNodes] of fields) { - const fieldPath = addPath(path, responseName, parentType.name); - const result = executeField( - exeContext, - parentType, - sourceValue, - fieldNodes, - fieldPath, - asyncPayloadRecord, - ); + for (const [responseName, fieldGroup] of groupedFieldSet) { + const fieldPath = exeContext.addPath(path, responseName, parentType.name); + + if (shouldExecute(fieldGroup, asyncPayloadRecord?.deferDepth)) { + const result = executeField( + exeContext, + parentType, + sourceValue, + fieldGroup, + fieldPath, + asyncPayloadRecord, + ); - if (result !== undefined) { - results[responseName] = result; - if (isPromise(result)) { - containsPromise = true; + if (result !== undefined) { + results[responseName] = result; + if (isPromise(result)) { + containsPromise = true; + } } } } @@ -673,6 +723,9 @@ function executeFields( return promiseForObject(results); } +function toNodes(fieldGroup: FieldGroup): ReadonlyArray { + return fieldGroup.map(({ fieldNode }) => fieldNode); +} /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by @@ -683,12 +736,12 @@ function executeField( exeContext: ExecutionContext, parentType: GraphQLObjectType, source: unknown, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, path: Path, asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue { const errors = asyncPayloadRecord?.errors ?? exeContext.errors; - const fieldName = fieldNodes[0].name.value; + const fieldName = fieldGroup[0].fieldNode.name.value; const fieldDef = exeContext.schema.getField(parentType, fieldName); if (!fieldDef) { return; @@ -700,9 +753,10 @@ function executeField( const info = buildResolveInfo( exeContext, fieldDef, - fieldNodes, + fieldGroup, parentType, path, + asyncPayloadRecord, ); // Get the resolve function, regardless of if its result is normal or abrupt (error). @@ -712,7 +766,7 @@ function executeField( // TODO: find a way to memoize, in case this field is within a List type. const args = getArgumentValues( fieldDef, - fieldNodes[0], + fieldGroup[0].fieldNode, exeContext.variableValues, ); @@ -727,7 +781,7 @@ function executeField( return completePromisedValue( exeContext, returnType, - fieldNodes, + fieldGroup, info, path, result, @@ -738,7 +792,7 @@ function executeField( const completed = completeValue( exeContext, returnType, - fieldNodes, + fieldGroup, info, path, result, @@ -749,7 +803,11 @@ function executeField( // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. return completed.then(undefined, (rawError) => { - const error = locatedError(rawError, fieldNodes, pathToArray(path)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(path), + ); const handledError = handleFieldError(error, returnType, errors); filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); return handledError; @@ -757,7 +815,11 @@ function executeField( } return completed; } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(path)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(path), + ); const handledError = handleFieldError(error, returnType, errors); filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); return handledError; @@ -771,15 +833,17 @@ function executeField( export function buildResolveInfo( exeContext: ExecutionContext, fieldDef: GraphQLField, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, parentType: GraphQLObjectType, path: Path, + asyncPayloadRecord?: AsyncPayloadRecord | undefined, ): GraphQLResolveInfo { // The resolve function's optional fourth argument is a collection of // information about the current execution state. return { fieldName: fieldDef.name, - fieldNodes, + fieldGroup, + deferDepth: asyncPayloadRecord?.deferDepth, returnType: fieldDef.type, parentType, path, @@ -832,7 +896,7 @@ function handleFieldError( function completeValue( exeContext: ExecutionContext, returnType: GraphQLOutputType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, result: unknown, @@ -849,7 +913,7 @@ function completeValue( const completed = completeValue( exeContext, returnType.ofType, - fieldNodes, + fieldGroup, info, path, result, @@ -873,7 +937,7 @@ function completeValue( return completeListValue( exeContext, returnType, - fieldNodes, + fieldGroup, info, path, result, @@ -893,7 +957,7 @@ function completeValue( return completeAbstractValue( exeContext, returnType, - fieldNodes, + fieldGroup, info, path, result, @@ -906,7 +970,7 @@ function completeValue( return completeObjectValue( exeContext, returnType, - fieldNodes, + fieldGroup, info, path, result, @@ -924,7 +988,7 @@ function completeValue( async function completePromisedValue( exeContext: ExecutionContext, returnType: GraphQLOutputType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, result: Promise, @@ -935,7 +999,7 @@ async function completePromisedValue( let completed = completeValue( exeContext, returnType, - fieldNodes, + fieldGroup, info, path, resolved, @@ -947,7 +1011,11 @@ async function completePromisedValue( return completed; } catch (rawError) { const errors = asyncPayloadRecord?.errors ?? exeContext.errors; - const error = locatedError(rawError, fieldNodes, pathToArray(path)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(path), + ); const handledError = handleFieldError(error, returnType, errors); filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); return handledError; @@ -961,13 +1029,12 @@ async function completePromisedValue( */ function getStreamValues( exeContext: ExecutionContext, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, path: Path, ): | undefined | { initialCount: number | undefined; - label: string | undefined; } { // do not stream inner lists of multi-dimensional lists if (typeof path.key === 'number') { @@ -978,7 +1045,7 @@ function getStreamValues( // safe to only check the first fieldNode for the stream directive const stream = getDirectiveValues( GraphQLStreamDirective, - fieldNodes[0], + fieldGroup[0].fieldNode, exeContext.variableValues, ); @@ -1007,7 +1074,6 @@ function getStreamValues( return { initialCount: stream.initialCount, - label: typeof stream.label === 'string' ? stream.label : undefined, }; } @@ -1018,14 +1084,14 @@ function getStreamValues( async function completeAsyncIteratorValue( exeContext: ExecutionContext, itemType: GraphQLOutputType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, iterator: AsyncIterator, asyncPayloadRecord?: AsyncPayloadRecord, ): Promise> { const errors = asyncPayloadRecord?.errors ?? exeContext.errors; - const stream = getStreamValues(exeContext, fieldNodes, path); + const stream = getStreamValues(exeContext, fieldGroup, path); let containsPromise = false; const completedResults: Array = []; let index = 0; @@ -1041,17 +1107,16 @@ async function completeAsyncIteratorValue( index, iterator, exeContext, - fieldNodes, + fieldGroup, info, itemType, path, - stream.label, asyncPayloadRecord, ); break; } - const itemPath = addPath(path, index, undefined); + const itemPath = exeContext.addPath(path, index, undefined); let iteration; try { // eslint-disable-next-line no-await-in-loop @@ -1060,7 +1125,11 @@ async function completeAsyncIteratorValue( break; } } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); completedResults.push(handleFieldError(error, itemType, errors)); break; } @@ -1072,7 +1141,7 @@ async function completeAsyncIteratorValue( errors, exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, asyncPayloadRecord, @@ -1092,7 +1161,7 @@ async function completeAsyncIteratorValue( function completeListValue( exeContext: ExecutionContext, returnType: GraphQLList, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, result: unknown, @@ -1107,7 +1176,7 @@ function completeListValue( return completeAsyncIteratorValue( exeContext, itemType, - fieldNodes, + fieldGroup, info, path, iterator, @@ -1121,7 +1190,7 @@ function completeListValue( ); } - const stream = getStreamValues(exeContext, fieldNodes, path); + const stream = getStreamValues(exeContext, fieldGroup, path); // This is specified as a simple map, however we're optimizing the path // where the list contains no Promises by avoiding creating another Promise. @@ -1132,7 +1201,7 @@ function completeListValue( for (const item of result) { // No need to modify the info object containing the path, // since from here on it is not ever accessed by resolver functions. - const itemPath = addPath(path, index, undefined); + const itemPath = exeContext.addPath(path, index, undefined); if ( stream && @@ -1144,10 +1213,9 @@ function completeListValue( itemPath, item, exeContext, - fieldNodes, + fieldGroup, info, itemType, - stream.label, previousAsyncPayloadRecord, ); index++; @@ -1161,7 +1229,7 @@ function completeListValue( errors, exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, asyncPayloadRecord, @@ -1187,7 +1255,7 @@ function completeListItemValue( errors: Array, exeContext: ExecutionContext, itemType: GraphQLOutputType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemPath: Path, asyncPayloadRecord?: AsyncPayloadRecord, @@ -1197,7 +1265,7 @@ function completeListItemValue( completePromisedValue( exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, item, @@ -1212,7 +1280,7 @@ function completeListItemValue( const completedItem = completeValue( exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, item, @@ -1226,7 +1294,7 @@ function completeListItemValue( completedItem.then(undefined, (rawError) => { const error = locatedError( rawError, - fieldNodes, + toNodes(fieldGroup), pathToArray(itemPath), ); const handledError = handleFieldError(error, itemType, errors); @@ -1240,7 +1308,11 @@ function completeListItemValue( completedResults.push(completedItem); } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); const handledError = handleFieldError(error, itemType, errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); completedResults.push(handledError); @@ -1274,7 +1346,7 @@ function completeLeafValue( function completeAbstractValue( exeContext: ExecutionContext, returnType: GraphQLAbstractType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, result: unknown, @@ -1292,11 +1364,11 @@ function completeAbstractValue( resolvedRuntimeType, exeContext, returnType, - fieldNodes, + fieldGroup, info, result, ), - fieldNodes, + fieldGroup, info, path, result, @@ -1311,11 +1383,11 @@ function completeAbstractValue( runtimeType, exeContext, returnType, - fieldNodes, + fieldGroup, info, result, ), - fieldNodes, + fieldGroup, info, path, result, @@ -1327,14 +1399,14 @@ function ensureValidRuntimeType( runtimeTypeName: unknown, exeContext: ExecutionContext, returnType: GraphQLAbstractType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, result: unknown, ): GraphQLObjectType { if (runtimeTypeName == null) { throw new GraphQLError( `Abstract type "${returnType.name}" must resolve to an Object type at runtime for field "${info.parentType.name}.${info.fieldName}". Either the "${returnType.name}" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.`, - { nodes: fieldNodes }, + { nodes: toNodes(fieldGroup) }, ); } @@ -1357,21 +1429,21 @@ function ensureValidRuntimeType( if (runtimeType == null) { throw new GraphQLError( `Abstract type "${returnType.name}" was resolved to a type "${runtimeTypeName}" that does not exist inside the schema.`, - { nodes: fieldNodes }, + { nodes: toNodes(fieldGroup) }, ); } if (!isObjectType(runtimeType)) { throw new GraphQLError( `Abstract type "${returnType.name}" was resolved to a non-object type "${runtimeTypeName}".`, - { nodes: fieldNodes }, + { nodes: toNodes(fieldGroup) }, ); } if (!exeContext.schema.isSubType(returnType, runtimeType)) { throw new GraphQLError( `Runtime Object type "${runtimeType.name}" is not a possible type for "${returnType.name}".`, - { nodes: fieldNodes }, + { nodes: toNodes(fieldGroup) }, ); } @@ -1384,7 +1456,7 @@ function ensureValidRuntimeType( function completeObjectValue( exeContext: ExecutionContext, returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, path: Path, result: unknown, @@ -1399,12 +1471,12 @@ function completeObjectValue( if (isPromise(isTypeOf)) { return isTypeOf.then((resolvedIsTypeOf) => { if (!resolvedIsTypeOf) { - throw invalidReturnTypeError(returnType, result, fieldNodes); + throw invalidReturnTypeError(returnType, result, fieldGroup); } return collectAndExecuteSubfields( exeContext, returnType, - fieldNodes, + fieldGroup, path, result, asyncPayloadRecord, @@ -1413,14 +1485,14 @@ function completeObjectValue( } if (!isTypeOf) { - throw invalidReturnTypeError(returnType, result, fieldNodes); + throw invalidReturnTypeError(returnType, result, fieldGroup); } } return collectAndExecuteSubfields( exeContext, returnType, - fieldNodes, + fieldGroup, path, result, asyncPayloadRecord, @@ -1430,27 +1502,27 @@ function completeObjectValue( function invalidReturnTypeError( returnType: GraphQLObjectType, result: unknown, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, ): GraphQLError { return new GraphQLError( `Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, - { nodes: fieldNodes }, + { nodes: toNodes(fieldGroup) }, ); } function collectAndExecuteSubfields( exeContext: ExecutionContext, returnType: GraphQLObjectType, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, path: Path, result: unknown, asyncPayloadRecord?: AsyncPayloadRecord, ): PromiseOrValue> { // Collect sub-fields to execute to complete this value. - const { fields: subFieldNodes, patches: subPatches } = collectSubfields( + const { groupedFieldSet, newDeferDepth } = collectSubfields( exeContext, returnType, - fieldNodes, + fieldGroup, ); const subFields = executeFields( @@ -1458,18 +1530,20 @@ function collectAndExecuteSubfields( returnType, result, path, - subFieldNodes, + groupedFieldSet, asyncPayloadRecord, ); - for (const subPatch of subPatches) { - const { label, fields: subPatchFieldNodes } = subPatch; + if ( + newDeferDepth !== undefined && + shouldBranch(groupedFieldSet, exeContext, path) + ) { executeDeferredFragment( exeContext, returnType, result, - subPatchFieldNodes, - label, + groupedFieldSet, + newDeferDepth, path, asyncPayloadRecord, ); @@ -1691,7 +1765,7 @@ function executeSubscription( ); } - const { fields: rootFields } = collectFields( + const { groupedFieldSet } = collectFields( schema, fragments, variableValues, @@ -1699,23 +1773,26 @@ function executeSubscription( operation, ); - const firstRootField = rootFields.entries().next().value; - const [responseName, fieldNodes] = firstRootField; - const fieldName = fieldNodes[0].name.value; + const firstRootField = groupedFieldSet.entries().next().value as [ + string, + FieldGroup, + ]; + const [responseName, fieldGroup] = firstRootField; + const fieldName = fieldGroup[0].fieldNode.name.value; const fieldDef = schema.getField(rootType, fieldName); if (!fieldDef) { throw new GraphQLError( `The subscription field "${fieldName}" is not defined.`, - { nodes: fieldNodes }, + { nodes: toNodes(fieldGroup) }, ); } - const path = addPath(undefined, responseName, rootType.name); + const path = exeContext.addPath(undefined, responseName, rootType.name); const info = buildResolveInfo( exeContext, fieldDef, - fieldNodes, + fieldGroup, rootType, path, ); @@ -1726,7 +1803,11 @@ function executeSubscription( // Build a JS object of arguments from the field.arguments AST, using the // variables scope to fulfill any variable references. - const args = getArgumentValues(fieldDef, fieldNodes[0], variableValues); + const args = getArgumentValues( + fieldDef, + fieldGroup[0].fieldNode, + variableValues, + ); // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly @@ -1740,13 +1821,13 @@ function executeSubscription( if (isPromise(result)) { return result.then(assertEventStream).then(undefined, (error) => { - throw locatedError(error, fieldNodes, pathToArray(path)); + throw locatedError(error, toNodes(fieldGroup), pathToArray(path)); }); } return assertEventStream(result); } catch (error) { - throw locatedError(error, fieldNodes, pathToArray(path)); + throw locatedError(error, toNodes(fieldGroup), pathToArray(path)); } } @@ -1770,13 +1851,13 @@ function executeDeferredFragment( exeContext: ExecutionContext, parentType: GraphQLObjectType, sourceValue: unknown, - fields: Map>, - label?: string, + groupedFieldSet: GroupedFieldSet, + newDeferDepth: number, path?: Path, parentContext?: AsyncPayloadRecord, ): void { const asyncPayloadRecord = new DeferredFragmentRecord({ - label, + deferDepth: newDeferDepth, path, parentContext, exeContext, @@ -1788,7 +1869,7 @@ function executeDeferredFragment( parentType, sourceValue, path, - fields, + groupedFieldSet, asyncPayloadRecord, ); @@ -1810,14 +1891,13 @@ function executeStreamField( itemPath: Path, item: PromiseOrValue, exeContext: ExecutionContext, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - label?: string, parentContext?: AsyncPayloadRecord, ): AsyncPayloadRecord { const asyncPayloadRecord = new StreamRecord({ - label, + deferDepth: parentContext?.deferDepth, path: itemPath, parentContext, exeContext, @@ -1826,7 +1906,7 @@ function executeStreamField( const completedItems = completePromisedValue( exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, item, @@ -1850,14 +1930,18 @@ function executeStreamField( completedItem = completeValue( exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, item, asyncPayloadRecord, ); } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); completedItem = handleFieldError( error, itemType, @@ -1875,7 +1959,11 @@ function executeStreamField( if (isPromise(completedItem)) { const completedItems = completedItem .then(undefined, (rawError) => { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); const handledError = handleFieldError( error, itemType, @@ -1904,7 +1992,7 @@ function executeStreamField( async function executeStreamIteratorItem( iterator: AsyncIterator, exeContext: ExecutionContext, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, asyncPayloadRecord: StreamRecord, @@ -1919,7 +2007,11 @@ async function executeStreamIteratorItem( } item = value; } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); // don't continue if iterator throws return { done: true, value }; @@ -1929,7 +2021,7 @@ async function executeStreamIteratorItem( completedItem = completeValue( exeContext, itemType, - fieldNodes, + fieldGroup, info, itemPath, item, @@ -1938,7 +2030,11 @@ async function executeStreamIteratorItem( if (isPromise(completedItem)) { completedItem = completedItem.then(undefined, (rawError) => { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); const handledError = handleFieldError( error, itemType, @@ -1950,7 +2046,11 @@ async function executeStreamIteratorItem( } return { done: false, value: completedItem }; } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + toNodes(fieldGroup), + pathToArray(itemPath), + ); const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); return { done: false, value }; @@ -1961,21 +2061,21 @@ async function executeStreamIterator( initialIndex: number, iterator: AsyncIterator, exeContext: ExecutionContext, - fieldNodes: ReadonlyArray, + fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, path: Path, - label?: string, parentContext?: AsyncPayloadRecord, ): Promise { let index = initialIndex; + const deferDepth = parentContext?.deferDepth; let previousAsyncPayloadRecord = parentContext ?? undefined; // eslint-disable-next-line no-constant-condition while (true) { - const itemPath = addPath(path, index, undefined); + const itemPath = exeContext.addPath(path, index, undefined); const asyncPayloadRecord = new StreamRecord({ - label, path: itemPath, + deferDepth, parentContext: previousAsyncPayloadRecord, iterator, exeContext, @@ -1987,7 +2087,7 @@ async function executeStreamIterator( iteration = await executeStreamIteratorItem( iterator, exeContext, - fieldNodes, + fieldGroup, info, itemType, asyncPayloadRecord, @@ -2082,9 +2182,6 @@ function getCompletedIncrementalResults( } incrementalResult.path = asyncPayloadRecord.path; - if (asyncPayloadRecord.label) { - incrementalResult.label = asyncPayloadRecord.label; - } if (asyncPayloadRecord.errors.length > 0) { incrementalResult.errors = asyncPayloadRecord.errors; } @@ -2169,8 +2266,8 @@ function yieldSubsequentPayloads( class DeferredFragmentRecord { type: 'defer'; errors: Array; - label: string | undefined; path: Array; + deferDepth: number | undefined; promise: Promise; data: ObjMap | null; parentContext: AsyncPayloadRecord | undefined; @@ -2178,14 +2275,14 @@ class DeferredFragmentRecord { _exeContext: ExecutionContext; _resolve?: (arg: PromiseOrValue | null>) => void; constructor(opts: { - label: string | undefined; path: Path | undefined; + deferDepth: number | undefined; parentContext: AsyncPayloadRecord | undefined; exeContext: ExecutionContext; }) { this.type = 'defer'; - this.label = opts.label; this.path = pathToArray(opts.path); + this.deferDepth = opts.deferDepth; this.parentContext = opts.parentContext; this.errors = []; this._exeContext = opts.exeContext; @@ -2215,8 +2312,8 @@ class DeferredFragmentRecord { class StreamRecord { type: 'stream'; errors: Array; - label: string | undefined; path: Array; + deferDepth: number | undefined; items: Array | null; promise: Promise; parentContext: AsyncPayloadRecord | undefined; @@ -2226,16 +2323,16 @@ class StreamRecord { _exeContext: ExecutionContext; _resolve?: (arg: PromiseOrValue | null>) => void; constructor(opts: { - label: string | undefined; path: Path | undefined; + deferDepth: number | undefined; iterator?: AsyncIterator; parentContext: AsyncPayloadRecord | undefined; exeContext: ExecutionContext; }) { this.type = 'stream'; this.items = null; - this.label = opts.label; this.path = pathToArray(opts.path); + this.deferDepth = opts.deferDepth; this.parentContext = opts.parentContext; this.iterator = opts.iterator; this.errors = []; diff --git a/src/jsutils/Path.ts b/src/jsutils/Path.ts index d223b6e752..2a8eb9d599 100644 --- a/src/jsutils/Path.ts +++ b/src/jsutils/Path.ts @@ -1,20 +1,39 @@ import type { Maybe } from './Maybe.js'; -export interface Path { +/** + * @internal + */ +export class Path { readonly prev: Path | undefined; readonly key: string | number; readonly typename: string | undefined; -} -/** - * Given a Path and a key, return a new Path containing the new key. - */ -export function addPath( - prev: Readonly | undefined, - key: string | number, - typename: string | undefined, -): Path { - return { prev, key, typename }; + readonly _subPaths: Map; + + constructor( + prev: Path | undefined, + key: string | number, + typename: string | undefined, + ) { + this.prev = prev; + this.key = key; + this.typename = typename; + this._subPaths = new Map(); + } + + /** + * Given a Path and a key, return a new Path containing the new key. + */ + addPath(key: string | number, typeName: string | undefined): Path { + let path = this._subPaths.get(key); + if (path !== undefined) { + return path; + } + + path = new Path(this, key, typeName); + this._subPaths.set(key, path); + return path; + } } /** @@ -31,3 +50,27 @@ export function pathToArray( } return flattened.reverse(); } + +export type PathFactory = ( + path: Path | undefined, + key: string | number, + typeName: string | undefined, +) => Path; + +export function createPathFactory(): PathFactory { + const paths = new Map(); + return (path, key, typeName) => { + if (path !== undefined) { + return path.addPath(key, typeName); + } + + let newPath = paths.get(key as string); + if (newPath !== undefined) { + return newPath; + } + + newPath = new Path(undefined, key, typeName); + paths.set(key as string, newPath); + return newPath; + }; +} diff --git a/src/jsutils/__tests__/Path-test.ts b/src/jsutils/__tests__/Path-test.ts index 0484377db9..c671eafc4e 100644 --- a/src/jsutils/__tests__/Path-test.ts +++ b/src/jsutils/__tests__/Path-test.ts @@ -1,13 +1,13 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { addPath, pathToArray } from '../Path.js'; +import { Path, pathToArray } from '../Path.js'; describe('Path', () => { it('can create a Path', () => { - const first = addPath(undefined, 1, 'First'); + const first = new Path(undefined, 1, 'First'); - expect(first).to.deep.equal({ + expect(first).to.deep.include({ prev: undefined, key: 1, typename: 'First', @@ -15,10 +15,10 @@ describe('Path', () => { }); it('can add a new key to an existing Path', () => { - const first = addPath(undefined, 1, 'First'); - const second = addPath(first, 'two', 'Second'); + const first = new Path(undefined, 1, 'First'); + const second = first.addPath('two', 'Second'); - expect(second).to.deep.equal({ + expect(second).to.deep.include({ prev: first, key: 'two', typename: 'Second', @@ -26,9 +26,9 @@ describe('Path', () => { }); it('can convert a Path to an array of its keys', () => { - const root = addPath(undefined, 0, 'Root'); - const first = addPath(root, 'one', 'First'); - const second = addPath(first, 2, 'Second'); + const root = new Path(undefined, 0, 'Root'); + const first = root.addPath('one', 'First'); + const second = first.addPath(2, 'Second'); const path = pathToArray(second); expect(path).to.deep.equal([0, 'one', 2]); diff --git a/src/type/definition.ts b/src/type/definition.ts index 81488efb39..83208b18d3 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -890,7 +890,12 @@ export type GraphQLFieldResolver< export interface GraphQLResolveInfo { readonly fieldName: string; - readonly fieldNodes: ReadonlyArray; + readonly fieldGroup: ReadonlyArray<{ + fieldNode: FieldNode; + depth: number; + deferDepth: number | undefined; + }>; + readonly deferDepth: number | undefined; readonly returnType: GraphQLOutputType; readonly parentType: GraphQLObjectType; readonly path: Path; diff --git a/src/type/directives.ts b/src/type/directives.ts index 8fd5a6a62e..3f5664a96a 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -170,10 +170,6 @@ export const GraphQLDeferDirective = new GraphQLDirective({ description: 'Deferred when true or undefined.', defaultValue: true, }, - label: { - type: GraphQLString, - description: 'Unique name', - }, }, }); @@ -191,10 +187,6 @@ export const GraphQLStreamDirective = new GraphQLDirective({ description: 'Stream when true or undefined.', defaultValue: true, }, - label: { - type: GraphQLString, - description: 'Unique name', - }, initialCount: { defaultValue: 0, type: GraphQLInt, diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts index d1decf86a1..002f5d1192 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -3,8 +3,7 @@ import { inspect } from '../jsutils/inspect.js'; import { invariant } from '../jsutils/invariant.js'; import { isIterableObject } from '../jsutils/isIterableObject.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; -import type { Path } from '../jsutils/Path.js'; -import { addPath, pathToArray } from '../jsutils/Path.js'; +import { Path, pathToArray } from '../jsutils/Path.js'; import { printPathArray } from '../jsutils/printPathArray.js'; import { suggestionList } from '../jsutils/suggestionList.js'; @@ -48,6 +47,16 @@ function defaultOnError( throw error; } +function addPath( + path: Path | undefined, + key: string | number, + typeName: string | undefined, +): Path { + return path + ? path.addPath(key, typeName) + : new Path(undefined, key, typeName); +} + function coerceInputValueImpl( inputValue: unknown, type: GraphQLInputType, diff --git a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts deleted file mode 100644 index a5ffd1cfa9..0000000000 --- a/src/validation/__tests__/DeferStreamDirectiveLabelRule-test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, it } from 'mocha'; - -import { DeferStreamDirectiveLabelRule } from '../rules/DeferStreamDirectiveLabelRule.js'; - -import { expectValidationErrors } from './harness.js'; - -function expectErrors(queryStr: string) { - return expectValidationErrors(DeferStreamDirectiveLabelRule, queryStr); -} - -function expectValid(queryStr: string) { - expectErrors(queryStr).toDeepEqual([]); -} - -describe('Validate: Defer/Stream directive on root field', () => { - it('Defer fragments with no label', () => { - expectValid(` - { - dog { - ...dogFragmentA @defer - ...dogFragmentB @defer - } - } - fragment dogFragmentA on Dog { - name - } - fragment dogFragmentB on Dog { - nickname - } - `); - }); - - it('Defer fragments, one with label, one without', () => { - expectValid(` - { - dog { - ...dogFragmentA @defer(label: "fragA") - ...dogFragmentB @defer - } - } - fragment dogFragmentA on Dog { - name - } - fragment dogFragmentB on Dog { - nickname - } - `); - }); - - it('Defer fragment with variable label', () => { - expectErrors(` - query($label: String) { - dog { - ...dogFragmentA @defer(label: $label) - ...dogFragmentB @defer(label: "fragA") - } - } - fragment dogFragmentA on Dog { - name - } - fragment dogFragmentB on Dog { - nickname - } - `).toDeepEqual([ - { - message: 'Directive "defer"\'s label argument must be a static string.', - locations: [{ line: 4, column: 25 }], - }, - ]); - }); - - it('Defer fragments with different labels', () => { - expectValid(` - { - dog { - ...dogFragmentA @defer(label: "fragB") - ...dogFragmentB @defer(label: "fragA") - } - } - fragment dogFragmentA on Dog { - name - } - fragment dogFragmentB on Dog { - nickname - } - `); - }); - it('Defer fragments with same label', () => { - expectErrors(` - { - dog { - ...dogFragmentA @defer(label: "fragA") - ...dogFragmentB @defer(label: "fragA") - } - } - fragment dogFragmentA on Dog { - name - } - fragment dogFragmentB on Dog { - nickname - } - `).toDeepEqual([ - { - message: 'Defer/Stream directive label argument must be unique.', - locations: [ - { line: 4, column: 25 }, - { line: 5, column: 25 }, - ], - }, - ]); - }); - it('Defer and stream with no label', () => { - expectValid(` - { - dog { - ...dogFragment @defer - } - pets @stream(initialCount: 0) @stream { - name - } - } - fragment dogFragment on Dog { - name - } - `); - }); - it('Stream with variable label', () => { - expectErrors(` - query ($label: String!) { - dog { - ...dogFragment @defer - } - pets @stream(initialCount: 0) @stream(label: $label) { - name - } - } - fragment dogFragment on Dog { - name - } - `).toDeepEqual([ - { - message: - 'Directive "stream"\'s label argument must be a static string.', - locations: [{ line: 6, column: 39 }], - }, - ]); - }); - it('Defer and stream with the same label', () => { - expectErrors(` - { - dog { - ...dogFragment @defer(label: "MyLabel") - } - pets @stream(initialCount: 0) @stream(label: "MyLabel") { - name - } - } - fragment dogFragment on Dog { - name - } - `).toDeepEqual([ - { - message: 'Defer/Stream directive label argument must be unique.', - locations: [ - { line: 4, column: 26 }, - { line: 6, column: 39 }, - ], - }, - ]); - }); -}); diff --git a/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts index 52c2deb1a0..66e7a7721c 100644 --- a/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts +++ b/src/validation/__tests__/OverlappingFieldsCanBeMergedRule-test.ts @@ -101,35 +101,17 @@ describe('Validate: Overlapping fields can be merged', () => { it('Same stream directives supported', () => { expectValid(` fragment differentDirectivesWithDifferentAliases on Dog { - name @stream(label: "streamLabel", initialCount: 1) - name @stream(label: "streamLabel", initialCount: 1) + name @stream(initialCount: 1) + name @stream(initialCount: 1) } `); }); - it('different stream directive label', () => { - expectErrors(` - fragment conflictingArgs on Dog { - name @stream(label: "streamLabel", initialCount: 1) - name @stream(label: "anotherLabel", initialCount: 1) - } - `).toDeepEqual([ - { - message: - 'Fields "name" conflict because they have differing stream directives. Use different aliases on the fields to fetch both if this was intentional.', - locations: [ - { line: 3, column: 9 }, - { line: 4, column: 9 }, - ], - }, - ]); - }); - it('different stream directive initialCount', () => { expectErrors(` fragment conflictingArgs on Dog { - name @stream(label: "streamLabel", initialCount: 1) - name @stream(label: "streamLabel", initialCount: 2) + name @stream(initialCount: 1) + name @stream(initialCount: 2) } `).toDeepEqual([ { @@ -147,7 +129,7 @@ describe('Validate: Overlapping fields can be merged', () => { expectErrors(` fragment conflictingArgs on Dog { name @stream - name @stream(label: "streamLabel", initialCount: 1) + name @stream(initialCount: 1) } `).toDeepEqual([ { @@ -164,7 +146,7 @@ describe('Validate: Overlapping fields can be merged', () => { it('different stream directive second missing args', () => { expectErrors(` fragment conflictingArgs on Dog { - name @stream(label: "streamLabel", initialCount: 1) + name @stream(initialCount: 1) name @stream } `).toDeepEqual([ diff --git a/src/validation/index.ts b/src/validation/index.ts index b0cc754490..ceea29e84b 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -6,9 +6,6 @@ export type { ValidationRule } from './ValidationContext.js'; // All validation rules in the GraphQL Specification. export { specifiedRules } from './specifiedRules.js'; -// Spec Section: "Defer And Stream Directive Labels Are Unique" -export { DeferStreamDirectiveLabelRule } from './rules/DeferStreamDirectiveLabelRule.js'; - // Spec Section: "Defer And Stream Directives Are Used On Valid Root Field" export { DeferStreamDirectiveOnRootFieldRule } from './rules/DeferStreamDirectiveOnRootFieldRule.js'; diff --git a/src/validation/rules/DeferStreamDirectiveLabelRule.ts b/src/validation/rules/DeferStreamDirectiveLabelRule.ts deleted file mode 100644 index a0fc3cc424..0000000000 --- a/src/validation/rules/DeferStreamDirectiveLabelRule.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { GraphQLError } from '../../error/GraphQLError.js'; - -import { Kind } from '../../language/kinds.js'; -import type { ASTVisitor } from '../../language/visitor.js'; - -import { - GraphQLDeferDirective, - GraphQLStreamDirective, -} from '../../type/directives.js'; - -import type { ValidationContext } from '../ValidationContext.js'; - -/** - * Defer and stream directive labels are unique - * - * A GraphQL document is only valid if defer and stream directives' label argument is static and unique. - */ -export function DeferStreamDirectiveLabelRule( - context: ValidationContext, -): ASTVisitor { - const knownLabels = Object.create(null); - return { - Directive(node) { - if ( - node.name.value === GraphQLDeferDirective.name || - node.name.value === GraphQLStreamDirective.name - ) { - const labelArgument = node.arguments?.find( - (arg) => arg.name.value === 'label', - ); - const labelValue = labelArgument?.value; - if (!labelValue) { - return; - } - if (labelValue.kind !== Kind.STRING) { - context.reportError( - new GraphQLError( - `Directive "${node.name.value}"'s label argument must be a static string.`, - { nodes: node }, - ), - ); - } else if (knownLabels[labelValue.value]) { - context.reportError( - new GraphQLError( - 'Defer/Stream directive label argument must be unique.', - { nodes: [knownLabels[labelValue.value], node] }, - ), - ); - } else { - knownLabels[labelValue.value] = node; - } - } - }, - }; -} diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index 4a3d834124..9406b119da 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -41,27 +41,30 @@ export function SingleFieldSubscriptionsRule( fragments[definition.name.value] = definition; } } - const { fields } = collectFields( + const { groupedFieldSet } = collectFields( schema, fragments, variableValues, subscriptionType, node, ); - if (fields.size > 1) { - const fieldSelectionLists = [...fields.values()]; - const extraFieldSelectionLists = fieldSelectionLists.slice(1); - const extraFieldSelections = extraFieldSelectionLists.flat(); + if (groupedFieldSet.size > 1) { + const fieldGroups = [...groupedFieldSet.values()]; + const extraFieldGroups = fieldGroups.slice(1); + const extraFields = extraFieldGroups + .flat() + .map(({ fieldNode }) => fieldNode); context.reportError( new GraphQLError( operationName != null ? `Subscription "${operationName}" must select only one top level field.` : 'Anonymous Subscription must select only one top level field.', - { nodes: extraFieldSelections }, + { nodes: extraFields }, ), ); } - for (const fieldNodes of fields.values()) { + for (const fieldSet of groupedFieldSet.values()) { + const fieldNodes = fieldSet.map(({ fieldNode }) => fieldNode); const fieldName = fieldNodes[0].name.value; if (fieldName.startsWith('__')) { context.reportError( diff --git a/src/validation/specifiedRules.ts b/src/validation/specifiedRules.ts index 60c967f8f0..6187ac0182 100644 --- a/src/validation/specifiedRules.ts +++ b/src/validation/specifiedRules.ts @@ -1,5 +1,3 @@ -// Spec Section: "Defer And Stream Directive Labels Are Unique" -import { DeferStreamDirectiveLabelRule } from './rules/DeferStreamDirectiveLabelRule.js'; // Spec Section: "Defer And Stream Directives Are Used On Valid Root Field" import { DeferStreamDirectiveOnRootFieldRule } from './rules/DeferStreamDirectiveOnRootFieldRule.js'; // Spec Section: "Defer And Stream Directives Are Used On Valid Operations" @@ -103,7 +101,6 @@ export const specifiedRules: ReadonlyArray = Object.freeze([ UniqueDirectivesPerLocationRule, DeferStreamDirectiveOnRootFieldRule, DeferStreamDirectiveOnValidOperationsRule, - DeferStreamDirectiveLabelRule, StreamDirectiveOnListFieldRule, KnownArgumentNamesRule, UniqueArgumentNamesRule,