diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 31ddf9e6c0..04dbdfa296 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -37,7 +37,79 @@ const friends = [ { name: 'C-3PO', id: 4 }, ]; -const hero = { name: 'Luke', id: 1, friends }; +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: { type: GraphQLString, resolve: () => 'foo' }, + }, + name: 'NestedObject', +}); + +const anotherNestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject, resolve: () => ({}) }, + }, + name: 'AnotherNestedObject', +}); + +const hero = { + name: 'Luke', + id: 1, + friends, + nestedObject, + anotherNestedObject, +}; + +const c = new GraphQLObjectType({ + fields: { + d: { type: GraphQLString, resolve: () => 'd' }, + nonNullErrorField: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => null, + }, + }, + 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: () => ({}) }, + someField: { type: GraphQLString, resolve: () => 'someField' }, + }, + name: 'a', +}); + +const g = new GraphQLObjectType({ + fields: { + h: { type: GraphQLString, resolve: () => 'h' }, + }, + name: 'g', +}); const heroType = new GraphQLObjectType({ fields: { @@ -47,6 +119,8 @@ const heroType = new GraphQLObjectType({ friends: { type: new GraphQLList(friendType), }, + nestedObject: { type: nestedObject }, + anotherNestedObject: { type: anotherNestedObject }, }, name: 'Hero', }); @@ -56,6 +130,8 @@ const query = new GraphQLObjectType({ hero: { type: heroType, }, + a: { type: a, resolve: () => ({}) }, + g: { type: g, resolve: () => ({}) }, }, name: 'Query', }); @@ -282,17 +358,17 @@ describe('Execute: defer directive', () => { incremental: [ { data: { - friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + id: '1', }, path: ['hero'], - label: 'DeferNested', + label: 'DeferTop', }, { data: { - id: '1', + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], }, path: ['hero'], - label: 'DeferTop', + label: 'DeferNested', }, ], hasNext: false, @@ -398,6 +474,1125 @@ describe('Execute: defer directive', () => { }, ]); }); + + it('Emits empty defer fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ... @defer { + name @skip(if: true) + } + } + } + fragment TopFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: {}, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Can separately emit defer fragments with different labels with varying fields', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ... @defer(label: "DeferID") { + id + } + ... @defer(label: "DeferName") { + name + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + id: '1', + }, + path: ['hero'], + label: 'DeferID', + }, + { + data: { + name: 'Luke', + }, + path: ['hero'], + label: 'DeferName', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Separately emits defer fragments with different labels with varying subfields', async () => { + const document = parse(` + query HeroNameQuery { + ... @defer(label: "DeferID") { + hero { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + id: '1', + }, + }, + path: [], + label: 'DeferID', + }, + { + data: { + hero: { + name: 'Luke', + }, + }, + path: [], + label: 'DeferName', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Separately emits defer fragments with varying subfields of same priorities but different level of defers', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ... @defer(label: "DeferID") { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + name: 'Luke', + }, + }, + path: [], + label: 'DeferName', + }, + { + data: { + id: '1', + }, + path: ['hero'], + label: 'DeferID', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Separately emits nested defer fragments with varying subfields of same priorities but different level of defers', async () => { + const document = parse(` + query HeroNameQuery { + ... @defer(label: "DeferName") { + hero { + name + ... @defer(label: "DeferID") { + id + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + name: 'Luke', + }, + }, + path: [], + label: 'DeferName', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { + id: '1', + }, + path: ['hero'], + label: 'DeferID', + }, + ], + 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: { 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 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 present in a parent defer payload', async () => { + const document = parse(` + query { + hero { + ... @defer { + nestedObject { + deeperObject { + foo + ... @defer { + foo + bar + } + } + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + nestedObject: { + deeperObject: { + foo: 'foo', + }, + }, + }, + path: ['hero'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { + foo: 'foo', + bar: 'bar', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + 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: { + nestedObject: { + deeperObject: { + foo: 'foo', + bar: 'bar', + }, + }, + }, + path: ['hero'], + }, + { + data: { + deeperObject: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + }, + }, + path: ['hero', 'nestedObject'], + }, + { + data: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + bak: 'bak', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + 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: { + nestedObject: { + deeperObject: {}, + }, + }, + path: ['hero'], + }, + { + data: { + foo: 'foo', + bar: 'bar', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not 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: { + a: { + b: { + e: { + f: 'f', + }, + }, + }, + g: { + h: 'h', + }, + }, + path: [], + }, + { + data: { + e: { + f: 'f', + }, + }, + path: ['a', 'b'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Preserves error boundaries, null first', async () => { + const document = parse(` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + a: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + a: { + b: { + c: null, + }, + someField: 'someField', + }, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 8, column: 17 }], + path: ['a', 'b', 'c', 'nonNullErrorField'], + }, + ], + path: [], + }, + { + data: { + b: { + c: { + d: 'd', + }, + }, + }, + path: ['a'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Preserves error boundaries, value first', async () => { + const document = parse(` + query { + ... @defer { + a { + b { + c { + d + } + } + } + } + a { + ... @defer { + someField + b { + c { + nonNullErrorField + } + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + a: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + a: { + b: { + c: { + d: 'd', + }, + }, + }, + }, + path: [], + }, + { + data: { + b: { + c: null, + }, + someField: 'someField', + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 17, column: 17 }], + path: ['a', 'b', 'c', 'nonNullErrorField'], + }, + ], + path: ['a'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Cancels deferred fields when initial result exhibits null bubbling', async () => { + const document = parse(` + query { + hero { + nonNullName + } + ... @defer { + hero { + name + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + nonNullName: () => null, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + hero: null, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [{ line: 4, column: 11 }], + path: ['hero', 'nonNullName'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: null, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [{ line: 4, column: 11 }], + path: ['hero', 'nonNullName'], + }, + ], + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Cancels deferred fields when deferred result exhibits null bubbling', async () => { + const document = parse(` + query { + ... @defer { + hero { + nonNullName + name + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + nonNullName: () => null, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: null, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [{ line: 5, column: 13 }], + path: ['hero', 'nonNullName'], + }, + ], + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate list fields', async () => { + const document = parse(` + query { + hero { + friends { + name + } + ... @defer { + friends { + name + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate async iterable list fields', async () => { + const document = parse(` + query { + hero { + friends { + name + } + ... @defer { + friends { + name + } + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + friends: async function* resolve() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { friends: [{ name: 'Han' }] } }, + hasNext: true, + }, + { + incremental: [ + { + data: { friends: [{ name: 'Han' }] }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate empty async iterable list fields', async () => { + const document = parse(` + query { + hero { + friends { + name + } + ... @defer { + friends { + name + } + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + // eslint-disable-next-line require-yield + friends: async function* resolve() { + await resolveOnNextTick(); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { friends: [] } }, + hasNext: true, + }, + { + incremental: [ + { + data: { friends: [] }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate list fields with non-overlapping fields', async () => { + const document = parse(` + query { + hero { + friends { + name + } + ... @defer { + friends { + id + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + friends: [{ id: '2' }, { id: '3' }, { id: '4' }], + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate list fields that return empty lists', async () => { + const document = parse(` + query { + hero { + friends { + name + } + ... @defer { + friends { + name + } + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + friends: () => [], + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { friends: [] } }, + hasNext: true, + }, + { + incremental: [ + { + data: { friends: [] }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate null object fields', async () => { + const document = parse(` + query { + hero { + nestedObject { + name + } + ... @defer { + nestedObject { + name + } + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + nestedObject: () => null, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { nestedObject: null } }, + hasNext: true, + }, + { + incremental: [ + { + data: { nestedObject: null }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not deduplicate promise object fields', async () => { + const document = parse(` + query { + hero { + nestedObject { + name + } + ... @defer { + nestedObject { + name + } + } + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + nestedObject: () => Promise.resolve({}), + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { nestedObject: { name: 'foo' } } }, + hasNext: true, + }, + { + incremental: [ + { + data: { nestedObject: { name: 'foo' } }, + path: ['hero'], + }, + ], + 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 c29b4ae60d..91f8882391 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: { @@ -239,13 +241,22 @@ describe('Execute: Handles basic execution tasks', () => { const field = operation.selectionSet.selections[0]; expect(resolvedInfo).to.deep.include({ fieldNodes: [field], - path: { prev: undefined, key: 'result', typename: 'Test' }, variableValues: { var: 'abc' }, }); + + expect(resolvedInfo?.path).to.deep.include({ + prev: undefined, + key: 'result', + }); + + expect(resolvedInfo?.path.info).to.deep.include({ + parentType: testType, + fieldName: 'test', + }); }); it('populates path correctly with complex types', () => { - let path; + let path: Path | undefined; const someObject = new GraphQLObjectType({ name: 'SomeObject', fields: { @@ -288,18 +299,31 @@ 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?.info).to.deep.include({ + parentType: someObject, + fieldName: 'test', + }); + + expect(path?.prev).to.deep.include({ + key: 0, + }); + + expect(path?.prev?.info).to.deep.include({ + parentType: testType, + fieldName: 'test', + }); + + expect(path?.prev?.prev).to.deep.include({ + key: 'l1', + }); + + expect(path?.prev?.prev?.info).to.deep.include({ + parentType: testType, + fieldName: 'test', }); }); diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 2b9ad82721..a266913510 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -2,6 +2,7 @@ import { assert } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; @@ -1134,7 +1135,7 @@ describe('Execute: stream directive', () => { }, ]); }); - it('Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list', async () => { + it('Handles async errors thrown by completeValue after initialCount is reached from async generator for a non-nullable list', async () => { const document = parse(` query { nonNullFriendList @stream(initialCount: 1) { @@ -1181,6 +1182,158 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable does not provide a return method) ', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + let count = 0; + const result = await complete(document, { + nonNullFriendList: { + [Symbol.asyncIterator]: () => ({ + next: async () => { + switch (count++) { + case 0: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[0].name }, + }); + case 1: + return Promise.resolve({ + done: false, + value: { + nonNullName: () => Promise.reject(new Error('Oops')), + }, + }); + case 2: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[1].name }, + }); + // Not reached + /* c8 ignore next 5 */ + case 3: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[2].name }, + }); + } + }, + }), + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Handles async errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list when the async iterable provides concurrent next/return methods and has a slow return ', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + let count = 0; + let returned = false; + const result = await complete(document, { + nonNullFriendList: { + [Symbol.asyncIterator]: () => ({ + next: async () => { + /* c8 ignore next 3 */ + if (returned) { + return Promise.resolve({ done: true }); + } + switch (count++) { + case 0: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[0].name }, + }); + case 1: + return Promise.resolve({ + done: false, + value: { + nonNullName: () => Promise.reject(new Error('Oops')), + }, + }); + case 2: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[1].name }, + }); + // Not reached + /* c8 ignore next 5 */ + case 3: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[2].name }, + }); + } + }, + return: async () => { + await resolveOnNextTick(); + returned = true; + return { done: true }; + }, + }), + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + path: ['nonNullFriendList', 1], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); it('Filters payloads that are nulled', async () => { const document = parse(` query { @@ -1359,9 +1512,6 @@ describe('Execute: stream directive', () => { ], }, ], - hasNext: true, - }, - { hasNext: false, }, ]); @@ -1421,10 +1571,11 @@ describe('Execute: stream directive', () => { const iterable = { [Symbol.asyncIterator]: () => ({ next: () => { + /* c8 ignore start */ if (requested) { - // Ignores further errors when filtered. + // stream is filtered, next is not called, and so this is not reached. return Promise.reject(new Error('Oops')); - } + } /* c8 ignore stop */ requested = true; const friend = friends[0]; return Promise.resolve({ @@ -1563,6 +1714,70 @@ describe('Execute: stream directive', () => { }, ]); }); + it('Handles overlapping deferred and non-deferred streams', async () => { + const document = parse(` + query { + nestedObject { + nestedFriendList @stream(initialCount: 0) { + id + } + } + nestedObject { + ... @defer { + nestedFriendList @stream(initialCount: 0) { + id + name + } + } + } + } + `); + const result = await complete(document, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nestedObject: { + nestedFriendList: [], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + nestedFriendList: [], + }, + path: ['nestedObject'], + }, + { + items: [{ id: '1', name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2', name: 'Han' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); it('Returns payloads in correct order when parent deferred fragment resolves slower than stream', async () => { const [slowFieldPromise, resolveSlowField] = createResolvablePromise(); const document = parse(` diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index af263112ec..aae91456cf 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -26,18 +26,28 @@ import { typeFromAST } from '../utilities/typeFromAST.js'; import { getDirectiveValues } from './values.js'; -export type FieldGroup = ReadonlyArray; +export interface DeferUsage { + label: string | undefined; +} + +export interface FieldGroup { + parentType: GraphQLObjectType; + fieldName: string; + fields: Map>; + inInitialResult: boolean; + shouldInitiateDefer: boolean; +} +interface MutableFieldGroup extends FieldGroup { + fields: AccumulatorMap; +} export type GroupedFieldSet = Map; -export interface PatchFields { - label: string | undefined; - groupedFieldSet: GroupedFieldSet; -} +type MutableGroupedFieldSet = Map; -export interface FieldsAndPatches { +export interface CollectFieldsResult { groupedFieldSet: GroupedFieldSet; - patches: Array; + deferUsages: ReadonlyArray; } /** @@ -55,9 +65,10 @@ export function collectFields( variableValues: { [variable: string]: unknown }, runtimeType: GraphQLObjectType, operation: OperationDefinitionNode, -): FieldsAndPatches { - const groupedFieldSet = new AccumulatorMap(); - const patches: Array = []; +): CollectFieldsResult { + const groupedFieldSet = new Map(); + const deferUsages = new Map(); + collectFieldsImpl( schema, fragments, @@ -66,10 +77,14 @@ export function collectFields( runtimeType, operation.selectionSet, groupedFieldSet, - patches, + deferUsages, new Set(), ); - return { groupedFieldSet, patches }; + + return { + groupedFieldSet, + deferUsages: Array.from(deferUsages.values()), + }; } /** @@ -90,32 +105,34 @@ export function collectSubfields( operation: OperationDefinitionNode, returnType: GraphQLObjectType, fieldGroup: FieldGroup, -): FieldsAndPatches { - const subGroupedFieldSet = new AccumulatorMap(); +): CollectFieldsResult { + const subGroupedFieldSet = new Map(); + const deferUsages = new Map(); const visitedFragmentNames = new Set(); - const subPatches: Array = []; - const subFieldsAndPatches = { - groupedFieldSet: subGroupedFieldSet, - patches: subPatches, - }; - - for (const node of fieldGroup) { - if (node.selectionSet) { - collectFieldsImpl( - schema, - fragments, - variableValues, - operation, - returnType, - node.selectionSet, - subGroupedFieldSet, - subPatches, - visitedFragmentNames, - ); + for (const [deferUsage, fieldNodes] of fieldGroup.fields) { + for (const node of fieldNodes) { + if (node.selectionSet) { + collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + returnType, + node.selectionSet, + subGroupedFieldSet, + deferUsages, + visitedFragmentNames, + deferUsage, + ); + } } } - return subFieldsAndPatches; + + return { + groupedFieldSet: subGroupedFieldSet, + deferUsages: Array.from(deferUsages.values()), + }; } // eslint-disable-next-line max-params @@ -126,9 +143,11 @@ function collectFieldsImpl( operation: OperationDefinitionNode, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - groupedFieldSet: AccumulatorMap, - patches: Array, + groupedFieldSet: MutableGroupedFieldSet, + deferUsages: Map, visitedFragmentNames: Set, + parentDeferUsage?: DeferUsage | undefined, + newDeferUsage?: DeferUsage | undefined, ): void { for (const selection of selectionSet.selections) { switch (selection.kind) { @@ -136,7 +155,40 @@ function collectFieldsImpl( if (!shouldIncludeNode(variableValues, selection)) { continue; } - groupedFieldSet.add(getFieldEntryKey(selection), selection); + const key = getFieldEntryKey(selection); + const fieldGroup = groupedFieldSet.get(key); + if (fieldGroup) { + fieldGroup.fields.add(newDeferUsage ?? parentDeferUsage, selection); + if (newDeferUsage === undefined) { + if (parentDeferUsage === undefined) { + fieldGroup.inInitialResult = true; + } + fieldGroup.shouldInitiateDefer = false; + } + } else { + const fields = new AccumulatorMap< + DeferUsage | undefined, + FieldNode + >(); + fields.add(newDeferUsage ?? parentDeferUsage, selection); + + let inInitialResult = false; + let shouldInitiateDefer = true; + if (newDeferUsage === undefined) { + if (parentDeferUsage === undefined) { + inInitialResult = true; + } + shouldInitiateDefer = false; + } + + groupedFieldSet.set(key, { + parentType: runtimeType, + fieldName: selection.name.value, + fields, + inInitialResult, + shouldInitiateDefer, + }); + } break; } case Kind.INLINE_FRAGMENT: { @@ -149,24 +201,7 @@ function collectFieldsImpl( 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, - groupedFieldSet: patchFields, - }); - } else { + if (!defer) { collectFieldsImpl( schema, fragments, @@ -175,10 +210,27 @@ function collectFieldsImpl( runtimeType, selection.selectionSet, groupedFieldSet, - patches, + deferUsages, visitedFragmentNames, + parentDeferUsage, + newDeferUsage, ); + break; } + + collectDeferredFragmentFields( + schema, + fragments, + variableValues, + operation, + runtimeType, + selection.selectionSet, + groupedFieldSet, + deferUsages, + visitedFragmentNames, + defer, + parentDeferUsage, + ); break; } case Kind.FRAGMENT_SPREAD: { @@ -203,26 +255,6 @@ function collectFieldsImpl( if (!defer) { 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, - groupedFieldSet: patchFields, - }); - } else { collectFieldsImpl( schema, fragments, @@ -231,16 +263,82 @@ function collectFieldsImpl( runtimeType, fragment.selectionSet, groupedFieldSet, - patches, + deferUsages, visitedFragmentNames, + parentDeferUsage, + newDeferUsage, ); + break; } + + collectDeferredFragmentFields( + schema, + fragments, + variableValues, + operation, + runtimeType, + fragment.selectionSet, + groupedFieldSet, + deferUsages, + visitedFragmentNames, + defer, + parentDeferUsage, + ); break; } } } } +// eslint-disable-next-line max-params +function collectDeferredFragmentFields( + schema: GraphQLSchema, + fragments: ObjMap, + variableValues: { [variable: string]: unknown }, + operation: OperationDefinitionNode, + runtimeType: GraphQLObjectType, + selectionSet: SelectionSetNode, + groupedFieldSet: MutableGroupedFieldSet, + deferUsages: Map, + visitedFragmentNames: Set, + defer: { label: string | undefined }, + parentDeferUsage?: DeferUsage | undefined, +): void { + const existingNewDefer = deferUsages.get(defer.label); + if (existingNewDefer !== undefined) { + collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + runtimeType, + selectionSet, + groupedFieldSet, + deferUsages, + visitedFragmentNames, + parentDeferUsage, + existingNewDefer, + ); + return; + } + + const newDefer = { ...defer }; + deferUsages.set(defer.label, newDefer); + collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + runtimeType, + selectionSet, + groupedFieldSet, + deferUsages, + visitedFragmentNames, + parentDeferUsage, + newDefer, + ); +} + /** * Returns an object containing the `@defer` arguments if a field should be * deferred based on the experimental flag, defer directive present and diff --git a/src/execution/execute.ts b/src/execution/execute.ts index df048480ba..c16bf709e0 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,3 +1,4 @@ +import { AccumulatorMap } from '../jsutils/AccumulatorMap.js'; import { inspect } from '../jsutils/inspect.js'; import { invariant } from '../jsutils/invariant.js'; import { isAsyncIterable } from '../jsutils/isAsyncIterable.js'; @@ -19,6 +20,7 @@ import { locatedError } from '../error/locatedError.js'; import type { DocumentNode, + FieldNode, FragmentDefinitionNode, OperationDefinitionNode, } from '../language/ast.js'; @@ -41,13 +43,18 @@ import { isLeafType, isListType, isNonNullType, + isNullableType, isObjectType, } from '../type/definition.js'; 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 type { + DeferUsage, + FieldGroup, + GroupedFieldSet, +} from './collectFields.js'; import { collectFields, collectSubfields as _collectSubfields, @@ -122,6 +129,7 @@ export interface ExecutionContext { subscribeFieldResolver: GraphQLFieldResolver; errors: Array; subsequentPayloads: Set; + streams: Set; } /** @@ -263,12 +271,28 @@ export interface ExecutionArgs { subscribeFieldResolver?: Maybe>; } +export interface StreamUsage { + label: string | undefined; + initialCount: number; + fieldGroup: FieldGroup; +} + +declare module './collectFields.js' { + export interface FieldGroup { + // for memoization + _streamUsage?: StreamUsage | undefined; + } +} + const UNEXPECTED_EXPERIMENTAL_DIRECTIVES = 'The provided schema unexpectedly contains experimental directives (@defer or @stream). These directives may only be utilized if experimental execution features are explicitly enabled.'; const UNEXPECTED_MULTIPLE_PAYLOADS = 'Executing this GraphQL operation would unexpectedly produce multiple payloads (due to @defer or @stream directive)'; +const OBJECT_VALUE = Object.create(null); +const ARRAY_VALUE: Array = []; + /** * Implements the "Executing requests" section of the GraphQL specification. * @@ -504,6 +528,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, subsequentPayloads: new Set(), + streams: new Set(), errors: [], }; } @@ -515,7 +540,7 @@ function buildPerEventExecutionContext( return { ...exeContext, rootValue: payload, - subsequentPayloads: new Set(), + // no need to override subsequentPayloads/streams as incremental delivery is not enabled for subscriptions errors: [], }; } @@ -536,7 +561,7 @@ function executeOperation( ); } - const { groupedFieldSet, patches } = collectFields( + const collectFieldsResult = collectFields( schema, fragments, variableValues, @@ -546,6 +571,20 @@ function executeOperation( const path = undefined; let result; + const { groupedFieldSet, deferUsages } = collectFieldsResult; + + const deferredFragmentRecords: Array = []; + const newDefers = new Map(); + for (const deferUsage of deferUsages) { + const deferredFragmentRecord = new DeferredFragmentRecord({ + deferUsage, + path, + exeContext, + }); + deferredFragmentRecords.push(deferredFragmentRecord); + newDefers.set(deferUsage, deferredFragmentRecord); + } + switch (operation.operation) { case OperationTypeNode.QUERY: result = executeFields( @@ -554,6 +593,7 @@ function executeOperation( rootValue, path, groupedFieldSet, + newDefers, ); break; case OperationTypeNode.MUTATION: @@ -563,6 +603,7 @@ function executeOperation( rootValue, path, groupedFieldSet, + newDefers, ); break; case OperationTypeNode.SUBSCRIPTION: @@ -574,19 +615,12 @@ function executeOperation( rootValue, path, groupedFieldSet, + newDefers, ); } - for (const patch of patches) { - const { label, groupedFieldSet: patchGroupedFieldSet } = patch; - executeDeferredFragment( - exeContext, - rootType, - rootValue, - patchGroupedFieldSet, - label, - path, - ); + for (const deferredFragmentRecord of deferredFragmentRecords) { + deferredFragmentRecord.completeIfReady(); } return result; @@ -600,29 +634,61 @@ function executeFieldsSerially( exeContext: ExecutionContext, parentType: GraphQLObjectType, sourceValue: unknown, - path: Path | undefined, - fields: GroupedFieldSet, + path: Path | undefined, + groupedFieldSet: GroupedFieldSet, + deferMap: Map, ): PromiseOrValue> { return promiseReduce( - fields, + groupedFieldSet, (results, [responseName, fieldGroup]) => { - const fieldPath = addPath(path, responseName, parentType.name); + const fieldPath = addPath(path, responseName, fieldGroup); + + const fieldDef = exeContext.schema.getField( + parentType, + fieldGroup.fieldName, + ); + if (!fieldDef) { + return results; + } + + addPendingDeferredField(fieldGroup, fieldPath, deferMap); + + if (fieldGroup.shouldInitiateDefer) { + executeDeferredField( + exeContext, + parentType, + sourceValue, + fieldGroup, + fieldDef, + fieldPath, + deferMap, + ); + return results; + } + const result = executeField( exeContext, parentType, sourceValue, fieldGroup, + fieldDef, fieldPath, + deferMap, ); - if (result === undefined) { + + // TODO: add test for this case + /* c8 ignore next 3 */ + if (!fieldGroup.inInitialResult) { return results; } + if (isPromise(result)) { return result.then((resolvedResult) => { results[responseName] = resolvedResult; return results; }); } + results[responseName] = result; return results; }, @@ -638,26 +704,75 @@ function executeFields( exeContext: ExecutionContext, parentType: GraphQLObjectType, sourceValue: unknown, - path: Path | undefined, - fields: GroupedFieldSet, - asyncPayloadRecord?: AsyncPayloadRecord, + path: Path | undefined, + groupedFieldSet: GroupedFieldSet, + deferMap: Map, + streamRecord?: StreamRecord | undefined, + parentRecords?: Array | undefined, ): PromiseOrValue> { + const contextByFieldGroup = new Map< + string, + { + fieldGroup: FieldGroup; + fieldPath: Path; + fieldDef: GraphQLField; + } + >(); + + for (const [responseName, fieldGroup] of groupedFieldSet) { + const fieldPath = addPath(path, responseName, fieldGroup); + const fieldDef = exeContext.schema.getField( + parentType, + fieldGroup.fieldName, + ); + if (!fieldDef) { + continue; + } + + const fieldGroupContext = { + fieldGroup, + fieldPath, + fieldDef, + }; + contextByFieldGroup.set(responseName, fieldGroupContext); + addPendingDeferredField(fieldGroup, fieldPath, deferMap); + } + const results = Object.create(null); let containsPromise = false; try { - for (const [responseName, fieldGroup] of fields) { - const fieldPath = addPath(path, responseName, parentType.name); + for (const [responseName, context] of contextByFieldGroup) { + const { fieldGroup, fieldPath, fieldDef } = context; + + if (fieldGroup.shouldInitiateDefer) { + executeDeferredField( + exeContext, + parentType, + sourceValue, + fieldGroup, + fieldDef, + fieldPath, + deferMap, + streamRecord, + parentRecords, + ); + continue; + } + const result = executeField( exeContext, parentType, sourceValue, fieldGroup, + fieldDef, fieldPath, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); - if (result !== undefined) { + if (fieldGroup.inInitialResult) { results[responseName] = result; if (isPromise(result)) { containsPromise = true; @@ -685,6 +800,38 @@ function executeFields( return promiseForObject(results); } +function toNodes(fieldGroup: FieldGroup): ReadonlyArray { + return Array.from(fieldGroup.fields.values()).flat(); +} + +function executeDeferredField( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + source: unknown, + fieldGroup: FieldGroup, + fieldDef: GraphQLField, + path: Path, + deferMap: Map, + streamRecord?: StreamRecord | undefined, + parentRecords?: Array | undefined, +): void { + // executeField only throws with a field in the initial result + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(() => + executeField( + exeContext, + parentType, + source, + fieldGroup, + fieldDef, + path, + deferMap, + streamRecord, + parentRecords, + ), + ); +} + /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by @@ -696,15 +843,12 @@ function executeField( parentType: GraphQLObjectType, source: unknown, fieldGroup: FieldGroup, - path: Path, - asyncPayloadRecord?: AsyncPayloadRecord, + fieldDef: GraphQLField, + path: Path, + deferMap: Map, + streamRecord?: StreamRecord | undefined, + parentRecords?: Array | undefined, ): PromiseOrValue { - const fieldName = fieldGroup[0].name.value; - const fieldDef = exeContext.schema.getField(parentType, fieldName); - if (!fieldDef) { - return; - } - const returnType = fieldDef.type; const resolveFn = fieldDef.resolve ?? exeContext.fieldResolver; @@ -723,7 +867,7 @@ function executeField( // TODO: find a way to memoize, in case this field is within a List type. const args = getArgumentValues( fieldDef, - fieldGroup[0], + toNodes(fieldGroup)[0], exeContext.variableValues, ); @@ -742,7 +886,9 @@ function executeField( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -753,7 +899,9 @@ function executeField( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completed)) { @@ -766,9 +914,10 @@ function executeField( returnType, fieldGroup, path, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, path, parentRecords); return null; }); } @@ -780,9 +929,10 @@ function executeField( returnType, fieldGroup, path, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, path, parentRecords); return null; } } @@ -796,13 +946,13 @@ export function buildResolveInfo( fieldDef: GraphQLField, fieldGroup: FieldGroup, parentType: GraphQLObjectType, - path: Path, + path: Path, ): GraphQLResolveInfo { // The resolve function's optional fourth argument is a collection of // information about the current execution state. return { fieldName: fieldDef.name, - fieldNodes: fieldGroup, + fieldNodes: toNodes(fieldGroup), returnType: fieldDef.type, parentType, path, @@ -819,10 +969,17 @@ function handleFieldError( exeContext: ExecutionContext, returnType: GraphQLOutputType, fieldGroup: FieldGroup, - path: Path, - asyncPayloadRecord?: AsyncPayloadRecord | undefined, + path: Path, + deferMap: Map, + streamRecord: StreamRecord | undefined, ): void { - const error = locatedError(rawError, fieldGroup, pathToArray(path)); + const error = locatedError(rawError, toNodes(fieldGroup), pathToArray(path)); + + addDeferredError(exeContext, error, fieldGroup, path, deferMap); + + if (!fieldGroup.inInitialResult) { + return; + } // If the field type is non-nullable, then it is resolved without any // protection from errors, however it still properly locates the error. @@ -830,13 +987,104 @@ function handleFieldError( throw error; } - const errors = asyncPayloadRecord?.errors ?? exeContext.errors; + const errors = streamRecord?.errors ?? exeContext.errors; // Otherwise, error protection is applied, logging the error and resolving // a null value for this field if one is encountered. errors.push(error); } +function getNullableParent( + exeContext: ExecutionContext, + path: Path, +): Path | undefined { + let depth = 0; + let fieldPath: Path = path; + + while (typeof fieldPath.key === 'number') { + invariant(fieldPath.prev !== undefined); + fieldPath = fieldPath.prev; + depth++; + } + + const fieldGroup = fieldPath.info; + + const type = fieldGroup.parentType; + const returnType = type.getFields()[fieldGroup.fieldName].type; + + if (depth > 0) { + const nullable: Array = []; + let outerType = returnType as GraphQLList; + for (let i = 0; i < depth; i++) { + const innerType = outerType.ofType; + nullable.unshift(isNullableType(innerType)); + outerType = innerType as GraphQLList; + } + let maybeNullablePath = fieldPath; + for (let i = 0; i < depth; i++) { + if (nullable[i]) { + return maybeNullablePath; + } + // safe as above + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + maybeNullablePath = maybeNullablePath.prev!; + } + } + + if (isNullableType(returnType)) { + return fieldPath; + } + + const parentPath = fieldPath.prev; + + if (parentPath === undefined) { + return undefined; + } + + return getNullableParent(exeContext, parentPath); +} + +function addDeferredError( + exeContext: ExecutionContext, + error: GraphQLError, + fieldGroup: FieldGroup, + path: Path, + deferMap: Map, +): void { + const nullablePath = getNullableParent(exeContext, path); + const nullablePathAsArray = pathToArray(nullablePath); + + const deferredFragmentRecords: Array = []; + const filterPaths = new Set | undefined>(); + + for (const [deferUsage] of fieldGroup.fields) { + if (deferUsage !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const deferredFragmentRecord = deferMap.get(deferUsage)!; + deferredFragmentRecords.push(deferredFragmentRecord); + + if ( + nullablePathAsArray.length <= deferredFragmentRecord.pathAsArray.length + ) { + filterPaths.add(deferredFragmentRecord.path); + deferredFragmentRecord.data = null; + deferredFragmentRecord.errors.push(error); + deferredFragmentRecord.complete(); + } else { + filterPaths.add(nullablePath); + // nullablePath cannot be undefined if it is longer than a deferredFragmentRecord path + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + deferredFragmentRecord.addError(nullablePath!, error); + deferredFragmentRecord.completeIfReady(); + } + } + } + + for (const filterPath of filterPaths) { + filterSubsequentPayloads(exeContext, filterPath, deferredFragmentRecords); + } +} + /** * Implements the instructions for completeValue as defined in the * "Value Completion" section of the spec. @@ -860,40 +1108,37 @@ function handleFieldError( */ function completeValue( exeContext: ExecutionContext, - returnType: GraphQLOutputType, + maybeReturnType: GraphQLOutputType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - path: Path, + path: Path, result: unknown, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): PromiseOrValue { // If result is an Error, throw a located error. if (result instanceof Error) { throw result; } - // If field type is NonNull, complete for inner type, and throw field error - // if result is null. - if (isNonNullType(returnType)) { - const completed = completeValue( - exeContext, - returnType.ofType, - fieldGroup, - info, - path, - result, - asyncPayloadRecord, - ); - if (completed === null) { + const returnType = isNonNullType(maybeReturnType) + ? maybeReturnType.ofType + : maybeReturnType; + + // If result value is null or undefined then return null. + if (result === null) { + if (returnType !== maybeReturnType) { + removePendingDeferredField(fieldGroup, path, deferMap); throw new Error( `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, ); } - return completed; + reportDeferredValue(null, fieldGroup, path, deferMap); + return null; } - // If result value is null or undefined then return null. - if (result == null) { + if (result === undefined) { return null; } @@ -906,14 +1151,18 @@ function completeValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } // If field type is a leaf type, Scalar or Enum, serialize to a valid value, // returning null if serialization is not possible. if (isLeafType(returnType)) { - return completeLeafValue(returnType, result); + const completed = completeLeafValue(returnType, result); + reportDeferredValue(completed, fieldGroup, path, deferMap); + return completed; } // If field type is an abstract type, Interface or Union, determine the @@ -926,7 +1175,9 @@ function completeValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -939,7 +1190,9 @@ function completeValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } /* c8 ignore next 6 */ @@ -950,14 +1203,59 @@ function completeValue( ); } +function addPendingDeferredField( + fieldGroup: FieldGroup, + path: Path, + deferMap: Map, +): void { + for (const [deferUsage] of fieldGroup.fields) { + if (deferUsage !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const deferredFragmentRecord = deferMap.get(deferUsage)!; + deferredFragmentRecord.addPendingField(path); + } + } +} + +function removePendingDeferredField( + fieldGroup: FieldGroup, + path: Path, + deferMap: Map, +): void { + for (const [deferUsage] of fieldGroup.fields) { + if (deferUsage) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const deferredFragmentRecord = deferMap.get(deferUsage)!; + deferredFragmentRecord.removePendingField(path); + } + } +} + +function reportDeferredValue( + result: unknown, + fieldGroup: FieldGroup, + path: Path, + deferMap: Map, +): void { + for (const [deferUsage] of fieldGroup.fields) { + if (deferUsage !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const deferredFragmentRecord = deferMap.get(deferUsage)!; + deferredFragmentRecord.reportDeferredValue(path, result); + } + } +} + async function completePromisedValue( exeContext: ExecutionContext, returnType: GraphQLOutputType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - path: Path, + path: Path, result: Promise, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): Promise { try { const resolved = await result; @@ -968,7 +1266,9 @@ async function completePromisedValue( info, path, resolved, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completed)) { completed = await completed; @@ -981,38 +1281,40 @@ async function completePromisedValue( returnType, fieldGroup, path, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, path, parentRecords); return null; } } /** - * Returns an object containing the `@stream` arguments if a field should be + * Returns an object containing info for streaming if a field should be * streamed based on the experimental flag, stream directive present and * not disabled by the "if" argument. */ -function getStreamValues( +function getStreamUsage( exeContext: ExecutionContext, fieldGroup: FieldGroup, - path: Path, -): - | undefined - | { - initialCount: number | undefined; - label: string | undefined; - } { + path: Path, +): StreamUsage | undefined { // do not stream inner lists of multi-dimensional lists if (typeof path.key === 'number') { return; } + // TODO: add test for this case (a streamed list nested under a list). + /* c8 ignore next 3 */ + if (fieldGroup._streamUsage !== undefined) { + return fieldGroup._streamUsage; + } + // validation only allows equivalent streams on multiple fields, so it is // safe to only check the first fieldNode for the stream directive const stream = getDirectiveValues( GraphQLStreamDirective, - fieldGroup[0], + toNodes(fieldGroup)[0], exeContext.variableValues, ); @@ -1039,12 +1341,27 @@ function getStreamValues( '`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.', ); - return { + const streamFields = new AccumulatorMap(); + for (const [, fieldNodes] of fieldGroup.fields) { + for (const node of fieldNodes) { + streamFields.add(undefined, node); + } + } + const streamedFieldGroup: FieldGroup = { + ...fieldGroup, + fields: streamFields, + }; + + const streamUsage = { initialCount: stream.initialCount, label: typeof stream.label === 'string' ? stream.label : undefined, + fieldGroup: streamedFieldGroup, }; -} + fieldGroup._streamUsage = streamUsage; + + return streamUsage; +} /** * Complete a async iterator value by completing the result and calling * recursively until all the results are completed. @@ -1054,42 +1371,49 @@ async function completeAsyncIteratorValue( itemType: GraphQLOutputType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - path: Path, + path: Path, iterator: AsyncIterator, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): Promise> { - const stream = getStreamValues(exeContext, fieldGroup, path); + const streamUsage = getStreamUsage(exeContext, fieldGroup, path); let containsPromise = false; const completedResults: Array = []; let index = 0; // eslint-disable-next-line no-constant-condition while (true) { - if ( - stream && - typeof stream.initialCount === 'number' && - index >= stream.initialCount - ) { + if (streamUsage && index >= streamUsage.initialCount) { + const streamContext: StreamContext = { + label: streamUsage.label, + path: pathToArray(path), + iterator, + }; + exeContext.streams.add(streamContext); // eslint-disable-next-line @typescript-eslint/no-floating-promises executeStreamAsyncIterator( index, iterator, exeContext, - fieldGroup, + streamUsage.fieldGroup, info, itemType, path, - stream.label, - asyncPayloadRecord, + streamContext, + deferMap, + parentRecords, ); break; } - const itemPath = addPath(path, index, undefined); + const itemPath = addPath(path, index, fieldGroup); let iteration; + addPendingDeferredField(fieldGroup, itemPath, deferMap); try { // eslint-disable-next-line no-await-in-loop iteration = await iterator.next(); if (iteration.done) { + removePendingDeferredField(fieldGroup, itemPath, deferMap); break; } } catch (rawError) { @@ -1099,7 +1423,8 @@ async function completeAsyncIteratorValue( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); completedResults.push(null); break; @@ -1114,13 +1439,18 @@ async function completeAsyncIteratorValue( fieldGroup, info, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ) ) { containsPromise = true; } index += 1; } + + reportDeferredValue(ARRAY_VALUE, fieldGroup, path, deferMap); + return containsPromise ? Promise.all(completedResults) : completedResults; } @@ -1133,9 +1463,11 @@ function completeListValue( returnType: GraphQLList, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - path: Path, + path: Path, result: unknown, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): PromiseOrValue> { const itemType = returnType.ofType; @@ -1149,7 +1481,9 @@ function completeListValue( info, path, iterator, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -1159,39 +1493,44 @@ function completeListValue( ); } - const stream = getStreamValues(exeContext, fieldGroup, path); + const streamUsage = getStreamUsage(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. let containsPromise = false; - let previousAsyncPayloadRecord = asyncPayloadRecord; + let currentParents = parentRecords; const completedResults: Array = []; let index = 0; + let streamContext: StreamContext | undefined; 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); - - if ( - stream && - typeof stream.initialCount === 'number' && - index >= stream.initialCount - ) { - previousAsyncPayloadRecord = executeStreamField( + const itemPath = addPath(path, index, fieldGroup); + + if (streamUsage && index >= streamUsage.initialCount) { + if (streamContext === undefined) { + streamContext = { + label: streamUsage.label, + path: pathToArray(path), + }; + } + currentParents = executeStreamField( path, itemPath, item, exeContext, - fieldGroup, + streamUsage.fieldGroup, info, itemType, - stream.label, - previousAsyncPayloadRecord, + streamContext, + deferMap, + currentParents, ); index++; continue; } + addPendingDeferredField(fieldGroup, itemPath, deferMap); if ( completeListItemValue( item, @@ -1201,7 +1540,9 @@ function completeListValue( fieldGroup, info, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ) ) { containsPromise = true; @@ -1210,6 +1551,8 @@ function completeListValue( index++; } + reportDeferredValue(ARRAY_VALUE, fieldGroup, path, deferMap); + return containsPromise ? Promise.all(completedResults) : completedResults; } @@ -1225,8 +1568,10 @@ function completeListItemValue( itemType: GraphQLOutputType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - itemPath: Path, - asyncPayloadRecord?: AsyncPayloadRecord, + itemPath: Path, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): boolean { if (isPromise(item)) { completedResults.push( @@ -1237,7 +1582,9 @@ function completeListItemValue( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ), ); @@ -1252,7 +1599,9 @@ function completeListItemValue( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completedItem)) { @@ -1266,9 +1615,10 @@ function completeListItemValue( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); return null; }), ); @@ -1284,9 +1634,10 @@ function completeListItemValue( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); completedResults.push(null); } @@ -1320,9 +1671,11 @@ function completeAbstractValue( returnType: GraphQLAbstractType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - path: Path, + path: Path, result: unknown, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): PromiseOrValue> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; @@ -1344,7 +1697,9 @@ function completeAbstractValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ), ); } @@ -1363,7 +1718,9 @@ function completeAbstractValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -1378,7 +1735,7 @@ function ensureValidRuntimeType( 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: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ); } @@ -1401,21 +1758,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: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ); } if (!isObjectType(runtimeType)) { throw new GraphQLError( `Abstract type "${returnType.name}" was resolved to a non-object type "${runtimeTypeName}".`, - { nodes: fieldGroup }, + { 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: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ); } @@ -1430,9 +1787,11 @@ function completeObjectValue( returnType: GraphQLObjectType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, - path: Path, + path: Path, result: unknown, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): PromiseOrValue> { // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather @@ -1451,7 +1810,9 @@ function completeObjectValue( fieldGroup, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); }); } @@ -1467,7 +1828,9 @@ function completeObjectValue( fieldGroup, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -1478,7 +1841,7 @@ function invalidReturnTypeError( ): GraphQLError { return new GraphQLError( `Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, - { nodes: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ); } @@ -1486,34 +1849,56 @@ function collectAndExecuteSubfields( exeContext: ExecutionContext, returnType: GraphQLObjectType, fieldGroup: FieldGroup, - path: Path, + path: Path, result: unknown, - asyncPayloadRecord?: AsyncPayloadRecord, + deferMap: Map, + streamRecord: StreamRecord | undefined, + parentRecords: Array | undefined, ): PromiseOrValue> { + let newParentRecords: Array | undefined = []; + for (const [deferUsage] of fieldGroup.fields) { + if (deferUsage) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const deferredFragmentRecord = deferMap.get(deferUsage)!; + newParentRecords.push(deferredFragmentRecord); + } else { + newParentRecords = parentRecords; + break; + } + } + // Collect sub-fields to execute to complete this value. - const { groupedFieldSet: subGroupedFieldSet, patches: subPatches } = + const { groupedFieldSet: subGroupedFieldSet, deferUsages: subDeferUsages } = collectSubfields(exeContext, returnType, fieldGroup); + const deferredFragmentRecords: Array = []; + const newDefers = new Map(deferMap); + for (const deferUsage of subDeferUsages) { + const deferredFragmentRecord = new DeferredFragmentRecord({ + deferUsage, + path, + parents: newParentRecords, + exeContext, + }); + deferredFragmentRecords.push(deferredFragmentRecord); + newDefers.set(deferUsage, deferredFragmentRecord); + } + const subFields = executeFields( exeContext, returnType, result, path, subGroupedFieldSet, - asyncPayloadRecord, + newDefers, + streamRecord, + newParentRecords, ); - for (const subPatch of subPatches) { - const { label, groupedFieldSet: subPatchGroupedFieldSet } = subPatch; - executeDeferredFragment( - exeContext, - returnType, - result, - subPatchGroupedFieldSet, - label, - path, - asyncPayloadRecord, - ); + reportDeferredValue(OBJECT_VALUE, fieldGroup, path, deferMap); + + for (const deferredFragmentRecord of deferredFragmentRecords) { + deferredFragmentRecord.completeIfReady(); } return subFields; @@ -1742,17 +2127,17 @@ function executeSubscription( const firstRootField = groupedFieldSet.entries().next().value; const [responseName, fieldGroup] = firstRootField; - const fieldName = fieldGroup[0].name.value; + const fieldName = fieldGroup.fieldName; const fieldDef = schema.getField(rootType, fieldName); if (!fieldDef) { throw new GraphQLError( `The subscription field "${fieldName}" is not defined.`, - { nodes: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ); } - const path = addPath(undefined, responseName, rootType.name); + const path = addPath(undefined, responseName, fieldGroup); const info = buildResolveInfo( exeContext, fieldDef, @@ -1767,7 +2152,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, fieldGroup[0], variableValues); + const args = getArgumentValues( + fieldDef, + toNodes(fieldGroup)[0], + 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 @@ -1781,13 +2170,13 @@ function executeSubscription( if (isPromise(result)) { return result.then(assertEventStream).then(undefined, (error) => { - throw locatedError(error, fieldGroup, pathToArray(path)); + throw locatedError(error, toNodes(fieldGroup), pathToArray(path)); }); } return assertEventStream(result); } catch (error) { - throw locatedError(error, fieldGroup, pathToArray(path)); + throw locatedError(error, toNodes(fieldGroup), pathToArray(path)); } } @@ -1807,62 +2196,25 @@ function assertEventStream(result: unknown): AsyncIterable { return result; } -function executeDeferredFragment( - exeContext: ExecutionContext, - parentType: GraphQLObjectType, - sourceValue: unknown, - fields: GroupedFieldSet, - label?: string, - path?: Path, - parentContext?: AsyncPayloadRecord, -): void { - const asyncPayloadRecord = new DeferredFragmentRecord({ - label, - path, - parentContext, - exeContext, - }); - let promiseOrData; - try { - promiseOrData = executeFields( - exeContext, - parentType, - sourceValue, - path, - fields, - asyncPayloadRecord, - ); - - if (isPromise(promiseOrData)) { - promiseOrData = promiseOrData.then(null, (e) => { - asyncPayloadRecord.errors.push(e); - return null; - }); - } - } catch (e) { - asyncPayloadRecord.errors.push(e); - promiseOrData = null; - } - asyncPayloadRecord.addData(promiseOrData); -} - function executeStreamField( - path: Path, - itemPath: Path, + path: Path, + itemPath: Path, item: PromiseOrValue, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - label?: string, - parentContext?: AsyncPayloadRecord, -): AsyncPayloadRecord { - const asyncPayloadRecord = new StreamRecord({ - label, + streamContext: StreamContext, + deferMap: Map, + parents?: Array | undefined, +): Array { + const streamRecord = new StreamRecord({ + streamContext, path: itemPath, - parentContext, + parents, exeContext, }); + const currentParents = [streamRecord]; if (isPromise(item)) { const completedItems = completePromisedValue( exeContext, @@ -1871,18 +2223,20 @@ function executeStreamField( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + currentParents, ).then( (value) => [value], (error) => { - asyncPayloadRecord.errors.push(error); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + streamRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, currentParents); return null; }, ); - asyncPayloadRecord.addItems(completedItems); - return asyncPayloadRecord; + streamRecord.addItems(completedItems); + return currentParents; } let completedItem: PromiseOrValue; @@ -1895,7 +2249,9 @@ function executeStreamField( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + currentParents, ); } catch (rawError) { handleFieldError( @@ -1904,16 +2260,17 @@ function executeStreamField( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); completedItem = null; - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, currentParents); } } catch (error) { - asyncPayloadRecord.errors.push(error); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); - asyncPayloadRecord.addItems(null); - return asyncPayloadRecord; + streamRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, currentParents); + streamRecord.addItems(null); + return currentParents; } if (isPromise(completedItem)) { @@ -1925,26 +2282,27 @@ function executeStreamField( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, currentParents); return null; }) .then( (value) => [value], (error) => { - asyncPayloadRecord.errors.push(error); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + streamRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, currentParents); return null; }, ); - asyncPayloadRecord.addItems(completedItems); - return asyncPayloadRecord; + streamRecord.addItems(completedItems); + return currentParents; } - asyncPayloadRecord.addItems([completedItem]); - return asyncPayloadRecord; + streamRecord.addItems([completedItem]); + return currentParents; } async function executeStreamAsyncIteratorItem( @@ -1953,17 +2311,19 @@ async function executeStreamAsyncIteratorItem( fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - asyncPayloadRecord: StreamRecord, - itemPath: Path, + streamRecord: StreamRecord, + itemPath: Path, + deferMap: Map, + parentRecords: Array, ): Promise> { let item; try { - const { value, done } = await iterator.next(); - if (done) { - asyncPayloadRecord.setIsCompletedIterator(); - return { done, value: undefined }; + const iteration = await iterator.next(); + if (!exeContext.streams.has(streamRecord.streamContext) || iteration.done) { + streamRecord.setIsCompletedIterator(); + return { done: true, value: undefined }; } - item = value; + item = iteration.value; } catch (rawError) { handleFieldError( rawError, @@ -1971,7 +2331,8 @@ async function executeStreamAsyncIteratorItem( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); // don't continue if iterator throws return { done: true, value: null }; @@ -1985,7 +2346,9 @@ async function executeStreamAsyncIteratorItem( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completedItem)) { @@ -1996,9 +2359,10 @@ async function executeStreamAsyncIteratorItem( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); return null; }); } @@ -2010,9 +2374,10 @@ async function executeStreamAsyncIteratorItem( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); return { done: false, value: null }; } } @@ -2024,22 +2389,23 @@ async function executeStreamAsyncIterator( fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - path: Path, - label?: string, - parentContext?: AsyncPayloadRecord, + path: Path, + streamContext: StreamContext, + deferMap: Map, + parents?: Array | undefined, ): Promise { let index = initialIndex; - let previousAsyncPayloadRecord = parentContext ?? undefined; + let currentParents = parents; // eslint-disable-next-line no-constant-condition while (true) { - const itemPath = addPath(path, index, undefined); - const asyncPayloadRecord = new StreamRecord({ - label, + const itemPath = addPath(path, index, fieldGroup); + const streamRecord = new StreamRecord({ + streamContext, path: itemPath, - parentContext: previousAsyncPayloadRecord, - iterator, + parents: currentParents, exeContext, }); + currentParents = [streamRecord]; let iteration; try { @@ -2050,13 +2416,15 @@ async function executeStreamAsyncIterator( fieldGroup, info, itemType, - asyncPayloadRecord, + streamRecord, itemPath, + deferMap, + currentParents, ); } catch (error) { - asyncPayloadRecord.errors.push(error); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); - asyncPayloadRecord.addItems(null); + streamRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, currentParents); + streamRecord.addItems(null); // entire stream has errored and bubbled upwards if (iterator?.return) { iterator.return().catch(() => { @@ -2073,8 +2441,8 @@ async function executeStreamAsyncIterator( completedItems = completedItem.then( (value) => [value], (error) => { - asyncPayloadRecord.errors.push(error); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + streamRecord.errors.push(error); + filterSubsequentPayloads(exeContext, path, [streamRecord]); return null; }, ); @@ -2082,41 +2450,49 @@ async function executeStreamAsyncIterator( completedItems = [completedItem]; } - asyncPayloadRecord.addItems(completedItems); + streamRecord.addItems(completedItems); if (done) { break; } - previousAsyncPayloadRecord = asyncPayloadRecord; index++; } } function filterSubsequentPayloads( exeContext: ExecutionContext, - nullPath: Path, - currentAsyncRecord: AsyncPayloadRecord | undefined, + nullPath: Path | undefined, + currentAsyncRecords: Array | undefined, ): void { const nullPathArray = pathToArray(nullPath); + const streams = new Set(); exeContext.subsequentPayloads.forEach((asyncRecord) => { - if (asyncRecord === currentAsyncRecord) { + if (currentAsyncRecords?.includes(asyncRecord)) { // don't remove payload from where error originates return; } for (let i = 0; i < nullPathArray.length; i++) { - if (asyncRecord.path[i] !== nullPathArray[i]) { + if (asyncRecord.pathAsArray[i] !== nullPathArray[i]) { // asyncRecord points to a path unaffected by this payload return; } } // asyncRecord path points to nulled error field - if (isStreamPayload(asyncRecord) && asyncRecord.iterator?.return) { - asyncRecord.iterator.return().catch(() => { - // ignore error - }); + if (isStreamPayload(asyncRecord)) { + streams.add(asyncRecord.streamContext); } exeContext.subsequentPayloads.delete(asyncRecord); }); + streams.forEach((stream) => { + returnStreamIteratorIgnoringError(stream); + exeContext.streams.delete(stream); + }); +} + +function returnStreamIteratorIgnoringError(streamContext: StreamContext): void { + streamContext.iterator?.return?.().catch(() => { + // ignore error + }); } function getCompletedIncrementalResults( @@ -2135,16 +2511,20 @@ function getCompletedIncrementalResults( // async iterable resolver just finished but there may be pending payloads continue; } - (incrementalResult as IncrementalStreamResult).items = items; + (incrementalResult as IncrementalStreamResult).items = items ?? null; + if (asyncPayloadRecord.streamContext.label !== undefined) { + incrementalResult.label = asyncPayloadRecord.streamContext.label; + } } else { const data = asyncPayloadRecord.data; (incrementalResult as IncrementalDeferResult).data = data ?? null; + if (asyncPayloadRecord.deferUsage.label !== undefined) { + incrementalResult.label = asyncPayloadRecord.deferUsage.label; + } } - incrementalResult.path = asyncPayloadRecord.path; - if (asyncPayloadRecord.label != null) { - incrementalResult.label = asyncPayloadRecord.label; - } + incrementalResult.path = asyncPayloadRecord.pathAsArray; + if (asyncPayloadRecord.errors.length > 0) { incrementalResult.errors = asyncPayloadRecord.errors; } @@ -2193,12 +2573,9 @@ function yieldSubsequentPayloads( function returnStreamIterators() { const promises: Array>> = []; - exeContext.subsequentPayloads.forEach((asyncPayloadRecord) => { - if ( - isStreamPayload(asyncPayloadRecord) && - asyncPayloadRecord.iterator?.return - ) { - promises.push(asyncPayloadRecord.iterator.return()); + exeContext.streams.forEach((streamContext) => { + if (streamContext.iterator?.return) { + promises.push(streamContext.iterator.return()); } }); return Promise.all(promises); @@ -2212,15 +2589,15 @@ function yieldSubsequentPayloads( async return(): Promise< IteratorResult > { - await returnStreamIterators(); isDone = true; + await returnStreamIterators(); return { value: undefined, done: true }; }, async throw( error?: unknown, ): Promise> { - await returnStreamIterators(); isDone = true; + await returnStreamIterators(); return Promise.reject(error); }, }; @@ -2229,29 +2606,36 @@ function yieldSubsequentPayloads( class DeferredFragmentRecord { type: 'defer'; errors: Array; - label: string | undefined; - path: Array; + deferUsage: DeferUsage; + path: Path | undefined; + pathAsArray: Array; promise: Promise; data: ObjMap | null; - parentContext: AsyncPayloadRecord | undefined; + parents: Array | undefined; isCompleted: boolean; _exeContext: ExecutionContext; _resolve?: (arg: PromiseOrValue | null>) => void; + _pending: Set>; + _results: Map | undefined, Map, unknown>>; + constructor(opts: { - label: string | undefined; - path: Path | undefined; - parentContext: AsyncPayloadRecord | undefined; + deferUsage: DeferUsage; + path: Path | undefined; + parents?: Array | undefined; exeContext: ExecutionContext; }) { this.type = 'defer'; - this.label = opts.label; - this.path = pathToArray(opts.path); - this.parentContext = opts.parentContext; + this.deferUsage = opts.deferUsage; + this.path = opts.path; + this.pathAsArray = pathToArray(opts.path); + this.parents = opts.parents; this.errors = []; this._exeContext = opts.exeContext; this._exeContext.subsequentPayloads.add(this); this.isCompleted = false; - this.data = null; + this.data = Object.create(null); + this._pending = new Set(); + this._results = new Map(); this.promise = new Promise | null>((resolve) => { this._resolve = (promiseOrValue) => { resolve(promiseOrValue); @@ -2262,47 +2646,136 @@ class DeferredFragmentRecord { }); } - addData(data: PromiseOrValue | null>) { - const parentData = this.parentContext?.promise; - if (parentData) { - this._resolve?.(parentData.then(() => data)); + addPendingField(path: Path) { + this._pending.add(path); + let siblings = this._results.get(path.prev); + if (siblings === undefined) { + siblings = new Map, unknown>(); + this._results.set(path.prev, siblings); + } + siblings.set(path, undefined); + } + + removePendingField(path: Path) { + this._pending.delete(path); + this._results.delete(path); + const siblings = this._results.get(path.prev); + if (siblings !== undefined) { + siblings.delete(path); + } + } + + reportDeferredValue(path: Path, result: unknown) { + this._pending.delete(path); + const siblings = this._results.get(path.prev); + if (siblings !== undefined) { + const existingValue = siblings.get(path); + // if a null has already bubbled, do not overwrite + if (existingValue === undefined) { + siblings.set(path, result); + } + } + this.completeIfReady(); + } + + completeIfReady() { + if (this._pending.size === 0) { + this.complete(); + } + } + + complete(): void { + this._buildData(this.data, this._results.get(this.path)); + + if (this.parents !== undefined) { + const parentPromises = this.parents.map((parent) => parent.promise); + this._resolve?.(Promise.any(parentPromises).then(() => this.data)); + return; + } + this._resolve?.(this.data); + } + + addError(path: Path, error: GraphQLError): void { + this.errors.push(error); + this.removePendingTree(path); + const siblings = this._results.get(path.prev); + if (siblings !== undefined) { + // overwrite current value to support null bubbling + siblings.set(path, null); + } + } + + removePendingTree(path: Path) { + const children = this._results.get(path); + if (children !== undefined) { + for (const [childPath] of children) { + this.removePendingTree(childPath); + } + } + this.removePendingField(path); + } + + _buildData( + parent: any, + children: Map, unknown> | undefined, + ): void { + if (children === undefined) { return; } - this._resolve?.(data); + for (const [childPath, value] of children) { + const key = childPath.key; + switch (value) { + case null: + parent[key] = null; + break; + case OBJECT_VALUE: + parent[key] = Object.create(null); + this._buildData(parent[key], this._results.get(childPath)); + break; + case ARRAY_VALUE: + parent[key] = []; + this._buildData(parent[key], this._results.get(childPath)); + break; + default: + parent[key] = value; + } + } } } +interface StreamContext { + label: string | undefined; + path: Array; + iterator?: AsyncIterator | undefined; +} + class StreamRecord { type: 'stream'; errors: Array; - label: string | undefined; - path: Array; + streamContext: StreamContext; + pathAsArray: Array; items: Array | null; promise: Promise; - parentContext: AsyncPayloadRecord | undefined; - iterator: AsyncIterator | undefined; + parents: Array | undefined; isCompletedIterator?: boolean; isCompleted: boolean; _exeContext: ExecutionContext; _resolve?: (arg: PromiseOrValue | null>) => void; constructor(opts: { - label: string | undefined; - path: Path | undefined; - iterator?: AsyncIterator; - parentContext: AsyncPayloadRecord | undefined; + streamContext: StreamContext; + path: Path | undefined; + parents: Array | undefined; exeContext: ExecutionContext; }) { this.type = 'stream'; - this.items = null; - this.label = opts.label; - this.path = pathToArray(opts.path); - this.parentContext = opts.parentContext; - this.iterator = opts.iterator; + this.streamContext = opts.streamContext; + this.pathAsArray = pathToArray(opts.path); + this.parents = opts.parents; this.errors = []; this._exeContext = opts.exeContext; this._exeContext.subsequentPayloads.add(this); this.isCompleted = false; - this.items = null; + this.items = []; this.promise = new Promise | null>((resolve) => { this._resolve = (promiseOrValue) => { resolve(promiseOrValue); @@ -2314,9 +2787,9 @@ class StreamRecord { } addItems(items: PromiseOrValue | null>) { - const parentData = this.parentContext?.promise; - if (parentData) { - this._resolve?.(parentData.then(() => items)); + if (this.parents !== undefined) { + const parentPromises = this.parents.map((parent) => parent.promise); + this._resolve?.(Promise.any(parentPromises).then(() => items)); return; } this._resolve?.(items); diff --git a/src/jsutils/Path.ts b/src/jsutils/Path.ts index d223b6e752..44b0fb8448 100644 --- a/src/jsutils/Path.ts +++ b/src/jsutils/Path.ts @@ -1,27 +1,27 @@ import type { Maybe } from './Maybe.js'; -export interface Path { - readonly prev: Path | undefined; +export interface Path { + readonly prev: Path | undefined; readonly key: string | number; - readonly typename: string | undefined; + readonly info: T; } /** * Given a Path and a key, return a new Path containing the new key. */ -export function addPath( - prev: Readonly | undefined, +export function addPath( + prev: Readonly> | undefined, key: string | number, - typename: string | undefined, -): Path { - return { prev, key, typename }; + info: T, +): Path { + return { prev, key, info }; } /** * Given a Path, return an Array of the path keys. */ export function pathToArray( - path: Maybe>, + path: Maybe>>, ): Array { const flattened = []; let curr = path; diff --git a/src/jsutils/__tests__/Path-test.ts b/src/jsutils/__tests__/Path-test.ts index 0484377db9..3181c2941e 100644 --- a/src/jsutils/__tests__/Path-test.ts +++ b/src/jsutils/__tests__/Path-test.ts @@ -7,22 +7,14 @@ describe('Path', () => { it('can create a Path', () => { const first = addPath(undefined, 1, 'First'); - expect(first).to.deep.equal({ - prev: undefined, - key: 1, - typename: 'First', - }); + expect(first).to.deep.equal({ prev: undefined, key: 1, info: 'First' }); }); it('can add a new key to an existing Path', () => { const first = addPath(undefined, 1, 'First'); const second = addPath(first, 'two', 'Second'); - expect(second).to.deep.equal({ - prev: first, - key: 'two', - typename: 'Second', - }); + expect(second).to.deep.equal({ prev: first, key: 'two', info: 'Second' }); }); it('can convert a Path to an array of its keys', () => { diff --git a/src/type/definition.ts b/src/type/definition.ts index 25f4133a42..78fa90c241 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -890,7 +890,8 @@ export interface GraphQLResolveInfo { readonly fieldNodes: ReadonlyArray; readonly returnType: GraphQLOutputType; readonly parentType: GraphQLObjectType; - readonly path: Path; + // TODO: we are now using path for significant internals, so we have to figure out how much to expose + readonly path: Path; readonly schema: GraphQLSchema; readonly fragments: ObjMap; readonly rootValue: unknown; diff --git a/src/utilities/coerceInputValue.ts b/src/utilities/coerceInputValue.ts index eebddcba83..53c563dfce 100644 --- a/src/utilities/coerceInputValue.ts +++ b/src/utilities/coerceInputValue.ts @@ -52,7 +52,7 @@ function coerceInputValueImpl( inputValue: unknown, type: GraphQLInputType, onError: OnErrorCB, - path: Path | undefined, + path: Path | undefined, ): unknown { if (isNonNullType(type)) { if (inputValue != null) { diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index c6cd93ab58..8ce9474b3f 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -3,16 +3,22 @@ import type { ObjMap } from '../../jsutils/ObjMap.js'; import { GraphQLError } from '../../error/GraphQLError.js'; import type { + FieldNode, FragmentDefinitionNode, OperationDefinitionNode, } from '../../language/ast.js'; import { Kind } from '../../language/kinds.js'; import type { ASTVisitor } from '../../language/visitor.js'; +import type { FieldGroup } from '../../execution/collectFields.js'; import { collectFields } from '../../execution/collectFields.js'; import type { ValidationContext } from '../ValidationContext.js'; +function toNodes(fieldGroup: FieldGroup): ReadonlyArray { + return Array.from(fieldGroup.fields.values()).flat(); +} + /** * Subscriptions must only include a non-introspection field. * @@ -49,9 +55,11 @@ export function SingleFieldSubscriptionsRule( node, ); if (groupedFieldSet.size > 1) { - const fieldSelectionLists = [...groupedFieldSet.values()]; - const extraFieldSelectionLists = fieldSelectionLists.slice(1); - const extraFieldSelections = extraFieldSelectionLists.flat(); + const fieldGroups = [...groupedFieldSet.values()]; + const extraFieldGroups = fieldGroups.slice(1); + const extraFieldSelections = extraFieldGroups.flatMap( + (fieldGroup) => toNodes(fieldGroup), + ); context.reportError( new GraphQLError( operationName != null @@ -62,14 +70,14 @@ export function SingleFieldSubscriptionsRule( ); } for (const fieldGroup of groupedFieldSet.values()) { - const fieldName = fieldGroup[0].name.value; + const fieldName = toNodes(fieldGroup)[0].name.value; if (fieldName.startsWith('__')) { context.reportError( new GraphQLError( operationName != null ? `Subscription "${operationName}" must not select an introspection top level field.` : 'Anonymous Subscription must not select an introspection top level field.', - { nodes: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ), ); }