From 0857b7ef9bc12bfc40eec463523bb96cad4c6021 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 7 Jan 2025 12:13:18 +0200 Subject: [PATCH] initial version of current to legacy format transformer --- src/transform/__tests__/defer-test.ts | 2370 ++++++++++++++++++ src/transform/__tests__/stream-test.ts | 2439 +++++++++++++++++++ src/transform/buildTransformationContext.ts | 327 +++ src/transform/collectFields.ts | 330 +++ src/transform/completeValue.ts | 193 ++ src/transform/embedErrors.ts | 120 + src/transform/getObjectAtPath.ts | 31 + src/transform/legacyExecuteIncrementally.ts | 37 + src/transform/memoize3of4.ts | 40 + src/transform/transformResult.ts | 393 +++ 10 files changed, 6280 insertions(+) create mode 100644 src/transform/__tests__/defer-test.ts create mode 100644 src/transform/__tests__/stream-test.ts create mode 100644 src/transform/buildTransformationContext.ts create mode 100644 src/transform/collectFields.ts create mode 100644 src/transform/completeValue.ts create mode 100644 src/transform/embedErrors.ts create mode 100644 src/transform/getObjectAtPath.ts create mode 100644 src/transform/legacyExecuteIncrementally.ts create mode 100644 src/transform/memoize3of4.ts create mode 100644 src/transform/transformResult.ts diff --git a/src/transform/__tests__/defer-test.ts b/src/transform/__tests__/defer-test.ts new file mode 100644 index 0000000000..b189fd1007 --- /dev/null +++ b/src/transform/__tests__/defer-test.ts @@ -0,0 +1,2370 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js'; + +import type { DocumentNode } from '../../language/ast.js'; +import { parse } from '../../language/parser.js'; + +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition.js'; +import { GraphQLID, GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.js'; +import type { + LegacyInitialIncrementalExecutionResult, + LegacySubsequentIncrementalExecutionResult, +} from '../transformResult.js'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: 'Friend', +}); + +const friends = [ + { name: 'Han', id: 2 }, + { name: 'Leia', id: 3 }, + { name: 'C-3PO', id: 4 }, +]; + +const deeperObject = new GraphQLObjectType({ + fields: { + foo: { type: GraphQLString }, + bar: { type: GraphQLString }, + baz: { type: GraphQLString }, + bak: { type: GraphQLString }, + }, + name: 'DeeperObject', +}); + +const nestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject }, + name: { type: GraphQLString }, + }, + name: 'NestedObject', +}); + +const anotherNestedObject = new GraphQLObjectType({ + fields: { + deeperObject: { type: deeperObject }, + }, + name: 'AnotherNestedObject', +}); + +const hero = { + name: 'Luke', + id: 1, + friends, + nestedObject, + anotherNestedObject, +}; + +const c = new GraphQLObjectType({ + fields: { + d: { type: GraphQLString }, + nonNullErrorField: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: 'c', +}); + +const e = new GraphQLObjectType({ + fields: { + f: { type: GraphQLString }, + }, + name: 'e', +}); + +const b = new GraphQLObjectType({ + fields: { + c: { type: c }, + e: { type: e }, + }, + name: 'b', +}); + +const a = new GraphQLObjectType({ + fields: { + b: { type: b }, + someField: { type: GraphQLString }, + }, + name: 'a', +}); + +const g = new GraphQLObjectType({ + fields: { + h: { type: GraphQLString }, + }, + name: 'g', +}); + +const heroType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + friends: { + type: new GraphQLList(friendType), + }, + nestedObject: { type: nestedObject }, + anotherNestedObject: { type: anotherNestedObject }, + }, + name: 'Hero', +}); + +const query = new GraphQLObjectType({ + fields: { + hero: { + type: heroType, + }, + a: { type: a }, + g: { type: g }, + }, + name: 'Query', +}); + +const schema = new GraphQLSchema({ query }); + +async function complete( + document: DocumentNode, + rootValue: unknown = { hero }, + enableEarlyExecution = false, +) { + const result = await legacyExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ('initialResult' in result) { + const results: Array< + | LegacyInitialIncrementalExecutionResult + | LegacySubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; + } + return result; +} + +describe('Execute: legacy defer directive format', () => { + it('Can defer fragments containing scalar types', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + it('Can disable defer using if argument', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer(if: false) + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual({ + data: { + hero: { + id: '1', + name: 'Luke', + }, + }, + }); + }); + it('Does not disable defer with null if argument', async () => { + const document = parse(` + query HeroNameQuery($shouldDefer: Boolean) { + hero { + id + ...NameFragment @defer(if: $shouldDefer) + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: { name: 'Luke' }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + it('Does not execute deferred fragments early when not specified', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const order: Array = []; + const result = await complete(document, { + hero: { + ...hero, + id: async () => { + await resolveOnNextTick(); + await resolveOnNextTick(); + order.push('slow-id'); + return hero.id; + }, + name: () => { + order.push('fast-name'); + return hero.name; + }, + }, + }); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + expect(order).to.deep.equal(['slow-id', 'fast-name']); + }); + it('Does execute deferred fragments early when specified', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const order: Array = []; + const result = await complete( + document, + { + hero: { + ...hero, + id: async () => { + await resolveOnNextTick(); + await resolveOnNextTick(); + order.push('slow-id'); + return hero.id; + }, + name: () => { + order.push('fast-name'); + return hero.name; + }, + }, + }, + true, + ); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + expect(order).to.deep.equal(['fast-name', 'slow-id']); + }); + it('Can defer fragments on the top level Query field', async () => { + const document = parse(` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + id + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + id: '1', + }, + }, + path: [], + label: 'DeferQuery', + }, + ], + hasNext: false, + }, + ]); + }); + it('Can defer fragments with errors on the top level Query field', async () => { + const document = parse(` + query HeroNameQuery { + ...QueryFragment @defer(label: "DeferQuery") + } + fragment QueryFragment on Query { + hero { + name + } + } + `); + const result = await complete(document, { + hero: { + ...hero, + name: () => { + throw new Error('bad'); + }, + }, + }); + + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + hero: { + name: null, + }, + }, + errors: [ + { + message: 'bad', + locations: [{ line: 7, column: 11 }], + path: ['hero', 'name'], + }, + ], + path: [], + label: 'DeferQuery', + }, + ], + hasNext: false, + }, + ]); + }); + it('Can defer a fragment within an already deferred fragment', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ...TopFragment @defer(label: "DeferTop") + } + } + fragment TopFragment on Hero { + id + ...NestedFragment @defer(label: "DeferNested") + } + fragment NestedFragment on Hero { + friends { + name + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + id: '1', + }, + path: ['hero'], + label: 'DeferTop', + }, + { + data: { + friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], + }, + path: ['hero'], + label: 'DeferNested', + }, + ], + hasNext: false, + }, + ]); + }); + it('Can defer a fragment that is also not deferred, deferred fragment is first', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ...TopFragment @defer(label: "DeferTop") + ...TopFragment + } + } + fragment TopFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual({ + data: { + hero: { + name: 'Luke', + }, + }, + }); + }); + it('Can defer a fragment that is also not deferred, non-deferred fragment is first', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ...TopFragment + ...TopFragment @defer(label: "DeferTop") + } + } + fragment TopFragment on Hero { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual({ + data: { + hero: { + name: 'Luke', + }, + }, + }); + }); + + it('Can defer an inline fragment', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ... on Hero @defer(label: "InlineDeferred") { + name + } + } + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { data: { name: 'Luke' }, path: ['hero'], label: 'InlineDeferred' }, + ], + hasNext: false, + }, + ]); + }); + + it('Does not emit 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: {}, + }, + }); + }); + + it('Emits children of empty defer fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + ... @defer { + ... @defer { + name + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [{ data: { name: 'Luke' }, 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 different labels with varying subfields that return promises', async () => { + const document = parse(` + query HeroNameQuery { + ... @defer(label: "DeferID") { + hero { + id + } + } + ... @defer(label: "DeferName") { + hero { + name + } + } + } + `); + const result = await complete(document, { + hero: { + id: () => Promise.resolve('1'), + name: () => Promise.resolve('Luke'), + }, + }); + 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', + }, + { + data: { + id: '1', + }, + path: ['hero'], + label: 'DeferID', + }, + ], + hasNext: false, + }, + ]); + }); + + it('Initiates deferred grouped field sets only if they have been released as pending', async () => { + const document = parse(` + query { + ... @defer { + a { + ... @defer { + b { + c { d } + } + } + } + } + ... @defer { + a { + someField + ... @defer { + b { + e { f } + } + } + } + } + } + `); + + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + let cResolverCalled = false; + let eResolverCalled = false; + const executeResult = legacyExecuteIncrementally({ + schema, + document, + rootValue: { + a: { + someField: slowFieldPromise, + b: { + c: () => { + cResolverCalled = true; + return { d: 'd' }; + }, + e: () => { + eResolverCalled = true; + return { f: 'f' }; + }, + }, + }, + }, + enableEarlyExecution: false, + }); + + assert('initialResult' in executeResult); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: {}, + hasNext: true, + }); + + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + expect(cResolverCalled).to.equal(false); + expect(eResolverCalled).to.equal(false); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { a: {} }, + path: [], + }, + { + data: { b: { c: { d: 'd' } } }, + path: ['a'], + }, + ], + hasNext: true, + }, + done: false, + }); + + expect(cResolverCalled).to.equal(true); + expect(eResolverCalled).to.equal(false); + + resolveSlowField('someField'); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + data: { a: { someField: 'someField' } }, + path: [], + }, + { + data: { + b: { e: { f: 'f' } }, + }, + path: ['a'], + }, + ], + hasNext: false, + }, + done: false, + }); + + expect(eResolverCalled).to.equal(true); + + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: undefined, + done: true, + }); + }); + + it('Initiates unique deferred grouped field sets after those that are common to sibling defers', async () => { + const document = parse(` + query { + ... @defer { + a { + ... @defer { + b { + c { d } + } + } + } + } + ... @defer { + a { + ... @defer { + b { + c { d } + e { f } + } + } + } + } + } + `); + + const { promise: cPromise, resolve: resolveC } = + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + let cResolverCalled = false; + let eResolverCalled = false; + const executeResult = legacyExecuteIncrementally({ + schema, + document, + rootValue: { + a: { + b: { + c: async () => { + cResolverCalled = true; + await cPromise; + return { d: 'd' }; + }, + e: () => { + eResolverCalled = true; + return { f: 'f' }; + }, + }, + }, + }, + enableEarlyExecution: false, + }); + + assert('initialResult' in executeResult); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: {}, + hasNext: true, + }); + + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + expect(cResolverCalled).to.equal(false); + expect(eResolverCalled).to.equal(false); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { a: {} }, + path: [], + }, + { + data: { a: {} }, + path: [], + }, + ], + hasNext: true, + }, + done: false, + }); + + resolveC(); + + expect(cResolverCalled).to.equal(true); + expect(eResolverCalled).to.equal(false); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + data: { b: { c: { d: 'd' } } }, + path: ['a'], + }, + { + data: { b: { c: { d: 'd' }, e: { f: 'f' } } }, + path: ['a'], + }, + ], + hasNext: false, + }, + done: false, + }); + + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: undefined, + done: true, + }); + }); + + it('Handles multiple defers on the same object', async () => { + const document = parse(` + query { + hero { + friends { + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + ... @defer { + ...FriendFrag + } + } + } + } + } + } + } + + fragment FriendFrag on Friend { + id + name + } + `); + const result = await complete(document); + + expectJSON(result).toDeepEqual([ + { + data: { hero: { friends: [{}, {}, {}] } }, + hasNext: true, + }, + { + incremental: [ + { data: { id: '2', name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: { id: '3', name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: { id: '4', name: 'C-3PO' }, path: ['hero', 'friends', 2] }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles overlapping 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, { + hero: { + nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } }, + anotherNestedObject: { deeperObject: { foo: 'foo' } }, + }, + }); + 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('Handles overlapping 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, { + hero: { nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } } }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + hero: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + nestedObject: { + deeperObject: { foo: 'foo' }, + }, + }, + path: ['hero'], + }, + { + data: { foo: 'foo', bar: 'bar' }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles overlapping 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, { + hero: { + nestedObject: { + deeperObject: { foo: 'foo', bar: 'bar', baz: 'baz', bak: 'bak' }, + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + nestedObject: { + deeperObject: { + foo: 'foo', + }, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } }, + }, + path: ['hero'], + }, + { + data: { deeperObject: { foo: 'foo', bar: 'bar', baz: 'baz' } }, + path: ['hero', 'nestedObject'], + }, + { + data: { foo: 'foo', bar: 'bar', baz: 'baz', bak: 'bak' }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles overlapping 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, { + hero: { nestedObject: { deeperObject: { foo: 'foo', bar: 'bar' } } }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + nestedObject: { + deeperObject: {}, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + foo: 'foo', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + { + data: { + foo: 'foo', + bar: 'bar', + }, + path: ['hero', 'nestedObject', 'deeperObject'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles 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, { + a: { + b: { + c: { d: 'd' }, + e: { f: 'f' }, + }, + }, + g: { h: 'h' }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + a: { + b: { + c: { + d: 'd', + }, + }, + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { e: { f: 'f' } }, + path: ['a', 'b'], + }, + { + data: { a: { b: { e: { f: 'f' } } }, g: { h: 'h' } }, + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles varying subfields with overlapping fields', async () => { + const document = parse(` + query HeroNameQuery { + ... @defer { + hero { + id + } + } + ... @defer { + hero { + name + shouldBeWithNameDespiteAdditionalDefer: name + ... @defer { + shouldBeWithNameDespiteAdditionalDefer: name + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { hero: { id: '1' } }, + path: [], + }, + { + data: { + hero: { + name: 'Luke', + shouldBeWithNameDespiteAdditionalDefer: 'Luke', + }, + }, + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Nulls cross defer boundaries, null first', async () => { + const document = parse(` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `); + const result = await complete(document, { + a: { b: { c: { d: 'd' } }, someField: 'someField' }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + a: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 8, column: 17 }], + path: ['a', 'b', 'c', 'nonNullErrorField'], + }, + ], + path: [], + }, + { + data: { b: { c: { d: 'd' } } }, + path: ['a'], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Nulls cross defer boundaries, value first', async () => { + const document = parse(` + query { + ... @defer { + a { + b { + c { + d + } + } + } + } + a { + ... @defer { + someField + b { + c { + nonNullErrorField + } + } + } + } + } + `); + const result = await complete(document, { + a: { + b: { c: { d: 'd' }, nonNullErrorFIeld: null }, + someField: 'someField', + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + a: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { a: { b: { c: { d: 'd' } } } }, + path: [], + }, + { + data: null, + 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('Handles multiple erroring deferred grouped field sets', async () => { + const document = parse(` + query { + ... @defer { + a { + b { + c { + someError: nonNullErrorField + } + } + } + } + ... @defer { + a { + b { + c { + anotherError: nonNullErrorField + } + } + } + } + } + `); + const result = await complete(document, { + a: { + b: { c: { nonNullErrorField: null } }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 7, column: 17 }], + path: ['a', 'b', 'c', 'someError'], + }, + ], + path: [], + }, + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 16, column: 17 }], + path: ['a', 'b', 'c', 'anotherError'], + }, + ], + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles multiple erroring deferred grouped field sets for the same fragment', async () => { + const document = parse(` + query { + ... @defer { + a { + b { + someC: c { + d: d + } + anotherC: c { + d: d + } + } + } + } + ... @defer { + a { + b { + someC: c { + someError: nonNullErrorField + } + anotherC: c { + anotherError: nonNullErrorField + } + } + } + } + } + `); + const result = await complete(document, { + a: { + b: { c: { d: 'd', nonNullErrorField: null } }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 19, column: 17 }], + path: ['a', 'b', 'someC', 'someError'], + }, + ], + path: [], + }, + { + data: { a: { b: { someC: { d: 'd' }, anotherC: { d: 'd' } } } }, + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('handles a payload with a null that cannot be merged', async () => { + const document = parse(` + query { + ... @defer { + a { + someField + b { + c { + nonNullErrorField + } + } + } + } + a { + ... @defer { + b { + c { + d + } + } + } + } + } + `); + const result = await complete( + document, + { + a: { + b: { + c: { + d: 'd', + nonNullErrorField: async () => { + await resolveOnNextTick(); + return null; + }, + }, + }, + someField: 'someField', + }, + }, + true, + ); + expectJSON(result).toDeepEqual([ + { + data: { + a: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { b: { c: { d: 'd' } } }, + path: ['a'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: null, + 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, + }, + }, + true, + ); + 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'], + }, + ], + }); + }); + + 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, + }, + }, + true, + ); + 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('Deduplicates 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' }], + }, + }, + }); + }); + + it('Deduplicates 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' }] } }, + }); + }); + + it('Handles 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: [] } }, + }); + }); + + it('Handles 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('Handles 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: [] } }, + }); + }); + + it('Deduplicates 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 } }, + }); + }); + + it('Deduplicates promise object fields', async () => { + const document = parse(` + query { + hero { + nestedObject { + name + } + ... @defer { + nestedObject { + name + } + } + } + } + `); + const result = await complete(document, { + hero: { + nestedObject: () => Promise.resolve({ name: 'foo' }), + }, + }); + expectJSON(result).toDeepEqual({ + data: { hero: { nestedObject: { name: 'foo' } } }, + }); + }); + + it('Handles errors thrown in deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const result = await complete(document, { + hero: { + ...hero, + name: () => { + throw new Error('bad'); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: { name: null }, + errors: [ + { + message: 'bad', + locations: [{ line: 9, column: 9 }], + path: ['hero', 'name'], + }, + ], + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles non-nullable errors thrown in deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullName + } + `); + const result = await complete(document, { + hero: { + ...hero, + nonNullName: () => null, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [{ line: 9, column: 9 }], + path: ['hero', 'nonNullName'], + }, + ], + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles non-nullable errors thrown outside deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + nonNullName + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + id + } + `); + const result = await complete(document, { + hero: { + ...hero, + nonNullName: () => null, + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [ + { + line: 4, + column: 11, + }, + ], + path: ['hero', 'nonNullName'], + }, + ], + data: { + hero: null, + }, + }); + }); + it('Handles async non-nullable errors thrown in deferred fragments', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + nonNullName + } + `); + const result = await complete(document, { + hero: { + ...hero, + nonNullName: () => Promise.resolve(null), + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { hero: { id: '1' } }, + hasNext: true, + }, + { + incremental: [ + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field Hero.nonNullName.', + locations: [{ line: 9, column: 9 }], + path: ['hero', 'nonNullName'], + }, + ], + path: ['hero'], + }, + ], + hasNext: false, + }, + ]); + }); + it('Returns payloads in correct order', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + friends { + ...NestedFragment @defer + } + } + fragment NestedFragment on Friend { + name + } + `); + const result = await complete(document, { + hero: { + ...hero, + name: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { name: 'slow', friends: [{}, {}, {}] }, + path: ['hero'], + }, + { data: { name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: { name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: { name: 'C-3PO' }, path: ['hero', 'friends', 2] }, + ], + hasNext: false, + }, + ]); + }); + it('Returns payloads from synchronous data in correct order', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + friends { + ...NestedFragment @defer + } + } + fragment NestedFragment on Friend { + name + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + friends: [{}, {}, {}], + }, + path: ['hero'], + }, + { data: { name: 'Han' }, path: ['hero', 'friends', 0] }, + { data: { name: 'Leia' }, path: ['hero', 'friends', 1] }, + { data: { name: 'C-3PO' }, path: ['hero', 'friends', 2] }, + ], + hasNext: false, + }, + ]); + }); + + it('Filters deferred payloads when a list item returned by an async iterable is nulled', async () => { + const document = parse(` + query { + hero { + friends { + nonNullName + ...NameFragment @defer + } + } + } + fragment NameFragment on Friend { + name + } + `); + const result = await complete(document, { + hero: { + ...hero, + async *friends() { + yield await Promise.resolve({ + ...friends[0], + nonNullName: () => Promise.resolve(null), + }); + }, + }, + }); + expectJSON(result).toDeepEqual({ + data: { + hero: { + friends: [null], + }, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field Friend.nonNullName.', + locations: [{ line: 5, column: 11 }], + path: ['hero', 'friends', 0, 'nonNullName'], + }, + ], + }); + }); +}); diff --git a/src/transform/__tests__/stream-test.ts b/src/transform/__tests__/stream-test.ts new file mode 100644 index 0000000000..fd2c3207e6 --- /dev/null +++ b/src/transform/__tests__/stream-test.ts @@ -0,0 +1,2439 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; +import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js'; + +import type { DocumentNode } from '../../language/ast.js'; +import { parse } from '../../language/parser.js'; + +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition.js'; +import { GraphQLID, GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.js'; +import type { + LegacyInitialIncrementalExecutionResult, + LegacySubsequentIncrementalExecutionResult, +} from '../transformResult.js'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + nonNullName: { type: new GraphQLNonNull(GraphQLString) }, + }, + name: 'Friend', +}); + +const friends = [ + { name: 'Luke', id: 1 }, + { name: 'Han', id: 2 }, + { name: 'Leia', id: 3 }, +]; + +const query = new GraphQLObjectType({ + fields: { + scalarList: { + type: new GraphQLList(GraphQLString), + }, + scalarListList: { + type: new GraphQLList(new GraphQLList(GraphQLString)), + }, + friendList: { + type: new GraphQLList(friendType), + }, + nonNullFriendList: { + type: new GraphQLList(new GraphQLNonNull(friendType)), + }, + nestedObject: { + type: new GraphQLObjectType({ + name: 'NestedObject', + fields: { + scalarField: { + type: GraphQLString, + }, + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + nestedFriendList: { type: new GraphQLList(friendType) }, + deeperNestedObject: { + type: new GraphQLObjectType({ + name: 'DeeperNestedObject', + fields: { + nonNullScalarField: { + type: new GraphQLNonNull(GraphQLString), + }, + deeperNestedFriendList: { type: new GraphQLList(friendType) }, + }, + }), + }, + }, + }), + }, + }, + name: 'Query', +}); + +const schema = new GraphQLSchema({ query }); + +async function complete( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false, +) { + const result = await legacyExecuteIncrementally({ + schema, + document, + rootValue, + enableEarlyExecution, + }); + + if ('initialResult' in result) { + const results: Array< + | LegacyInitialIncrementalExecutionResult + | LegacySubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; + } + return result; +} + +async function completeAsync( + document: DocumentNode, + numCalls: number, + rootValue: unknown = {}, +) { + const result = await legacyExecuteIncrementally({ + schema, + document, + rootValue, + }); + + assert('initialResult' in result); + + const iterator = result.subsequentResults[Symbol.asyncIterator](); + + const promises: Array< + PromiseOrValue< + IteratorResult< + | LegacyInitialIncrementalExecutionResult + | LegacySubsequentIncrementalExecutionResult + > + > + > = [{ done: false, value: result.initialResult }]; + for (let i = 0; i < numCalls; i++) { + promises.push(iterator.next()); + } + return Promise.all(promises); +} + +describe('Execute: legacy stream directive', () => { + it('Can stream a list field', async () => { + const document = parse('{ scalarList @stream(initialCount: 1) }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + incremental: [ + { items: ['banana', 'coconut'], path: ['scalarList', 1] }, + ], + hasNext: false, + }, + ]); + }); + it('Can use default value of initialCount', async () => { + const document = parse('{ scalarList @stream }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: [], + }, + hasNext: true, + }, + { + incremental: [ + { items: ['apple', 'banana', 'coconut'], path: ['scalarList', 0] }, + ], + hasNext: false, + }, + ]); + }); + it('Negative values of initialCount throw field errors', async () => { + const document = parse('{ scalarList @stream(initialCount: -2) }'); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'initialCount must be a positive integer', + locations: [ + { + line: 1, + column: 3, + }, + ], + path: ['scalarList'], + }, + ], + data: { + scalarList: null, + }, + }); + }); + it('Returns label from stream directive', async () => { + const document = parse( + '{ scalarList @stream(initialCount: 1, label: "scalar-stream") }', + ); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['apple'], + }, + hasNext: true, + }, + { + incremental: [ + { + items: ['banana', 'coconut'], + path: ['scalarList', 1], + label: 'scalar-stream', + }, + ], + hasNext: false, + }, + ]); + }); + it('Can disable @stream using if argument', async () => { + const document = parse( + '{ scalarList @stream(initialCount: 0, if: false) }', + ); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual({ + data: { scalarList: ['apple', 'banana', 'coconut'] }, + }); + }); + it('Does not disable stream with null if argument', async () => { + const document = parse( + 'query ($shouldStream: Boolean) { scalarList @stream(initialCount: 2, if: $shouldStream) }', + ); + const result = await complete(document, { + scalarList: () => ['apple', 'banana', 'coconut'], + }); + expectJSON(result).toDeepEqual([ + { + data: { scalarList: ['apple', 'banana'] }, + hasNext: true, + }, + { + incremental: [{ items: ['coconut'], path: ['scalarList', 2] }], + hasNext: false, + }, + ]); + }); + it('Can stream multi-dimensional lists', async () => { + const document = parse('{ scalarListList @stream(initialCount: 1) }'); + const result = await complete(document, { + scalarListList: () => [ + ['apple', 'apple', 'apple'], + ['banana', 'banana', 'banana'], + ['coconut', 'coconut', 'coconut'], + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarListList: [['apple', 'apple', 'apple']], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [ + ['banana', 'banana', 'banana'], + ['coconut', 'coconut', 'coconut'], + ], + path: ['scalarListList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Can stream a field that returns a list of promises', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Can stream in correct order with lists of promises', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => friends.map((f) => Promise.resolve(f)), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Luke', id: '1' }], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Han', id: '2' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Does not execute early if not specified', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + const result = await complete(document, { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(i); + return f.id; + }, + })), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + expect(order).to.deep.equal([0, 1, 2]); + }); + it('Executes early if specified', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + const result = await complete( + document, + { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(i); + return f.id; + }, + })), + }, + true, + ); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }, { id: '2' }, { id: '3' }], + path: ['friendList', 0], + }, + ], + hasNext: false, + }, + ]); + expect(order).to.deep.equal([2, 1, 0]); + }); + it('Can stream a field that returns a list with nested promises', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => + friends.map((f) => ({ + name: Promise.resolve(f.name), + id: Promise.resolve(f.id), + })), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [ + { + name: 'Luke', + id: '1', + }, + { + name: 'Han', + id: '2', + }, + ], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [ + { + name: 'Leia', + id: '3', + }, + ], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles rejections in a field that returns a list of promises before initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(f); + }), + }); + expectJSON(result).toDeepEqual([ + { + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + data: { + friendList: [{ name: 'Luke', id: '1' }, null], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles rejections in a field that returns a list of promises after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document, { + friendList: () => + friends.map((f, i) => { + if (i === 1) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(f); + }), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke', id: '1' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList', 1], + }, + ], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Can stream a field that returns an async iterable', async () => { + const document = parse(` + query { + friendList @stream { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Luke', id: '1' }], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Han', id: '2' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Can stream a field that returns an async iterable, using a non-zero initialCount', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [ + { name: 'Luke', id: '1' }, + { name: 'Han', id: '2' }, + ], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Negative values of initialCount throw field errors on a field that returns an async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: -2) { + name + id + } + } + `); + const result = await complete(document, { + // eslint-disable-next-line @typescript-eslint/no-empty-function + async *friendList() {}, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'initialCount must be a positive integer', + locations: [{ line: 3, column: 9 }], + path: ['friendList'], + }, + ], + data: { + friendList: null, + }, + }); + }); + it('Does not execute early if not specified, when streaming from an async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + // eslint-disable-next-line @typescript-eslint/require-await + const slowFriend = async (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + expect(order).to.deep.equal([0, 1, 2]); + }); + it('Executes early if specified when streaming from an async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + const slowFriend = (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + const result = await complete( + document, + { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }, + true, + ); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }, { id: '2' }, { id: '3' }], + path: ['friendList', 0], + }, + ], + hasNext: false, + }, + ]); + expect(order).to.deep.equal([2, 1, 0]); + }); + it('Can handle concurrent calls to .next() without waiting', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await completeAsync(document, 3, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }); + expectJSON(result).toDeepEqual([ + { + done: false, + value: { + data: { + friendList: [ + { name: 'Luke', id: '1' }, + { name: 'Han', id: '2' }, + ], + }, + hasNext: true, + }, + }, + { + done: false, + value: { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + }, + { + done: false, + value: { + hasNext: false, + }, + }, + { done: true, value: undefined }, + ]); + }); + it('Handles error thrown in async iterable before initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 2) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error('bad'); + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList'], + }, + ], + data: { + friendList: null, + }, + }); + }); + it('Handles error thrown in async iterable after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + throw new Error('bad'); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ name: 'Luke', id: '1' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: 'bad', + locations: [{ line: 3, column: 9 }], + path: ['friendList'], + }, + ], + path: ['friendList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles null returned in non-null list items after initialCount is reached', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [friends[0], null, friends[1]], + }); + + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.nonNullFriendList.', + locations: [{ line: 3, column: 9 }], + path: ['nonNullFriendList', 1], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles null returned in non-null async iterable list items after initialCount is reached', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + name + } + } + `); + const result = await complete(document, { + async *nonNullFriendList() { + try { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(null); /* c8 ignore start */ + // Not reachable, early return + } finally { + /* c8 ignore stop */ + // eslint-disable-next-line no-unsafe-finally + throw new Error('Oops'); + } + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.nonNullFriendList.', + locations: [{ line: 3, column: 9 }], + path: ['nonNullFriendList', 1], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + scalarList @stream(initialCount: 1) + } + `); + const result = await complete(document, { + scalarList: () => [friends[0].name, {}], + }); + expectJSON(result).toDeepEqual([ + { + data: { + scalarList: ['Luke'], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'String cannot represent value: {}', + locations: [{ line: 3, column: 9 }], + path: ['scalarList', 1], + }, + ], + path: ['scalarList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles async errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + friendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles nested async errors thrown by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + friendList: () => [ + { nonNullName: Promise.resolve(friends[0].name) }, + { nonNullName: Promise.reject(new Error('Oops')) }, + { nonNullName: Promise.resolve(friends[1].name) }, + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles async errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [ + Promise.resolve({ nonNullName: friends[0].name }), + Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }), + Promise.resolve({ nonNullName: friends[1].name }), + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles nested async errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => { + const document = parse(` + query { + nonNullFriendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + nonNullFriendList: () => [ + { nonNullName: Promise.resolve(friends[0].name) }, + { nonNullName: Promise.reject(new Error('Oops')) }, + { nonNullName: Promise.resolve(friends[1].name) }, + ], + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + hasNext: false, + }, + ]); + }); + it('Handles async errors thrown by completeValue after initialCount is reached from async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + nonNullName + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }); + yield await Promise.resolve({ nonNullName: friends[1].name }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['friendList', 1, 'nonNullName'], + }, + ], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + 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) { + nonNullName + } + } + `); + const result = await complete(document, { + async *nonNullFriendList() { + yield await Promise.resolve({ nonNullName: friends[0].name }); + yield await Promise.resolve({ + nonNullName: () => Promise.reject(new Error('Oops')), + }); /* c8 ignore start */ + } /* c8 ignore stop */, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + 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 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')), + }, + }); + // Not reached + /* c8 ignore next 5 */ + case 2: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[1].name }, + }); + } + }, + }), + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + 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')), + }, + }); + // Not reached + /* c8 ignore next 5 */ + case 2: + return Promise.resolve({ + done: false, + value: { nonNullName: friends[1].name }, + }); + } + }, + return: async () => { + await resolveOnNextTick(); + returned = true; + return { done: true }; + }, + }), + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nonNullFriendList: [{ nonNullName: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 4, column: 11 }], + path: ['nonNullFriendList', 1, 'nonNullName'], + }, + ], + path: ['nonNullFriendList', 1], + }, + ], + hasNext: false, + }, + ]); + expect(returned).to.equal(true); + }); + it('Filters payloads that are nulled', async () => { + const document = parse(` + query { + nestedObject { + nonNullScalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `); + const result = await complete(document, { + nestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', + locations: [{ line: 4, column: 11 }], + path: ['nestedObject', 'nonNullScalarField'], + }, + ], + data: { + nestedObject: null, + }, + }); + }); + it('Filters payloads that are nulled by a later synchronous error', async () => { + const document = parse(` + query { + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + nonNullScalarField + } + } + `); + const result = await complete(document, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + nonNullScalarField: () => null, + }, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Cannot return null for non-nullable field NestedObject.nonNullScalarField.', + locations: [{ line: 7, column: 11 }], + path: ['nestedObject', 'nonNullScalarField'], + }, + ], + data: { + nestedObject: null, + }, + }); + }); + it('Does not filter payloads when null error is in a different path', async () => { + const document = parse(` + query { + otherNestedObject: nestedObject { + ... @defer { + scalarField + } + } + nestedObject { + nestedFriendList @stream(initialCount: 0) { + name + } + } + } + `); + const result = await complete(document, { + nestedObject: { + scalarField: () => Promise.reject(new Error('Oops')), + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + otherNestedObject: {}, + nestedObject: { nestedFriendList: [] }, + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + { + data: { scalarField: null }, + errors: [ + { + message: 'Oops', + locations: [{ line: 5, column: 13 }], + path: ['otherNestedObject', 'scalarField'], + }, + ], + path: ['otherNestedObject'], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Filters stream payloads that are nulled in a deferred payload', async () => { + const document = parse(` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `); + const result = await complete(document, { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + async *deeperNestedFriendList() { + yield await Promise.resolve(friends[0]); /* c8 ignore start */ + } /* c8 ignore stop */, + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nestedObject: {}, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + deeperNestedObject: null, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', + locations: [{ line: 6, column: 15 }], + path: [ + 'nestedObject', + 'deeperNestedObject', + 'nonNullScalarField', + ], + }, + ], + path: ['nestedObject'], + }, + ], + hasNext: false, + }, + ]); + }); + it('Filters defer payloads that are nulled in a stream response', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + nonNullName + ... @defer { + name + } + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve({ + name: friends[0].name, + nonNullName: () => Promise.resolve(null), + }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: + 'Cannot return null for non-nullable field Friend.nonNullName.', + locations: [{ line: 4, column: 9 }], + path: ['friendList', 0, 'nonNullName'], + }, + ], + path: ['friendList', 0], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + + it('Returns iterator and ignores errors when stream payloads are filtered', async () => { + let returned = false; + let requested = false; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + /* c8 ignore start */ + if (requested) { + // 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({ + done: false, + value: { + name: friend.name, + nonNullName: null, + }, + }); + }, + return: () => { + returned = true; + // Ignores errors from return. + return Promise.reject(new Error('Oops')); + }, + }), + }; + + const document = parse(` + query { + nestedObject { + ... @defer { + deeperNestedObject { + nonNullScalarField + deeperNestedFriendList @stream(initialCount: 0) { + name + } + } + } + } + } + `); + + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + nestedObject: { + deeperNestedObject: { + nonNullScalarField: () => Promise.resolve(null), + deeperNestedFriendList: iterable, + }, + }, + }, + enableEarlyExecution: true, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + nestedObject: {}, + }, + hasNext: true, + }); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: false, + value: { + incremental: [ + { + data: { + deeperNestedObject: null, + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field DeeperNestedObject.nonNullScalarField.', + locations: [{ line: 6, column: 15 }], + path: [ + 'nestedObject', + 'deeperNestedObject', + 'nonNullScalarField', + ], + }, + ], + path: ['nestedObject'], + }, + ], + hasNext: false, + }, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ done: true, value: undefined }); + + assert(returned); + }); + it('Handles promises returned by completeValue after initialCount is reached', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 1) { + id + name + } + } + `); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve({ + id: friends[2].id, + name: () => Promise.resolve(friends[2].name), + }); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [{ id: '1', name: 'Luke' }], + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2', name: 'Han' }], + path: ['friendList', 1], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3', name: 'Leia' }], + path: ['friendList', 2], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + 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', name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2', name: 'Han' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Returns payloads in correct order when parent deferred fragment resolves slower than stream', async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const document = parse(` + query { + nestedObject { + ... DeferFragment @defer + } + } + fragment DeferFragment on NestedObject { + scalarField + nestedFriendList @stream(initialCount: 0) { + name + } + } + `); + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + nestedObject: { + scalarField: () => slowFieldPromise, + async *nestedFriendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve(friends[1]); + }, + }, + }, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + nestedObject: {}, + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + resolveSlowField('slow'); + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { scalarField: 'slow', nestedFriendList: [] }, + path: ['nestedObject'], + }, + ], + hasNext: true, + }, + done: false, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + items: [{ name: 'Luke' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, + ], + hasNext: true, + }, + done: false, + }); + + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: { + incremental: [ + { + items: [{ name: 'Han' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + ], + hasNext: true, + }, + done: false, + }); + + const result5 = await iterator.next(); + expectJSON(result5).toDeepEqual({ + value: { + hasNext: false, + }, + done: false, + }); + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ + value: undefined, + done: true, + }); + }); + it('Can @defer fields that are resolved after async iterable is complete', async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const document = parse(` + query { + friendList @stream(label:"stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `); + + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [], + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + resolveIterableCompletion(null); + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + items: [{ id: '1' }], + path: ['friendList', 0], + label: 'stream-label', + }, + { + data: { name: 'Luke' }, + path: ['friendList', 0], + label: 'DeferName', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result3Promise = iterator.next(); + resolveSlowField('Han'); + const result3 = await result3Promise; + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + items: [{ id: '2' }], + path: ['friendList', 1], + label: 'stream-label', + }, + ], + hasNext: true, + }, + done: false, + }); + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: { + hasNext: true, + }, + done: false, + }); + const result5 = await iterator.next(); + expectJSON(result5).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Han' }, + path: ['friendList', 1], + label: 'DeferName', + }, + ], + hasNext: false, + }, + done: false, + }); + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ + value: undefined, + done: true, + }); + }); + it('Can @defer fields that are resolved before async iterable is complete', async () => { + const { promise: slowFieldPromise, resolve: resolveSlowField } = + promiseWithResolvers(); + const { + promise: iterableCompletionPromise, + resolve: resolveIterableCompletion, + } = promiseWithResolvers(); + + const document = parse(` + query { + friendList @stream(initialCount: 1, label:"stream-label") { + ...NameFragment @defer(label: "DeferName") @defer(label: "DeferName") + id + } + } + fragment NameFragment on Friend { + name + } + `); + + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + async *friendList() { + yield await Promise.resolve(friends[0]); + yield await Promise.resolve({ + id: friends[1].id, + name: () => slowFieldPromise, + }); + await iterableCompletionPromise; + }, + }, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [{ id: '1' }], + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + resolveSlowField('Han'); + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Luke' }, + path: ['friendList', 0], + label: 'DeferName', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + items: [{ id: '2' }], + path: ['friendList', 1], + label: 'stream-label', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: { + incremental: [ + { + data: { name: 'Han' }, + path: ['friendList', 1], + label: 'DeferName', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result5Promise = iterator.next(); + resolveIterableCompletion(null); + const result5 = await result5Promise; + expectJSON(result5).toDeepEqual({ + value: { + hasNext: false, + }, + done: false, + }); + + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ + value: undefined, + done: true, + }); + }); + it('Returns underlying async iterables when returned generator is returned', async () => { + let returned = false; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise(() => { + /* never resolves */ + }), + return: () => { + returned = true; + }, + }), + }; + + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + friendList: iterable, + }, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [], + }, + hasNext: true, + }); + + const result2Promise = iterator.next(); + const returnPromise = iterator.return(); + + const result2 = await result2Promise; + expectJSON(result2).toDeepEqual({ + done: true, + value: undefined, + }); + await returnPromise; + assert(returned); + }); + it('Can return async iterable when underlying iterable does not have a return method', async () => { + let index = 0; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + const friend = friends[index++]; + if (friend == null) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + }), + }; + + const document = parse(` + query { + friendList @stream(initialCount: 1) { + name + id + } + } + `); + + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + friendList: iterable, + }, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [ + { + id: '1', + name: 'Luke', + }, + ], + }, + hasNext: true, + }); + + const returnPromise = iterator.return(); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: true, + value: undefined, + }); + await returnPromise; + }); + it('Returns underlying async iterables when returned generator is thrown', async () => { + let index = 0; + let returned = false; + const iterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + const friend = friends[index++]; + if (friend == null) { + return Promise.resolve({ done: true, value: undefined }); + } + return Promise.resolve({ done: false, value: friend }); + }, + return: () => { + returned = true; + }, + }), + }; + const document = parse(` + query { + friendList @stream(initialCount: 1) { + ... @defer { + name + } + id + } + } + `); + + const executeResult = await legacyExecuteIncrementally({ + schema, + document, + rootValue: { + friendList: iterable, + }, + }); + assert('initialResult' in executeResult); + const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); + + const result1 = executeResult.initialResult; + expectJSON(result1).toDeepEqual({ + data: { + friendList: [ + { + id: '1', + }, + ], + }, + hasNext: true, + }); + + const throwPromise = iterator.throw(new Error('bad')); + + const result2 = await iterator.next(); + expectJSON(result2).toDeepEqual({ + done: true, + value: undefined, + }); + await expectPromise(throwPromise).toRejectWith('bad'); + assert(returned); + }); +}); diff --git a/src/transform/buildTransformationContext.ts b/src/transform/buildTransformationContext.ts new file mode 100644 index 0000000000..a1707d21b5 --- /dev/null +++ b/src/transform/buildTransformationContext.ts @@ -0,0 +1,327 @@ +import { invariant } from '../jsutils/invariant.js'; +import { mapValue } from '../jsutils/mapValue.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; + +import type { + ArgumentNode, + DirectiveNode, + SelectionNode, + SelectionSetNode, +} from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; + +import { + GraphQLDeferDirective, + GraphQLStreamDirective, +} from '../type/directives.js'; +import { TypeNameMetaFieldDef } from '../type/introspection.js'; + +import { collectSubfields as _collectSubfields } from '../execution/collectFields.js'; +import type { ValidatedExecutionArgs } from '../execution/execute.js'; +import type { PendingResult } from '../execution/types.js'; + +type SelectionSetNodeOrFragmentName = + | { node: SelectionSetNode; fragmentName?: never } + | { node?: never; fragmentName: string }; + +interface DeferUsageContext { + originalLabel: string | undefined; + selectionSet: SelectionSetNodeOrFragmentName; +} + +interface StreamUsageContext { + originalLabel: string | undefined; + selectionSet: SelectionSetNode | undefined; +} + +export interface TransformationContext { + transformedArgs: ValidatedExecutionArgs; + deferUsageMap: Map; + streamUsageMap: Map; + prefix: string; + pendingResultsById: Map; + pendingLabelsByPath: Map>; + mergedResult: ObjMap; +} + +interface RequestTransformationContext { + prefix: string; + incrementalCounter: number; + deferUsageMap: Map; + streamUsageMap: Map; +} + +export function buildTransformationContext( + originalArgs: ValidatedExecutionArgs, + prefix: string, +): TransformationContext { + const { operation, fragments } = originalArgs; + + const context: RequestTransformationContext = { + prefix, + incrementalCounter: 0, + deferUsageMap: new Map(), + streamUsageMap: new Map(), + }; + + const transformedFragments = mapValue(fragments, (details) => ({ + ...details, + definition: { + ...details.definition, + selectionSet: transformRootSelectionSet( + context, + details.definition.selectionSet, + ), + }, + })); + + const transformedArgs: ValidatedExecutionArgs = { + ...originalArgs, + operation: { + ...operation, + selectionSet: transformRootSelectionSet(context, operation.selectionSet), + }, + fragmentDefinitions: mapValue( + transformedFragments, + ({ definition }) => definition, + ), + fragments: transformedFragments, + }; + + return { + transformedArgs, + deferUsageMap: context.deferUsageMap, + streamUsageMap: context.streamUsageMap, + prefix, + pendingResultsById: new Map(), + pendingLabelsByPath: new Map(), + mergedResult: {}, + }; +} + +function transformRootSelectionSet( + context: RequestTransformationContext, + selectionSet: SelectionSetNode, +): SelectionSetNode { + return { + ...selectionSet, + selections: [ + ...selectionSet.selections.map((node) => + transformSelection(context, node), + ), + ], + }; +} + +function transformNestedSelectionSet( + context: RequestTransformationContext, + selectionSet: SelectionSetNode, +): SelectionSetNode { + return { + ...selectionSet, + selections: [ + ...selectionSet.selections.map((node) => + transformSelection(context, node), + ), + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: TypeNameMetaFieldDef.name, + }, + alias: { + kind: Kind.NAME, + value: context.prefix, + }, + }, + ], + }; +} + +function transformSelection( + context: RequestTransformationContext, + selection: SelectionNode, +): SelectionNode { + if (selection.kind === Kind.FIELD) { + const selectionSet = selection.selectionSet; + if (selectionSet) { + const transformedSelectionSet = transformNestedSelectionSet( + context, + selectionSet, + ); + return { + ...selection, + selectionSet: transformedSelectionSet, + directives: selection.directives?.map((directive) => + transformMaybeStreamDirective( + context, + directive, + transformedSelectionSet, + ), + ), + }; + } + return { + ...selection, + directives: selection.directives?.map((directive) => + transformMaybeStreamDirective(context, directive, undefined), + ), + }; + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const transformedSelectionSet = transformRootSelectionSet( + context, + selection.selectionSet, + ); + + return { + ...selection, + selectionSet: transformedSelectionSet, + directives: selection.directives?.map((directive) => + transformMaybeDeferDirective(context, directive, { + node: transformedSelectionSet, + }), + ), + }; + } + + return { + ...selection, + directives: selection.directives?.map((directive) => + transformMaybeDeferDirective(context, directive, { + fragmentName: selection.name.value, + }), + ), + }; +} + +function transformMaybeDeferDirective( + context: RequestTransformationContext, + directive: DirectiveNode, + selectionSet: SelectionSetNodeOrFragmentName, +): DirectiveNode { + const name = directive.name.value; + + if (name !== GraphQLDeferDirective.name) { + return directive; + } + + let foundLabel = false; + const newArgs: Array = []; + const args = directive.arguments; + if (args) { + for (const arg of args) { + if (arg.name.value === 'label') { + foundLabel = true; + const value = arg.value; + + invariant(value.kind === Kind.STRING); + + const originalLabel = value.value; + const prefixedLabel = `${context.prefix}defer${context.incrementalCounter++}__${originalLabel}`; + context.deferUsageMap.set(prefixedLabel, { + originalLabel, + selectionSet, + }); + newArgs.push({ + ...arg, + value: { + ...value, + value: prefixedLabel, + }, + }); + } else { + newArgs.push(arg); + } + } + } + + if (!foundLabel) { + const newLabel = `${context.prefix}defer${context.incrementalCounter++}`; + context.deferUsageMap.set(newLabel, { + originalLabel: undefined, + selectionSet, + }); + newArgs.push({ + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'label', + }, + value: { + kind: Kind.STRING, + value: newLabel, + }, + }); + } + + return { + ...directive, + arguments: newArgs, + }; +} + +function transformMaybeStreamDirective( + context: RequestTransformationContext, + directive: DirectiveNode, + selectionSet: SelectionSetNode | undefined, +): DirectiveNode { + const name = directive.name.value; + + if (name !== GraphQLStreamDirective.name) { + return directive; + } + + let foundLabel = false; + const newArgs: Array = []; + const args = directive.arguments; + if (args) { + for (const arg of args) { + if (arg.name.value === 'label') { + foundLabel = true; + const value = arg.value; + + invariant(value.kind === Kind.STRING); + + const originalLabel = value.value; + const prefixedLabel = `${context.prefix}stream${context.incrementalCounter++}__${originalLabel}`; + context.streamUsageMap.set(prefixedLabel, { + originalLabel, + selectionSet, + }); + newArgs.push({ + ...arg, + value: { + ...value, + value: prefixedLabel, + }, + }); + } else { + newArgs.push(arg); + } + } + } + + if (!foundLabel) { + const newLabel = `${context.prefix}stream${context.incrementalCounter++}`; + context.streamUsageMap.set(newLabel, { + originalLabel: undefined, + selectionSet, + }); + newArgs.push({ + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'label', + }, + value: { + kind: Kind.STRING, + value: newLabel, + }, + }); + } + + return { + ...directive, + arguments: newArgs, + }; +} diff --git a/src/transform/collectFields.ts b/src/transform/collectFields.ts new file mode 100644 index 0000000000..c8fb455272 --- /dev/null +++ b/src/transform/collectFields.ts @@ -0,0 +1,330 @@ +import { AccumulatorMap } from '../jsutils/AccumulatorMap.js'; +import { invariant } from '../jsutils/invariant.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; +import type { Path } from '../jsutils/Path.js'; +import { pathToArray } from '../jsutils/Path.js'; + +import type { + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + SelectionSetNode, +} from '../language/ast.js'; +import { Kind } from '../language/kinds.js'; + +import type { GraphQLObjectType } from '../type/definition.js'; +import { isAbstractType } from '../type/definition.js'; +import { + GraphQLDeferDirective, + GraphQLIncludeDirective, + GraphQLSkipDirective, +} from '../type/directives.js'; +import type { GraphQLSchema } from '../type/schema.js'; + +import type { GraphQLVariableSignature } from '../execution/getVariableSignature.js'; +import type { VariableValues } from '../execution/values.js'; +import { + getDirectiveValues, + getFragmentVariableValues, +} from '../execution/values.js'; + +import { typeFromAST } from '../utilities/typeFromAST.js'; + +import type { TransformationContext } from './buildTransformationContext.js'; + +export interface FieldDetails { + node: FieldNode; + fragmentVariableValues?: VariableValues | undefined; +} + +export type FieldDetailsList = ReadonlyArray; + +export type GroupedFieldSet = ReadonlyMap; + +export interface FragmentDetails { + definition: FragmentDefinitionNode; + variableSignatures?: ObjMap | undefined; +} + +interface CollectFieldsContext { + schema: GraphQLSchema; + fragments: ObjMap; + variableValues: VariableValues; + runtimeType: GraphQLObjectType; + visitedFragmentNames: Set; + pendingLabelsByPath: Map>; + hideSuggestions: boolean; +} + +/** + * Given a selectionSet, collects all of the fields and returns them. + * + * CollectFields requires the "runtime type" of an object. For a field that + * returns an Interface or Union type, the "runtime type" will be the actual + * object type returned by that field. + * + * @internal + */ + +export function collectFields( + transformationContext: TransformationContext, + runtimeType: GraphQLObjectType, + selectionSet: SelectionSetNode, + path: Path | undefined, +): GroupedFieldSet { + const { + transformedArgs: { schema, fragments, variableValues, hideSuggestions }, + pendingLabelsByPath, + } = transformationContext; + const groupedFieldSet = new AccumulatorMap(); + const context: CollectFieldsContext = { + schema, + fragments, + variableValues, + runtimeType, + visitedFragmentNames: new Set(), + pendingLabelsByPath, + hideSuggestions, + }; + + collectFieldsImpl(context, selectionSet, groupedFieldSet, path); + return groupedFieldSet; +} + +/** + * Given an array of field nodes, collects all of the subfields of the passed + * in fields, and returns them at the end. + * + * CollectSubFields requires the "return type" of an object. For a field that + * returns an Interface or Union type, the "return type" will be the actual + * object type returned by that field. + * + * @internal + */ +export function collectSubfields( + transformationContext: TransformationContext, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + path: Path | undefined, +): GroupedFieldSet { + const { + transformedArgs: { schema, fragments, variableValues, hideSuggestions }, + pendingLabelsByPath, + } = transformationContext; + const context: CollectFieldsContext = { + schema, + fragments, + variableValues, + runtimeType: returnType, + visitedFragmentNames: new Set(), + pendingLabelsByPath, + hideSuggestions, + }; + const subGroupedFieldSet = new AccumulatorMap(); + + for (const fieldDetail of fieldDetailsList) { + const selectionSet = fieldDetail.node.selectionSet; + if (selectionSet) { + const { fragmentVariableValues } = fieldDetail; + collectFieldsImpl( + context, + selectionSet, + subGroupedFieldSet, + path, + fragmentVariableValues, + ); + } + } + + return subGroupedFieldSet; +} + +function collectFieldsImpl( + context: CollectFieldsContext, + selectionSet: SelectionSetNode, + groupedFieldSet: AccumulatorMap, + path?: Path | undefined, + fragmentVariableValues?: VariableValues, +): void { + const { + schema, + fragments, + variableValues, + runtimeType, + visitedFragmentNames, + pendingLabelsByPath, + hideSuggestions, + } = context; + + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: { + if ( + !shouldIncludeNode(selection, variableValues, fragmentVariableValues) + ) { + continue; + } + groupedFieldSet.add(getFieldEntryKey(selection), { + node: selection, + fragmentVariableValues, + }); + break; + } + case Kind.INLINE_FRAGMENT: { + if ( + isDeferred(selection, path, pendingLabelsByPath) || + !shouldIncludeNode( + selection, + variableValues, + fragmentVariableValues, + ) || + !doesFragmentConditionMatch(schema, selection, runtimeType) + ) { + continue; + } + + collectFieldsImpl( + context, + selection.selectionSet, + groupedFieldSet, + path, + fragmentVariableValues, + ); + + break; + } + case Kind.FRAGMENT_SPREAD: { + const fragName = selection.name.value; + + if ( + visitedFragmentNames.has(fragName) || + isDeferred(selection, path, pendingLabelsByPath) || + !shouldIncludeNode(selection, variableValues, fragmentVariableValues) + ) { + continue; + } + + const fragment = fragments[fragName]; + if ( + fragment == null || + !doesFragmentConditionMatch(schema, fragment.definition, runtimeType) + ) { + continue; + } + + const fragmentVariableSignatures = fragment.variableSignatures; + let newFragmentVariableValues: VariableValues | undefined; + if (fragmentVariableSignatures) { + newFragmentVariableValues = getFragmentVariableValues( + selection, + fragmentVariableSignatures, + variableValues, + fragmentVariableValues, + hideSuggestions, + ); + } + + visitedFragmentNames.add(fragName); + collectFieldsImpl( + context, + fragment.definition.selectionSet, + groupedFieldSet, + path, + newFragmentVariableValues, + ); + break; + } + } + } +} + +/** + * Determines if a field should be included based on the `@include` and `@skip` + * directives, where `@skip` has higher precedence than `@include`. + */ +function shouldIncludeNode( + node: FragmentSpreadNode | FieldNode | InlineFragmentNode, + variableValues: VariableValues, + fragmentVariableValues: VariableValues | undefined, +): boolean { + const skip = getDirectiveValues( + GraphQLSkipDirective, + node, + variableValues, + fragmentVariableValues, + ); + if (skip?.if === true) { + return false; + } + + const include = getDirectiveValues( + GraphQLIncludeDirective, + node, + variableValues, + fragmentVariableValues, + ); + if (include?.if === false) { + return false; + } + return true; +} + +/** + * Determines if a fragment is applicable to the given type. + */ +function doesFragmentConditionMatch( + schema: GraphQLSchema, + fragment: FragmentDefinitionNode | InlineFragmentNode, + type: GraphQLObjectType, +): boolean { + const typeConditionNode = fragment.typeCondition; + if (!typeConditionNode) { + return true; + } + const conditionalType = typeFromAST(schema, typeConditionNode); + if (conditionalType === type) { + return true; + } + if (isAbstractType(conditionalType)) { + return schema.isSubType(conditionalType, type); + } + return false; +} + +/** + * Implements the logic to compute the key of a given field's entry + */ +function getFieldEntryKey(node: FieldNode): string { + return node.alias ? node.alias.value : node.name.value; +} + +/** + * Implements the logic to check if a fragment annotated with the `@defer` + * directive has been actually deferred or inlined. + */ +function isDeferred( + selection: FragmentSpreadNode | InlineFragmentNode, + path: Path | undefined, + pendingLabelsByPath: Map>, +): boolean { + const deferDirective = selection.directives?.find( + (directive) => directive.name.value === GraphQLDeferDirective.name, + ); + if (!deferDirective) { + return false; + } + const pathStr = pathToArray(path).join('.'); + const labels = pendingLabelsByPath.get(pathStr); + if (labels == null) { + return false; + } + const labelArg = deferDirective.arguments?.find( + (arg) => arg.name.value === 'label', + ); + invariant(labelArg != null); + const labelValue = labelArg.value; + invariant(labelValue.kind === Kind.STRING); + const label = labelValue.value; + return labels.has(label); +} diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts new file mode 100644 index 0000000000..62916201c7 --- /dev/null +++ b/src/transform/completeValue.ts @@ -0,0 +1,193 @@ +import { invariant } from '../jsutils/invariant.js'; +import { isObjectLike } from '../jsutils/isObjectLike.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; +import type { Path } from '../jsutils/Path.js'; +import { addPath } from '../jsutils/Path.js'; + +import type { GraphQLError } from '../error/GraphQLError.js'; + +import type { + GraphQLObjectType, + GraphQLOutputType, +} from '../type/definition.js'; +import { + isLeafType, + isListType, + isNonNullType, + isObjectType, +} from '../type/definition.js'; + +import type { TransformationContext } from './buildTransformationContext.js'; +import type { FieldDetailsList, GroupedFieldSet } from './collectFields.js'; +import { collectSubfields as _collectSubfields } from './collectFields.js'; +import { memoize3of4 } from './memoize3of4.js'; + +const collectSubfields = memoize3of4( + ( + context: TransformationContext, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + path: Path | undefined, + ) => _collectSubfields(context, returnType, fieldDetailsList, path), +); + +// eslint-disable-next-line @typescript-eslint/max-params +export function completeValue( + context: TransformationContext, + rootValue: ObjMap, + rootType: GraphQLObjectType, + groupedFieldSet: GroupedFieldSet, + errors: Array, + path: Path | undefined, +): ObjMap { + const data = Object.create(null); + for (const [responseName, fieldDetailsList] of groupedFieldSet) { + if (responseName === context.prefix) { + continue; + } + + const fieldName = fieldDetailsList[0].node.name.value; + const fieldDef = context.transformedArgs.schema.getField( + rootType, + fieldName, + ); + invariant(fieldDef != null); + + data[responseName] = completeSubValue( + context, + errors, + fieldDef.type, + fieldDetailsList, + rootValue[responseName], + addPath(path, responseName, undefined), + ); + } + + return data; +} + +// eslint-disable-next-line @typescript-eslint/max-params +function completeSubValue( + context: TransformationContext, + errors: Array, + returnType: GraphQLOutputType, + fieldDetailsList: FieldDetailsList, + result: unknown, + path: Path, +): unknown { + if (isNonNullType(returnType)) { + return completeSubValue( + context, + errors, + returnType.ofType, + fieldDetailsList, + result, + path, + ); + } + + if (result == null) { + return null; + } + + if (result instanceof AggregateError) { + for (const error of result.errors) { + errors.push(error as GraphQLError); + } + return null; + } + + if (isLeafType(returnType)) { + return result; + } + + if (isListType(returnType)) { + invariant(Array.isArray(result)); + return completeListValue( + context, + errors, + returnType.ofType, + fieldDetailsList, + result, + path, + ); + } + + invariant(isObjectLike(result)); + return completeObjectType(context, errors, fieldDetailsList, result, path); +} + +function completeObjectType( + context: TransformationContext, + errors: Array, + fieldDetailsList: FieldDetailsList, + result: ObjMap, + path: Path, +): ObjMap { + const { prefix } = context; + + const typeName = result[prefix]; + + invariant(typeof typeName === 'string'); + + const runtimeType = context.transformedArgs.schema.getType(typeName); + + invariant(isObjectType(runtimeType)); + + const completed = Object.create(null); + + const groupedFieldSet = collectSubfields( + context, + runtimeType, + fieldDetailsList, + path, + ); + + for (const [responseName, subFieldDetailsList] of groupedFieldSet) { + if (responseName === context.prefix) { + continue; + } + + const fieldName = subFieldDetailsList[0].node.name.value; + const fieldDef = context.transformedArgs.schema.getField( + runtimeType, + fieldName, + ); + invariant(fieldDef != null); + + completed[responseName] = completeSubValue( + context, + errors, + fieldDef.type, + subFieldDetailsList, + result[responseName], + addPath(path, responseName, undefined), + ); + } + + return completed; +} + +// eslint-disable-next-line @typescript-eslint/max-params +function completeListValue( + context: TransformationContext, + errors: Array, + returnType: GraphQLOutputType, + fieldDetailsList: FieldDetailsList, + result: Array, + path: Path, +): Array { + const completedItems = []; + for (let index = 0; index < result.length; index++) { + const completed = completeSubValue( + context, + errors, + returnType, + fieldDetailsList, + result[index], + addPath(path, index, undefined), + ); + completedItems.push(completed); + } + return completedItems; +} diff --git a/src/transform/embedErrors.ts b/src/transform/embedErrors.ts new file mode 100644 index 0000000000..db2a6648af --- /dev/null +++ b/src/transform/embedErrors.ts @@ -0,0 +1,120 @@ +import { invariant } from '../jsutils/invariant.js'; +import { isObjectLike } from '../jsutils/isObjectLike.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; + +import type { GraphQLError } from '../error/GraphQLError.js'; + +export function embedErrors( + data: ObjMap | null, + errors: ReadonlyArray | undefined, +): Array { + if (errors == null || errors.length === 0) { + return []; + } + const errorsWithoutValidPath: Array = []; + for (const error of errors) { + if (!error.path || error.path.length === 0) { + errorsWithoutValidPath.push(error); + continue; + } + embedErrorByPath( + error, + error.path, + error.path[0], + 1, + data, + errorsWithoutValidPath, + ); + } + return errorsWithoutValidPath; +} + +// eslint-disable-next-line @typescript-eslint/max-params +function embedErrorByPath( + error: GraphQLError, + path: ReadonlyArray, + currentKey: string | number, + nextIndex: number, + parent: unknown, + errorsWithoutValidPath: Array, +): void { + if (nextIndex === path.length) { + if (Array.isArray(parent)) { + if (typeof currentKey !== 'number') { + errorsWithoutValidPath.push(error); + return; + } + invariant( + maybeEmbed( + parent as unknown as ObjMap, + currentKey as unknown as string, + error, + ) instanceof AggregateError, + ); + return; + } + if (isObjectLike(parent)) { + if (typeof currentKey !== 'string') { + errorsWithoutValidPath.push(error); + return; + } + invariant( + maybeEmbed(parent, currentKey, error) instanceof AggregateError, + ); + return; + } + errorsWithoutValidPath.push(error); + return; + } + + let next: unknown; + if (Array.isArray(parent)) { + if (typeof currentKey !== 'number') { + errorsWithoutValidPath.push(error); + return; + } + next = maybeEmbed( + parent as unknown as ObjMap, + currentKey as unknown as string, + error, + ); + if (next instanceof AggregateError) { + return; + } + } else if (isObjectLike(parent)) { + if (typeof currentKey !== 'string') { + errorsWithoutValidPath.push(error); + return; + } + next = maybeEmbed(parent, currentKey, error); + if (next instanceof AggregateError) { + return; + } + } else { + errorsWithoutValidPath.push(error); + return; + } + + embedErrorByPath( + error, + path, + path[nextIndex], + nextIndex + 1, + next, + errorsWithoutValidPath, + ); +} + +function maybeEmbed( + parent: ObjMap, + key: string, + error: GraphQLError, +): unknown { + let next = parent[key]; + if (next == null) { + next = parent[key] = new AggregateError([error]); + } else if (next instanceof AggregateError) { + next.errors.push(error); + } + return next; +} diff --git a/src/transform/getObjectAtPath.ts b/src/transform/getObjectAtPath.ts new file mode 100644 index 0000000000..6b9b2ec214 --- /dev/null +++ b/src/transform/getObjectAtPath.ts @@ -0,0 +1,31 @@ +import { invariant } from '../jsutils/invariant.js'; +import { isObjectLike } from '../jsutils/isObjectLike.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; + +export function getObjectAtPath( + data: ObjMap, + path: ReadonlyArray, +): ObjMap | Array { + if (path.length === 0) { + return data; + } + + let current: unknown = data[path[0]]; + for (let i = 1; i < path.length; i++) { + const key = path[i]; + if (Array.isArray(current)) { + invariant(typeof key === 'number'); + current = current[key]; + continue; + } else if (isObjectLike(current)) { + invariant(typeof key === 'string'); + current = current[key]; + continue; + } + invariant(false); + } + + invariant(isObjectLike(current) || Array.isArray(current)); + + return current; +} diff --git a/src/transform/legacyExecuteIncrementally.ts b/src/transform/legacyExecuteIncrementally.ts new file mode 100644 index 0000000000..fd27ea5c0e --- /dev/null +++ b/src/transform/legacyExecuteIncrementally.ts @@ -0,0 +1,37 @@ +import { isPromise } from '../jsutils/isPromise.js'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; + +import { collectSubfields as _collectSubfields } from '../execution/collectFields.js'; +import type { ExecutionArgs } from '../execution/execute.js'; +import { + experimentalExecuteQueryOrMutationOrSubscriptionEvent, + validateExecutionArgs, +} from '../execution/execute.js'; +import type { ExecutionResult } from '../execution/types.js'; + +import { buildTransformationContext } from './buildTransformationContext.js'; +import type { LegacyExperimentalIncrementalExecutionResults } from './transformResult.js'; +import { transformResult } from './transformResult.js'; + +export function legacyExecuteIncrementally( + args: ExecutionArgs, + prefix = '__legacyExecuteIncrementally__', +): PromiseOrValue< + ExecutionResult | LegacyExperimentalIncrementalExecutionResults +> { + const originalArgs = validateExecutionArgs(args); + + if (!('schema' in originalArgs)) { + return { errors: originalArgs }; + } + + const context = buildTransformationContext(originalArgs, prefix); + + const originalResult = experimentalExecuteQueryOrMutationOrSubscriptionEvent( + context.transformedArgs, + ); + + return isPromise(originalResult) + ? originalResult.then((resolved) => transformResult(context, resolved)) + : transformResult(context, originalResult); +} diff --git a/src/transform/memoize3of4.ts b/src/transform/memoize3of4.ts new file mode 100644 index 0000000000..cb27c60d8e --- /dev/null +++ b/src/transform/memoize3of4.ts @@ -0,0 +1,40 @@ +/** + * Memoizes the provided four-argument function via the first three arguments. + */ +export function memoize3of4< + A1 extends object, + A2 extends object, + A3 extends object, + A4, + R, +>( + fn: (a1: A1, a2: A2, a3: A3, a4: A4) => R, +): (a1: A1, a2: A2, a3: A3, a4: A4) => R { + let cache0: WeakMap>>; + + return function memoized(a1, a2, a3, a4) { + if (cache0 === undefined) { + cache0 = new WeakMap(); + } + + let cache1 = cache0.get(a1); + if (cache1 === undefined) { + cache1 = new WeakMap(); + cache0.set(a1, cache1); + } + + let cache2 = cache1.get(a2); + if (cache2 === undefined) { + cache2 = new WeakMap(); + cache1.set(a2, cache2); + } + + let fnResult = cache2.get(a3); + if (fnResult === undefined) { + fnResult = fn(a1, a2, a3, a4); + cache2.set(a3, fnResult); + } + + return fnResult; + }; +} diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts new file mode 100644 index 0000000000..005951a057 --- /dev/null +++ b/src/transform/transformResult.ts @@ -0,0 +1,393 @@ +import { invariant } from '../jsutils/invariant.js'; +import { isObjectLike } from '../jsutils/isObjectLike.js'; +import type { ObjMap } from '../jsutils/ObjMap.js'; +import type { Path } from '../jsutils/Path.js'; +import { addPath } from '../jsutils/Path.js'; + +import type { GraphQLError } from '../error/GraphQLError.js'; + +import type { SelectionSetNode } from '../language/ast.js'; + +import type { GraphQLObjectType } from '../type/definition.js'; +import { isObjectType } from '../type/definition.js'; + +import { mapAsyncIterable } from '../execution/mapAsyncIterable.js'; +import type { + CompletedResult, + ExecutionResult, + ExperimentalIncrementalExecutionResults, + IncrementalResult, + InitialIncrementalExecutionResult, + PendingResult, + SubsequentIncrementalExecutionResult, +} from '../execution/types.js'; + +import type { TransformationContext } from './buildTransformationContext.js'; +import { collectFields as _collectFields } from './collectFields.js'; +import { completeValue } from './completeValue.js'; +import { embedErrors } from './embedErrors.js'; +import { getObjectAtPath } from './getObjectAtPath.js'; +import { memoize3of4 } from './memoize3of4.js'; + +export interface LegacyExperimentalIncrementalExecutionResults { + initialResult: LegacyInitialIncrementalExecutionResult; + subsequentResults: AsyncGenerator< + LegacySubsequentIncrementalExecutionResult, + void, + void + >; +} + +export interface LegacyInitialIncrementalExecutionResult + extends ExecutionResult { + data: ObjMap; + hasNext: true; +} + +export interface LegacySubsequentIncrementalExecutionResult { + incremental?: ReadonlyArray; + hasNext: boolean; +} + +interface LegacyIncrementalDeferResult extends ExecutionResult { + path: ReadonlyArray; + label?: string; +} + +interface LegacyIncrementalStreamResult { + items: ReadonlyArray | null; + errors?: ReadonlyArray; + path: ReadonlyArray; + label?: string; +} + +type LegacyIncrementalResult = + | LegacyIncrementalDeferResult + | LegacyIncrementalStreamResult; + +const collectFields = memoize3of4( + ( + context: TransformationContext, + returnType: GraphQLObjectType, + selectionSet: SelectionSetNode, + path: Path | undefined, + ) => _collectFields(context, returnType, selectionSet, path), +); + +export function transformResult( + context: TransformationContext, + result: ExecutionResult | ExperimentalIncrementalExecutionResults, +): ExecutionResult | LegacyExperimentalIncrementalExecutionResults { + if ('initialResult' in result) { + const initialResult = transformInitialResult(context, result.initialResult); + + return { + initialResult, + subsequentResults: mapAsyncIterable( + result.subsequentResults, + (subsequentResult) => transformSubsequent(context, subsequentResult), + ), + }; + } + return transformInitialResult(context, result); +} + +function transformSubsequent( + context: TransformationContext, + result: SubsequentIncrementalExecutionResult, +): LegacySubsequentIncrementalExecutionResult { + const newResult: LegacySubsequentIncrementalExecutionResult = { + hasNext: result.hasNext, + }; + if (result.pending) { + processPending(context, result.pending); + } + if (result.incremental) { + newResult.incremental = transformIncremental(context, result.incremental); + } + if (result.completed) { + const transformedCompleted = transformCompleted(context, result.completed); + if (newResult.incremental) { + newResult.incremental = [ + ...newResult.incremental, + ...transformedCompleted, + ]; + } else if (transformedCompleted.length > 0) { + newResult.incremental = transformedCompleted; + } + } + return newResult; +} + +function processPending( + context: TransformationContext, + pendingResults: ReadonlyArray, +): void { + for (const pendingResult of pendingResults) { + context.pendingResultsById.set(pendingResult.id, pendingResult); + const path = pendingResult.path; + const pathStr = path.join('.'); + let labels = context.pendingLabelsByPath.get(pathStr); + if (!labels) { + labels = new Set(); + context.pendingLabelsByPath.set(pathStr, labels); + } + invariant(pendingResult.label != null); + labels.add(pendingResult.label); + } +} + +function transformIncremental( + context: TransformationContext, + incrementalResults: ReadonlyArray, +): ReadonlyArray { + const newIncremental: Array = []; + for (const incrementalResult of incrementalResults) { + const id = incrementalResult.id; + const pendingResult = context.pendingResultsById.get(id); + invariant(pendingResult != null); + const path = incrementalResult.subPath + ? [...pendingResult.path, ...incrementalResult.subPath] + : pendingResult.path; + + const incompleteAtPath = getObjectAtPath(context.mergedResult, path); + if (Array.isArray(incompleteAtPath)) { + const index = incompleteAtPath.length; + invariant('items' in incrementalResult); + const items = incrementalResult.items as ReadonlyArray; + const errors = incrementalResult.errors; + incompleteAtPath.push(...items); + embedErrors(context.mergedResult, errors); + const label = pendingResult.label; + invariant(label != null); + const streamUsageContext = context.streamUsageMap.get(label); + invariant(streamUsageContext != null); + const { originalLabel, selectionSet } = streamUsageContext; + let newIncrementalResult: LegacyIncrementalStreamResult; + if (selectionSet == null) { + newIncrementalResult = { + items, + path: [...path, index], + }; + if (errors != null) { + newIncrementalResult.errors = errors; + } + } else { + const embeddedErrors: Array = []; + const listPath = pathFromArray(path); + newIncrementalResult = { + items: items.map((item, itemIndex) => { + if (item === null) { + const aggregate = incompleteAtPath[index + itemIndex]; + invariant(aggregate instanceof AggregateError); + embeddedErrors.push(...aggregate.errors); + return null; + } + + invariant(isObjectLike(item)); + const typeName = item[context.prefix]; + invariant(typeof typeName === 'string'); + + const runtimeType = + context.transformedArgs.schema.getType(typeName); + invariant(isObjectType(runtimeType)); + + const itemPath = addPath(listPath, index + itemIndex, undefined); + const groupedFieldSet = collectFields( + context, + runtimeType, + selectionSet, + itemPath, + ); + + return completeValue( + context, + item, + runtimeType, + groupedFieldSet, + embeddedErrors, + itemPath, + ); + }), + path: [...path, index], + }; + if (embeddedErrors.length > 0) { + newIncrementalResult.errors = embeddedErrors; + } + } + if (originalLabel != null) { + newIncrementalResult.label = originalLabel; + } + newIncremental.push(newIncrementalResult); + } else { + invariant('data' in incrementalResult); + for (const [key, value] of Object.entries( + incrementalResult.data as ObjMap, + )) { + incompleteAtPath[key] = value; + } + embedErrors(context.mergedResult, incrementalResult.errors); + } + } + return newIncremental; +} + +function transformCompleted( + context: TransformationContext, + completedResults: ReadonlyArray, +): ReadonlyArray { + const incremental: Array = []; + for (const completedResult of completedResults) { + const pendingResult = context.pendingResultsById.get(completedResult.id); + invariant(pendingResult != null); + const label = pendingResult.label; + invariant(label != null); + + if (context.streamUsageMap.has(label)) { + context.streamUsageMap.delete(label); + if ('errors' in completedResult) { + const list = getObjectAtPath(context.mergedResult, pendingResult.path); + invariant(Array.isArray(list)); + const incrementalResult: LegacyIncrementalStreamResult = { + items: null, + errors: completedResult.errors, + path: [...pendingResult.path, list.length], + }; + incremental.push(incrementalResult); + } + + context.pendingResultsById.delete(completedResult.id); + const path = pendingResult.path.join('.'); + const labels = context.pendingLabelsByPath.get(path); + invariant(labels != null); + labels.delete(label); + if (labels.size === 0) { + context.pendingLabelsByPath.delete(path); + } + continue; + } + + const deferUsageContext = context.deferUsageMap.get(label); + invariant(deferUsageContext != null); + + let incrementalResult: LegacyIncrementalDeferResult; + if ('errors' in completedResult) { + incrementalResult = { + data: null, + errors: completedResult.errors, + path: pendingResult.path, + }; + } else { + const object = getObjectAtPath(context.mergedResult, pendingResult.path); + invariant(isObjectLike(object)); + const typeName = object[context.prefix]; + invariant(typeof typeName === 'string'); + const runtimeType = context.transformedArgs.schema.getType(typeName); + invariant(isObjectType(runtimeType)); + + const errors: Array = []; + + const selectionSet = deferUsageContext.selectionSet; + const selectionSetNode = selectionSet.node + ? selectionSet.node + : context.transformedArgs.fragments[selectionSet.fragmentName] + .definition.selectionSet; + + const objectPath = pathFromArray(pendingResult.path); + + const groupedFieldSet = collectFields( + context, + runtimeType, + selectionSetNode, + objectPath, + ); + + const data = completeValue( + context, + object, + runtimeType, + groupedFieldSet, + errors, + objectPath, + ); + + incrementalResult = { + data, + path: pendingResult.path, + }; + + if (errors.length > 0) { + incrementalResult.errors = errors; + } + } + + const originalLabel = deferUsageContext.originalLabel; + if (originalLabel != null) { + incrementalResult.label = originalLabel; + } + + incremental.push(incrementalResult); + + context.pendingResultsById.delete(completedResult.id); + const path = pendingResult.path.join('.'); + const labels = context.pendingLabelsByPath.get(path); + invariant(labels != null); + labels.delete(label); + if (labels.size === 0) { + context.pendingLabelsByPath.delete(path); + } + } + return incremental; +} + +function transformInitialResult< + T extends ExecutionResult | InitialIncrementalExecutionResult, +>(context: TransformationContext, result: T): T { + const originalData = result.data; + if (originalData == null) { + return result; + } + + const errors = embedErrors(originalData, result.errors); + context.mergedResult = originalData; + + const { schema, operation } = context.transformedArgs; + const rootType = schema.getRootType(operation.operation); + invariant(rootType != null); + + const { pending, ...rest } = result as InitialIncrementalExecutionResult; + + if (pending != null) { + context.mergedResult[context.prefix] = rootType.name; + processPending(context, pending); + } + + // no need to memoize for the initial result as will be called only once + const groupedFieldSet = _collectFields( + context, + rootType, + operation.selectionSet, + undefined, + ); + const data = completeValue( + context, + originalData, + rootType, + groupedFieldSet, + errors, + undefined, + ); + + return (rest.errors ? { ...rest, errors, data } : { ...rest, data }) as T; +} + +function pathFromArray(path: ReadonlyArray): Path | undefined { + if (path.length === 0) { + return undefined; + } + let current = addPath(undefined, path[0], undefined); + for (let i = 1; i < path.length; i++) { + current = addPath(current, path[i], undefined); + } + return current; +}