From 2b69fa2aec3c2fbbba156086886046ce11e29ea6 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 18 Apr 2023 16:39:56 +0300 Subject: [PATCH 1/5] add additional tests --- src/execution/__tests__/defer-test.ts | 1152 +++++++++++++++++++++++- src/execution/__tests__/stream-test.ts | 75 +- 2 files changed, 1225 insertions(+), 2 deletions(-) diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 31ddf9e6c0..c2da2b837c 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', }); @@ -398,6 +474,1080 @@ 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: { + id: '1', + }, + path: ['hero'], + label: 'DeferID', + }, + { + data: { + hero: { + name: 'Luke', + }, + }, + path: [], + label: 'DeferName', + }, + ], + 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('Does not 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: {}, path: ['hero', 'friends', 0] }, + { data: {}, path: ['hero', 'friends', 0] }, + { data: {}, path: ['hero', 'friends', 0] }, + { data: { id: '2', name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: {}, path: ['hero', 'friends', 1] }, + { data: {}, path: ['hero', 'friends', 1] }, + { data: {}, path: ['hero', 'friends', 1] }, + { data: { id: '3', name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: {}, path: ['hero', 'friends', 2] }, + { data: {}, path: ['hero', 'friends', 2] }, + { data: {}, path: ['hero', 'friends', 2] }, + { 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'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { + deeperObject: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + }, + }, + path: ['hero', 'nestedObject'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { + foo: 'foo', + bar: 'bar', + baz: 'baz', + bak: 'bak', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not 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', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + { + data: { + nestedObject: { + deeperObject: {}, + }, + }, + path: ['hero'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + 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: { + e: { + f: 'f', + }, + }, + path: ['a', 'b'], + }, + { + data: { + a: { + b: { + e: { + f: 'f', + }, + }, + }, + g: { + h: 'h', + }, + }, + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Preserves error boundaries', 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: { + b: { + c: { + d: 'd', + }, + }, + }, + path: ['a'], + }, + { + 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: [], + }, + ], + 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: { + name: 'Luke', + }, + }, + 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__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 2b9ad82721..373a0a30f8 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) { @@ -1563,6 +1564,78 @@ 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: [ + { + items: [{ id: '1' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + { + data: { + nestedFriendList: [], + }, + path: ['nestedObject'], + }, + { + items: [{ id: '1', name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + { + 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(` From 1e9086116b1d9707f4db868a99139d96b3545664 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 18 Apr 2023 12:19:25 +0300 Subject: [PATCH 2/5] incremental delivery without branching --- src/execution/__tests__/defer-test.ts | 104 +- src/execution/__tests__/executor-test.ts | 52 +- src/execution/__tests__/stream-test.ts | 168 ++- src/execution/collectFields.ts | 304 +++-- src/execution/execute.ts | 1118 ++++++++++++----- src/jsutils/Path.ts | 18 +- src/jsutils/__tests__/Path-test.ts | 12 +- src/type/definition.ts | 3 +- src/utilities/coerceInputValue.ts | 2 +- .../rules/SingleFieldSubscriptionsRule.ts | 18 +- 10 files changed, 1269 insertions(+), 530 deletions(-) diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index c2da2b837c..5ba820c3ea 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -358,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, @@ -623,13 +623,6 @@ describe('Execute: defer directive', () => { }, { incremental: [ - { - data: { - id: '1', - }, - path: ['hero'], - label: 'DeferID', - }, { data: { hero: { @@ -639,6 +632,13 @@ describe('Execute: defer directive', () => { path: [], label: 'DeferName', }, + { + data: { + id: '1', + }, + path: ['hero'], + label: 'DeferID', + }, ], hasNext: false, }, @@ -693,7 +693,7 @@ describe('Execute: defer directive', () => { ]); }); - it('Does not deduplicate multiple defers on the same object', async () => { + it('Can deduplicate multiple defers on the same object', async () => { const document = parse(` query { hero { @@ -728,17 +728,8 @@ describe('Execute: defer directive', () => { }, { incremental: [ - { data: {}, path: ['hero', 'friends', 0] }, - { data: {}, path: ['hero', 'friends', 0] }, - { data: {}, path: ['hero', 'friends', 0] }, { data: { id: '2', name: 'Han' }, path: ['hero', 'friends', 0] }, - { data: {}, path: ['hero', 'friends', 1] }, - { data: {}, path: ['hero', 'friends', 1] }, - { data: {}, path: ['hero', 'friends', 1] }, { data: { id: '3', name: 'Leia' }, path: ['hero', 'friends', 1] }, - { data: {}, path: ['hero', 'friends', 2] }, - { data: {}, path: ['hero', 'friends', 2] }, - { data: {}, path: ['hero', 'friends', 2] }, { data: { id: '4', name: 'C-3PO' }, path: ['hero', 'friends', 2] }, ], hasNext: false, @@ -933,11 +924,6 @@ describe('Execute: defer directive', () => { }, path: ['hero'], }, - ], - hasNext: true, - }, - { - incremental: [ { data: { deeperObject: { @@ -948,11 +934,6 @@ describe('Execute: defer directive', () => { }, path: ['hero', 'nestedObject'], }, - ], - hasNext: true, - }, - { - incremental: [ { data: { foo: 'foo', @@ -968,7 +949,7 @@ describe('Execute: defer directive', () => { ]); }); - it('Does not combine multiple fields from deferred fragments from different branches occurring at the same level', async () => { + it('Can combine multiple fields from deferred fragments from different branches occurring at the same level', async () => { const document = parse(` query { hero { @@ -1006,12 +987,6 @@ describe('Execute: defer directive', () => { }, { incremental: [ - { - data: { - foo: 'foo', - }, - path: ['hero', 'nestedObject', 'deeperObject'], - }, { data: { nestedObject: { @@ -1020,11 +995,6 @@ describe('Execute: defer directive', () => { }, path: ['hero'], }, - ], - hasNext: true, - }, - { - incremental: [ { data: { foo: 'foo', @@ -1083,14 +1053,6 @@ describe('Execute: defer directive', () => { }, { incremental: [ - { - data: { - e: { - f: 'f', - }, - }, - path: ['a', 'b'], - }, { data: { a: { @@ -1106,6 +1068,14 @@ describe('Execute: defer directive', () => { }, path: [], }, + { + data: { + e: { + f: 'f', + }, + }, + path: ['a', 'b'], + }, ], hasNext: false, }, @@ -1146,16 +1116,6 @@ describe('Execute: defer directive', () => { }, { incremental: [ - { - data: { - b: { - c: { - d: 'd', - }, - }, - }, - path: ['a'], - }, { data: { a: { @@ -1175,6 +1135,16 @@ describe('Execute: defer directive', () => { ], path: [], }, + { + data: { + b: { + c: { + d: 'd', + }, + }, + }, + path: ['a'], + }, ], hasNext: false, }, @@ -1219,10 +1189,16 @@ describe('Execute: defer directive', () => { incremental: [ { data: { - hero: { - name: 'Luke', - }, + hero: null, }, + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [{ line: 4, column: 11 }], + path: ['hero', 'nonNullName'], + }, + ], path: [], }, ], 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 373a0a30f8..a266913510 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -1182,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 { @@ -1360,9 +1512,6 @@ describe('Execute: stream directive', () => { ], }, ], - hasNext: true, - }, - { hasNext: false, }, ]); @@ -1422,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({ @@ -1601,10 +1751,6 @@ describe('Execute: stream directive', () => { }, { incremental: [ - { - items: [{ id: '1' }], - path: ['nestedObject', 'nestedFriendList', 0], - }, { data: { nestedFriendList: [], @@ -1620,10 +1766,6 @@ describe('Execute: stream directive', () => { }, { incremental: [ - { - items: [{ id: '2' }], - path: ['nestedObject', 'nestedFriendList', 1], - }, { items: [{ id: '2', name: 'Han' }], path: ['nestedObject', 'nestedFriendList', 1], diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index af263112ec..46fab8de72 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -19,6 +19,7 @@ import { GraphQLDeferDirective, GraphQLIncludeDirective, GraphQLSkipDirective, + GraphQLStreamDirective, } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; @@ -26,18 +27,40 @@ import { typeFromAST } from '../utilities/typeFromAST.js'; import { getDirectiveValues } from './values.js'; -export type FieldGroup = ReadonlyArray; +export interface DeferUsage { + label: string | undefined; +} + +// initialCount is validated during field execution +export interface PreValidatedStreamUsage { + label: string | undefined; + initialCount: unknown; +} + +export interface FieldGroup { + parentType: GraphQLObjectType; + fieldName: string; + fields: Map>; + inInitialResult: boolean; + shouldInitiateDefer: boolean; + streamUsage: PreValidatedStreamUsage | undefined; +} +interface MutableFieldGroup extends FieldGroup { + fields: AccumulatorMap; +} export type GroupedFieldSet = Map; -export interface PatchFields { - label: string | undefined; +type MutableGroupedFieldSet = Map; + +export interface CollectFieldsResult { groupedFieldSet: GroupedFieldSet; + deferUsages: Map; } -export interface FieldsAndPatches { - groupedFieldSet: GroupedFieldSet; - patches: Array; +interface MutableCollectFieldsResult { + groupedFieldSet: MutableGroupedFieldSet; + deferUsages: Map; } /** @@ -55,9 +78,15 @@ 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(); + + const collectFieldsResult = { + groupedFieldSet, + deferUsages, + }; + collectFieldsImpl( schema, fragments, @@ -65,11 +94,11 @@ export function collectFields( operation, runtimeType, operation.selectionSet, - groupedFieldSet, - patches, + collectFieldsResult, new Set(), ); - return { groupedFieldSet, patches }; + + return collectFieldsResult; } /** @@ -90,32 +119,34 @@ export function collectSubfields( operation: OperationDefinitionNode, returnType: GraphQLObjectType, fieldGroup: FieldGroup, -): FieldsAndPatches { - const subGroupedFieldSet = new AccumulatorMap(); - const visitedFragmentNames = new Set(); - - const subPatches: Array = []; - const subFieldsAndPatches = { +): CollectFieldsResult { + const subGroupedFieldSet = new Map(); + const deferUsages = new Map(); + const collectSubfieldsResult = { groupedFieldSet: subGroupedFieldSet, - patches: subPatches, + deferUsages, }; + const visitedFragmentNames = new Set(); - 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, + collectSubfieldsResult, + visitedFragmentNames, + deferUsage, + ); + } } } - return subFieldsAndPatches; + + return collectSubfieldsResult; } // eslint-disable-next-line max-params @@ -126,17 +157,55 @@ function collectFieldsImpl( operation: OperationDefinitionNode, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - groupedFieldSet: AccumulatorMap, - patches: Array, + collectFieldsResult: MutableCollectFieldsResult, visitedFragmentNames: Set, + parentDeferUsage?: DeferUsage | undefined, + newDeferUsage?: DeferUsage | undefined, ): void { + const { groupedFieldSet } = collectFieldsResult; for (const selection of selectionSet.selections) { switch (selection.kind) { case Kind.FIELD: { 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 stream = getStreamValues(variableValues, selection); + + 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, + streamUsage: stream, + }); + } break; } case Kind.INLINE_FRAGMENT: { @@ -149,24 +218,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, @@ -174,11 +226,26 @@ function collectFieldsImpl( operation, runtimeType, selection.selectionSet, - groupedFieldSet, - patches, + collectFieldsResult, visitedFragmentNames, + parentDeferUsage, + newDeferUsage, ); + break; } + + collectDeferredFragmentFields( + schema, + fragments, + variableValues, + operation, + runtimeType, + selection.selectionSet, + collectFieldsResult, + visitedFragmentNames, + defer, + parentDeferUsage, + ); break; } case Kind.FRAGMENT_SPREAD: { @@ -203,10 +270,6 @@ function collectFieldsImpl( if (!defer) { visitedFragmentNames.add(fragName); - } - - if (defer) { - const patchFields = new AccumulatorMap(); collectFieldsImpl( schema, fragments, @@ -214,33 +277,79 @@ function collectFieldsImpl( operation, runtimeType, fragment.selectionSet, - patchFields, - patches, - visitedFragmentNames, - ); - patches.push({ - label: defer.label, - groupedFieldSet: patchFields, - }); - } else { - collectFieldsImpl( - schema, - fragments, - variableValues, - operation, - runtimeType, - fragment.selectionSet, - groupedFieldSet, - patches, + collectFieldsResult, visitedFragmentNames, + parentDeferUsage, + newDeferUsage, ); + break; } + + collectDeferredFragmentFields( + schema, + fragments, + variableValues, + operation, + runtimeType, + fragment.selectionSet, + collectFieldsResult, + 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, + collectFieldsResult: MutableCollectFieldsResult, + visitedFragmentNames: Set, + defer: { label: string | undefined }, + parentDeferUsage?: DeferUsage | undefined, +): void { + const deferUsages = collectFieldsResult.deferUsages; + const existingNewDefer = deferUsages.get(defer.label); + if (existingNewDefer !== undefined) { + collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + runtimeType, + selectionSet, + collectFieldsResult, + visitedFragmentNames, + parentDeferUsage, + existingNewDefer, + ); + return; + } + + const newDefer = { ...defer }; + deferUsages.set(defer.label, newDefer); + collectFieldsImpl( + schema, + fragments, + variableValues, + operation, + runtimeType, + selectionSet, + collectFieldsResult, + 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 @@ -271,6 +380,45 @@ function getDeferValues( }; } +/** + * Returns an object containing the `@stream` arguments if a field should be + * streamed based on the experimental flag, stream directive present and + * not disabled by the "if" argument. + * + * We validate `initialCount` argument later so as to use the correct path + * if an error occurs. + */ +function getStreamValues( + variableValues: { [variable: string]: unknown }, + node: FieldNode, +): + | undefined + | { + initialCount: unknown; + label: string | undefined; + } { + // 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, + node, + variableValues, + ); + + if (!stream) { + return; + } + + if (stream.if === false) { + return; + } + + return { + initialCount: stream.initialCount, + label: typeof stream.label === 'string' ? stream.label : undefined, + }; +} + /** * Determines if a field should be included based on the `@include` and `@skip` * directives, where `@skip` has higher precedence than `@include`. diff --git a/src/execution/execute.ts b/src/execution/execute.ts index df048480ba..119f587bbe 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,23 +43,24 @@ 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, + PreValidatedStreamUsage, +} from './collectFields.js'; import { collectFields, collectSubfields as _collectSubfields, } from './collectFields.js'; import { mapAsyncIterable } from './mapAsyncIterable.js'; -import { - getArgumentValues, - getDirectiveValues, - getVariableValues, -} from './values.js'; +import { getArgumentValues, getVariableValues } from './values.js'; /* eslint-disable max-params */ // This file contains a lot of such errors but we plan to refactor it anyway @@ -122,6 +125,7 @@ export interface ExecutionContext { subscribeFieldResolver: GraphQLFieldResolver; errors: Array; subsequentPayloads: Set; + streams: Set; } /** @@ -263,12 +267,21 @@ export interface ExecutionArgs { subscribeFieldResolver?: Maybe>; } +export interface ValidatedStreamUsage extends PreValidatedStreamUsage { + initialCount: number; + // for memoization of the streamed field's FieldGroup + _fieldGroup?: FieldGroup | 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 +517,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, subsequentPayloads: new Set(), + streams: new Set(), errors: [], }; } @@ -515,7 +529,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 +550,7 @@ function executeOperation( ); } - const { groupedFieldSet, patches } = collectFields( + const collectFieldsResult = collectFields( schema, fragments, variableValues, @@ -546,6 +560,20 @@ function executeOperation( const path = undefined; let result; + const { groupedFieldSet, deferUsages } = collectFieldsResult; + + const deferredFragmentRecords: Array = []; + const newDefers = new Map(); + for (const deferUsage of deferUsages.values()) { + const deferredFragmentRecord = new DeferredFragmentRecord({ + deferUsage, + path, + exeContext, + }); + deferredFragmentRecords.push(deferredFragmentRecord); + newDefers.set(deferUsage, deferredFragmentRecord); + } + switch (operation.operation) { case OperationTypeNode.QUERY: result = executeFields( @@ -554,6 +582,7 @@ function executeOperation( rootValue, path, groupedFieldSet, + newDefers, ); break; case OperationTypeNode.MUTATION: @@ -563,6 +592,7 @@ function executeOperation( rootValue, path, groupedFieldSet, + newDefers, ); break; case OperationTypeNode.SUBSCRIPTION: @@ -574,19 +604,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 +623,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 +693,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 +789,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 +832,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 +856,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 +875,9 @@ function executeField( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -753,7 +888,9 @@ function executeField( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completed)) { @@ -766,9 +903,10 @@ function executeField( returnType, fieldGroup, path, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, path, parentRecords); return null; }); } @@ -780,9 +918,10 @@ function executeField( returnType, fieldGroup, path, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, path, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, path, parentRecords); return null; } } @@ -796,13 +935,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 +958,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 +976,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 +1097,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 +1140,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 +1164,9 @@ function completeValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -939,7 +1179,9 @@ function completeValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } /* c8 ignore next 6 */ @@ -950,14 +1192,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 +1255,9 @@ async function completePromisedValue( info, path, resolved, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completed)) { completed = await completed; @@ -981,58 +1270,38 @@ 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 - * streamed based on the experimental flag, stream directive present and - * not disabled by the "if" argument. + * Returns an object containing the validated `@stream` arguments. */ -function getStreamValues( +function getValidatedStreamUsage( exeContext: ExecutionContext, fieldGroup: FieldGroup, - path: Path, -): - | undefined - | { - initialCount: number | undefined; - label: string | undefined; - } { + path: Path, +): ValidatedStreamUsage | undefined { // do not stream inner lists of multi-dimensional lists if (typeof path.key === 'number') { return; } - // 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], - exeContext.variableValues, - ); + const streamUsage = fieldGroup.streamUsage; - if (!stream) { + if (!streamUsage) { return; } - if (stream.if === false) { - return; - } + const { label, initialCount } = streamUsage; - invariant( - typeof stream.initialCount === 'number', - 'initialCount must be a number', - ); + invariant(typeof initialCount === 'number', 'initialCount must be a number'); - invariant( - stream.initialCount >= 0, - 'initialCount must be a positive integer', - ); + invariant(initialCount >= 0, 'initialCount must be a positive integer'); invariant( exeContext.operation.operation !== OperationTypeNode.SUBSCRIPTION, @@ -1040,8 +1309,8 @@ function getStreamValues( ); return { - initialCount: stream.initialCount, - label: typeof stream.label === 'string' ? stream.label : undefined, + label: typeof label === 'string' ? label : undefined, + initialCount, }; } @@ -1054,42 +1323,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 = getValidatedStreamUsage(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, + getStreamedFieldGroup(fieldGroup, streamUsage), 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 +1375,8 @@ async function completeAsyncIteratorValue( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); completedResults.push(null); break; @@ -1114,16 +1391,44 @@ 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; } +function getStreamedFieldGroup( + fieldGroup: FieldGroup, + streamUsage: ValidatedStreamUsage, +): FieldGroup { + // TODO: add test for this case + /* c8 ignore next 3 */ + if (streamUsage._fieldGroup) { + return streamUsage._fieldGroup; + } + 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, + }; + streamUsage._fieldGroup = streamedFieldGroup; + return streamedFieldGroup; +} + /** * Complete a list value by completing each item in the list with the * inner type @@ -1133,9 +1438,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 +1456,9 @@ function completeListValue( info, path, iterator, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -1159,39 +1468,44 @@ function completeListValue( ); } - const stream = getStreamValues(exeContext, fieldGroup, path); + const streamUsage = getValidatedStreamUsage(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, + getStreamedFieldGroup(fieldGroup, streamUsage), info, itemType, - stream.label, - previousAsyncPayloadRecord, + streamContext, + deferMap, + currentParents, ); index++; continue; } + addPendingDeferredField(fieldGroup, itemPath, deferMap); if ( completeListItemValue( item, @@ -1201,7 +1515,9 @@ function completeListValue( fieldGroup, info, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ) ) { containsPromise = true; @@ -1210,6 +1526,8 @@ function completeListValue( index++; } + reportDeferredValue(ARRAY_VALUE, fieldGroup, path, deferMap); + return containsPromise ? Promise.all(completedResults) : completedResults; } @@ -1225,8 +1543,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 +1557,9 @@ function completeListItemValue( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ), ); @@ -1252,7 +1574,9 @@ function completeListItemValue( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completedItem)) { @@ -1266,9 +1590,10 @@ function completeListItemValue( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); return null; }), ); @@ -1284,9 +1609,10 @@ function completeListItemValue( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); completedResults.push(null); } @@ -1320,9 +1646,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 +1672,9 @@ function completeAbstractValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ), ); } @@ -1363,7 +1693,9 @@ function completeAbstractValue( info, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -1378,7 +1710,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 +1733,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 +1762,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 +1785,9 @@ function completeObjectValue( fieldGroup, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); }); } @@ -1467,7 +1803,9 @@ function completeObjectValue( fieldGroup, path, result, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); } @@ -1478,7 +1816,7 @@ function invalidReturnTypeError( ): GraphQLError { return new GraphQLError( `Expected value of type "${returnType.name}" but got: ${inspect(result)}.`, - { nodes: fieldGroup }, + { nodes: toNodes(fieldGroup) }, ); } @@ -1486,34 +1824,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.values()) { + 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 +2102,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 +2127,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 +2145,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 +2171,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 +2198,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 +2224,9 @@ function executeStreamField( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + currentParents, ); } catch (rawError) { handleFieldError( @@ -1904,16 +2235,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 +2257,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 +2286,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 +2306,8 @@ async function executeStreamAsyncIteratorItem( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); // don't continue if iterator throws return { done: true, value: null }; @@ -1985,7 +2321,9 @@ async function executeStreamAsyncIteratorItem( info, itemPath, item, - asyncPayloadRecord, + deferMap, + streamRecord, + parentRecords, ); if (isPromise(completedItem)) { @@ -1996,9 +2334,10 @@ async function executeStreamAsyncIteratorItem( itemType, fieldGroup, itemPath, - asyncPayloadRecord, + deferMap, + streamRecord, ); - filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); + filterSubsequentPayloads(exeContext, itemPath, parentRecords); return null; }); } @@ -2010,9 +2349,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 +2364,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 +2391,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 +2416,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 +2425,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 +2486,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 +2548,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 +2564,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 +2581,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 +2621,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 +2762,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) }, ), ); } From 039775a5d9feca8cba8a794c5d1c041d3d320fdb Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 20 Apr 2023 12:22:14 +0300 Subject: [PATCH 3/5] add additional test --- src/execution/__tests__/defer-test.ts | 71 ++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 5ba820c3ea..04dbdfa296 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -1082,7 +1082,7 @@ describe('Execute: defer directive', () => { ]); }); - it('Preserves error boundaries', async () => { + it('Preserves error boundaries, null first', async () => { const document = parse(` query { ... @defer { @@ -1151,6 +1151,75 @@ describe('Execute: defer directive', () => { ]); }); + 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 { From d48f3d476b6a066a2f3b85e97fc7d44b8f040205 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 20 Apr 2023 12:40:04 +0300 Subject: [PATCH 4/5] remove streamUsage from FieldGroup --- src/execution/collectFields.ts | 50 --------------- src/execution/execute.ts | 113 ++++++++++++++++++++------------- 2 files changed, 69 insertions(+), 94 deletions(-) diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index 46fab8de72..30a498f68e 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -19,7 +19,6 @@ import { GraphQLDeferDirective, GraphQLIncludeDirective, GraphQLSkipDirective, - GraphQLStreamDirective, } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; @@ -31,19 +30,12 @@ export interface DeferUsage { label: string | undefined; } -// initialCount is validated during field execution -export interface PreValidatedStreamUsage { - label: string | undefined; - initialCount: unknown; -} - export interface FieldGroup { parentType: GraphQLObjectType; fieldName: string; fields: Map>; inInitialResult: boolean; shouldInitiateDefer: boolean; - streamUsage: PreValidatedStreamUsage | undefined; } interface MutableFieldGroup extends FieldGroup { fields: AccumulatorMap; @@ -180,8 +172,6 @@ function collectFieldsImpl( fieldGroup.shouldInitiateDefer = false; } } else { - const stream = getStreamValues(variableValues, selection); - const fields = new AccumulatorMap< DeferUsage | undefined, FieldNode @@ -203,7 +193,6 @@ function collectFieldsImpl( fields, inInitialResult, shouldInitiateDefer, - streamUsage: stream, }); } break; @@ -380,45 +369,6 @@ function getDeferValues( }; } -/** - * Returns an object containing the `@stream` arguments if a field should be - * streamed based on the experimental flag, stream directive present and - * not disabled by the "if" argument. - * - * We validate `initialCount` argument later so as to use the correct path - * if an error occurs. - */ -function getStreamValues( - variableValues: { [variable: string]: unknown }, - node: FieldNode, -): - | undefined - | { - initialCount: unknown; - label: string | undefined; - } { - // 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, - node, - variableValues, - ); - - if (!stream) { - return; - } - - if (stream.if === false) { - return; - } - - return { - initialCount: stream.initialCount, - label: typeof stream.label === 'string' ? stream.label : undefined, - }; -} - /** * Determines if a field should be included based on the `@include` and `@skip` * directives, where `@skip` has higher precedence than `@include`. diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 119f587bbe..75a35d7985 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -46,6 +46,7 @@ import { 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'; @@ -53,14 +54,17 @@ import type { DeferUsage, FieldGroup, GroupedFieldSet, - PreValidatedStreamUsage, } from './collectFields.js'; import { collectFields, collectSubfields as _collectSubfields, } from './collectFields.js'; import { mapAsyncIterable } from './mapAsyncIterable.js'; -import { getArgumentValues, getVariableValues } from './values.js'; +import { + getArgumentValues, + getDirectiveValues, + getVariableValues, +} from './values.js'; /* eslint-disable max-params */ // This file contains a lot of such errors but we plan to refactor it anyway @@ -267,10 +271,17 @@ export interface ExecutionArgs { subscribeFieldResolver?: Maybe>; } -export interface ValidatedStreamUsage extends PreValidatedStreamUsage { +export interface StreamUsage { + label: string | undefined; initialCount: number; - // for memoization of the streamed field's FieldGroup - _fieldGroup?: FieldGroup | undefined; + fieldGroup: FieldGroup; +} + +declare module './collectFields.js' { + export interface FieldGroup { + // for memoization + _streamUsage?: StreamUsage | undefined; + } } const UNEXPECTED_EXPERIMENTAL_DIRECTIVES = @@ -1279,41 +1290,78 @@ async function completePromisedValue( } /** - * Returns an object containing the validated `@stream` arguments. + * 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 getValidatedStreamUsage( +function getStreamUsage( exeContext: ExecutionContext, fieldGroup: FieldGroup, path: Path, -): ValidatedStreamUsage | undefined { +): StreamUsage | undefined { // do not stream inner lists of multi-dimensional lists if (typeof path.key === 'number') { return; } - const streamUsage = fieldGroup.streamUsage; + // 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, + toNodes(fieldGroup)[0], + exeContext.variableValues, + ); - if (!streamUsage) { + if (!stream) { return; } - const { label, initialCount } = streamUsage; + if (stream.if === false) { + return; + } - invariant(typeof initialCount === 'number', 'initialCount must be a number'); + invariant( + typeof stream.initialCount === 'number', + 'initialCount must be a number', + ); - invariant(initialCount >= 0, 'initialCount must be a positive integer'); + invariant( + stream.initialCount >= 0, + 'initialCount must be a positive integer', + ); invariant( exeContext.operation.operation !== OperationTypeNode.SUBSCRIPTION, '`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.', ); - return { - label: typeof label === 'string' ? label : undefined, - initialCount, + 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. @@ -1329,7 +1377,7 @@ async function completeAsyncIteratorValue( streamRecord: StreamRecord | undefined, parentRecords: Array | undefined, ): Promise> { - const streamUsage = getValidatedStreamUsage(exeContext, fieldGroup, path); + const streamUsage = getStreamUsage(exeContext, fieldGroup, path); let containsPromise = false; const completedResults: Array = []; let index = 0; @@ -1347,7 +1395,7 @@ async function completeAsyncIteratorValue( index, iterator, exeContext, - getStreamedFieldGroup(fieldGroup, streamUsage), + streamUsage.fieldGroup, info, itemType, path, @@ -1406,29 +1454,6 @@ async function completeAsyncIteratorValue( return containsPromise ? Promise.all(completedResults) : completedResults; } -function getStreamedFieldGroup( - fieldGroup: FieldGroup, - streamUsage: ValidatedStreamUsage, -): FieldGroup { - // TODO: add test for this case - /* c8 ignore next 3 */ - if (streamUsage._fieldGroup) { - return streamUsage._fieldGroup; - } - 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, - }; - streamUsage._fieldGroup = streamedFieldGroup; - return streamedFieldGroup; -} - /** * Complete a list value by completing each item in the list with the * inner type @@ -1468,7 +1493,7 @@ function completeListValue( ); } - const streamUsage = getValidatedStreamUsage(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. @@ -1494,7 +1519,7 @@ function completeListValue( itemPath, item, exeContext, - getStreamedFieldGroup(fieldGroup, streamUsage), + streamUsage.fieldGroup, info, itemType, streamContext, From 7df0bfc186677c81b63c486b29071c11be44645b Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 22 Apr 2023 21:41:33 +0300 Subject: [PATCH 5/5] collectFields does not need to return a map of labels the labels were only checked to be unique (and only have to be unique) in combination with the path at which they are used execute was never using the keys, just the values --- src/execution/collectFields.ts | 58 +++++++++++++++++----------------- src/execution/execute.ts | 4 +-- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index 30a498f68e..aae91456cf 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -47,12 +47,7 @@ type MutableGroupedFieldSet = Map; export interface CollectFieldsResult { groupedFieldSet: GroupedFieldSet; - deferUsages: Map; -} - -interface MutableCollectFieldsResult { - groupedFieldSet: MutableGroupedFieldSet; - deferUsages: Map; + deferUsages: ReadonlyArray; } /** @@ -74,11 +69,6 @@ export function collectFields( const groupedFieldSet = new Map(); const deferUsages = new Map(); - const collectFieldsResult = { - groupedFieldSet, - deferUsages, - }; - collectFieldsImpl( schema, fragments, @@ -86,11 +76,15 @@ export function collectFields( operation, runtimeType, operation.selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, new Set(), ); - return collectFieldsResult; + return { + groupedFieldSet, + deferUsages: Array.from(deferUsages.values()), + }; } /** @@ -114,10 +108,6 @@ export function collectSubfields( ): CollectFieldsResult { const subGroupedFieldSet = new Map(); const deferUsages = new Map(); - const collectSubfieldsResult = { - groupedFieldSet: subGroupedFieldSet, - deferUsages, - }; const visitedFragmentNames = new Set(); for (const [deferUsage, fieldNodes] of fieldGroup.fields) { @@ -130,7 +120,8 @@ export function collectSubfields( operation, returnType, node.selectionSet, - collectSubfieldsResult, + subGroupedFieldSet, + deferUsages, visitedFragmentNames, deferUsage, ); @@ -138,7 +129,10 @@ export function collectSubfields( } } - return collectSubfieldsResult; + return { + groupedFieldSet: subGroupedFieldSet, + deferUsages: Array.from(deferUsages.values()), + }; } // eslint-disable-next-line max-params @@ -149,12 +143,12 @@ function collectFieldsImpl( operation: OperationDefinitionNode, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - collectFieldsResult: MutableCollectFieldsResult, + groupedFieldSet: MutableGroupedFieldSet, + deferUsages: Map, visitedFragmentNames: Set, parentDeferUsage?: DeferUsage | undefined, newDeferUsage?: DeferUsage | undefined, ): void { - const { groupedFieldSet } = collectFieldsResult; for (const selection of selectionSet.selections) { switch (selection.kind) { case Kind.FIELD: { @@ -215,7 +209,8 @@ function collectFieldsImpl( operation, runtimeType, selection.selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, visitedFragmentNames, parentDeferUsage, newDeferUsage, @@ -230,7 +225,8 @@ function collectFieldsImpl( operation, runtimeType, selection.selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, visitedFragmentNames, defer, parentDeferUsage, @@ -266,7 +262,8 @@ function collectFieldsImpl( operation, runtimeType, fragment.selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, visitedFragmentNames, parentDeferUsage, newDeferUsage, @@ -281,7 +278,8 @@ function collectFieldsImpl( operation, runtimeType, fragment.selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, visitedFragmentNames, defer, parentDeferUsage, @@ -300,12 +298,12 @@ function collectDeferredFragmentFields( operation: OperationDefinitionNode, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - collectFieldsResult: MutableCollectFieldsResult, + groupedFieldSet: MutableGroupedFieldSet, + deferUsages: Map, visitedFragmentNames: Set, defer: { label: string | undefined }, parentDeferUsage?: DeferUsage | undefined, ): void { - const deferUsages = collectFieldsResult.deferUsages; const existingNewDefer = deferUsages.get(defer.label); if (existingNewDefer !== undefined) { collectFieldsImpl( @@ -315,7 +313,8 @@ function collectDeferredFragmentFields( operation, runtimeType, selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, visitedFragmentNames, parentDeferUsage, existingNewDefer, @@ -332,7 +331,8 @@ function collectDeferredFragmentFields( operation, runtimeType, selectionSet, - collectFieldsResult, + groupedFieldSet, + deferUsages, visitedFragmentNames, parentDeferUsage, newDefer, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 75a35d7985..c16bf709e0 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -575,7 +575,7 @@ function executeOperation( const deferredFragmentRecords: Array = []; const newDefers = new Map(); - for (const deferUsage of deferUsages.values()) { + for (const deferUsage of deferUsages) { const deferredFragmentRecord = new DeferredFragmentRecord({ deferUsage, path, @@ -1873,7 +1873,7 @@ function collectAndExecuteSubfields( const deferredFragmentRecords: Array = []; const newDefers = new Map(deferMap); - for (const deferUsage of subDeferUsages.values()) { + for (const deferUsage of subDeferUsages) { const deferredFragmentRecord = new DeferredFragmentRecord({ deferUsage, path,