From 0857b7ef9bc12bfc40eec463523bb96cad4c6021 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 7 Jan 2025 12:13:18 +0200 Subject: [PATCH 01/16] 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; +} From bc532a5f87e98f3494f4872a28ee661f3e584789 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 7 Jan 2025 23:34:20 +0200 Subject: [PATCH 02/16] fix last edge case --- src/transform/buildTransformationContext.ts | 34 ++--- src/transform/completeValue.ts | 88 +++++++++++-- src/transform/transformResult.ts | 137 +++++++++----------- 3 files changed, 159 insertions(+), 100 deletions(-) diff --git a/src/transform/buildTransformationContext.ts b/src/transform/buildTransformationContext.ts index a1707d21b5..3dd6084589 100644 --- a/src/transform/buildTransformationContext.ts +++ b/src/transform/buildTransformationContext.ts @@ -1,6 +1,7 @@ import { invariant } from '../jsutils/invariant.js'; import { mapValue } from '../jsutils/mapValue.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; +import type { Path } from '../jsutils/Path.js'; import type { ArgumentNode, @@ -14,12 +15,15 @@ import { GraphQLDeferDirective, GraphQLStreamDirective, } from '../type/directives.js'; +import type { GraphQLOutputType } from '../type/index.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'; +import type { FieldDetails } from './collectFields.js'; + type SelectionSetNodeOrFragmentName = | { node: SelectionSetNode; fragmentName?: never } | { node?: never; fragmentName: string }; @@ -29,9 +33,16 @@ interface DeferUsageContext { selectionSet: SelectionSetNodeOrFragmentName; } +export interface Stream { + path: Path; + itemType: GraphQLOutputType; + fieldDetailsList: ReadonlyArray; +} + interface StreamUsageContext { originalLabel: string | undefined; - selectionSet: SelectionSetNode | undefined; + streams: Set; + nextIndex: number; } export interface TransformationContext { @@ -145,26 +156,18 @@ function transformSelection( if (selection.kind === Kind.FIELD) { const selectionSet = selection.selectionSet; if (selectionSet) { - const transformedSelectionSet = transformNestedSelectionSet( - context, - selectionSet, - ); return { ...selection, - selectionSet: transformedSelectionSet, + selectionSet: transformNestedSelectionSet(context, selectionSet), directives: selection.directives?.map((directive) => - transformMaybeStreamDirective( - context, - directive, - transformedSelectionSet, - ), + transformMaybeStreamDirective(context, directive), ), }; } return { ...selection, directives: selection.directives?.map((directive) => - transformMaybeStreamDirective(context, directive, undefined), + transformMaybeStreamDirective(context, directive), ), }; } else if (selection.kind === Kind.INLINE_FRAGMENT) { @@ -263,7 +266,6 @@ function transformMaybeDeferDirective( function transformMaybeStreamDirective( context: RequestTransformationContext, directive: DirectiveNode, - selectionSet: SelectionSetNode | undefined, ): DirectiveNode { const name = directive.name.value; @@ -286,7 +288,8 @@ function transformMaybeStreamDirective( const prefixedLabel = `${context.prefix}stream${context.incrementalCounter++}__${originalLabel}`; context.streamUsageMap.set(prefixedLabel, { originalLabel, - selectionSet, + streams: new Set(), + nextIndex: 0, }); newArgs.push({ ...arg, @@ -305,7 +308,8 @@ function transformMaybeStreamDirective( const newLabel = `${context.prefix}stream${context.incrementalCounter++}`; context.streamUsageMap.set(newLabel, { originalLabel: undefined, - selectionSet, + streams: new Set(), + nextIndex: 0, }); newArgs.push({ kind: Kind.ARGUMENT, diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 62916201c7..146b30603e 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -2,10 +2,12 @@ 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 { addPath, pathToArray } from '../jsutils/Path.js'; import type { GraphQLError } from '../error/GraphQLError.js'; +import { Kind } from '../language/kinds.js'; + import type { GraphQLObjectType, GraphQLOutputType, @@ -16,9 +18,10 @@ import { isNonNullType, isObjectType, } from '../type/definition.js'; +import { GraphQLStreamDirective } from '../type/directives.js'; import type { TransformationContext } from './buildTransformationContext.js'; -import type { FieldDetailsList, GroupedFieldSet } from './collectFields.js'; +import type { FieldDetails, GroupedFieldSet } from './collectFields.js'; import { collectSubfields as _collectSubfields } from './collectFields.js'; import { memoize3of4 } from './memoize3of4.js'; @@ -26,7 +29,7 @@ const collectSubfields = memoize3of4( ( context: TransformationContext, returnType: GraphQLObjectType, - fieldDetailsList: FieldDetailsList, + fieldDetailsList: ReadonlyArray, path: Path | undefined, ) => _collectSubfields(context, returnType, fieldDetailsList, path), ); @@ -67,13 +70,14 @@ export function completeValue( } // eslint-disable-next-line @typescript-eslint/max-params -function completeSubValue( +export function completeSubValue( context: TransformationContext, errors: Array, returnType: GraphQLOutputType, - fieldDetailsList: FieldDetailsList, + fieldDetailsList: ReadonlyArray, result: unknown, path: Path, + listDepth = 0, ): unknown { if (isNonNullType(returnType)) { return completeSubValue( @@ -110,6 +114,7 @@ function completeSubValue( fieldDetailsList, result, path, + listDepth, ); } @@ -120,7 +125,7 @@ function completeSubValue( function completeObjectType( context: TransformationContext, errors: Array, - fieldDetailsList: FieldDetailsList, + fieldDetailsList: ReadonlyArray, result: ObjMap, path: Path, ): ObjMap { @@ -172,22 +177,87 @@ function completeObjectType( function completeListValue( context: TransformationContext, errors: Array, - returnType: GraphQLOutputType, - fieldDetailsList: FieldDetailsList, + itemType: GraphQLOutputType, + fieldDetailsList: ReadonlyArray, result: Array, path: Path, + listDepth: number, ): Array { const completedItems = []; + for (let index = 0; index < result.length; index++) { const completed = completeSubValue( context, errors, - returnType, + itemType, fieldDetailsList, result[index], addPath(path, index, undefined), + listDepth + 1, ); completedItems.push(completed); } + + maybeAddStream( + context, + itemType, + fieldDetailsList, + listDepth, + path, + result.length, + ); + return completedItems; } + +// eslint-disable-next-line @typescript-eslint/max-params +function maybeAddStream( + context: TransformationContext, + itemType: GraphQLOutputType, + fieldDetailsList: ReadonlyArray, + listDepth: number, + path: Path, + nextIndex: number, +): void { + if (listDepth > 0) { + return; + } + + let stream; + for (const fieldDetails of fieldDetailsList) { + const directives = fieldDetails.node.directives; + if (!directives) { + continue; + } + stream = directives.find( + (directive) => directive.name.value === GraphQLStreamDirective.name, + ); + if (stream != null) { + break; + } + } + + if (stream == null) { + return; + } + + const labelArg = stream.arguments?.find((arg) => arg.name.value === 'label'); + invariant(labelArg != null); + const labelValue = labelArg.value; + invariant(labelValue.kind === Kind.STRING); + const label = labelValue.value; + invariant(label != null); + const pendingLabels = context.pendingLabelsByPath.get( + pathToArray(path).join('.'), + ); + if (pendingLabels?.has(label)) { + const streamUsage = context.streamUsageMap.get(label); + invariant(streamUsage != null); + streamUsage.nextIndex = nextIndex; + streamUsage.streams.add({ + path, + itemType, + fieldDetailsList, + }); + } +} diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts index 005951a057..5966d67bae 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -2,7 +2,7 @@ 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 { addPath, pathToArray } from '../jsutils/Path.js'; import type { GraphQLError } from '../error/GraphQLError.js'; @@ -24,7 +24,7 @@ import type { import type { TransformationContext } from './buildTransformationContext.js'; import { collectFields as _collectFields } from './collectFields.js'; -import { completeValue } from './completeValue.js'; +import { completeSubValue, completeValue } from './completeValue.js'; import { embedErrors } from './embedErrors.js'; import { getObjectAtPath } from './getObjectAtPath.js'; import { memoize3of4 } from './memoize3of4.js'; @@ -102,20 +102,25 @@ function transformSubsequent( if (result.pending) { processPending(context, result.pending); } + if (result.incremental) { - newResult.incremental = transformIncremental(context, result.incremental); + const incremental = processIncremental(context, result.incremental); + if (incremental.length > 0) { + newResult.incremental = 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; + const incremental = processCompleted(context, result.completed); + if (incremental.length > 0) { + if (newResult.incremental) { + newResult.incremental = [...newResult.incremental, ...incremental]; + } else { + newResult.incremental = incremental; + } } } + return newResult; } @@ -137,11 +142,11 @@ function processPending( } } -function transformIncremental( +function processIncremental( context: TransformationContext, incrementalResults: ReadonlyArray, ): ReadonlyArray { - const newIncremental: Array = []; + const streamLabels = new Set(); for (const incrementalResult of incrementalResults) { const id = incrementalResult.id; const pendingResult = context.pendingResultsById.get(id); @@ -152,7 +157,6 @@ function transformIncremental( 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; @@ -160,65 +164,7 @@ function transformIncremental( 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); + streamLabels.add(label); } else { invariant('data' in incrementalResult); for (const [key, value] of Object.entries( @@ -229,10 +175,48 @@ function transformIncremental( embedErrors(context.mergedResult, incrementalResult.errors); } } - return newIncremental; + + const incremental: Array = []; + for (const label of streamLabels) { + const streamUsageContext = context.streamUsageMap.get(label); + invariant(streamUsageContext != null); + const { originalLabel, nextIndex, streams } = streamUsageContext; + for (const stream of streams) { + const { path, itemType, fieldDetailsList } = stream; + const list = getObjectAtPath(context.mergedResult, pathToArray(path)); + invariant(Array.isArray(list)); + const items: Array = []; + const errors: Array = []; + for (let i = nextIndex; i < list.length; i++) { + const item = completeSubValue( + context, + errors, + itemType, + fieldDetailsList, + list[i], + addPath(path, i, undefined), + 1, + ); + items.push(item); + } + streamUsageContext.nextIndex = list.length; + const newIncrementalResult: LegacyIncrementalStreamResult = { + items, + path: [...pathToArray(path), nextIndex], + }; + if (errors.length > 0) { + newIncrementalResult.errors = errors; + } + if (originalLabel != null) { + newIncrementalResult.label = originalLabel; + } + incremental.push(newIncrementalResult); + } + } + return incremental; } -function transformCompleted( +function processCompleted( context: TransformationContext, completedResults: ReadonlyArray, ): ReadonlyArray { @@ -243,7 +227,8 @@ function transformCompleted( const label = pendingResult.label; invariant(label != null); - if (context.streamUsageMap.has(label)) { + const streamUsageContext = context.streamUsageMap.get(label); + if (streamUsageContext) { context.streamUsageMap.delete(label); if ('errors' in completedResult) { const list = getObjectAtPath(context.mergedResult, pendingResult.path); From 0f40a2ea44a03119e6f6574d083f1f9a880db684 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 9 Jan 2025 21:28:29 +0200 Subject: [PATCH 03/16] allow inlining of defers nested under list types to vary --- src/transform/collectFields.ts | 146 +++++++++++++---------- src/transform/completeValue.ts | 24 ++-- src/transform/groupedFieldSetFromTree.ts | 59 +++++++++ src/transform/memoize3of4.ts | 40 ------- src/transform/transformResult.ts | 30 +++-- 5 files changed, 178 insertions(+), 121 deletions(-) create mode 100644 src/transform/groupedFieldSetFromTree.ts delete mode 100644 src/transform/memoize3of4.ts diff --git a/src/transform/collectFields.ts b/src/transform/collectFields.ts index c8fb455272..b5a9cfe950 100644 --- a/src/transform/collectFields.ts +++ b/src/transform/collectFields.ts @@ -1,8 +1,6 @@ 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, @@ -22,7 +20,11 @@ import { } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; -import type { GraphQLVariableSignature } from '../execution/getVariableSignature.js'; +import type { + FragmentDetails, + GroupedFieldSet, +} from '../execution/collectFields.js'; +import type { ValidatedExecutionArgs } from '../execution/execute.js'; import type { VariableValues } from '../execution/values.js'; import { getDirectiveValues, @@ -31,32 +33,24 @@ import { 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; } +export interface GroupedFieldSetTree { + groupedFieldSet: GroupedFieldSet; + deferredGroupedFieldSets: Map; +} /** * Given a selectionSet, collects all of the fields and returns them. * @@ -68,28 +62,25 @@ interface CollectFieldsContext { */ export function collectFields( - transformationContext: TransformationContext, + validateExecutionArgs: ValidatedExecutionArgs, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, - path: Path | undefined, -): GroupedFieldSet { - const { - transformedArgs: { schema, fragments, variableValues, hideSuggestions }, - pendingLabelsByPath, - } = transformationContext; - const groupedFieldSet = new AccumulatorMap(); +): GroupedFieldSetTree { const context: CollectFieldsContext = { - schema, - fragments, - variableValues, + ...validateExecutionArgs, runtimeType, visitedFragmentNames: new Set(), - pendingLabelsByPath, - hideSuggestions, }; - collectFieldsImpl(context, selectionSet, groupedFieldSet, path); - return groupedFieldSet; + const groupedFieldSet = new AccumulatorMap(); + const deferredGroupedFieldSets = new Map(); + collectFieldsImpl( + context, + selectionSet, + groupedFieldSet, + deferredGroupedFieldSets, + ); + return { groupedFieldSet, deferredGroupedFieldSets }; } /** @@ -103,25 +94,17 @@ export function collectFields( * @internal */ export function collectSubfields( - transformationContext: TransformationContext, + validatedExecutionArgs: ValidatedExecutionArgs, returnType: GraphQLObjectType, - fieldDetailsList: FieldDetailsList, - path: Path | undefined, -): GroupedFieldSet { - const { - transformedArgs: { schema, fragments, variableValues, hideSuggestions }, - pendingLabelsByPath, - } = transformationContext; + fieldDetailsList: ReadonlyArray, +): GroupedFieldSetTree { const context: CollectFieldsContext = { - schema, - fragments, - variableValues, + ...validatedExecutionArgs, runtimeType: returnType, visitedFragmentNames: new Set(), - pendingLabelsByPath, - hideSuggestions, }; - const subGroupedFieldSet = new AccumulatorMap(); + const groupedFieldSet = new AccumulatorMap(); + const deferredGroupedFieldSets = new Map(); for (const fieldDetail of fieldDetailsList) { const selectionSet = fieldDetail.node.selectionSet; @@ -130,21 +113,21 @@ export function collectSubfields( collectFieldsImpl( context, selectionSet, - subGroupedFieldSet, - path, + groupedFieldSet, + deferredGroupedFieldSets, fragmentVariableValues, ); } } - return subGroupedFieldSet; + return { groupedFieldSet, deferredGroupedFieldSets }; } function collectFieldsImpl( context: CollectFieldsContext, selectionSet: SelectionSetNode, groupedFieldSet: AccumulatorMap, - path?: Path | undefined, + deferredGroupedFieldSets: Map, fragmentVariableValues?: VariableValues, ): void { const { @@ -153,7 +136,6 @@ function collectFieldsImpl( variableValues, runtimeType, visitedFragmentNames, - pendingLabelsByPath, hideSuggestions, } = context; @@ -172,8 +154,30 @@ function collectFieldsImpl( break; } case Kind.INLINE_FRAGMENT: { + const deferLabel = isDeferred(selection); + if (deferLabel !== undefined) { + const deferredGroupedFieldSet = new AccumulatorMap< + string, + FieldDetails + >(); + const nestedDeferredGroupedFieldSets = new Map< + string, + GroupedFieldSetTree + >(); + collectFieldsImpl( + context, + selection.selectionSet, + deferredGroupedFieldSet, + nestedDeferredGroupedFieldSets, + ); + deferredGroupedFieldSets.set(deferLabel, { + groupedFieldSet: deferredGroupedFieldSet, + deferredGroupedFieldSets: nestedDeferredGroupedFieldSets, + }); + continue; + } + if ( - isDeferred(selection, path, pendingLabelsByPath) || !shouldIncludeNode( selection, variableValues, @@ -188,7 +192,7 @@ function collectFieldsImpl( context, selection.selectionSet, groupedFieldSet, - path, + deferredGroupedFieldSets, fragmentVariableValues, ); @@ -199,7 +203,6 @@ function collectFieldsImpl( if ( visitedFragmentNames.has(fragName) || - isDeferred(selection, path, pendingLabelsByPath) || !shouldIncludeNode(selection, variableValues, fragmentVariableValues) ) { continue; @@ -213,6 +216,29 @@ function collectFieldsImpl( continue; } + const deferLabel = isDeferred(selection); + if (deferLabel !== undefined) { + const deferredGroupedFieldSet = new AccumulatorMap< + string, + FieldDetails + >(); + const nestedDeferredGroupedFieldSets = new Map< + string, + GroupedFieldSetTree + >(); + collectFieldsImpl( + context, + fragment.definition.selectionSet, + deferredGroupedFieldSet, + nestedDeferredGroupedFieldSets, + ); + deferredGroupedFieldSets.set(deferLabel, { + groupedFieldSet: deferredGroupedFieldSet, + deferredGroupedFieldSets: nestedDeferredGroupedFieldSets, + }); + continue; + } + const fragmentVariableSignatures = fragment.variableSignatures; let newFragmentVariableValues: VariableValues | undefined; if (fragmentVariableSignatures) { @@ -230,7 +256,7 @@ function collectFieldsImpl( context, fragment.definition.selectionSet, groupedFieldSet, - path, + deferredGroupedFieldSets, newFragmentVariableValues, ); break; @@ -305,19 +331,12 @@ function getFieldEntryKey(node: FieldNode): string { */ function isDeferred( selection: FragmentSpreadNode | InlineFragmentNode, - path: Path | undefined, - pendingLabelsByPath: Map>, -): boolean { +): string | undefined { 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; + return; } const labelArg = deferDirective.arguments?.find( (arg) => arg.name.value === 'label', @@ -325,6 +344,5 @@ function isDeferred( invariant(labelArg != null); const labelValue = labelArg.value; invariant(labelValue.kind === Kind.STRING); - const label = labelValue.value; - return labels.has(label); + return labelValue.value; } diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 146b30603e..74d0fdc1c6 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -1,5 +1,6 @@ import { invariant } from '../jsutils/invariant.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; +import { memoize3 } from '../jsutils/memoize3.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import type { Path } from '../jsutils/Path.js'; import { addPath, pathToArray } from '../jsutils/Path.js'; @@ -20,18 +21,20 @@ import { } from '../type/definition.js'; import { GraphQLStreamDirective } from '../type/directives.js'; +import type { GroupedFieldSet } from '../execution/collectFields.js'; +import type { ValidatedExecutionArgs } from '../execution/execute.js'; + import type { TransformationContext } from './buildTransformationContext.js'; -import type { FieldDetails, GroupedFieldSet } from './collectFields.js'; +import type { FieldDetails } from './collectFields.js'; import { collectSubfields as _collectSubfields } from './collectFields.js'; -import { memoize3of4 } from './memoize3of4.js'; +import { groupedFieldSetFromTree } from './groupedFieldSetFromTree.js'; -const collectSubfields = memoize3of4( +const collectSubfields = memoize3( ( - context: TransformationContext, + validatedExecutionArgs: ValidatedExecutionArgs, returnType: GraphQLObjectType, fieldDetailsList: ReadonlyArray, - path: Path | undefined, - ) => _collectSubfields(context, returnType, fieldDetailsList, path), + ) => _collectSubfields(validatedExecutionArgs, returnType, fieldDetailsList), ); // eslint-disable-next-line @typescript-eslint/max-params @@ -141,10 +144,15 @@ function completeObjectType( const completed = Object.create(null); - const groupedFieldSet = collectSubfields( - context, + const groupedFieldSetTree = collectSubfields( + context.transformedArgs, runtimeType, fieldDetailsList, + ); + + const groupedFieldSet = groupedFieldSetFromTree( + context, + groupedFieldSetTree, path, ); diff --git a/src/transform/groupedFieldSetFromTree.ts b/src/transform/groupedFieldSetFromTree.ts new file mode 100644 index 0000000000..b34af0af3f --- /dev/null +++ b/src/transform/groupedFieldSetFromTree.ts @@ -0,0 +1,59 @@ +import { AccumulatorMap } from '../jsutils/AccumulatorMap.js'; +import type { Path } from '../jsutils/Path.js'; +import { pathToArray } from '../jsutils/Path.js'; + +import type { + FieldDetails, + GroupedFieldSet, +} from '../execution/collectFields.js'; + +import type { TransformationContext } from './buildTransformationContext.js'; +import type { GroupedFieldSetTree } from './collectFields.js'; + +export function groupedFieldSetFromTree( + context: TransformationContext, + groupedFieldSetTree: GroupedFieldSetTree, + path: Path | undefined, +): GroupedFieldSet { + const groupedFieldSetWithInlinedDefers = new AccumulatorMap< + string, + FieldDetails + >(); + groupedFieldSetFromTreeImpl( + context, + groupedFieldSetWithInlinedDefers, + groupedFieldSetTree, + path, + ); + return groupedFieldSetWithInlinedDefers; +} + +function groupedFieldSetFromTreeImpl( + context: TransformationContext, + groupedFieldSetWithInlinedDefers: AccumulatorMap, + groupedFieldSetTree: GroupedFieldSetTree, + path: Path | undefined, +): void { + const { groupedFieldSet, deferredGroupedFieldSets } = groupedFieldSetTree; + + for (const [responseName, fieldDetailsList] of groupedFieldSet) { + for (const fieldDetails of fieldDetailsList) { + groupedFieldSetWithInlinedDefers.add(responseName, fieldDetails); + } + } + + for (const [label, childGroupedFieldSetTree] of deferredGroupedFieldSets) { + const pathStr = pathToArray(path).join('.'); + const labels = context.pendingLabelsByPath.get(pathStr); + if (labels?.has(label)) { + continue; + } + + groupedFieldSetFromTreeImpl( + context, + groupedFieldSetWithInlinedDefers, + childGroupedFieldSetTree, + path, + ); + } +} diff --git a/src/transform/memoize3of4.ts b/src/transform/memoize3of4.ts deleted file mode 100644 index cb27c60d8e..0000000000 --- a/src/transform/memoize3of4.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 index 5966d67bae..722b0ba38f 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -1,5 +1,6 @@ import { invariant } from '../jsutils/invariant.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; +import { memoize3 } from '../jsutils/memoize3.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import type { Path } from '../jsutils/Path.js'; import { addPath, pathToArray } from '../jsutils/Path.js'; @@ -11,6 +12,7 @@ import type { SelectionSetNode } from '../language/ast.js'; import type { GraphQLObjectType } from '../type/definition.js'; import { isObjectType } from '../type/definition.js'; +import type { ValidatedExecutionArgs } from '../execution/execute.js'; import { mapAsyncIterable } from '../execution/mapAsyncIterable.js'; import type { CompletedResult, @@ -27,7 +29,7 @@ import { collectFields as _collectFields } from './collectFields.js'; import { completeSubValue, completeValue } from './completeValue.js'; import { embedErrors } from './embedErrors.js'; import { getObjectAtPath } from './getObjectAtPath.js'; -import { memoize3of4 } from './memoize3of4.js'; +import { groupedFieldSetFromTree } from './groupedFieldSetFromTree.js'; export interface LegacyExperimentalIncrementalExecutionResults { initialResult: LegacyInitialIncrementalExecutionResult; @@ -65,13 +67,12 @@ type LegacyIncrementalResult = | LegacyIncrementalDeferResult | LegacyIncrementalStreamResult; -const collectFields = memoize3of4( +const collectFields = memoize3( ( - context: TransformationContext, + validatedExecutionArgs: ValidatedExecutionArgs, returnType: GraphQLObjectType, selectionSet: SelectionSetNode, - path: Path | undefined, - ) => _collectFields(context, returnType, selectionSet, path), + ) => _collectFields(validatedExecutionArgs, returnType, selectionSet), ); export function transformResult( @@ -280,10 +281,15 @@ function processCompleted( const objectPath = pathFromArray(pendingResult.path); - const groupedFieldSet = collectFields( - context, + const groupedFieldSetTree = collectFields( + context.transformedArgs, runtimeType, selectionSetNode, + ); + + const groupedFieldSet = groupedFieldSetFromTree( + context, + groupedFieldSetTree, objectPath, ); @@ -348,12 +354,18 @@ function transformInitialResult< } // no need to memoize for the initial result as will be called only once - const groupedFieldSet = _collectFields( - context, + const groupedFieldSetTree = _collectFields( + context.transformedArgs, rootType, operation.selectionSet, + ); + + const groupedFieldSet = groupedFieldSetFromTree( + context, + groupedFieldSetTree, undefined, ); + const data = completeValue( context, originalData, From c9c11cf5ac2410285bcb7eac65e6cdaf49547b42 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 13 Jan 2025 23:10:21 +0200 Subject: [PATCH 04/16] add tests --- .../buildTransformationContext-test.ts | 57 ++++ src/transform/__tests__/collectFields-test.ts | 278 ++++++++++++++++++ src/transform/__tests__/embedErrors-test.ts | 133 +++++++++ .../__tests__/getObjectAtPath-test.ts | 35 +++ .../legacyExecuteIncrementally-test.ts | 55 ++++ src/transform/completeValue.ts | 4 - src/transform/getObjectAtPath.ts | 7 +- 7 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 src/transform/__tests__/buildTransformationContext-test.ts create mode 100644 src/transform/__tests__/collectFields-test.ts create mode 100644 src/transform/__tests__/embedErrors-test.ts create mode 100644 src/transform/__tests__/getObjectAtPath-test.ts create mode 100644 src/transform/__tests__/legacyExecuteIncrementally-test.ts diff --git a/src/transform/__tests__/buildTransformationContext-test.ts b/src/transform/__tests__/buildTransformationContext-test.ts new file mode 100644 index 0000000000..aa6d5cab4b --- /dev/null +++ b/src/transform/__tests__/buildTransformationContext-test.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { invariant } from '../../jsutils/invariant.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { validateExecutionArgs } from '../../execution/execute.js'; + +import { buildTransformationContext } from '../buildTransformationContext.js'; + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { someField: { type: GraphQLString } }, + }), +}); + +describe('buildTransformationContext', () => { + it('should build a transformation context', () => { + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document: parse('{ someField }'), + }); + + invariant('schema' in validatedExecutionArgs); + + const context = buildTransformationContext( + validatedExecutionArgs, + '__prefix__', + ); + + expect(context.deferUsageMap instanceof Map).to.equal(true); + expect(context.streamUsageMap instanceof Map).to.equal(true); + expect(context.prefix).to.equal('__prefix__'); + expect(context.pendingLabelsByPath instanceof Map).to.equal(true); + expect(context.pendingResultsById instanceof Map).to.equal(true); + expect(context.mergedResult).to.deep.equal({}); + }); + + it('should handle non-standard directives', () => { + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document: parse('{ ... @someDirective { someField } }'), + }); + + invariant('schema' in validatedExecutionArgs); + + expect(() => + buildTransformationContext(validatedExecutionArgs, '__prefix__'), + ).not.to.throw(); + }); +}); diff --git a/src/transform/__tests__/collectFields-test.ts b/src/transform/__tests__/collectFields-test.ts new file mode 100644 index 0000000000..b9167d34a2 --- /dev/null +++ b/src/transform/__tests__/collectFields-test.ts @@ -0,0 +1,278 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { invariant } from '../../jsutils/invariant.js'; + +import { Kind } from '../../language/kinds.js'; +import { parse } from '../../language/parser.js'; + +import { + GraphQLInterfaceType, + GraphQLObjectType, +} from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { validateExecutionArgs } from '../../execution/execute.js'; + +import { collectFields, collectSubfields } from '../collectFields.js'; + +describe('collectFields', () => { + const someParentInterface = new GraphQLInterfaceType({ + name: 'SomeParentInterface', + fields: { someField: { type: GraphQLString } }, + }); + const someChildInterface = new GraphQLInterfaceType({ + name: 'SomeChildInterface', + interfaces: [someParentInterface], + fields: { someField: { type: GraphQLString } }, + }); + const someObjectType = new GraphQLObjectType({ + name: 'SomeObjectType', + interfaces: [someChildInterface, someParentInterface], + fields: { someField: { type: GraphQLString } }, + }); + const query = new GraphQLObjectType({ + name: 'Query', + fields: { + someField: { type: GraphQLString }, + anotherField: { type: someParentInterface }, + }, + }); + const schema = new GraphQLSchema({ + query, + types: [someObjectType], + }); + + it('collects fields from a selection set', () => { + const document = parse('{ someField }'); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.get('someField')).to.deep.equal([ + { + node: validatedExecutionArgs.operation.selectionSet.selections[0], + fragmentVariableValues: undefined, + }, + ]); + }); + + it('collects fields, skipping a field', () => { + const document = parse('{ someField @skip(if: true) }'); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.size).to.equal(0); + }); + + it('collects fields, not including a field', () => { + const document = parse('{ someField @include(if: false) }'); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.size).to.equal(0); + }); + + it('collects fields from a selection with an inline fragment', () => { + const document = parse('{ ... { someField } }'); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const inlineFragment = + validatedExecutionArgs.operation.selectionSet.selections[0]; + + invariant(inlineFragment.kind === Kind.INLINE_FRAGMENT); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.get('someField')).to.deep.equal([ + { + node: inlineFragment.selectionSet.selections[0], + fragmentVariableValues: undefined, + }, + ]); + }); + + it('collects fields from a selection with a named fragment with a non-matching conditional type', () => { + const document = parse(` + query { ...SomeFragment } + fragment SomeFragment on SomeObject { someField } + `); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.size).to.equal(0); + }); + + it('collects fields from a selection with an inline fragment with a conditional type', () => { + const document = parse('{ ... on Query { someField } }'); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const inlineFragment = + validatedExecutionArgs.operation.selectionSet.selections[0]; + + invariant(inlineFragment.kind === Kind.INLINE_FRAGMENT); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.get('someField')).to.deep.equal([ + { + node: inlineFragment.selectionSet.selections[0], + fragmentVariableValues: undefined, + }, + ]); + }); + + it('collects fields from a selection with an inline fragment with a conditional abstract subtype', () => { + const document = parse( + '{ anotherField { ... on SomeChildInterface { someField } } }', + ); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + const fieldDetailsList = groupedFieldSet.get('anotherField'); + + invariant(fieldDetailsList != null); + + const { groupedFieldSet: nestedGroupedFieldSet } = collectSubfields( + validatedExecutionArgs, + someObjectType, + fieldDetailsList, + ); + + const field = validatedExecutionArgs.operation.selectionSet.selections[0]; + + invariant(field.kind === Kind.FIELD); + + const inlineFragment = field.selectionSet?.selections[0]; + + invariant(inlineFragment?.kind === Kind.INLINE_FRAGMENT); + + expect(nestedGroupedFieldSet.get('someField')).to.deep.equal([ + { + node: inlineFragment.selectionSet.selections[0], + fragmentVariableValues: undefined, + }, + ]); + }); + + it('collects fields from a selection with an inline fragment with a non-matching conditional subtype', () => { + const document = parse('{ ... on SomeObject { someField } }'); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.size).to.equal(0); + }); + + it('collects fields, using fragment variables', () => { + const document = parse( + ` + query { ...SomeFragment(skip: false) } + fragment SomeFragment($skip: Boolean ) on Query { someField @skip(if: $skip) } + `, + { + experimentalFragmentArguments: true, + }, + ); + + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + }); + + invariant('schema' in validatedExecutionArgs); + + const { groupedFieldSet } = collectFields( + validatedExecutionArgs, + query, + validatedExecutionArgs.operation.selectionSet, + ); + + expect(groupedFieldSet.size).to.equal(1); + }); +}); diff --git a/src/transform/__tests__/embedErrors-test.ts b/src/transform/__tests__/embedErrors-test.ts new file mode 100644 index 0000000000..f1952ed4f3 --- /dev/null +++ b/src/transform/__tests__/embedErrors-test.ts @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { GraphQLError } from '../../error/GraphQLError.js'; + +import { embedErrors } from '../embedErrors.js'; + +describe('embedErrors', () => { + it('returns empty array when errors is undefined', () => { + const embedded = embedErrors({}, undefined); + expect(embedded).to.deep.equal([]); + }); + + it('returns empty array when errors is an empty list', () => { + const embedded = embedErrors({}, []); + expect(embedded).to.deep.equal([]); + }); + + it('can embed an error', () => { + const error = new GraphQLError('error message', { path: ['a', 'b', 'c'] }); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual({ + a: { b: { c: new AggregateError([error]) } }, + }); + expect(embedded).to.deep.equal([]); + }); + + it('can embed a bubbled error', () => { + const error = new GraphQLError('error message', { + path: ['a', 'b', 'c', 'd'], + }); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual({ + a: { b: { c: new AggregateError([error]) } }, + }); + expect(embedded).to.deep.equal([]); + }); + + it('can embed multiple errors', () => { + const error1 = new GraphQLError('error message 1', { + path: ['a', 'b', 'c'], + }); + const error2 = new GraphQLError('error message 2', { + path: ['a', 'b', 'c'], + }); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error1, error2]); + expectJSON(data).toDeepEqual({ + a: { b: { c: new AggregateError([error1, error2]) } }, + }); + expect(embedded).to.deep.equal([]); + }); + + it('returns errors with no path', () => { + const error = new GraphQLError('error message'); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with empty path', () => { + const error = new GraphQLError('error message', { path: [] }); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with invalid numeric path', () => { + const error = new GraphQLError('error message', { + path: ['a', 0], + }); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with invalid non-terminating numeric path segment', () => { + const error = new GraphQLError('error message', { + path: ['a', 0, 'c'], + }); + const data = { a: { b: { c: null } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with invalid string path', () => { + const error = new GraphQLError('error message', { + path: ['a', 'b'], + }); + const data = { a: [{ b: { c: null } }] }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with invalid non-terminating string path segment', () => { + const error = new GraphQLError('error message', { + path: ['a', 'b', 'c'], + }); + const data = { a: [{ b: { c: null } }] }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with invalid path without null', () => { + const error = new GraphQLError('error message', { + path: ['a', 'b', 'c', 'd'], + }); + const data = { a: { b: { c: 1 } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); + + it('returns errors with invalid path without null with invalid non-terminating path segment', () => { + const error = new GraphQLError('error message', { + path: ['a', 'b', 'c', 'd', 'e'], + }); + const data = { a: { b: { c: 1 } } }; + const embedded = embedErrors(data, [error]); + expectJSON(data).toDeepEqual(data); + expectJSON(embedded).toDeepEqual([error]); + }); +}); diff --git a/src/transform/__tests__/getObjectAtPath-test.ts b/src/transform/__tests__/getObjectAtPath-test.ts new file mode 100644 index 0000000000..f678d608f0 --- /dev/null +++ b/src/transform/__tests__/getObjectAtPath-test.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { getObjectAtPath } from '../getObjectAtPath.js'; + +describe('getObjectAtPath', () => { + it('should return the object at the path', () => { + const object = getObjectAtPath({ a: { b: { c: 1 } } }, ['a', 'b']); + + expect(object).to.deep.equal({ c: 1 }); + }); + + it('should return the array at the path', () => { + const object = getObjectAtPath({ a: { b: [{ c: 1 }] } }, ['a', 'b']); + + expect(object).to.deep.equal([{ c: 1 }]); + }); + + it('should throw for invalid path missing array index', () => { + expect(() => + getObjectAtPath({ a: [{ b: { c: 1 } }] }, ['a', 'b']), + ).to.throw(); + }); + + it('should throw for invalid path with unexpected array index', () => { + expect(() => getObjectAtPath({ a: { b: { c: 1 } } }, ['a', 0])).to.throw(); + }); + + it('should throw for invalid path with neither string nor array index', () => { + expect(() => + // @ts-expect-error + getObjectAtPath({ a: [{ b: { c: 1 } }] }, ['a', {}]), + ).to.throw(); + }); +}); diff --git a/src/transform/__tests__/legacyExecuteIncrementally-test.ts b/src/transform/__tests__/legacyExecuteIncrementally-test.ts new file mode 100644 index 0000000000..7ef70d3f06 --- /dev/null +++ b/src/transform/__tests__/legacyExecuteIncrementally-test.ts @@ -0,0 +1,55 @@ +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { Kind } from '../../language/kinds.js'; +import { parse } from '../../language/parser.js'; + +import { GraphQLNonNull, GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.js'; + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { someField: { type: new GraphQLNonNull(GraphQLString) } }, + }), +}); + +describe('legacyExecuteIncrementally', () => { + it('handles invalid document', () => { + const result = legacyExecuteIncrementally({ + schema, + document: { kind: Kind.DOCUMENT, definitions: [] }, + }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Must provide an operation.', + }, + ], + }); + }); + + it('handles non-nullable root field', () => { + const result = legacyExecuteIncrementally({ + schema, + document: parse('{ someField }'), + rootValue: { someField: null }, + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Cannot return null for non-nullable field Query.someField.', + locations: [{ line: 1, column: 3 }], + path: ['someField'], + }, + ], + }); + }); +}); diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 74d0fdc1c6..4f17c3ad12 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -48,10 +48,6 @@ export function completeValue( ): 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, diff --git a/src/transform/getObjectAtPath.ts b/src/transform/getObjectAtPath.ts index 6b9b2ec214..b66108d2a6 100644 --- a/src/transform/getObjectAtPath.ts +++ b/src/transform/getObjectAtPath.ts @@ -22,10 +22,11 @@ export function getObjectAtPath( current = current[key]; continue; } - invariant(false); - } + invariant(false); /* c8 ignore start */ + } /* c8 ignore stop */ - invariant(isObjectLike(current) || Array.isArray(current)); + // arrays are object-like, so no need to check twice + invariant(isObjectLike(current)); return current; } From f62077d42e973f82b4344a0a513c2695caee256d Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 14 Jan 2025 13:47:30 +0200 Subject: [PATCH 05/16] add more tests, handle more invalid cases --- src/transform/__tests__/abstract-test.ts | 639 +++++++ src/transform/__tests__/cancellation-test.ts | 800 +++++++++ src/transform/__tests__/defer-test.ts | 9 +- src/transform/__tests__/directives-test.ts | 311 ++++ src/transform/__tests__/execute.ts | 27 + src/transform/__tests__/executor-test.ts | 1342 ++++++++++++++ src/transform/__tests__/lists-test.ts | 442 +++++ src/transform/__tests__/mutations-test.ts | 329 ++++ src/transform/__tests__/nonnull-test.ts | 786 +++++++++ src/transform/__tests__/oneof-test.ts | 331 ++++ src/transform/__tests__/resolve-test.ts | 129 ++ src/transform/__tests__/schema-test.ts | 188 ++ src/transform/__tests__/sync-test.ts | 195 ++ .../__tests__/union-interface-test.ts | 636 +++++++ src/transform/__tests__/variables-test.ts | 1564 +++++++++++++++++ src/transform/completeValue.ts | 82 +- 16 files changed, 7767 insertions(+), 43 deletions(-) create mode 100644 src/transform/__tests__/abstract-test.ts create mode 100644 src/transform/__tests__/cancellation-test.ts create mode 100644 src/transform/__tests__/directives-test.ts create mode 100644 src/transform/__tests__/execute.ts create mode 100644 src/transform/__tests__/executor-test.ts create mode 100644 src/transform/__tests__/lists-test.ts create mode 100644 src/transform/__tests__/mutations-test.ts create mode 100644 src/transform/__tests__/nonnull-test.ts create mode 100644 src/transform/__tests__/oneof-test.ts create mode 100644 src/transform/__tests__/resolve-test.ts create mode 100644 src/transform/__tests__/schema-test.ts create mode 100644 src/transform/__tests__/sync-test.ts create mode 100644 src/transform/__tests__/union-interface-test.ts create mode 100644 src/transform/__tests__/variables-test.ts diff --git a/src/transform/__tests__/abstract-test.ts b/src/transform/__tests__/abstract-test.ts new file mode 100644 index 0000000000..9b694f5642 --- /dev/null +++ b/src/transform/__tests__/abstract-test.ts @@ -0,0 +1,639 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { parse } from '../../language/parser.js'; + +import { + assertInterfaceType, + GraphQLInterfaceType, + GraphQLList, + GraphQLObjectType, + GraphQLUnionType, +} from '../../type/definition.js'; +import { GraphQLBoolean, GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { execute, executeSync } from './execute.js'; + +interface Context { + async: boolean; +} + +async function executeQuery(args: { + schema: GraphQLSchema; + query: string; + rootValue?: unknown; +}) { + const { schema, query, rootValue } = args; + const document = parse(query); + const result = executeSync({ + schema, + document, + rootValue, + contextValue: { async: false } satisfies Context, + }); + const asyncResult = await execute({ + schema, + document, + rootValue, + contextValue: { async: true } satisfies Context, + }); + + expectJSON(result).toDeepEqual(asyncResult); + return result; +} + +class Dog { + name: string; + woofs: boolean; + + constructor(name: string, woofs: boolean) { + this.name = name; + this.woofs = woofs; + } +} + +class Cat { + name: string; + meows: boolean; + + constructor(name: string, meows: boolean) { + this.name = name; + this.meows = meows; + } +} + +describe('Execute: Handles execution of abstract types', () => { + it('isTypeOf used to resolve runtime type for Interface', async () => { + const PetType = new GraphQLInterfaceType({ + name: 'Pet', + fields: { + name: { type: GraphQLString }, + }, + }); + + const DogType = new GraphQLObjectType({ + name: 'Dog', + interfaces: [PetType], + isTypeOf(obj, context) { + const isDog = obj instanceof Dog; + return context.async ? Promise.resolve(isDog) : isDog; + }, + fields: { + name: { type: GraphQLString }, + woofs: { type: GraphQLBoolean }, + }, + }); + + const CatType = new GraphQLObjectType({ + name: 'Cat', + interfaces: [PetType], + isTypeOf(obj, context) { + const isCat = obj instanceof Cat; + return context.async ? Promise.resolve(isCat) : isCat; + }, + fields: { + name: { type: GraphQLString }, + meows: { type: GraphQLBoolean }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + pets: { + type: new GraphQLList(PetType), + resolve() { + return [new Dog('Odie', true), new Cat('Garfield', false)]; + }, + }, + }, + }), + types: [CatType, DogType], + }); + + const query = ` + { + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + `; + + expect(await executeQuery({ schema, query })).to.deep.equal({ + data: { + pets: [ + { + name: 'Odie', + woofs: true, + }, + { + name: 'Garfield', + meows: false, + }, + ], + }, + }); + }); + + it('isTypeOf can throw', async () => { + const PetType = new GraphQLInterfaceType({ + name: 'Pet', + fields: { + name: { type: GraphQLString }, + }, + }); + + const DogType = new GraphQLObjectType({ + name: 'Dog', + interfaces: [PetType], + isTypeOf(_source, context) { + const error = new Error('We are testing this error'); + if (context.async) { + return Promise.reject(error); + } + throw error; + }, + fields: { + name: { type: GraphQLString }, + woofs: { type: GraphQLBoolean }, + }, + }); + + const CatType = new GraphQLObjectType({ + name: 'Cat', + interfaces: [PetType], + isTypeOf: undefined, + fields: { + name: { type: GraphQLString }, + meows: { type: GraphQLBoolean }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + pets: { + type: new GraphQLList(PetType), + resolve() { + return [new Dog('Odie', true), new Cat('Garfield', false)]; + }, + }, + }, + }), + types: [DogType, CatType], + }); + + const query = ` + { + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + `; + + expectJSON(await executeQuery({ schema, query })).toDeepEqual({ + data: { + pets: [null, null], + }, + errors: [ + { + message: 'We are testing this error', + locations: [{ line: 3, column: 9 }], + path: ['pets', 0], + }, + { + message: 'We are testing this error', + locations: [{ line: 3, column: 9 }], + path: ['pets', 1], + }, + ], + }); + }); + + it('isTypeOf can return false', async () => { + const PetType = new GraphQLInterfaceType({ + name: 'Pet', + fields: { + name: { type: GraphQLString }, + }, + }); + + const DogType = new GraphQLObjectType({ + name: 'Dog', + interfaces: [PetType], + isTypeOf(_source, context) { + return context.async ? Promise.resolve(false) : false; + }, + fields: { + name: { type: GraphQLString }, + woofs: { type: GraphQLBoolean }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + pet: { + type: PetType, + resolve: () => ({}), + }, + }, + }), + types: [DogType], + }); + + const query = ` + { + pet { + name + } + } + `; + + expectJSON(await executeQuery({ schema, query })).toDeepEqual({ + data: { pet: null }, + errors: [ + { + message: + 'Abstract type "Pet" must resolve to an Object type at runtime for field "Query.pet". Either the "Pet" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.', + locations: [{ line: 3, column: 9 }], + path: ['pet'], + }, + ], + }); + }); + + it('isTypeOf used to resolve runtime type for Union', async () => { + const DogType = new GraphQLObjectType({ + name: 'Dog', + isTypeOf(obj, context) { + const isDog = obj instanceof Dog; + return context.async ? Promise.resolve(isDog) : isDog; + }, + fields: { + name: { type: GraphQLString }, + woofs: { type: GraphQLBoolean }, + }, + }); + + const CatType = new GraphQLObjectType({ + name: 'Cat', + isTypeOf(obj, context) { + const isCat = obj instanceof Cat; + return context.async ? Promise.resolve(isCat) : isCat; + }, + fields: { + name: { type: GraphQLString }, + meows: { type: GraphQLBoolean }, + }, + }); + + const PetType = new GraphQLUnionType({ + name: 'Pet', + types: [DogType, CatType], + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + pets: { + type: new GraphQLList(PetType), + resolve() { + return [new Dog('Odie', true), new Cat('Garfield', false)]; + }, + }, + }, + }), + }); + + const query = `{ + pets { + ... on Dog { + name + woofs + } + ... on Cat { + name + meows + } + } + }`; + + expect(await executeQuery({ schema, query })).to.deep.equal({ + data: { + pets: [ + { + name: 'Odie', + woofs: true, + }, + { + name: 'Garfield', + meows: false, + }, + ], + }, + }); + }); + + it('resolveType can throw', async () => { + const PetType = new GraphQLInterfaceType({ + name: 'Pet', + resolveType(_source, context) { + const error = new Error('We are testing this error'); + if (context.async) { + return Promise.reject(error); + } + throw error; + }, + fields: { + name: { type: GraphQLString }, + }, + }); + + const DogType = new GraphQLObjectType({ + name: 'Dog', + interfaces: [PetType], + fields: { + name: { type: GraphQLString }, + woofs: { type: GraphQLBoolean }, + }, + }); + + const CatType = new GraphQLObjectType({ + name: 'Cat', + interfaces: [PetType], + fields: { + name: { type: GraphQLString }, + meows: { type: GraphQLBoolean }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + pets: { + type: new GraphQLList(PetType), + resolve() { + return [new Dog('Odie', true), new Cat('Garfield', false)]; + }, + }, + }, + }), + types: [CatType, DogType], + }); + + const query = ` + { + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + `; + + expectJSON(await executeQuery({ schema, query })).toDeepEqual({ + data: { + pets: [null, null], + }, + errors: [ + { + message: 'We are testing this error', + locations: [{ line: 3, column: 9 }], + path: ['pets', 0], + }, + { + message: 'We are testing this error', + locations: [{ line: 3, column: 9 }], + path: ['pets', 1], + }, + ], + }); + }); + + it('resolve Union type using __typename on source object', async () => { + const schema = buildSchema(` + type Query { + pets: [Pet] + } + + union Pet = Cat | Dog + + type Cat { + name: String + meows: Boolean + } + + type Dog { + name: String + woofs: Boolean + } + `); + + const query = ` + { + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + `; + + const rootValue = { + pets: [ + { + __typename: 'Dog', + name: 'Odie', + woofs: true, + }, + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + }, + ], + }; + + expect(await executeQuery({ schema, query, rootValue })).to.deep.equal({ + data: { + pets: [ + { + name: 'Odie', + woofs: true, + }, + { + name: 'Garfield', + meows: false, + }, + ], + }, + }); + }); + + it('resolve Interface type using __typename on source object', async () => { + const schema = buildSchema(` + type Query { + pets: [Pet] + } + + interface Pet { + name: String + } + + type Cat implements Pet { + name: String + meows: Boolean + } + + type Dog implements Pet { + name: String + woofs: Boolean + } + `); + + const query = ` + { + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + } + `; + + const rootValue = { + pets: [ + { + __typename: 'Dog', + name: 'Odie', + woofs: true, + }, + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + }, + ], + }; + + expect(await executeQuery({ schema, query, rootValue })).to.deep.equal({ + data: { + pets: [ + { + name: 'Odie', + woofs: true, + }, + { + name: 'Garfield', + meows: false, + }, + ], + }, + }); + }); + + it('resolveType on Interface yields useful error', () => { + const schema = buildSchema(` + type Query { + pet: Pet + } + + interface Pet { + name: String + } + + type Cat implements Pet { + name: String + } + + type Dog implements Pet { + name: String + } + `); + + const document = parse(` + { + pet { + name + } + } + `); + + function expectError({ forTypeName }: { forTypeName: unknown }) { + const rootValue = { pet: { __typename: forTypeName } }; + const result = executeSync({ schema, document, rootValue }); + return { + toEqual(message: string) { + expectJSON(result).toDeepEqual({ + data: { pet: null }, + errors: [ + { + message, + locations: [{ line: 3, column: 9 }], + path: ['pet'], + }, + ], + }); + }, + }; + } + + expectError({ forTypeName: undefined }).toEqual( + 'Abstract type "Pet" must resolve to an Object type at runtime for field "Query.pet". Either the "Pet" type should provide a "resolveType" function or each possible type should provide an "isTypeOf" function.', + ); + + expectError({ forTypeName: 'Human' }).toEqual( + 'Abstract type "Pet" was resolved to a type "Human" that does not exist inside the schema.', + ); + + expectError({ forTypeName: 'String' }).toEqual( + 'Abstract type "Pet" was resolved to a non-object type "String".', + ); + + expectError({ forTypeName: '__Schema' }).toEqual( + 'Runtime Object type "__Schema" is not a possible type for "Pet".', + ); + + // FIXME: workaround since we can't inject resolveType into SDL + // @ts-expect-error + assertInterfaceType(schema.getType('Pet')).resolveType = () => []; + expectError({ forTypeName: undefined }).toEqual( + 'Abstract type "Pet" must resolve to an Object type at runtime for field "Query.pet" with value { __typename: undefined }, received "[]", which is not a valid Object type name.', + ); + }); +}); diff --git a/src/transform/__tests__/cancellation-test.ts b/src/transform/__tests__/cancellation-test.ts new file mode 100644 index 0000000000..1a1a3bd05b --- /dev/null +++ b/src/transform/__tests__/cancellation-test.ts @@ -0,0 +1,800 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { invariant } from '../../jsutils/invariant.js'; + +import type { DocumentNode } from '../../language/ast.js'; +import { parse } from '../../language/parser.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import type { + LegacyInitialIncrementalExecutionResult, + LegacySubsequentIncrementalExecutionResult, +} from '../transformResult.js'; + +import { execute } from './execute.js'; + +async function complete( + document: DocumentNode, + rootValue: unknown, + abortSignal: AbortSignal, +) { + const result = await execute({ + schema, + document, + rootValue, + abortSignal, + }); + + if ('initialResult' in result) { + const results: Array< + | LegacyInitialIncrementalExecutionResult + | LegacySubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; + } +} + +const schema = buildSchema(` + type Todo { + id: ID + items: [String] + author: User + } + + type User { + id: ID + name: String + } + + type Query { + todo: Todo + nonNullableTodo: Todo! + } + + type Mutation { + foo: String + bar: String + } + + type Subscription { + foo: String + } +`); + +describe('Execute: Cancellation', () => { + it('should stop the execution when aborted during object field completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + invariant(!('initialResult' in result)); + + expect(result.errors?.[0].originalError?.name).to.equal('AbortError'); + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should provide access to the abort signal within resolvers', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + } + } + `); + + const cancellableAsyncFn = async (abortSignal: AbortSignal) => { + await resolveOnNextTick(); + abortSignal.throwIfAborted(); + }; + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: { + id: (_args: any, _context: any, _info: any, signal: AbortSignal) => + cancellableAsyncFn(signal), + }, + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: { + id: null, + }, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo', 'id'], + locations: [{ line: 4, column: 11 }], + }, + ], + }); + }); + + it('should stop the execution when aborted during object field completion with a custom error', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + const customError = new Error('Custom abort error'); + abortController.abort(customError); + + const result = await resultPromise; + + invariant(!('initialResult' in result)); + + expect(result.errors?.[0].originalError).to.equal(customError); + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'Custom abort error', + path: ['todo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should stop the execution when aborted during object field completion with a custom string', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort('Custom abort error message'); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'Unexpected error value: "Custom abort error message"', + path: ['todo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should stop the execution when aborted during nested object field completion', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: { + id: '1', + /* c8 ignore next 3 */ + author: async () => + Promise.resolve(() => expect.fail('Should not be called')), + }, + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: { + id: '1', + author: null, + }, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo', 'author'], + locations: [{ line: 5, column: 11 }], + }, + ], + }); + }); + + it('should stop the execution when aborted despite a hanging resolver', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: () => + new Promise(() => { + /* will never resolve */ + }), + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + invariant(!('initialResult' in result)); + + expect(result.errors?.[0].originalError?.name).to.equal('AbortError'); + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should stop the execution when aborted despite a hanging item', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + items + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: () => ({ + id: '1', + items: [ + new Promise(() => { + /* will never resolve */ + }), + ], + }), + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + invariant(!('initialResult' in result)); + + expect(result.errors?.[0].originalError?.name).to.equal('AbortError'); + + expectJSON(result).toDeepEqual({ + data: { + todo: { + id: '1', + items: [null], + }, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo', 'items', 0], + locations: [{ line: 5, column: 11 }], + }, + ], + }); + }); + + it('should stop the execution when aborted despite a hanging async item', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + items + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + todo: () => ({ + id: '1', + async *items() { + yield await new Promise(() => { + /* will never resolve */ + }); /* c8 ignore start */ + } /* c8 ignore stop */, + }), + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + invariant(!('initialResult' in result)); + + expect(result.errors?.[0].originalError?.name).to.equal('AbortError'); + + expectJSON(result).toDeepEqual({ + data: { + todo: { + id: '1', + items: null, + }, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo', 'items'], + locations: [{ line: 5, column: 11 }], + }, + ], + }); + }); + + it('should stop the execution when aborted with proper null bubbling', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + nonNullableTodo { + id + author { + id + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + nonNullableTodo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + }); + + abortController.abort(); + + const result = await resultPromise; + + invariant(!('initialResult' in result)); + + expect(result.errors?.[0].originalError?.name).to.equal('AbortError'); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'This operation was aborted', + path: ['nonNullableTodo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should stop deferred execution when aborted', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + ... on Todo @defer { + author { + id + } + } + } + } + `); + + const resultPromise = execute({ + document, + schema, + rootValue: { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next */ + author: () => expect.fail('Should not be called'), + }), + }, + abortSignal: abortController.signal, + }); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + todo: null, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('should stop deferred execution when aborted mid-execution', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + ... on Query @defer { + todo { + id + author { + id + } + } + } + } + `); + + const resultPromise = complete( + document, + { + todo: async () => + Promise.resolve({ + id: '1', + /* c8 ignore next 2 */ + author: async () => + Promise.resolve(() => expect.fail('Should not be called')), + }), + }, + abortController.signal, + ); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual([ + { + data: {}, + hasNext: true, + }, + { + incremental: [ + { + data: { + todo: null, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['todo'], + locations: [{ line: 4, column: 11 }], + }, + ], + path: [], + }, + ], + hasNext: false, + }, + ]); + }); + + it('should stop streamed execution when aborted', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + items @stream + } + } + `); + + const resultPromise = complete( + document, + { + todo: { + id: '1', + items: [Promise.resolve('item')], + }, + }, + abortController.signal, + ); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual([ + { + data: { + todo: { + id: '1', + items: [], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'This operation was aborted', + path: ['todo', 'items', 0], + locations: [{ line: 5, column: 11 }], + }, + ], + path: ['todo', 'items', 0], + }, + ], + hasNext: false, + }, + ]); + }); + + it('should stop streamed execution when aborted', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + items @stream + } + } + `); + + const resultPromise = complete( + document, + { + todo: { + id: '1', + items: [Promise.resolve('item')], + }, + }, + abortController.signal, + ); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual([ + { + data: { + todo: { + id: '1', + items: [], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + items: [null], + errors: [ + { + message: 'This operation was aborted', + path: ['todo', 'items', 0], + locations: [{ line: 5, column: 11 }], + }, + ], + path: ['todo', 'items', 0], + }, + ], + hasNext: false, + }, + ]); + }); + + it('should stop the execution when aborted mid-mutation', async () => { + const abortController = new AbortController(); + const document = parse(` + mutation { + foo + bar + } + `); + + const resultPromise = execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + foo: async () => Promise.resolve('baz'), + /* c8 ignore next */ + bar: () => expect.fail('Should not be called'), + }, + }); + + await resolveOnNextTick(); + await resolveOnNextTick(); + await resolveOnNextTick(); + + abortController.abort(); + + const result = await resultPromise; + + expectJSON(result).toDeepEqual({ + data: { + foo: 'baz', + bar: null, + }, + errors: [ + { + message: 'This operation was aborted', + path: ['bar'], + locations: [{ line: 4, column: 9 }], + }, + ], + }); + }); + + it('should stop the execution when aborted pre-execute', async () => { + const abortController = new AbortController(); + const document = parse(` + query { + todo { + id + author { + id + } + } + } + `); + abortController.abort(); + const result = await execute({ + document, + schema, + abortSignal: abortController.signal, + rootValue: { + /* c8 ignore next */ + todo: () => expect.fail('Should not be called'), + }, + }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'This operation was aborted', + }, + ], + }); + }); +}); diff --git a/src/transform/__tests__/defer-test.ts b/src/transform/__tests__/defer-test.ts index b189fd1007..9bdfbb9eb7 100644 --- a/src/transform/__tests__/defer-test.ts +++ b/src/transform/__tests__/defer-test.ts @@ -17,12 +17,13 @@ import { 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'; +import { execute } from './execute.js'; + const friendType = new GraphQLObjectType({ fields: { id: { type: GraphQLID }, @@ -141,7 +142,7 @@ async function complete( rootValue: unknown = { hero }, enableEarlyExecution = false, ) { - const result = await legacyExecuteIncrementally({ + const result = await execute({ schema, document, rootValue, @@ -848,7 +849,7 @@ describe('Execute: legacy defer directive format', () => { promiseWithResolvers(); let cResolverCalled = false; let eResolverCalled = false; - const executeResult = legacyExecuteIncrementally({ + const executeResult = execute({ schema, document, rootValue: { @@ -964,7 +965,7 @@ describe('Execute: legacy defer directive format', () => { promiseWithResolvers(); let cResolverCalled = false; let eResolverCalled = false; - const executeResult = legacyExecuteIncrementally({ + const executeResult = execute({ schema, document, rootValue: { diff --git a/src/transform/__tests__/directives-test.ts b/src/transform/__tests__/directives-test.ts new file mode 100644 index 0000000000..6b3b3627bd --- /dev/null +++ b/src/transform/__tests__/directives-test.ts @@ -0,0 +1,311 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { executeSync } from './execute.js'; + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'TestType', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + }, + }), +}); + +const rootValue = { + a() { + return 'a'; + }, + b() { + return 'b'; + }, +}; + +function executeTestQuery(query: string) { + const document = parse(query); + return executeSync({ schema, document, rootValue }); +} + +describe('Execute: handles directives', () => { + describe('works without directives', () => { + it('basic query works', () => { + const result = executeTestQuery('{ a, b }'); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + }); + + describe('works on scalars', () => { + it('if true includes scalar', () => { + const result = executeTestQuery('{ a, b @include(if: true) }'); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('if false omits on scalar', () => { + const result = executeTestQuery('{ a, b @include(if: false) }'); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + + it('unless false includes scalar', () => { + const result = executeTestQuery('{ a, b @skip(if: false) }'); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless true omits scalar', () => { + const result = executeTestQuery('{ a, b @skip(if: true) }'); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + }); + + describe('works on fragment spreads', () => { + it('if false omits fragment spread', () => { + const result = executeTestQuery(` + query { + a + ...Frag @include(if: false) + } + fragment Frag on TestType { + b + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + + it('if true includes fragment spread', () => { + const result = executeTestQuery(` + query { + a + ...Frag @include(if: true) + } + fragment Frag on TestType { + b + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless false includes fragment spread', () => { + const result = executeTestQuery(` + query { + a + ...Frag @skip(if: false) + } + fragment Frag on TestType { + b + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless true omits fragment spread', () => { + const result = executeTestQuery(` + query { + a + ...Frag @skip(if: true) + } + fragment Frag on TestType { + b + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + }); + + describe('works on inline fragment', () => { + it('if false omits inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... on TestType @include(if: false) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + + it('if true includes inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... on TestType @include(if: true) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless false includes inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... on TestType @skip(if: false) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless true includes inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... on TestType @skip(if: true) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + }); + + describe('works on anonymous inline fragment', () => { + it('if false omits anonymous inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... @include(if: false) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + + it('if true includes anonymous inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... @include(if: true) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless false includes anonymous inline fragment', () => { + const result = executeTestQuery(` + query Q { + a + ... @skip(if: false) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('unless true includes anonymous inline fragment', () => { + const result = executeTestQuery(` + query { + a + ... @skip(if: true) { + b + } + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + }); + + describe('works with skip and include directives', () => { + it('include and no skip', () => { + const result = executeTestQuery(` + { + a + b @include(if: true) @skip(if: false) + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b' }, + }); + }); + + it('include and skip', () => { + const result = executeTestQuery(` + { + a + b @include(if: true) @skip(if: true) + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + + it('no include or skip', () => { + const result = executeTestQuery(` + { + a + b @include(if: false) @skip(if: false) + } + `); + + expect(result).to.deep.equal({ + data: { a: 'a' }, + }); + }); + }); +}); diff --git a/src/transform/__tests__/execute.ts b/src/transform/__tests__/execute.ts new file mode 100644 index 0000000000..78a9f1108c --- /dev/null +++ b/src/transform/__tests__/execute.ts @@ -0,0 +1,27 @@ +import { isPromise } from '../../jsutils/isPromise.js'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; + +import type { ExecutionArgs } from '../../execution/execute.js'; +import type { ExecutionResult } from '../../execution/types.js'; + +import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.js'; +import type { LegacyExperimentalIncrementalExecutionResults } from '../transformResult.js'; + +export function executeSync(args: ExecutionArgs): ExecutionResult { + const result = legacyExecuteIncrementally(args); + + // Assert that the execution was synchronous. + if (isPromise(result) || 'initialResult' in result) { + throw new Error('GraphQL execution failed to complete synchronously.'); + } + + return result; +} + +export function execute( + args: ExecutionArgs, +): PromiseOrValue< + ExecutionResult | LegacyExperimentalIncrementalExecutionResults +> { + return legacyExecuteIncrementally(args); +} diff --git a/src/transform/__tests__/executor-test.ts b/src/transform/__tests__/executor-test.ts new file mode 100644 index 0000000000..df6f478992 --- /dev/null +++ b/src/transform/__tests__/executor-test.ts @@ -0,0 +1,1342 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { inspect } from '../../jsutils/inspect.js'; + +import { Kind } from '../../language/kinds.js'; +import { parse } from '../../language/parser.js'; + +import { + GraphQLInterfaceType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLUnionType, +} from '../../type/definition.js'; +import { + GraphQLBoolean, + GraphQLInt, + GraphQLString, +} from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { execute, executeSync } from './execute.js'; + +describe('Execute: Handles basic execution tasks', () => { + it('executes arbitrary code', async () => { + const data = { + a: () => 'Apple', + b: () => 'Banana', + c: () => 'Cookie', + d: () => 'Donut', + e: () => 'Egg', + f: 'Fish', + // Called only by DataType::pic static resolver + pic: (size: number) => 'Pic of size: ' + size, + deep: () => deepData, + promise: promiseData, + }; + + const deepData = { + a: () => 'Already Been Done', + b: () => 'Boring', + c: () => ['Contrived', undefined, 'Confusing'], + deeper: () => [data, null, data], + }; + + function promiseData() { + return Promise.resolve(data); + } + + const DataType: GraphQLObjectType = new GraphQLObjectType({ + name: 'DataType', + fields: () => ({ + a: { type: GraphQLString }, + b: { type: GraphQLString }, + c: { type: GraphQLString }, + d: { type: GraphQLString }, + e: { type: GraphQLString }, + f: { type: GraphQLString }, + pic: { + args: { size: { type: GraphQLInt } }, + type: GraphQLString, + resolve: (obj, { size }) => obj.pic(size), + }, + deep: { type: DeepDataType }, + promise: { type: DataType }, + }), + }); + + const DeepDataType = new GraphQLObjectType({ + name: 'DeepDataType', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + c: { type: new GraphQLList(GraphQLString) }, + deeper: { type: new GraphQLList(DataType) }, + }, + }); + + const document = parse(` + query ($size: Int) { + a, + b, + x: c + ...c + f + ...on DataType { + pic(size: $size) + promise { + a + } + } + deep { + a + b + c + deeper { + a + b + } + } + } + + fragment c on DataType { + d + e + } + `); + + const result = await execute({ + schema: new GraphQLSchema({ query: DataType }), + document, + rootValue: data, + variableValues: { size: 100 }, + }); + + expect(result).to.deep.equal({ + data: { + a: 'Apple', + b: 'Banana', + x: 'Cookie', + d: 'Donut', + e: 'Egg', + f: 'Fish', + pic: 'Pic of size: 100', + promise: { a: 'Apple' }, + deep: { + a: 'Already Been Done', + b: 'Boring', + c: ['Contrived', null, 'Confusing'], + deeper: [ + { a: 'Apple', b: 'Banana' }, + null, + { a: 'Apple', b: 'Banana' }, + ], + }, + }, + }); + }); + + it('merges parallel fragments', () => { + const Type: GraphQLObjectType = new GraphQLObjectType({ + name: 'Type', + fields: () => ({ + a: { type: GraphQLString, resolve: () => 'Apple' }, + b: { type: GraphQLString, resolve: () => 'Banana' }, + c: { type: GraphQLString, resolve: () => 'Cherry' }, + deep: { type: Type, resolve: () => ({}) }, + }), + }); + const schema = new GraphQLSchema({ query: Type }); + + const document = parse(` + { a, ...FragOne, ...FragTwo } + + fragment FragOne on Type { + b + deep { b, deeper: deep { b } } + } + + fragment FragTwo on Type { + c + deep { c, deeper: deep { c } } + } + `); + + const result = executeSync({ schema, document }); + expect(result).to.deep.equal({ + data: { + a: 'Apple', + b: 'Banana', + c: 'Cherry', + deep: { + b: 'Banana', + c: 'Cherry', + deeper: { + b: 'Banana', + c: 'Cherry', + }, + }, + }, + }); + }); + + it('provides info about current execution state', () => { + let resolvedInfo; + const testType = new GraphQLObjectType({ + name: 'Test', + fields: { + test: { + type: GraphQLString, + resolve(_val, _args, _ctx, info) { + resolvedInfo = info; + }, + }, + }, + }); + const schema = new GraphQLSchema({ query: testType }); + + const document = parse('query ($var: String) { result: test }'); + const rootValue = { root: 'val' }; + const variableValues = { var: 'abc' }; + + executeSync({ schema, document, rootValue, variableValues }); + + expect(resolvedInfo).to.have.all.keys( + 'fieldName', + 'fieldNodes', + 'returnType', + 'parentType', + 'path', + 'schema', + 'fragments', + 'rootValue', + 'operation', + 'variableValues', + ); + + const operation = document.definitions[0]; + assert(operation.kind === Kind.OPERATION_DEFINITION); + + expect(resolvedInfo).to.include({ + fieldName: 'test', + returnType: GraphQLString, + parentType: testType, + schema, + rootValue, + }); + + expect(resolvedInfo).to.deep.include({ + path: { prev: undefined, key: 'result', typename: 'Test' }, + variableValues: { + sources: { + var: { + signature: { + name: 'var', + type: GraphQLString, + default: undefined, + }, + value: 'abc', + }, + }, + coerced: { var: 'abc' }, + }, + }); + }); + + it('populates path correctly with complex types', () => { + let path; + const someObject = new GraphQLObjectType({ + name: 'SomeObject', + fields: { + test: { + type: GraphQLString, + resolve(_val, _args, _ctx, info) { + path = info.path; + }, + }, + }, + }); + const someUnion = new GraphQLUnionType({ + name: 'SomeUnion', + types: [someObject], + resolveType() { + return 'SomeObject'; + }, + }); + const testType = new GraphQLObjectType({ + name: 'SomeQuery', + fields: { + test: { + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(someUnion)), + ), + }, + }, + }); + const schema = new GraphQLSchema({ query: testType }); + const rootValue = { test: [{}] }; + const document = parse(` + query { + l1: test { + ... on SomeObject { + l2: test + } + } + } + `); + + executeSync({ schema, document, rootValue }); + + expect(path).to.deep.equal({ + key: 'l2', + typename: 'SomeObject', + prev: { + key: 0, + typename: undefined, + prev: { + key: 'l1', + typename: 'SomeQuery', + prev: undefined, + }, + }, + }); + }); + + it('threads root value context correctly', () => { + let resolvedRootValue; + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { + type: GraphQLString, + resolve(rootValueArg) { + resolvedRootValue = rootValueArg; + }, + }, + }, + }), + }); + + const document = parse('query Example { a }'); + const rootValue = { contextThing: 'thing' }; + + executeSync({ schema, document, rootValue }); + expect(resolvedRootValue).to.equal(rootValue); + }); + + it('correctly threads arguments', () => { + let resolvedArgs; + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + b: { + args: { + numArg: { type: GraphQLInt }, + stringArg: { type: GraphQLString }, + }, + type: GraphQLString, + resolve(_, args) { + resolvedArgs = args; + }, + }, + }, + }), + }); + + const document = parse(` + query Example { + b(numArg: 123, stringArg: "foo") + } + `); + + executeSync({ schema, document }); + expect(resolvedArgs).to.deep.equal({ numArg: 123, stringArg: 'foo' }); + }); + + it('nulls out error subtrees', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + sync: { type: GraphQLString }, + syncError: { type: GraphQLString }, + syncRawError: { type: GraphQLString }, + syncReturnError: { type: GraphQLString }, + syncReturnErrorList: { type: new GraphQLList(GraphQLString) }, + async: { type: GraphQLString }, + asyncReject: { type: GraphQLString }, + asyncRejectWithExtensions: { type: GraphQLString }, + asyncRawReject: { type: GraphQLString }, + asyncEmptyReject: { type: GraphQLString }, + asyncError: { type: GraphQLString }, + asyncRawError: { type: GraphQLString }, + asyncReturnError: { type: GraphQLString }, + asyncReturnErrorWithExtensions: { type: GraphQLString }, + }, + }), + }); + + const document = parse(` + { + sync + syncError + syncRawError + syncReturnError + syncReturnErrorList + async + asyncReject + asyncRawReject + asyncEmptyReject + asyncError + asyncRawError + asyncReturnError + asyncReturnErrorWithExtensions + } + `); + + const rootValue = { + sync() { + return 'sync'; + }, + syncError() { + throw new Error('Error getting syncError'); + }, + syncRawError() { + // eslint-disable-next-line no-throw-literal, @typescript-eslint/only-throw-error + throw 'Error getting syncRawError'; + }, + syncReturnError() { + return new Error('Error getting syncReturnError'); + }, + syncReturnErrorList() { + return [ + 'sync0', + new Error('Error getting syncReturnErrorList1'), + 'sync2', + new Error('Error getting syncReturnErrorList3'), + ]; + }, + async() { + return new Promise((resolve) => resolve('async')); + }, + asyncReject() { + return new Promise((_, reject) => + reject(new Error('Error getting asyncReject')), + ); + }, + asyncRawReject() { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject('Error getting asyncRawReject'); + }, + asyncEmptyReject() { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(); + }, + asyncError() { + return new Promise(() => { + throw new Error('Error getting asyncError'); + }); + }, + asyncRawError() { + return new Promise(() => { + // eslint-disable-next-line no-throw-literal, @typescript-eslint/only-throw-error + throw 'Error getting asyncRawError'; + }); + }, + asyncReturnError() { + return Promise.resolve(new Error('Error getting asyncReturnError')); + }, + asyncReturnErrorWithExtensions() { + const error = new Error('Error getting asyncReturnErrorWithExtensions'); + // @ts-expect-error + error.extensions = { foo: 'bar' }; + + return Promise.resolve(error); + }, + }; + + const result = await execute({ schema, document, rootValue }); + expectJSON(result).toDeepEqual({ + data: { + sync: 'sync', + syncError: null, + syncRawError: null, + syncReturnError: null, + syncReturnErrorList: ['sync0', null, 'sync2', null], + async: 'async', + asyncReject: null, + asyncRawReject: null, + asyncEmptyReject: null, + asyncError: null, + asyncRawError: null, + asyncReturnError: null, + asyncReturnErrorWithExtensions: null, + }, + errors: [ + { + message: 'Error getting syncError', + locations: [{ line: 4, column: 9 }], + path: ['syncError'], + }, + { + message: 'Unexpected error value: "Error getting syncRawError"', + locations: [{ line: 5, column: 9 }], + path: ['syncRawError'], + }, + { + message: 'Error getting syncReturnError', + locations: [{ line: 6, column: 9 }], + path: ['syncReturnError'], + }, + { + message: 'Error getting syncReturnErrorList1', + locations: [{ line: 7, column: 9 }], + path: ['syncReturnErrorList', 1], + }, + { + message: 'Error getting syncReturnErrorList3', + locations: [{ line: 7, column: 9 }], + path: ['syncReturnErrorList', 3], + }, + { + message: 'Error getting asyncReject', + locations: [{ line: 9, column: 9 }], + path: ['asyncReject'], + }, + { + message: 'Unexpected error value: "Error getting asyncRawReject"', + locations: [{ line: 10, column: 9 }], + path: ['asyncRawReject'], + }, + { + message: 'Unexpected error value: undefined', + locations: [{ line: 11, column: 9 }], + path: ['asyncEmptyReject'], + }, + { + message: 'Error getting asyncError', + locations: [{ line: 12, column: 9 }], + path: ['asyncError'], + }, + { + message: 'Unexpected error value: "Error getting asyncRawError"', + locations: [{ line: 13, column: 9 }], + path: ['asyncRawError'], + }, + { + message: 'Error getting asyncReturnError', + locations: [{ line: 14, column: 9 }], + path: ['asyncReturnError'], + }, + { + message: 'Error getting asyncReturnErrorWithExtensions', + locations: [{ line: 15, column: 9 }], + path: ['asyncReturnErrorWithExtensions'], + extensions: { foo: 'bar' }, + }, + ], + }); + }); + + it('nulls error subtree for promise rejection #1071', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foods: { + type: new GraphQLList( + new GraphQLObjectType({ + name: 'Food', + fields: { + name: { type: GraphQLString }, + }, + }), + ), + resolve() { + return Promise.reject(new Error('Oops')); + }, + }, + }, + }), + }); + + const document = parse(` + query { + foods { + name + } + } + `); + + const result = await execute({ schema, document }); + + expectJSON(result).toDeepEqual({ + data: { foods: null }, + errors: [ + { + locations: [{ column: 9, line: 3 }], + message: 'Oops', + path: ['foods'], + }, + ], + }); + }); + + it('handles sync errors combined with rejections', async () => { + let isAsyncResolverFinished = false; + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + syncNullError: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => null, + }, + asyncNullError: { + type: new GraphQLNonNull(GraphQLString), + async resolve() { + await resolveOnNextTick(); + await resolveOnNextTick(); + await resolveOnNextTick(); + isAsyncResolverFinished = true; + return null; + }, + }, + }, + }), + }); + + // Order is important here, as the promise has to be created before the synchronous error is thrown + const document = parse(` + { + asyncNullError + syncNullError + } + `); + + const result = execute({ schema, document }); + + expect(isAsyncResolverFinished).to.equal(false); + expectJSON(await result).toDeepEqual({ + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.syncNullError.', + locations: [{ line: 4, column: 9 }], + path: ['syncNullError'], + }, + ], + }); + expect(isAsyncResolverFinished).to.equal(true); + }); + + it('handles async bubbling errors combined with non-bubbling errors', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + asyncNonNullError: { + type: new GraphQLNonNull(GraphQLString), + async resolve() { + await resolveOnNextTick(); + return null; + }, + }, + asyncError: { + type: GraphQLString, + async resolve() { + await resolveOnNextTick(); + throw new Error('Oops'); + }, + }, + }, + }), + }); + + // Order is important here, as the nullable error should resolve first + const document = parse(` + { + asyncError + asyncNonNullError + } + `); + + const result = execute({ schema, document }); + + expectJSON(await result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 3, column: 9 }], + path: ['asyncError'], + }, + { + message: + 'Cannot return null for non-nullable field Query.asyncNonNullError.', + locations: [{ line: 4, column: 9 }], + path: ['asyncNonNullError'], + }, + ], + }); + }); + + it('Full response path is included for non-nullable fields', () => { + const A: GraphQLObjectType = new GraphQLObjectType({ + name: 'A', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + nonNullA: { + type: new GraphQLNonNull(A), + resolve: () => ({}), + }, + throws: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('Catch me if you can'); + }, + }, + }), + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + }), + }), + }); + + const document = parse(` + query { + nullableA { + aliasedA: nullableA { + nonNullA { + anotherA: nonNullA { + throws + } + } + } + } + } + `); + + const result = executeSync({ schema, document }); + expectJSON(result).toDeepEqual({ + data: { + nullableA: { + aliasedA: null, + }, + }, + errors: [ + { + message: 'Catch me if you can', + locations: [{ line: 7, column: 17 }], + path: ['nullableA', 'aliasedA', 'nonNullA', 'anotherA', 'throws'], + }, + ], + }); + }); + + it('uses the inline operation if no operation name is provided', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse('{ a }'); + const rootValue = { a: 'b' }; + + const result = executeSync({ schema, document, rootValue }); + expect(result).to.deep.equal({ data: { a: 'b' } }); + }); + + it('uses the only operation if no operation name is provided', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse('query Example { a }'); + const rootValue = { a: 'b' }; + + const result = executeSync({ schema, document, rootValue }); + expect(result).to.deep.equal({ data: { a: 'b' } }); + }); + + it('uses the named operation if operation name is provided', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + + const document = parse(` + query Example { first: a } + query OtherExample { second: a } + `); + const rootValue = { a: 'b' }; + const operationName = 'OtherExample'; + + const result = executeSync({ schema, document, rootValue, operationName }); + expect(result).to.deep.equal({ data: { second: 'b' } }); + }); + + it('provides error if no operation is provided', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse('fragment Example on Type { a }'); + const rootValue = { a: 'b' }; + + const result = executeSync({ schema, document, rootValue }); + expectJSON(result).toDeepEqual({ + errors: [{ message: 'Must provide an operation.' }], + }); + }); + + it('errors if no op name is provided with multiple operations', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + query Example { a } + query OtherExample { a } + `); + + const result = executeSync({ schema, document }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Must provide operation name if query contains multiple operations.', + }, + ], + }); + }); + + it('errors if unknown operation name is provided', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + query Example { a } + query OtherExample { a } + `); + const operationName = 'UnknownExample'; + + const result = executeSync({ schema, document, operationName }); + expectJSON(result).toDeepEqual({ + errors: [{ message: 'Unknown operation named "UnknownExample".' }], + }); + }); + + it('errors if empty string is provided as operation name', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse('{ a }'); + const operationName = ''; + + const result = executeSync({ schema, document, operationName }); + expectJSON(result).toDeepEqual({ + errors: [{ message: 'Unknown operation named "".' }], + }); + }); + + it('uses the query schema for queries', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Q', + fields: { + a: { type: GraphQLString }, + }, + }), + mutation: new GraphQLObjectType({ + name: 'M', + fields: { + c: { type: GraphQLString }, + }, + }), + subscription: new GraphQLObjectType({ + name: 'S', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + query Q { a } + mutation M { c } + subscription S { a } + `); + const rootValue = { a: 'b', c: 'd' }; + const operationName = 'Q'; + + const result = executeSync({ schema, document, rootValue, operationName }); + expect(result).to.deep.equal({ data: { a: 'b' } }); + }); + + it('uses the mutation schema for mutations', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Q', + fields: { + a: { type: GraphQLString }, + }, + }), + mutation: new GraphQLObjectType({ + name: 'M', + fields: { + c: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + query Q { a } + mutation M { c } + `); + const rootValue = { a: 'b', c: 'd' }; + const operationName = 'M'; + + const result = executeSync({ schema, document, rootValue, operationName }); + expect(result).to.deep.equal({ data: { c: 'd' } }); + }); + + it('uses the subscription schema for subscriptions', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Q', + fields: { + a: { type: GraphQLString }, + }, + }), + subscription: new GraphQLObjectType({ + name: 'S', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + query Q { a } + subscription S { a } + `); + const rootValue = { a: 'b', c: 'd' }; + const operationName = 'S'; + + const result = executeSync({ schema, document, rootValue, operationName }); + expect(result).to.deep.equal({ data: { a: 'b' } }); + }); + + it('resolves to an error if schema does not support operation', () => { + const schema = new GraphQLSchema({ assumeValid: true }); + + const document = parse(` + query Q { __typename } + mutation M { __typename } + subscription S { __typename } + `); + + expectJSON( + executeSync({ schema, document, operationName: 'Q' }), + ).toDeepEqual({ + data: null, + errors: [ + { + message: 'Schema is not configured to execute query operation.', + locations: [{ line: 2, column: 7 }], + }, + ], + }); + + expectJSON( + executeSync({ schema, document, operationName: 'M' }), + ).toDeepEqual({ + data: null, + errors: [ + { + message: 'Schema is not configured to execute mutation operation.', + locations: [{ line: 3, column: 7 }], + }, + ], + }); + + expectJSON( + executeSync({ schema, document, operationName: 'S' }), + ).toDeepEqual({ + data: null, + errors: [ + { + message: + 'Schema is not configured to execute subscription operation.', + locations: [{ line: 4, column: 7 }], + }, + ], + }); + }); + + it('correct field ordering despite execution order', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + c: { type: GraphQLString }, + d: { type: GraphQLString }, + e: { type: GraphQLString }, + }, + }), + }); + const document = parse('{ a, b, c, d, e }'); + const rootValue = { + a: () => 'a', + b: () => new Promise((resolve) => resolve('b')), + c: () => 'c', + d: () => new Promise((resolve) => resolve('d')), + e: () => 'e', + }; + + const result = await execute({ schema, document, rootValue }); + expect(result).to.deep.equal({ + data: { a: 'a', b: 'b', c: 'c', d: 'd', e: 'e' }, + }); + }); + + it('Avoids recursion', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse(` + { + a + ...Frag + ...Frag + } + + fragment Frag on Type { + a, + ...Frag + } + `); + const rootValue = { a: 'b' }; + + const result = executeSync({ schema, document, rootValue }); + expect(result).to.deep.equal({ + data: { a: 'b' }, + }); + }); + + it('ignores missing sub selections on fields', () => { + const someType = new GraphQLObjectType({ + name: 'SomeType', + fields: { + b: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + a: { type: someType }, + }, + }), + }); + const document = parse('{ a }'); + const rootValue = { a: { b: 'c' } }; + + const result = executeSync({ schema, document, rootValue }); + expect(result).to.deep.equal({ + data: { a: {} }, + }); + }); + + it('does not include illegal fields in output', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Q', + fields: { + a: { type: GraphQLString }, + }, + }), + }); + const document = parse('{ thisIsIllegalDoNotIncludeMe }'); + + const result = executeSync({ schema, document }); + expect(result).to.deep.equal({ + data: {}, + }); + }); + + it('does not include arguments that were not set', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Type', + fields: { + field: { + type: GraphQLString, + resolve: (_source, args) => inspect(args), + args: { + a: { type: GraphQLBoolean }, + b: { type: GraphQLBoolean }, + c: { type: GraphQLBoolean }, + d: { type: GraphQLInt }, + e: { type: GraphQLInt }, + }, + }, + }, + }), + }); + const document = parse('{ field(a: true, c: false, e: 0) }'); + + const result = executeSync({ schema, document }); + expect(result).to.deep.equal({ + data: { + field: '{ a: true, c: false, e: 0 }', + }, + }); + }); + + it('fails when an isTypeOf check is not met', async () => { + class Special { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + class NotSpecial { + value: string; + + constructor(value: string) { + this.value = value; + } + } + + const SpecialType = new GraphQLObjectType({ + name: 'SpecialType', + isTypeOf(obj, context) { + const result = obj instanceof Special; + return context.async ? Promise.resolve(result) : result; + }, + fields: { value: { type: GraphQLString } }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + specials: { type: new GraphQLList(SpecialType) }, + }, + }), + }); + + const document = parse('{ specials { value } }'); + const rootValue = { + specials: [new Special('foo'), new NotSpecial('bar')], + }; + + const result = executeSync({ + schema, + document, + rootValue, + contextValue: { async: false }, + }); + expectJSON(result).toDeepEqual({ + data: { + specials: [{ value: 'foo' }, null], + }, + errors: [ + { + message: + 'Expected value of type "SpecialType" but got: { value: "bar" }.', + locations: [{ line: 1, column: 3 }], + path: ['specials', 1], + }, + ], + }); + + const asyncResult = await execute({ + schema, + document, + rootValue, + contextValue: { async: true }, + }); + expect(asyncResult).to.deep.equal(result); + }); + + it('fails when coerceOutputValue of custom scalar does not return a value', () => { + const customScalar = new GraphQLScalarType({ + name: 'CustomScalar', + coerceOutputValue() { + /* returns nothing */ + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + customScalar: { + type: customScalar, + resolve: () => 'CUSTOM_VALUE', + }, + }, + }), + }); + + const result = executeSync({ schema, document: parse('{ customScalar }') }); + expectJSON(result).toDeepEqual({ + data: { customScalar: null }, + errors: [ + { + message: + 'Expected `CustomScalar.coerceOutputValue("CUSTOM_VALUE")` to return non-nullable value, returned: undefined', + locations: [{ line: 1, column: 3 }], + path: ['customScalar'], + }, + ], + }); + }); + + it('executes ignoring invalid non-executable definitions', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + + const document = parse(` + { foo } + + type Query { bar: String } + `); + + const result = executeSync({ schema, document }); + expect(result).to.deep.equal({ data: { foo: null } }); + }); + + it('uses a custom field resolver', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: GraphQLString }, + }, + }), + }); + const document = parse('{ foo }'); + + const result = executeSync({ + schema, + document, + fieldResolver(_source, _args, _context, info) { + // For the purposes of test, just return the name of the field! + return info.fieldName; + }, + }); + + expect(result).to.deep.equal({ data: { foo: 'foo' } }); + }); + + it('uses a custom type resolver', () => { + const document = parse('{ foo { bar } }'); + + const fooInterface = new GraphQLInterfaceType({ + name: 'FooInterface', + fields: { + bar: { type: GraphQLString }, + }, + }); + + const fooObject = new GraphQLObjectType({ + name: 'FooObject', + interfaces: [fooInterface], + fields: { + bar: { type: GraphQLString }, + }, + }); + + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + foo: { type: fooInterface }, + }, + }), + types: [fooObject], + }); + + const rootValue = { foo: { bar: 'bar' } }; + + let possibleTypes; + const result = executeSync({ + schema, + document, + rootValue, + typeResolver(_source, _context, info, abstractType) { + // Resolver should be able to figure out all possible types on its own + possibleTypes = info.schema.getPossibleTypes(abstractType); + + return 'FooObject'; + }, + }); + + expect(result).to.deep.equal({ data: { foo: { bar: 'bar' } } }); + expect(possibleTypes).to.deep.equal([fooObject]); + }); +}); diff --git a/src/transform/__tests__/lists-test.ts b/src/transform/__tests__/lists-test.ts new file mode 100644 index 0000000000..d8c01e904c --- /dev/null +++ b/src/transform/__tests__/lists-test.ts @@ -0,0 +1,442 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { invariant } from '../../jsutils/invariant.js'; +import { isPromise } from '../../jsutils/isPromise.js'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js'; + +import { parse } from '../../language/parser.js'; + +import type { GraphQLFieldResolver } from '../../type/definition.js'; +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import type { ExecutionResult } from '../../execution/types.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { execute, executeSync } from './execute.js'; + +describe('Execute: Accepts any iterable as list value', () => { + function complete(rootValue: unknown) { + return executeSync({ + schema: buildSchema('type Query { listField: [String] }'), + document: parse('{ listField }'), + rootValue, + }); + } + + it('Accepts a Set as a List value', () => { + const listField = new Set(['apple', 'banana', 'apple', 'coconut']); + + expect(complete({ listField })).to.deep.equal({ + data: { listField: ['apple', 'banana', 'coconut'] }, + }); + }); + + it('Accepts a Generator function as a List value', () => { + function* listField() { + yield 'one'; + yield 2; + yield true; + } + + expect(complete({ listField })).to.deep.equal({ + data: { listField: ['one', '2', 'true'] }, + }); + }); + + it('Accepts function arguments as a List value', () => { + function getArgs(..._args: ReadonlyArray) { + return arguments; + } + const listField = getArgs('one', 'two'); + + expect(complete({ listField })).to.deep.equal({ + data: { listField: ['one', 'two'] }, + }); + }); + + it('Does not accept (Iterable) String-literal as a List value', () => { + const listField = 'Singular'; + + expectJSON(complete({ listField })).toDeepEqual({ + data: { listField: null }, + errors: [ + { + message: + 'Expected Iterable, but did not find one for field "Query.listField".', + locations: [{ line: 1, column: 3 }], + path: ['listField'], + }, + ], + }); + }); +}); + +describe('Execute: Accepts async iterables as list value', () => { + function complete(rootValue: unknown, as: string = '[String]') { + return execute({ + schema: buildSchema(`type Query { listField: ${as} }`), + document: parse('{ listField }'), + rootValue, + }); + } + + function completeObjectList( + resolve: GraphQLFieldResolver<{ index: number }, unknown>, + ): PromiseOrValue { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + listField: { + resolve: async function* listField() { + yield await Promise.resolve({ index: 0 }); + yield await Promise.resolve({ index: 1 }); + yield await Promise.resolve({ index: 2 }); + }, + type: new GraphQLList( + new GraphQLObjectType({ + name: 'ObjectWrapper', + fields: { + index: { + type: new GraphQLNonNull(GraphQLString), + resolve, + }, + }, + }), + ), + }, + }, + }), + }); + const result = execute({ + schema, + document: parse('{ listField { index } }'), + }); + invariant(isPromise(result)); + return result.then((resolved) => { + invariant(!('initialResult' in resolved)); + return resolved; + }); + } + + it('Accepts an AsyncGenerator function as a List value', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve(4); + yield await Promise.resolve(false); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: ['two', '4', 'false'] }, + }); + }); + + it('Handles an AsyncGenerator function that throws', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve(4); + throw new Error('bad'); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: null }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField'], + }, + ], + }); + }); + + it('Handles an AsyncGenerator function where an intermediate value triggers an error', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve({}); + yield await Promise.resolve(4); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: ['two', null, '4'] }, + errors: [ + { + message: 'String cannot represent value: {}', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ], + }); + }); + + it('Handles errors from `completeValue` in AsyncIterables', async () => { + async function* listField() { + yield await Promise.resolve('two'); + yield await Promise.resolve({}); + } + + expectJSON(await complete({ listField })).toDeepEqual({ + data: { listField: ['two', null] }, + errors: [ + { + message: 'String cannot represent value: {}', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ], + }); + }); + + it('Handles promises from `completeValue` in AsyncIterables', async () => { + expectJSON( + await completeObjectList(({ index }) => Promise.resolve(index)), + ).toDeepEqual({ + data: { listField: [{ index: '0' }, { index: '1' }, { index: '2' }] }, + }); + }); + + it('Handles rejected promises from `completeValue` in AsyncIterables', async () => { + expectJSON( + await completeObjectList(({ index }) => { + if (index === 2) { + return Promise.reject(new Error('bad')); + } + return Promise.resolve(index); + }), + ).toDeepEqual({ + data: { listField: [{ index: '0' }, { index: '1' }, null] }, + errors: [ + { + message: 'bad', + locations: [{ line: 1, column: 15 }], + path: ['listField', 2, 'index'], + }, + ], + }); + }); + it('Handles nulls yielded by async generator', async () => { + async function* listField() { + yield await Promise.resolve(1); + yield await Promise.resolve(null); + yield await Promise.resolve(2); + } + const errors = [ + { + message: 'Cannot return null for non-nullable field Query.listField.', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ]; + + expect(await complete({ listField }, '[Int]')).to.deep.equal({ + data: { listField: [1, null, 2] }, + }); + expect(await complete({ listField }, '[Int]!')).to.deep.equal({ + data: { listField: [1, null, 2] }, + }); + expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ + data: { listField: null }, + errors, + }); + expectJSON(await complete({ listField }, '[Int!]!')).toDeepEqual({ + data: null, + errors, + }); + }); + + it('Returns async iterable when list nulls', async () => { + const values = [1, null, 2]; + let i = 0; + let returned = false; + const listField = { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({ value: values[i++], done: false }), + return: () => { + returned = true; + return Promise.resolve({ value: undefined, done: true }); + }, + }), + }; + const errors = [ + { + message: 'Cannot return null for non-nullable field Query.listField.', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ]; + + expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({ + data: { listField: null }, + errors, + }); + assert(returned); + }); +}); + +describe('Execute: Handles list nullability', () => { + async function complete(args: { listField: unknown; as: string }) { + const { listField, as } = args; + const schema = buildSchema(`type Query { listField: ${as} }`); + const document = parse('{ listField }'); + + const result = await executeQuery(listField); + // Promise> === Array + expectJSON(await executeQuery(promisify(listField))).toDeepEqual(result); + if (Array.isArray(listField)) { + const listOfPromises = listField.map(promisify); + + // Array> === Array + expectJSON(await executeQuery(listOfPromises)).toDeepEqual(result); + // Promise>> === Array + expectJSON(await executeQuery(promisify(listOfPromises))).toDeepEqual( + result, + ); + } + return result; + + function executeQuery(listValue: unknown) { + return execute({ schema, document, rootValue: { listField: listValue } }); + } + + function promisify(value: unknown): Promise { + return value instanceof Error + ? Promise.reject(value) + : Promise.resolve(value); + } + } + + it('Contains values', async () => { + const listField = [1, 2]; + + expect(await complete({ listField, as: '[Int]' })).to.deep.equal({ + data: { listField: [1, 2] }, + }); + expect(await complete({ listField, as: '[Int]!' })).to.deep.equal({ + data: { listField: [1, 2] }, + }); + expect(await complete({ listField, as: '[Int!]' })).to.deep.equal({ + data: { listField: [1, 2] }, + }); + expect(await complete({ listField, as: '[Int!]!' })).to.deep.equal({ + data: { listField: [1, 2] }, + }); + }); + + it('Contains null', async () => { + const listField = [1, null, 2]; + const errors = [ + { + message: 'Cannot return null for non-nullable field Query.listField.', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ]; + + expect(await complete({ listField, as: '[Int]' })).to.deep.equal({ + data: { listField: [1, null, 2] }, + }); + expect(await complete({ listField, as: '[Int]!' })).to.deep.equal({ + data: { listField: [1, null, 2] }, + }); + expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({ + data: { listField: null }, + errors, + }); + expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ + data: null, + errors, + }); + }); + + it('Returns null', async () => { + const listField = null; + const errors = [ + { + message: 'Cannot return null for non-nullable field Query.listField.', + locations: [{ line: 1, column: 3 }], + path: ['listField'], + }, + ]; + + expect(await complete({ listField, as: '[Int]' })).to.deep.equal({ + data: { listField: null }, + }); + expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({ + data: null, + errors, + }); + expect(await complete({ listField, as: '[Int!]' })).to.deep.equal({ + data: { listField: null }, + }); + expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ + data: null, + errors, + }); + }); + + it('Contains error', async () => { + const listField = [1, new Error('bad'), 2]; + const errors = [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField', 1], + }, + ]; + + expectJSON(await complete({ listField, as: '[Int]' })).toDeepEqual({ + data: { listField: [1, null, 2] }, + errors, + }); + expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({ + data: { listField: [1, null, 2] }, + errors, + }); + expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({ + data: { listField: null }, + errors, + }); + expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ + data: null, + errors, + }); + }); + + it('Results in error', async () => { + const listField = new Error('bad'); + const errors = [ + { + message: 'bad', + locations: [{ line: 1, column: 3 }], + path: ['listField'], + }, + ]; + + expectJSON(await complete({ listField, as: '[Int]' })).toDeepEqual({ + data: { listField: null }, + errors, + }); + expectJSON(await complete({ listField, as: '[Int]!' })).toDeepEqual({ + data: null, + errors, + }); + expectJSON(await complete({ listField, as: '[Int!]' })).toDeepEqual({ + data: { listField: null }, + errors, + }); + expectJSON(await complete({ listField, as: '[Int!]!' })).toDeepEqual({ + data: null, + errors, + }); + }); +}); diff --git a/src/transform/__tests__/mutations-test.ts b/src/transform/__tests__/mutations-test.ts new file mode 100644 index 0000000000..c64dc5285e --- /dev/null +++ b/src/transform/__tests__/mutations-test.ts @@ -0,0 +1,329 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLInt } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { execute, executeSync } from './execute.js'; + +class NumberHolder { + theNumber: number; + + constructor(originalNumber: number) { + this.theNumber = originalNumber; + } +} + +class Root { + numberHolder: NumberHolder; + + constructor(originalNumber: number) { + this.numberHolder = new NumberHolder(originalNumber); + } + + immediatelyChangeTheNumber(newNumber: number): NumberHolder { + this.numberHolder.theNumber = newNumber; + return this.numberHolder; + } + + async promiseToChangeTheNumber(newNumber: number): Promise { + await resolveOnNextTick(); + return this.immediatelyChangeTheNumber(newNumber); + } + + failToChangeTheNumber(): NumberHolder { + throw new Error('Cannot change the number'); + } + + async promiseAndFailToChangeTheNumber(): Promise { + await resolveOnNextTick(); + throw new Error('Cannot change the number'); + } +} + +const numberHolderType = new GraphQLObjectType({ + fields: { + theNumber: { type: GraphQLInt }, + promiseToGetTheNumber: { + type: GraphQLInt, + resolve: async (root) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return root.theNumber; + }, + }, + }, + name: 'NumberHolder', +}); + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + fields: { + numberHolder: { type: numberHolderType }, + }, + name: 'Query', + }), + mutation: new GraphQLObjectType({ + fields: { + immediatelyChangeTheNumber: { + type: numberHolderType, + args: { newNumber: { type: GraphQLInt } }, + resolve(obj, { newNumber }) { + return obj.immediatelyChangeTheNumber(newNumber); + }, + }, + promiseToChangeTheNumber: { + type: numberHolderType, + args: { newNumber: { type: GraphQLInt } }, + resolve(obj, { newNumber }) { + return obj.promiseToChangeTheNumber(newNumber); + }, + }, + failToChangeTheNumber: { + type: numberHolderType, + args: { newNumber: { type: GraphQLInt } }, + resolve(obj, { newNumber }) { + return obj.failToChangeTheNumber(newNumber); + }, + }, + promiseAndFailToChangeTheNumber: { + type: numberHolderType, + args: { newNumber: { type: GraphQLInt } }, + resolve(obj, { newNumber }) { + return obj.promiseAndFailToChangeTheNumber(newNumber); + }, + }, + }, + name: 'Mutation', + }), +}); + +describe('Execute: Handles mutation execution ordering', () => { + it('evaluates mutations serially', async () => { + const document = parse(` + mutation M { + first: immediatelyChangeTheNumber(newNumber: 1) { + theNumber + }, + second: promiseToChangeTheNumber(newNumber: 2) { + theNumber + }, + third: immediatelyChangeTheNumber(newNumber: 3) { + theNumber + } + fourth: promiseToChangeTheNumber(newNumber: 4) { + theNumber + }, + fifth: immediatelyChangeTheNumber(newNumber: 5) { + theNumber + } + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ schema, document, rootValue }); + + expect(mutationResult).to.deep.equal({ + data: { + first: { theNumber: 1 }, + second: { theNumber: 2 }, + third: { theNumber: 3 }, + fourth: { theNumber: 4 }, + fifth: { theNumber: 5 }, + }, + }); + }); + + it('does not include illegal mutation fields in output', () => { + const document = parse('mutation { thisIsIllegalDoNotIncludeMe }'); + + const result = executeSync({ schema, document }); + expect(result).to.deep.equal({ + data: {}, + }); + }); + + it('evaluates mutations correctly in the presence of a failed mutation', async () => { + const document = parse(` + mutation M { + first: immediatelyChangeTheNumber(newNumber: 1) { + theNumber + }, + second: promiseToChangeTheNumber(newNumber: 2) { + theNumber + }, + third: failToChangeTheNumber(newNumber: 3) { + theNumber + } + fourth: promiseToChangeTheNumber(newNumber: 4) { + theNumber + }, + fifth: immediatelyChangeTheNumber(newNumber: 5) { + theNumber + } + sixth: promiseAndFailToChangeTheNumber(newNumber: 6) { + theNumber + } + } + `); + + const rootValue = new Root(6); + const result = await execute({ schema, document, rootValue }); + + expectJSON(result).toDeepEqual({ + data: { + first: { theNumber: 1 }, + second: { theNumber: 2 }, + third: null, + fourth: { theNumber: 4 }, + fifth: { theNumber: 5 }, + sixth: null, + }, + errors: [ + { + message: 'Cannot change the number', + locations: [{ line: 9, column: 9 }], + path: ['third'], + }, + { + message: 'Cannot change the number', + locations: [{ line: 18, column: 9 }], + path: ['sixth'], + }, + ], + }); + }); + it('Mutation fields with @defer do not block next mutation', async () => { + const document = parse(` + mutation M { + first: promiseToChangeTheNumber(newNumber: 1) { + ...DeferFragment @defer(label: "defer-label") + }, + second: immediatelyChangeTheNumber(newNumber: 2) { + theNumber + } + } + fragment DeferFragment on NumberHolder { + promiseToGetTheNumber + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ + schema, + document, + rootValue, + }); + const patches = []; + + assert('initialResult' in mutationResult); + patches.push(mutationResult.initialResult); + for await (const patch of mutationResult.subsequentResults) { + patches.push(patch); + } + + expect(patches).to.deep.equal([ + { + data: { + first: {}, + second: { theNumber: 2 }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + promiseToGetTheNumber: 2, + }, + path: ['first'], + label: 'defer-label', + }, + ], + hasNext: false, + }, + ]); + }); + it('Mutation inside of a fragment', async () => { + const document = parse(` + mutation M { + ...MutationFragment + second: immediatelyChangeTheNumber(newNumber: 2) { + theNumber + } + } + fragment MutationFragment on Mutation { + first: promiseToChangeTheNumber(newNumber: 1) { + theNumber + }, + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ schema, document, rootValue }); + + expect(mutationResult).to.deep.equal({ + data: { + first: { theNumber: 1 }, + second: { theNumber: 2 }, + }, + }); + }); + it('Mutation with @defer is not executed serially', async () => { + const document = parse(` + mutation M { + ...MutationFragment @defer(label: "defer-label") + second: immediatelyChangeTheNumber(newNumber: 2) { + theNumber + } + } + fragment MutationFragment on Mutation { + first: promiseToChangeTheNumber(newNumber: 1) { + theNumber + }, + } + `); + + const rootValue = new Root(6); + const mutationResult = await execute({ + schema, + document, + rootValue, + }); + const patches = []; + + assert('initialResult' in mutationResult); + patches.push(mutationResult.initialResult); + for await (const patch of mutationResult.subsequentResults) { + patches.push(patch); + } + + expect(patches).to.deep.equal([ + { + data: { + second: { theNumber: 2 }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + first: { + theNumber: 1, + }, + }, + path: [], + label: 'defer-label', + }, + ], + hasNext: false, + }, + ]); + }); +}); diff --git a/src/transform/__tests__/nonnull-test.ts b/src/transform/__tests__/nonnull-test.ts new file mode 100644 index 0000000000..90391d2a53 --- /dev/null +++ b/src/transform/__tests__/nonnull-test.ts @@ -0,0 +1,786 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { invariant } from '../../jsutils/invariant.js'; +import { isPromise } from '../../jsutils/isPromise.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLNonNull, GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import type { ExecutionResult } from '../../execution/types.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { execute, executeSync } from './execute.js'; + +const syncError = new Error('sync'); +const syncNonNullError = new Error('syncNonNull'); +const promiseError = new Error('promise'); +const promiseNonNullError = new Error('promiseNonNull'); + +const throwingData = { + sync() { + throw syncError; + }, + syncNonNull() { + throw syncNonNullError; + }, + promise() { + return new Promise(() => { + throw promiseError; + }); + }, + promiseNonNull() { + return new Promise(() => { + throw promiseNonNullError; + }); + }, + syncNest() { + return throwingData; + }, + syncNonNullNest() { + return throwingData; + }, + promiseNest() { + return new Promise((resolve) => { + resolve(throwingData); + }); + }, + promiseNonNullNest() { + return new Promise((resolve) => { + resolve(throwingData); + }); + }, +}; + +const nullingData = { + sync() { + return null; + }, + syncNonNull() { + return null; + }, + promise() { + return new Promise((resolve) => { + resolve(null); + }); + }, + promiseNonNull() { + return new Promise((resolve) => { + resolve(null); + }); + }, + syncNest() { + return nullingData; + }, + syncNonNullNest() { + return nullingData; + }, + promiseNest() { + return new Promise((resolve) => { + resolve(nullingData); + }); + }, + promiseNonNullNest() { + return new Promise((resolve) => { + resolve(nullingData); + }); + }, +}; + +const schema = buildSchema(` + type DataType { + sync: String + syncNonNull: String! + promise: String + promiseNonNull: String! + syncNest: DataType + syncNonNullNest: DataType! + promiseNest: DataType + promiseNonNullNest: DataType! + } + + schema { + query: DataType + } +`); + +function executeQuery( + query: string, + rootValue: unknown, +): Promise { + const result = execute({ schema, document: parse(query), rootValue }); + invariant(isPromise(result)); + return result.then((resolved) => { + invariant(!('initialResult' in resolved)); + return resolved; + }); +} + +function patch(str: string): string { + return str + .replace(/\bsync\b/g, 'promise') + .replace(/\bsyncNonNull\b/g, 'promiseNonNull'); +} + +// avoids also doing any nests +function patchData(data: ExecutionResult): ExecutionResult { + return JSON.parse(patch(JSON.stringify(data))); +} + +async function executeSyncAndAsync(query: string, rootValue: unknown) { + const syncResult = executeSync({ schema, document: parse(query), rootValue }); + const asyncResult = await execute({ + schema, + document: parse(patch(query)), + rootValue, + }); + + expectJSON(asyncResult).toDeepEqual(patchData(syncResult)); + return syncResult; +} + +describe('Execute: handles non-nullable types', () => { + describe('nulls a nullable field', () => { + const query = ` + { + sync + } + `; + + it('that returns null', async () => { + const result = await executeSyncAndAsync(query, nullingData); + expect(result).to.deep.equal({ + data: { sync: null }, + }); + }); + + it('that throws', async () => { + const result = await executeSyncAndAsync(query, throwingData); + expectJSON(result).toDeepEqual({ + data: { sync: null }, + errors: [ + { + message: syncError.message, + path: ['sync'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + }); + + describe('nulls a returned object that contains a non-nullable field', () => { + const query = ` + { + syncNest { + syncNonNull, + } + } + `; + + it('that returns null', async () => { + const result = await executeSyncAndAsync(query, nullingData); + expectJSON(result).toDeepEqual({ + data: { syncNest: null }, + errors: [ + { + message: + 'Cannot return null for non-nullable field DataType.syncNonNull.', + path: ['syncNest', 'syncNonNull'], + locations: [{ line: 4, column: 11 }], + }, + ], + }); + }); + + it('that throws', async () => { + const result = await executeSyncAndAsync(query, throwingData); + expectJSON(result).toDeepEqual({ + data: { syncNest: null }, + errors: [ + { + message: syncNonNullError.message, + path: ['syncNest', 'syncNonNull'], + locations: [{ line: 4, column: 11 }], + }, + ], + }); + }); + }); + + describe('nulls a complex tree of nullable fields, each', () => { + const query = ` + { + syncNest { + sync + promise + syncNest { sync promise } + promiseNest { sync promise } + } + promiseNest { + sync + promise + syncNest { sync promise } + promiseNest { sync promise } + } + } + `; + const data = { + syncNest: { + sync: null, + promise: null, + syncNest: { sync: null, promise: null }, + promiseNest: { sync: null, promise: null }, + }, + promiseNest: { + sync: null, + promise: null, + syncNest: { sync: null, promise: null }, + promiseNest: { sync: null, promise: null }, + }, + }; + + it('that returns null', async () => { + const result = await executeQuery(query, nullingData); + expect(result).to.deep.equal({ data }); + }); + + it('that throws', async () => { + const result = await executeQuery(query, throwingData); + expectJSON(result).toDeepEqual({ + data, + errors: [ + { + message: syncError.message, + path: ['syncNest', 'sync'], + locations: [{ line: 4, column: 11 }], + }, + { + message: promiseError.message, + path: ['syncNest', 'promise'], + locations: [{ line: 5, column: 11 }], + }, + { + message: syncError.message, + path: ['syncNest', 'syncNest', 'sync'], + locations: [{ line: 6, column: 22 }], + }, + { + message: promiseError.message, + path: ['syncNest', 'syncNest', 'promise'], + locations: [{ line: 6, column: 27 }], + }, + { + message: syncError.message, + path: ['syncNest', 'promiseNest', 'sync'], + locations: [{ line: 7, column: 25 }], + }, + { + message: promiseError.message, + path: ['syncNest', 'promiseNest', 'promise'], + locations: [{ line: 7, column: 30 }], + }, + { + message: syncError.message, + path: ['promiseNest', 'sync'], + locations: [{ line: 10, column: 11 }], + }, + { + message: promiseError.message, + path: ['promiseNest', 'promise'], + locations: [{ line: 11, column: 11 }], + }, + { + message: syncError.message, + path: ['promiseNest', 'syncNest', 'sync'], + locations: [{ line: 12, column: 22 }], + }, + { + message: promiseError.message, + path: ['promiseNest', 'syncNest', 'promise'], + locations: [{ line: 12, column: 27 }], + }, + { + message: syncError.message, + path: ['promiseNest', 'promiseNest', 'sync'], + locations: [{ line: 13, column: 25 }], + }, + { + message: promiseError.message, + path: ['promiseNest', 'promiseNest', 'promise'], + locations: [{ line: 13, column: 30 }], + }, + ], + }); + }); + }); + + describe('nulls the first nullable object after a field in a long chain of non-null fields', () => { + const query = ` + { + syncNest { + syncNonNullNest { + promiseNonNullNest { + syncNonNullNest { + promiseNonNullNest { + syncNonNull + } + } + } + } + } + promiseNest { + syncNonNullNest { + promiseNonNullNest { + syncNonNullNest { + promiseNonNullNest { + syncNonNull + } + } + } + } + } + anotherNest: syncNest { + syncNonNullNest { + promiseNonNullNest { + syncNonNullNest { + promiseNonNullNest { + promiseNonNull + } + } + } + } + } + anotherPromiseNest: promiseNest { + syncNonNullNest { + promiseNonNullNest { + syncNonNullNest { + promiseNonNullNest { + promiseNonNull + } + } + } + } + } + } + `; + const data = { + syncNest: null, + promiseNest: null, + anotherNest: null, + anotherPromiseNest: null, + }; + + it('that returns null', async () => { + const result = await executeQuery(query, nullingData); + expectJSON(result).toDeepEqual({ + data, + errors: [ + { + message: + 'Cannot return null for non-nullable field DataType.syncNonNull.', + path: [ + 'syncNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNull', + ], + locations: [{ line: 8, column: 19 }], + }, + { + message: + 'Cannot return null for non-nullable field DataType.syncNonNull.', + path: [ + 'promiseNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNull', + ], + locations: [{ line: 19, column: 19 }], + }, + { + message: + 'Cannot return null for non-nullable field DataType.promiseNonNull.', + path: [ + 'anotherNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'promiseNonNull', + ], + locations: [{ line: 30, column: 19 }], + }, + { + message: + 'Cannot return null for non-nullable field DataType.promiseNonNull.', + path: [ + 'anotherPromiseNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'promiseNonNull', + ], + locations: [{ line: 41, column: 19 }], + }, + ], + }); + }); + + it('that throws', async () => { + const result = await executeQuery(query, throwingData); + expectJSON(result).toDeepEqual({ + data, + errors: [ + { + message: syncNonNullError.message, + path: [ + 'syncNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNull', + ], + locations: [{ line: 8, column: 19 }], + }, + { + message: syncNonNullError.message, + path: [ + 'promiseNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNull', + ], + locations: [{ line: 19, column: 19 }], + }, + { + message: promiseNonNullError.message, + path: [ + 'anotherNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'promiseNonNull', + ], + locations: [{ line: 30, column: 19 }], + }, + { + message: promiseNonNullError.message, + path: [ + 'anotherPromiseNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'syncNonNullNest', + 'promiseNonNullNest', + 'promiseNonNull', + ], + locations: [{ line: 41, column: 19 }], + }, + ], + }); + }); + }); + + describe('nulls the top level if non-nullable field', () => { + const query = ` + { + syncNonNull + } + `; + + it('that returns null', async () => { + const result = await executeSyncAndAsync(query, nullingData); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field DataType.syncNonNull.', + path: ['syncNonNull'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + + it('that throws', async () => { + const result = await executeSyncAndAsync(query, throwingData); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: syncNonNullError.message, + path: ['syncNonNull'], + locations: [{ line: 3, column: 9 }], + }, + ], + }); + }); + }); + + describe('cancellation with null bubbling', () => { + function nestedPromise(n: number): string { + return n > 0 ? `promiseNest { ${nestedPromise(n - 1)} }` : 'promise'; + } + + it('returns an single error without cancellation', async () => { + const query = ` + { + promiseNonNull, + ${nestedPromise(4)} + } + `; + + const result = await executeQuery(query, throwingData); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + // does not include syncNullError because result returns prior to it being added + { + message: 'promiseNonNull', + path: ['promiseNonNull'], + locations: [{ line: 3, column: 11 }], + }, + ], + }); + }); + + it('stops running despite error', async () => { + const query = ` + { + promiseNonNull, + ${nestedPromise(10)} + } + `; + + let counter = 0; + const rootValue = { + ...throwingData, + promiseNest() { + return new Promise((resolve) => { + counter++; + resolve(rootValue); + }); + }, + }; + const result = await executeQuery(query, rootValue); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'promiseNonNull', + path: ['promiseNonNull'], + locations: [{ line: 3, column: 11 }], + }, + ], + }); + const counterAtExecutionEnd = counter; + await resolveOnNextTick(); + expect(counter).to.equal(counterAtExecutionEnd); + }); + }); + + describe('Handles non-null argument', () => { + const schemaWithNonNullArg = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + withNonNullArg: { + type: GraphQLString, + args: { + cannotBeNull: { + type: new GraphQLNonNull(GraphQLString), + }, + }, + resolve: (_, args) => 'Passed: ' + String(args.cannotBeNull), + }, + }, + }), + }); + + it('succeeds when passed non-null literal value', () => { + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query { + withNonNullArg (cannotBeNull: "literal value") + } + `), + }); + + expect(result).to.deep.equal({ + data: { + withNonNullArg: 'Passed: literal value', + }, + }); + }); + + it('succeeds when passed non-null variable value', () => { + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query ($testVar: String!) { + withNonNullArg (cannotBeNull: $testVar) + } + `), + variableValues: { + testVar: 'variable value', + }, + }); + + expect(result).to.deep.equal({ + data: { + withNonNullArg: 'Passed: variable value', + }, + }); + }); + + it('succeeds when missing variable has default value', () => { + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query ($testVar: String = "default value") { + withNonNullArg (cannotBeNull: $testVar) + } + `), + variableValues: { + // Intentionally missing variable + }, + }); + + expect(result).to.deep.equal({ + data: { + withNonNullArg: 'Passed: default value', + }, + }); + }); + + it('field error when missing non-null arg', () => { + // Note: validation should identify this issue first (missing args rule) + // however execution should still protect against this. + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query { + withNonNullArg + } + `), + }); + + expectJSON(result).toDeepEqual({ + data: { + withNonNullArg: null, + }, + errors: [ + { + message: + 'Argument "Query.withNonNullArg(cannotBeNull:)" of required type "String!" was not provided.', + locations: [{ line: 3, column: 13 }], + path: ['withNonNullArg'], + }, + ], + }); + }); + + it('field error when non-null arg provided null', () => { + // Note: validation should identify this issue first (values of correct + // type rule) however execution should still protect against this. + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query { + withNonNullArg(cannotBeNull: null) + } + `), + }); + + expectJSON(result).toDeepEqual({ + data: { + withNonNullArg: null, + }, + errors: [ + { + message: + 'Argument "Query.withNonNullArg(cannotBeNull:)" has invalid value: Expected value of non-null type "String!" not to be null.', + locations: [{ line: 3, column: 42 }], + path: ['withNonNullArg'], + }, + ], + }); + }); + + it('field error when non-null arg not provided variable value', () => { + // Note: validation should identify this issue first (variables in allowed + // position rule) however execution should still protect against this. + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query ($testVar: String) { + withNonNullArg(cannotBeNull: $testVar) + } + `), + variableValues: { + // Intentionally missing variable + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + withNonNullArg: null, + }, + errors: [ + { + message: + 'Argument "Query.withNonNullArg(cannotBeNull:)" has invalid value: Expected variable "$testVar" provided to type "String!" to provide a runtime value.', + locations: [{ line: 3, column: 42 }], + path: ['withNonNullArg'], + }, + ], + }); + }); + + it('field error when non-null arg provided variable with explicit null value', () => { + const result = executeSync({ + schema: schemaWithNonNullArg, + document: parse(` + query ($testVar: String = "default value") { + withNonNullArg (cannotBeNull: $testVar) + } + `), + variableValues: { + testVar: null, + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + withNonNullArg: null, + }, + errors: [ + { + message: + 'Argument "Query.withNonNullArg(cannotBeNull:)" has invalid value: Expected variable "$testVar" provided to non-null type "String!" not to be null.', + locations: [{ line: 3, column: 43 }], + path: ['withNonNullArg'], + }, + ], + }); + }); + }); +}); diff --git a/src/transform/__tests__/oneof-test.ts b/src/transform/__tests__/oneof-test.ts new file mode 100644 index 0000000000..d46f86e490 --- /dev/null +++ b/src/transform/__tests__/oneof-test.ts @@ -0,0 +1,331 @@ +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { parse } from '../../language/parser.js'; + +import type { ExecutionResult } from '../../execution/types.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { executeSync } from './execute.js'; + +const schema = buildSchema(` + type Query { + test(input: TestInputObject!): TestObject + } + + input TestInputObject @oneOf { + a: String + b: Int + } + + type TestObject { + a: String + b: Int + } +`); + +function executeQuery( + query: string, + rootValue: unknown, + variableValues?: { [variable: string]: unknown }, +): ExecutionResult { + return executeSync({ + schema, + document: parse(query, { experimentalFragmentArguments: true }), + rootValue, + variableValues, + }); +} + +describe('Execute: Handles OneOf Input Objects', () => { + describe('OneOf Input Objects', () => { + const rootValue = { + test({ input }: { input: { a?: string; b?: number } }) { + return input; + }, + }; + + it('accepts a good default value', () => { + const query = ` + query ($input: TestInputObject! = {a: "abc"}) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue); + + expectJSON(result).toDeepEqual({ + data: { + test: { + a: 'abc', + b: null, + }, + }, + }); + }); + + it('rejects a bad default value', () => { + const query = ` + query ($input: TestInputObject! = {a: "abc", b: 123}) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + locations: [{ column: 23, line: 3 }], + message: + // This type of error would be caught at validation-time + // hence the vague error message here. + 'Argument "Query.test(input:)" has invalid value: Expected variable "$input" provided to type "TestInputObject!" to provide a runtime value.', + path: ['test'], + }, + ], + }); + }); + + it('accepts a good variable', () => { + const query = ` + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { input: { a: 'abc' } }); + + expectJSON(result).toDeepEqual({ + data: { + test: { + a: 'abc', + b: null, + }, + }, + }); + }); + + it('accepts a good variable with an undefined key', () => { + const query = ` + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { + input: { a: 'abc', b: undefined }, + }); + + expectJSON(result).toDeepEqual({ + data: { + test: { + a: 'abc', + b: null, + }, + }, + }); + }); + + it('rejects a variable with a nulled key', () => { + const query = ` + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { input: { a: null } }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Field "a" for OneOf type "TestInputObject" must be non-null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('rejects a variable with multiple non-null keys', () => { + const query = ` + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { + input: { a: 'abc', b: 123 }, + }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + locations: [{ column: 16, line: 2 }], + message: + 'Variable "$input" has invalid value: Exactly one key must be specified for OneOf type "TestInputObject".', + }, + ], + }); + }); + + it('rejects a variable with multiple nullable keys', () => { + const query = ` + query ($input: TestInputObject!) { + test(input: $input) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { + input: { a: 'abc', b: null }, + }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + locations: [{ column: 16, line: 2 }], + message: + 'Variable "$input" has invalid value: Exactly one key must be specified for OneOf type "TestInputObject".', + }, + ], + }); + }); + + it('errors with nulled variable for field', () => { + const query = ` + query ($a: String) { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { a: null }); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.', + locations: [{ line: 3, column: 23 }], + path: ['test'], + }, + ], + }); + }); + + it('errors with missing variable for field', () => { + const query = ` + query ($a: String) { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.', + locations: [{ line: 3, column: 23 }], + path: ['test'], + }, + ], + }); + }); + + it('errors with nulled fragment variable for field', () => { + const query = ` + query { + ...TestFragment(a: null) + } + fragment TestFragment($a: String) on Query { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue, { a: null }); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" not to be null.', + locations: [{ line: 6, column: 23 }], + path: ['test'], + }, + ], + }); + }); + + it('errors with missing fragment variable for field', () => { + const query = ` + query { + ...TestFragment + } + fragment TestFragment($a: String) on Query { + test(input: { a: $a }) { + a + b + } + } + `; + const result = executeQuery(query, rootValue); + + expectJSON(result).toDeepEqual({ + data: { + test: null, + }, + errors: [ + { + // A nullable variable in a oneOf field position would be caught at validation-time + // hence the vague error message here. + message: + 'Argument "Query.test(input:)" has invalid value: Expected variable "$a" provided to field "a" for OneOf Input Object type "TestInputObject" to provide a runtime value.', + locations: [{ line: 6, column: 23 }], + path: ['test'], + }, + ], + }); + }); + }); +}); diff --git a/src/transform/__tests__/resolve-test.ts b/src/transform/__tests__/resolve-test.ts new file mode 100644 index 0000000000..e63798ca51 --- /dev/null +++ b/src/transform/__tests__/resolve-test.ts @@ -0,0 +1,129 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { parse } from '../../language/parser.js'; + +import type { GraphQLFieldConfig } from '../../type/definition.js'; +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLInt, GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { executeSync } from './execute.js'; + +describe('Execute: resolve function', () => { + function testSchema(testField: GraphQLFieldConfig) { + return new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + test: testField, + }, + }), + }); + } + + it('default function accesses properties', () => { + const result = executeSync({ + schema: testSchema({ type: GraphQLString }), + document: parse('{ test }'), + rootValue: { test: 'testValue' }, + }); + + expect(result).to.deep.equal({ + data: { + test: 'testValue', + }, + }); + }); + + it('default function calls methods', () => { + const rootValue = { + _secret: 'secretValue', + test() { + return this._secret; + }, + }; + + const result = executeSync({ + schema: testSchema({ type: GraphQLString }), + document: parse('{ test }'), + rootValue, + }); + expect(result).to.deep.equal({ + data: { + test: 'secretValue', + }, + }); + }); + + it('default function passes args and context', () => { + class Adder { + _num: number; + + constructor(num: number) { + this._num = num; + } + + test(args: { addend1: number }, context: { addend2: number }) { + return this._num + args.addend1 + context.addend2; + } + } + const rootValue = new Adder(700); + + const schema = testSchema({ + type: GraphQLInt, + args: { + addend1: { type: GraphQLInt }, + }, + }); + const contextValue = { addend2: 9 }; + const document = parse('{ test(addend1: 80) }'); + + const result = executeSync({ schema, document, rootValue, contextValue }); + expect(result).to.deep.equal({ + data: { test: 789 }, + }); + }); + + it('uses provided resolve function', () => { + const schema = testSchema({ + type: GraphQLString, + args: { + aStr: { type: GraphQLString }, + aInt: { type: GraphQLInt }, + }, + resolve: (source, args) => JSON.stringify([source, args]), + }); + + function executeQuery(query: string, rootValue?: unknown) { + const document = parse(query); + return executeSync({ schema, document, rootValue }); + } + + expect(executeQuery('{ test }')).to.deep.equal({ + data: { + test: '[null,{}]', + }, + }); + + expect(executeQuery('{ test }', 'Source!')).to.deep.equal({ + data: { + test: '["Source!",{}]', + }, + }); + + expect(executeQuery('{ test(aStr: "String!") }', 'Source!')).to.deep.equal({ + data: { + test: '["Source!",{"aStr":"String!"}]', + }, + }); + + expect( + executeQuery('{ test(aInt: -123, aStr: "String!") }', 'Source!'), + ).to.deep.equal({ + data: { + test: '["Source!",{"aStr":"String!","aInt":-123}]', + }, + }); + }); +}); diff --git a/src/transform/__tests__/schema-test.ts b/src/transform/__tests__/schema-test.ts new file mode 100644 index 0000000000..d663e2c7d1 --- /dev/null +++ b/src/transform/__tests__/schema-test.ts @@ -0,0 +1,188 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { parse } from '../../language/parser.js'; + +import { + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../type/definition.js'; +import { + GraphQLBoolean, + GraphQLID, + GraphQLInt, + GraphQLString, +} from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { executeSync } from './execute.js'; + +describe('Execute: Handles execution with a complex schema', () => { + it('executes using a schema', () => { + const BlogImage = new GraphQLObjectType({ + name: 'Image', + fields: { + url: { type: GraphQLString }, + width: { type: GraphQLInt }, + height: { type: GraphQLInt }, + }, + }); + + const BlogAuthor: GraphQLObjectType = new GraphQLObjectType({ + name: 'Author', + fields: () => ({ + id: { type: GraphQLString }, + name: { type: GraphQLString }, + pic: { + args: { width: { type: GraphQLInt }, height: { type: GraphQLInt } }, + type: BlogImage, + resolve: (obj, { width, height }) => obj.pic(width, height), + }, + recentArticle: { type: BlogArticle }, + }), + }); + + const BlogArticle = new GraphQLObjectType({ + name: 'Article', + fields: { + id: { type: new GraphQLNonNull(GraphQLString) }, + isPublished: { type: GraphQLBoolean }, + author: { type: BlogAuthor }, + title: { type: GraphQLString }, + body: { type: GraphQLString }, + keywords: { type: new GraphQLList(GraphQLString) }, + }, + }); + + const BlogQuery = new GraphQLObjectType({ + name: 'Query', + fields: { + article: { + type: BlogArticle, + args: { id: { type: GraphQLID } }, + resolve: (_, { id }) => article(id), + }, + feed: { + type: new GraphQLList(BlogArticle), + resolve: () => [ + article(1), + article(2), + article(3), + article(4), + article(5), + article(6), + article(7), + article(8), + article(9), + article(10), + ], + }, + }, + }); + + const BlogSchema = new GraphQLSchema({ + query: BlogQuery, + }); + + function article(id: number) { + return { + id, + isPublished: true, + author: { + id: 123, + name: 'John Smith', + pic: (width: number, height: number) => getPic(123, width, height), + recentArticle: () => article(1), + }, + title: 'My Article ' + id, + body: 'This is a post', + hidden: 'This data is not exposed in the schema', + keywords: ['foo', 'bar', 1, true, null], + }; + } + + function getPic(uid: number, width: number, height: number) { + return { + url: `cdn://${uid}`, + width: `${width}`, + height: `${height}`, + }; + } + + const document = parse(` + { + feed { + id, + title + }, + article(id: "1") { + ...articleFields, + author { + id, + name, + pic(width: 640, height: 480) { + url, + width, + height + }, + recentArticle { + ...articleFields, + keywords + } + } + } + } + + fragment articleFields on Article { + id, + isPublished, + title, + body, + hidden, + notDefined + } + `); + + // Note: this is intentionally not validating to ensure appropriate + // behavior occurs when executing an invalid query. + expect(executeSync({ schema: BlogSchema, document })).to.deep.equal({ + data: { + feed: [ + { id: '1', title: 'My Article 1' }, + { id: '2', title: 'My Article 2' }, + { id: '3', title: 'My Article 3' }, + { id: '4', title: 'My Article 4' }, + { id: '5', title: 'My Article 5' }, + { id: '6', title: 'My Article 6' }, + { id: '7', title: 'My Article 7' }, + { id: '8', title: 'My Article 8' }, + { id: '9', title: 'My Article 9' }, + { id: '10', title: 'My Article 10' }, + ], + article: { + id: '1', + isPublished: true, + title: 'My Article 1', + body: 'This is a post', + author: { + id: '123', + name: 'John Smith', + pic: { + url: 'cdn://123', + width: 640, + height: 480, + }, + recentArticle: { + id: '1', + isPublished: true, + title: 'My Article 1', + body: 'This is a post', + keywords: ['foo', 'bar', '1', 'true', null], + }, + }, + }, + }, + }); + }); +}); diff --git a/src/transform/__tests__/sync-test.ts b/src/transform/__tests__/sync-test.ts new file mode 100644 index 0000000000..c606c186ea --- /dev/null +++ b/src/transform/__tests__/sync-test.ts @@ -0,0 +1,195 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { validate } from '../../validation/validate.js'; + +import { graphqlSync } from '../../graphql.js'; + +import { execute, executeSync } from './execute.js'; + +describe('Execute: synchronously when possible', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + syncField: { + type: GraphQLString, + resolve(rootValue) { + return rootValue; + }, + }, + asyncField: { + type: GraphQLString, + resolve(rootValue) { + return Promise.resolve(rootValue); + }, + }, + }, + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: { + syncMutationField: { + type: GraphQLString, + resolve(rootValue) { + return rootValue; + }, + }, + }, + }), + }); + + it('does not return a Promise for initial errors', () => { + const doc = 'fragment Example on Query { syncField }'; + const result = execute({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + expectJSON(result).toDeepEqual({ + errors: [{ message: 'Must provide an operation.' }], + }); + }); + + it('does not return a Promise if fields are all synchronous', () => { + const doc = 'query Example { syncField }'; + const result = execute({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + expect(result).to.deep.equal({ data: { syncField: 'rootValue' } }); + }); + + it('does not return a Promise if mutation fields are all synchronous', () => { + const doc = 'mutation Example { syncMutationField }'; + const result = execute({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + expect(result).to.deep.equal({ data: { syncMutationField: 'rootValue' } }); + }); + + it('returns a Promise if any field is asynchronous', async () => { + const doc = 'query Example { syncField, asyncField }'; + const result = execute({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + expect(result).to.be.instanceOf(Promise); + expect(await result).to.deep.equal({ + data: { syncField: 'rootValue', asyncField: 'rootValue' }, + }); + }); + + describe('executeSync', () => { + it('does not return a Promise for sync execution', () => { + const doc = 'query Example { syncField }'; + const result = executeSync({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + expect(result).to.deep.equal({ data: { syncField: 'rootValue' } }); + }); + + it('throws if encountering async execution', () => { + const doc = 'query Example { syncField, asyncField }'; + expect(() => { + executeSync({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + }).to.throw('GraphQL execution failed to complete synchronously.'); + }); + + it('throws if encountering async iterable execution', () => { + const doc = ` + query Example { + ...deferFrag @defer(label: "deferLabel") + } + fragment deferFrag on Query { + syncField + } + `; + expect(() => { + executeSync({ + schema, + document: parse(doc), + rootValue: 'rootValue', + }); + }).to.throw('GraphQL execution failed to complete synchronously.'); + }); + }); + + describe('graphqlSync', () => { + it('report errors raised during schema validation', () => { + const badSchema = new GraphQLSchema({}); + const result = graphqlSync({ + schema: badSchema, + source: '{ __typename }', + }); + expectJSON(result).toDeepEqual({ + errors: [{ message: 'Query root type must be provided.' }], + }); + }); + + it('does not return a Promise for syntax errors', () => { + const doc = 'fragment Example on Query { { { syncField }'; + const result = graphqlSync({ + schema, + source: doc, + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: 'Syntax Error: Expected Name, found "{".', + locations: [{ line: 1, column: 29 }], + }, + ], + }); + }); + + it('does not return a Promise for validation errors', () => { + const doc = 'fragment Example on Query { unknownField }'; + const validationErrors = validate(schema, parse(doc)); + const result = graphqlSync({ + schema, + source: doc, + }); + expect(result).to.deep.equal({ errors: validationErrors }); + }); + + it('does not return a Promise for sync execution', () => { + const doc = 'query Example { syncField }'; + const result = graphqlSync({ + schema, + source: doc, + rootValue: 'rootValue', + }); + expect(result).to.deep.equal({ data: { syncField: 'rootValue' } }); + }); + + it('throws if encountering async execution', () => { + const doc = 'query Example { syncField, asyncField }'; + expect(() => { + graphqlSync({ + schema, + source: doc, + rootValue: 'rootValue', + }); + }).to.throw('GraphQL execution failed to complete synchronously.'); + }); + }); +}); diff --git a/src/transform/__tests__/union-interface-test.ts b/src/transform/__tests__/union-interface-test.ts new file mode 100644 index 0000000000..b844a12499 --- /dev/null +++ b/src/transform/__tests__/union-interface-test.ts @@ -0,0 +1,636 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { parse } from '../../language/parser.js'; + +import { + GraphQLInterfaceType, + GraphQLList, + GraphQLObjectType, + GraphQLUnionType, +} from '../../type/definition.js'; +import { GraphQLBoolean, GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { execute, executeSync } from './execute.js'; + +class Dog { + name: string; + barks: boolean; + mother?: Dog; + father?: Dog; + progeny: ReadonlyArray; + + constructor(name: string, barks: boolean) { + this.name = name; + this.barks = barks; + this.progeny = []; + } +} + +class Cat { + name: string; + meows: boolean; + mother?: Cat; + father?: Cat; + progeny: ReadonlyArray; + + constructor(name: string, meows: boolean) { + this.name = name; + this.meows = meows; + this.progeny = []; + } +} + +class Plant { + name: string; + + constructor(name: string) { + this.name = name; + } +} + +class Person { + name: string; + pets: ReadonlyArray | undefined; + friends: ReadonlyArray | undefined; + responsibilities: ReadonlyArray | undefined; + + constructor( + name: string, + pets?: ReadonlyArray, + friends?: ReadonlyArray, + responsibilities?: ReadonlyArray, + ) { + this.name = name; + this.pets = pets; + this.friends = friends; + this.responsibilities = responsibilities; + } +} + +const NamedType = new GraphQLInterfaceType({ + name: 'Named', + fields: { + name: { type: GraphQLString }, + }, +}); + +const LifeType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: 'Life', + fields: () => ({ + progeny: { type: new GraphQLList(LifeType) }, + }), +}); + +const MammalType: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: 'Mammal', + interfaces: [LifeType], + fields: () => ({ + progeny: { type: new GraphQLList(MammalType) }, + mother: { type: MammalType }, + father: { type: MammalType }, + }), +}); + +const DogType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Dog', + interfaces: [MammalType, LifeType, NamedType], + fields: () => ({ + name: { type: GraphQLString }, + barks: { type: GraphQLBoolean }, + progeny: { type: new GraphQLList(DogType) }, + mother: { type: DogType }, + father: { type: DogType }, + }), + isTypeOf: (value) => value instanceof Dog, +}); + +const CatType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Cat', + interfaces: [MammalType, LifeType, NamedType], + fields: () => ({ + name: { type: GraphQLString }, + meows: { type: GraphQLBoolean }, + progeny: { type: new GraphQLList(CatType) }, + mother: { type: CatType }, + father: { type: CatType }, + }), + isTypeOf: (value) => value instanceof Cat, +}); + +const PlantType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Plant', + interfaces: [NamedType], + fields: () => ({ + name: { type: GraphQLString }, + }), + // eslint-disable-next-line @typescript-eslint/require-await + isTypeOf: async () => { + throw new Error('Not sure if this is a plant'); + }, +}); + +const PetType = new GraphQLUnionType({ + name: 'Pet', + types: [DogType, CatType], + resolveType(value) { + if (value instanceof Dog) { + return DogType.name; + } + if (value instanceof Cat) { + return CatType.name; + } + /* c8 ignore next 3 */ + // Not reachable, all possible types have been considered. + expect.fail('Not reachable'); + }, +}); + +const PetOrPlantType = new GraphQLUnionType({ + name: 'PetOrPlantType', + types: [PlantType, DogType, CatType], +}); + +const PersonType: GraphQLObjectType = new GraphQLObjectType({ + name: 'Person', + interfaces: [NamedType, MammalType, LifeType], + fields: () => ({ + name: { type: GraphQLString }, + pets: { type: new GraphQLList(PetType) }, + friends: { type: new GraphQLList(NamedType) }, + responsibilities: { type: new GraphQLList(PetOrPlantType) }, + progeny: { type: new GraphQLList(PersonType) }, + mother: { type: PersonType }, + father: { type: PersonType }, + }), + isTypeOf: (value) => value instanceof Person, +}); + +const schema = new GraphQLSchema({ + query: PersonType, + types: [PetType], +}); + +const garfield = new Cat('Garfield', false); +garfield.mother = new Cat("Garfield's Mom", false); +garfield.mother.progeny = [garfield]; + +const odie = new Dog('Odie', true); +odie.mother = new Dog("Odie's Mom", true); +odie.mother.progeny = [odie]; + +const fern = new Plant('Fern'); +const liz = new Person('Liz'); +const john = new Person( + 'John', + [garfield, odie], + [liz, odie], + [garfield, fern], +); + +describe('Execute: Union and intersection types', () => { + it('can introspect on union and intersection types', () => { + const document = parse(` + { + Named: __type(name: "Named") { + kind + name + fields { name } + interfaces { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } + Mammal: __type(name: "Mammal") { + kind + name + fields { name } + interfaces { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } + Pet: __type(name: "Pet") { + kind + name + fields { name } + interfaces { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } + } + `); + + expect(executeSync({ schema, document })).to.deep.equal({ + data: { + Named: { + kind: 'INTERFACE', + name: 'Named', + fields: [{ name: 'name' }], + interfaces: [], + possibleTypes: [ + { name: 'Dog' }, + { name: 'Cat' }, + { name: 'Person' }, + { name: 'Plant' }, + ], + enumValues: null, + inputFields: null, + }, + Mammal: { + kind: 'INTERFACE', + name: 'Mammal', + fields: [{ name: 'progeny' }, { name: 'mother' }, { name: 'father' }], + interfaces: [{ name: 'Life' }], + possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }, { name: 'Person' }], + enumValues: null, + inputFields: null, + }, + Pet: { + kind: 'UNION', + name: 'Pet', + fields: null, + interfaces: null, + possibleTypes: [{ name: 'Dog' }, { name: 'Cat' }], + enumValues: null, + inputFields: null, + }, + }, + }); + }); + + it('executes using union types', () => { + // NOTE: This is an *invalid* query, but it should be an *executable* query. + const document = parse(` + { + __typename + name + pets { + __typename + name + barks + meows + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + pets: [ + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + }, + ], + }, + }); + }); + + it('executes union types with inline fragments', () => { + // This is the valid version of the query in the above test. + const document = parse(` + { + __typename + name + pets { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + pets: [ + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + }, + ], + }, + }); + }); + + it('executes using interface types', () => { + // NOTE: This is an *invalid* query, but it should be an *executable* query. + const document = parse(` + { + __typename + name + friends { + __typename + name + barks + meows + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + friends: [ + { __typename: 'Person', name: 'Liz' }, + { __typename: 'Dog', name: 'Odie', barks: true }, + ], + }, + }); + }); + + it('executes interface types with inline fragments', () => { + // This is the valid version of the query in the above test. + const document = parse(` + { + __typename + name + friends { + __typename + name + ... on Dog { + barks + } + ... on Cat { + meows + } + + ... on Mammal { + mother { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + } + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + friends: [ + { + __typename: 'Person', + name: 'Liz', + mother: null, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + mother: { __typename: 'Dog', name: "Odie's Mom", barks: true }, + }, + ], + }, + }); + }); + + it('executes interface types with named fragments', () => { + const document = parse(` + { + __typename + name + friends { + __typename + name + ...DogBarks + ...CatMeows + } + } + + fragment DogBarks on Dog { + barks + } + + fragment CatMeows on Cat { + meows + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + friends: [ + { + __typename: 'Person', + name: 'Liz', + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + }, + ], + }, + }); + }); + + it('allows fragment conditions to be abstract types', () => { + const document = parse(` + { + __typename + name + pets { + ...PetFields, + ...on Mammal { + mother { + ...ProgenyFields + } + } + } + friends { ...FriendFields } + } + + fragment PetFields on Pet { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + + fragment FriendFields on Named { + __typename + name + ... on Dog { + barks + } + ... on Cat { + meows + } + } + + fragment ProgenyFields on Life { + progeny { + __typename + } + } + `); + + expect(executeSync({ schema, document, rootValue: john })).to.deep.equal({ + data: { + __typename: 'Person', + name: 'John', + pets: [ + { + __typename: 'Cat', + name: 'Garfield', + meows: false, + mother: { progeny: [{ __typename: 'Cat' }] }, + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + mother: { progeny: [{ __typename: 'Dog' }] }, + }, + ], + friends: [ + { + __typename: 'Person', + name: 'Liz', + }, + { + __typename: 'Dog', + name: 'Odie', + barks: true, + }, + ], + }, + }); + }); + + it('gets execution info in resolver', () => { + let encounteredContext; + let encounteredSchema; + let encounteredRootValue; + + const NamedType2: GraphQLInterfaceType = new GraphQLInterfaceType({ + name: 'Named', + fields: { + name: { type: GraphQLString }, + }, + resolveType(_source, context, info) { + encounteredContext = context; + encounteredSchema = info.schema; + encounteredRootValue = info.rootValue; + return PersonType2.name; + }, + }); + + const PersonType2: GraphQLObjectType = new GraphQLObjectType({ + name: 'Person', + interfaces: [NamedType2], + fields: { + name: { type: GraphQLString }, + friends: { type: new GraphQLList(NamedType2) }, + }, + }); + const schema2 = new GraphQLSchema({ query: PersonType2 }); + const document = parse('{ name, friends { name } }'); + const rootValue = new Person('John', [], [liz]); + const contextValue = { authToken: '123abc' }; + + const result = executeSync({ + schema: schema2, + document, + rootValue, + contextValue, + }); + expect(result).to.deep.equal({ + data: { + name: 'John', + friends: [{ name: 'Liz' }], + }, + }); + + expect(encounteredSchema).to.equal(schema2); + expect(encounteredRootValue).to.equal(rootValue); + expect(encounteredContext).to.equal(contextValue); + }); + + it('it handles rejections from isTypeOf after after an isTypeOf returns true', async () => { + const document = parse(` + { + responsibilities { + __typename + ... on Dog { + name + barks + } + ... on Cat { + name + meows + } + } + } + `); + + const rootValue = new Person('John', [], [liz], [garfield]); + const contextValue = { authToken: '123abc' }; + + /* c8 ignore next 4 */ + // eslint-disable-next-line no-undef + process.on('unhandledRejection', () => { + expect.fail('Unhandled rejection'); + }); + + const result = await execute({ + schema, + document, + rootValue, + contextValue, + }); + + expectJSON(result).toDeepEqual({ + data: { + responsibilities: [ + { + __typename: 'Cat', + meows: false, + name: 'Garfield', + }, + ], + }, + }); + }); +}); diff --git a/src/transform/__tests__/variables-test.ts b/src/transform/__tests__/variables-test.ts new file mode 100644 index 0000000000..55ce5057bd --- /dev/null +++ b/src/transform/__tests__/variables-test.ts @@ -0,0 +1,1564 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectJSON } from '../../__testUtils__/expectJSON.js'; + +import { inspect } from '../../jsutils/inspect.js'; + +import { GraphQLError } from '../../error/GraphQLError.js'; + +import { DirectiveLocation } from '../../language/directiveLocation.js'; +import { Kind } from '../../language/kinds.js'; +import { parse } from '../../language/parser.js'; + +import type { + GraphQLArgumentConfig, + GraphQLFieldConfig, +} from '../../type/definition.js'; +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, +} from '../../type/definition.js'; +import { + GraphQLDirective, + GraphQLIncludeDirective, +} from '../../type/directives.js'; +import { GraphQLBoolean, GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { getVariableValues } from '../../execution/values.js'; + +import { execute, executeSync } from './execute.js'; + +const TestFaultyScalarGraphQLError = new GraphQLError( + 'FaultyScalarErrorMessage', + { + extensions: { + code: 'FaultyScalarErrorExtensionCode', + }, + }, +); + +const TestFaultyScalar = new GraphQLScalarType({ + name: 'FaultyScalar', + coerceInputValue() { + throw TestFaultyScalarGraphQLError; + }, + coerceInputLiteral() { + throw TestFaultyScalarGraphQLError; + }, +}); + +const TestComplexScalar = new GraphQLScalarType({ + name: 'ComplexScalar', + coerceInputValue(value) { + expect(value).to.equal('ExternalValue'); + return 'InternalValue'; + }, + coerceInputLiteral(ast) { + expect(ast).to.include({ kind: 'StringValue', value: 'ExternalValue' }); + return 'InternalValue'; + }, +}); + +const NestedType: GraphQLObjectType = new GraphQLObjectType({ + name: 'NestedType', + fields: { + echo: fieldWithInputArg({ type: GraphQLString }), + }, +}); + +const TestInputObject = new GraphQLInputObjectType({ + name: 'TestInputObject', + fields: { + a: { type: GraphQLString }, + b: { type: new GraphQLList(GraphQLString) }, + c: { type: new GraphQLNonNull(GraphQLString) }, + d: { type: TestComplexScalar }, + e: { type: TestFaultyScalar }, + }, +}); + +const TestOneOfInputObject = new GraphQLInputObjectType({ + name: 'TestOneOfInputObject', + fields: { + a: { type: GraphQLString }, + b: { type: GraphQLString }, + }, + isOneOf: true, +}); + +const TestNestedInputObject = new GraphQLInputObjectType({ + name: 'TestNestedInputObject', + fields: { + na: { type: new GraphQLNonNull(TestInputObject) }, + nb: { type: new GraphQLNonNull(GraphQLString) }, + }, +}); + +const TestEnum = new GraphQLEnumType({ + name: 'TestEnum', + values: { + NULL: { value: null }, + UNDEFINED: { value: undefined }, + NAN: { value: NaN }, + FALSE: { value: false }, + CUSTOM: { value: 'custom value' }, + DEFAULT_VALUE: {}, + }, +}); + +function fieldWithInputArg( + inputArg: GraphQLArgumentConfig, +): GraphQLFieldConfig { + return { + type: GraphQLString, + args: { input: inputArg }, + resolve(_, args) { + if ('input' in args) { + return inspect(args.input); + } + }, + }; +} + +const TestType = new GraphQLObjectType({ + name: 'TestType', + fields: { + fieldWithEnumInput: fieldWithInputArg({ type: TestEnum }), + fieldWithNonNullableEnumInput: fieldWithInputArg({ + type: new GraphQLNonNull(TestEnum), + }), + fieldWithObjectInput: fieldWithInputArg({ type: TestInputObject }), + fieldWithOneOfObjectInput: fieldWithInputArg({ + type: TestOneOfInputObject, + }), + fieldWithNullableStringInput: fieldWithInputArg({ type: GraphQLString }), + fieldWithNonNullableStringInput: fieldWithInputArg({ + type: new GraphQLNonNull(GraphQLString), + }), + fieldWithDefaultArgumentValue: fieldWithInputArg({ + type: GraphQLString, + default: { value: 'Hello World' }, + }), + fieldWithNonNullableStringInputAndDefaultArgumentValue: fieldWithInputArg({ + type: new GraphQLNonNull(GraphQLString), + default: { value: 'Hello World' }, + }), + fieldWithNestedInputObject: fieldWithInputArg({ + type: TestNestedInputObject, + }), + list: fieldWithInputArg({ type: new GraphQLList(GraphQLString) }), + nested: { + type: NestedType, + resolve: () => ({}), + }, + nnList: fieldWithInputArg({ + type: new GraphQLNonNull(new GraphQLList(GraphQLString)), + }), + listNN: fieldWithInputArg({ + type: new GraphQLList(new GraphQLNonNull(GraphQLString)), + }), + nnListNN: fieldWithInputArg({ + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(GraphQLString)), + ), + }), + }, +}); + +const schema = new GraphQLSchema({ + query: TestType, + directives: [ + new GraphQLDirective({ + name: 'skip', + description: + 'Directs the executor to skip this field or fragment when the `if` argument is true.', + locations: [ + DirectiveLocation.FIELD, + DirectiveLocation.FRAGMENT_SPREAD, + DirectiveLocation.INLINE_FRAGMENT, + ], + args: { + if: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Skipped when true.', + // default values will override operation variables in the setting of defined fragment variables that are not provided + default: { value: true }, + }, + }, + }), + GraphQLIncludeDirective, + ], +}); + +function executeQuery( + query: string, + variableValues?: { [variable: string]: unknown }, +) { + const document = parse(query); + return executeSync({ schema, document, variableValues }); +} + +function executeQueryWithFragmentArguments( + query: string, + variableValues?: { [variable: string]: unknown }, +) { + const document = parse(query, { experimentalFragmentArguments: true }); + return executeSync({ schema, document, variableValues }); +} + +describe('Execute: Handles inputs', () => { + describe('Handles objects and nullability', () => { + describe('using inline structs', () => { + it('executes with complex input', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: {a: "foo", b: ["bar"], c: "baz"}) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ a: "foo", b: ["bar"], c: "baz" }', + }, + }); + }); + + it('properly parses single value to list', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: {a: "foo", b: "bar", c: "baz"}) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ a: "foo", b: ["bar"], c: "baz" }', + }, + }); + }); + + it('properly parses null value to null', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: {a: null, b: null, c: "C", d: null}) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ a: null, b: null, c: "C", d: null }', + }, + }); + }); + + it('properly parses null value in list', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: {b: ["A",null,"C"], c: "C"}) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ b: ["A", null, "C"], c: "C" }', + }, + }); + }); + + it('does not use incorrect value', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: ["foo", "bar", "baz"]) + } + `); + + expectJSON(result).toDeepEqual({ + data: { + fieldWithObjectInput: null, + }, + errors: [ + { + message: + 'Argument "TestType.fieldWithObjectInput(input:)" has invalid value: Expected value of type "TestInputObject" to be an object, found: ["foo", "bar", "baz"].', + path: ['fieldWithObjectInput'], + locations: [{ line: 3, column: 41 }], + }, + ], + }); + }); + + it('properly runs coerceInputLiteral on complex scalar types', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: {c: "foo", d: "ExternalValue"}) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ c: "foo", d: "InternalValue" }', + }, + }); + }); + + it('errors on faulty scalar type input', () => { + const result = executeQuery(` + { + fieldWithObjectInput(input: {c: "foo", e: "bar"}) + } + `); + + expectJSON(result).toDeepEqual({ + data: { + fieldWithObjectInput: null, + }, + errors: [ + { + message: + 'Argument "TestType.fieldWithObjectInput(input:)" has invalid value at .e: FaultyScalarErrorMessage', + path: ['fieldWithObjectInput'], + locations: [{ line: 3, column: 13 }], + extensions: { code: 'FaultyScalarErrorExtensionCode' }, + }, + ], + }); + }); + }); + + describe('using variables', () => { + const doc = ` + query ($input: TestInputObject) { + fieldWithObjectInput(input: $input) + } + `; + + it('executes with complex input', () => { + const params = { input: { a: 'foo', b: ['bar'], c: 'baz' } }; + const result = executeQuery(doc, params); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ a: "foo", b: ["bar"], c: "baz" }', + }, + }); + }); + + it('uses undefined when variable not provided', () => { + const result = executeQuery( + ` + query q($input: String) { + fieldWithNullableStringInput(input: $input) + }`, + { + // Intentionally missing variable values. + }, + ); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: null, + }, + }); + }); + + it('uses null when variable provided explicit null value', () => { + const result = executeQuery( + ` + query q($input: String) { + fieldWithNullableStringInput(input: $input) + }`, + { input: null }, + ); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: 'null', + }, + }); + }); + + it('uses default value when not provided', () => { + const result = executeQuery(` + query ($input: TestInputObject = {a: "foo", b: ["bar"], c: "baz"}) { + fieldWithObjectInput(input: $input) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ a: "foo", b: ["bar"], c: "baz" }', + }, + }); + }); + + it('does not use default value when provided', () => { + const result = executeQuery( + ` + query q($input: String = "Default value") { + fieldWithNullableStringInput(input: $input) + } + `, + { input: 'Variable value' }, + ); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: '"Variable value"', + }, + }); + }); + + it('uses explicit null value instead of default value', () => { + const result = executeQuery( + ` + query q($input: String = "Default value") { + fieldWithNullableStringInput(input: $input) + }`, + { input: null }, + ); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: 'null', + }, + }); + }); + + it('uses null default value when not provided', () => { + const result = executeQuery( + ` + query q($input: String = null) { + fieldWithNullableStringInput(input: $input) + }`, + { + // Intentionally missing variable values. + }, + ); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: 'null', + }, + }); + }); + + it('properly parses single value to list', () => { + const params = { input: { a: 'foo', b: 'bar', c: 'baz' } }; + const result = executeQuery(doc, params); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ a: "foo", b: ["bar"], c: "baz" }', + }, + }); + }); + + it('executes with complex scalar input', () => { + const params = { input: { c: 'foo', d: 'ExternalValue' } }; + const result = executeQuery(doc, params); + + expect(result).to.deep.equal({ + data: { + fieldWithObjectInput: '{ c: "foo", d: "InternalValue" }', + }, + }); + }); + + it('errors on faulty scalar type input', () => { + const params = { input: { c: 'foo', e: 'ExternalValue' } }; + const result = executeQuery(doc, params); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value at .e: Argument "TestType.fieldWithObjectInput(input:)" has invalid value at .e: FaultyScalarErrorMessage', + locations: [{ line: 2, column: 16 }], + extensions: { code: 'FaultyScalarErrorExtensionCode' }, + }, + ], + }); + }); + + it('errors on null for nested non-null', () => { + const params = { input: { a: 'foo', b: 'bar', c: null } }; + const result = executeQuery(doc, params); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value at .c: Expected value of non-null type "String!" not to be null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('errors on incorrect type', () => { + const result = executeQuery(doc, { input: 'foo bar' }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Expected value of type "TestInputObject" to be an object, found: "foo bar".', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('errors on omission of nested non-null', () => { + const result = executeQuery(doc, { input: { a: 'foo', b: 'bar' } }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Expected value of type "TestInputObject" to include required field "c", found: { a: "foo", b: "bar" }.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('errors on deep nested errors and with many errors', () => { + const nestedDoc = ` + query ($input: TestNestedInputObject) { + fieldWithNestedObjectInput(input: $input) + } + `; + const result = executeQuery(nestedDoc, { input: { na: { a: 'foo' } } }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value at .na: Expected value of type "TestInputObject" to include required field "c", found: { a: "foo" }.', + locations: [{ line: 2, column: 18 }], + }, + { + message: + 'Variable "$input" has invalid value: Expected value of type "TestNestedInputObject" to include required field "nb", found: { na: { a: "foo" } }.', + locations: [{ line: 2, column: 18 }], + }, + ], + }); + }); + + it('errors on addition of unknown input field', () => { + const params = { + input: { a: 'foo', b: 'bar', c: 'baz', extra: 'dog' }, + }; + const result = executeQuery(doc, params); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Expected value of type "TestInputObject" not to include unknown field "extra", found: { a: "foo", b: "bar", c: "baz", extra: "dog" }.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + }); + }); + + describe('Handles custom enum values', () => { + it('allows custom enum values as inputs', () => { + const result = executeQuery(` + { + null: fieldWithEnumInput(input: NULL) + NaN: fieldWithEnumInput(input: NAN) + false: fieldWithEnumInput(input: FALSE) + customValue: fieldWithEnumInput(input: CUSTOM) + defaultValue: fieldWithEnumInput(input: DEFAULT_VALUE) + } + `); + + expect(result).to.deep.equal({ + data: { + null: 'null', + NaN: 'NaN', + false: 'false', + customValue: '"custom value"', + defaultValue: '"DEFAULT_VALUE"', + }, + }); + }); + + it('allows non-nullable inputs to have null as enum custom value', () => { + const result = executeQuery(` + { + fieldWithNonNullableEnumInput(input: NULL) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableEnumInput: 'null', + }, + }); + }); + }); + + describe('Handles nullable scalars', () => { + it('allows nullable inputs to be omitted', () => { + const result = executeQuery(` + { + fieldWithNullableStringInput + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: null, + }, + }); + }); + + it('allows nullable inputs to be omitted in a variable', () => { + const result = executeQuery(` + query ($value: String) { + fieldWithNullableStringInput(input: $value) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: null, + }, + }); + }); + + it('allows nullable inputs to be omitted in an unlisted variable', () => { + const result = executeQuery(` + query { + fieldWithNullableStringInput(input: $value) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: null, + }, + }); + }); + + it('allows nullable inputs to be set to null in a variable', () => { + const doc = ` + query ($value: String) { + fieldWithNullableStringInput(input: $value) + } + `; + const result = executeQuery(doc, { value: null }); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: 'null', + }, + }); + }); + + it('allows nullable inputs to be set to a value in a variable', () => { + const doc = ` + query ($value: String) { + fieldWithNullableStringInput(input: $value) + } + `; + const result = executeQuery(doc, { value: 'a' }); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: '"a"', + }, + }); + }); + + it('allows nullable inputs to be set to a value directly', () => { + const result = executeQuery(` + { + fieldWithNullableStringInput(input: "a") + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: '"a"', + }, + }); + }); + }); + + describe('Handles non-nullable scalars', () => { + it('allows non-nullable variable to be omitted given a default', () => { + const result = executeQuery(` + query ($value: String! = "default") { + fieldWithNullableStringInput(input: $value) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: '"default"', + }, + }); + }); + + it('allows non-nullable inputs to be omitted given a default', () => { + const result = executeQuery(` + query ($value: String = "default") { + fieldWithNonNullableStringInput(input: $value) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"default"', + }, + }); + }); + + it('does not allow non-nullable inputs to be omitted in a variable', () => { + const result = executeQuery(` + query ($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + `); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$value" has invalid value: Expected a value of non-null type "String!" to be provided.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('does not allow non-nullable inputs to be set to null in a variable', () => { + const doc = ` + query ($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + `; + const result = executeQuery(doc, { value: null }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$value" has invalid value: Expected value of non-null type "String!" not to be null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('allows non-nullable inputs to be set to a value in a variable', () => { + const doc = ` + query ($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + `; + const result = executeQuery(doc, { value: 'a' }); + + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"a"', + }, + }); + }); + + it('allows non-nullable inputs to be set to a value directly', () => { + const result = executeQuery(` + { + fieldWithNonNullableStringInput(input: "a") + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"a"', + }, + }); + }); + + it('reports error for missing non-nullable inputs', () => { + const result = executeQuery('{ fieldWithNonNullableStringInput }'); + + expectJSON(result).toDeepEqual({ + data: { + fieldWithNonNullableStringInput: null, + }, + errors: [ + { + message: + 'Argument "TestType.fieldWithNonNullableStringInput(input:)" of required type "String!" was not provided.', + locations: [{ line: 1, column: 3 }], + path: ['fieldWithNonNullableStringInput'], + }, + ], + }); + }); + + it('reports error for array passed into string input', () => { + const doc = ` + query ($value: String!) { + fieldWithNonNullableStringInput(input: $value) + } + `; + const result = executeQuery(doc, { value: [1, 2, 3] }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$value" has invalid value: String cannot represent a non string value: [1, 2, 3]', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + + expect(result).to.have.nested.property('errors[0].originalError'); + }); + + it('reports error for non-provided variables for non-nullable inputs', () => { + // Note: this test would typically fail validation before encountering + // this execution error, however for queries which previously validated + // and are being run against a new schema which have introduced a breaking + // change to make a formerly non-required argument required, this asserts + // failure before allowing the underlying code to receive a non-null value. + const result = executeQuery(` + { + fieldWithNonNullableStringInput(input: $foo) + } + `); + + expectJSON(result).toDeepEqual({ + data: { + fieldWithNonNullableStringInput: null, + }, + errors: [ + { + message: + 'Argument "TestType.fieldWithNonNullableStringInput(input:)" has invalid value: Expected variable "$foo" provided to type "String!" to provide a runtime value.', + locations: [{ line: 3, column: 50 }], + path: ['fieldWithNonNullableStringInput'], + }, + ], + }); + }); + }); + + describe('Handles lists and nullability', () => { + it('allows lists to be null', () => { + const doc = ` + query ($input: [String]) { + list(input: $input) + } + `; + const result = executeQuery(doc, { input: null }); + + expect(result).to.deep.equal({ data: { list: 'null' } }); + }); + + it('allows lists to contain values', () => { + const doc = ` + query ($input: [String]) { + list(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A'] }); + + expect(result).to.deep.equal({ data: { list: '["A"]' } }); + }); + + it('allows lists to contain null', () => { + const doc = ` + query ($input: [String]) { + list(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A', null, 'B'] }); + + expect(result).to.deep.equal({ data: { list: '["A", null, "B"]' } }); + }); + + it('does not allow non-null lists to be null', () => { + const doc = ` + query ($input: [String]!) { + nnList(input: $input) + } + `; + const result = executeQuery(doc, { input: null }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Expected value of non-null type "[String]!" not to be null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('allows non-null lists to contain values', () => { + const doc = ` + query ($input: [String]!) { + nnList(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A'] }); + + expect(result).to.deep.equal({ data: { nnList: '["A"]' } }); + }); + + it('allows non-null lists to contain null', () => { + const doc = ` + query ($input: [String]!) { + nnList(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A', null, 'B'] }); + + expect(result).to.deep.equal({ data: { nnList: '["A", null, "B"]' } }); + }); + + it('allows lists of non-nulls to be null', () => { + const doc = ` + query ($input: [String!]) { + listNN(input: $input) + } + `; + const result = executeQuery(doc, { input: null }); + + expect(result).to.deep.equal({ data: { listNN: 'null' } }); + }); + + it('allows lists of non-nulls to contain values', () => { + const doc = ` + query ($input: [String!]) { + listNN(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A'] }); + + expect(result).to.deep.equal({ data: { listNN: '["A"]' } }); + }); + + it('does not allow lists of non-nulls to contain null', () => { + const doc = ` + query ($input: [String!]) { + listNN(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A', null, 'B'] }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value at [1]: Expected value of non-null type "String!" not to be null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('does not allow non-null lists of non-nulls to be null', () => { + const doc = ` + query ($input: [String!]!) { + nnListNN(input: $input) + } + `; + const result = executeQuery(doc, { input: null }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value: Expected value of non-null type "[String!]!" not to be null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('allows non-null lists of non-nulls to contain values', () => { + const doc = ` + query ($input: [String!]!) { + nnListNN(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A'] }); + + expect(result).to.deep.equal({ data: { nnListNN: '["A"]' } }); + }); + + it('does not allow non-null lists of non-nulls to contain null', () => { + const doc = ` + query ($input: [String!]!) { + nnListNN(input: $input) + } + `; + const result = executeQuery(doc, { input: ['A', null, 'B'] }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" has invalid value at [1]: Expected value of non-null type "String!" not to be null.', + locations: [{ line: 2, column: 16 }], + }, + ], + }); + }); + + it('does not allow invalid types to be used as values', () => { + const doc = ` + query ($input: TestType!) { + fieldWithObjectInput(input: $input) + } + `; + const result = executeQuery(doc, { input: { list: ['A', 'B'] } }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" expected value of type "TestType!" which cannot be used as an input type.', + locations: [{ line: 2, column: 24 }], + }, + ], + }); + }); + + it('does not allow unknown types to be used as values', () => { + const doc = ` + query ($input: UnknownType!) { + fieldWithObjectInput(input: $input) + } + `; + const result = executeQuery(doc, { input: 'WhoKnows' }); + + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Variable "$input" expected value of type "UnknownType!" which cannot be used as an input type.', + locations: [{ line: 2, column: 24 }], + }, + ], + }); + }); + }); + + describe('Execute: Uses argument default values', () => { + it('when no argument provided', () => { + const result = executeQuery('{ fieldWithDefaultArgumentValue }'); + + expect(result).to.deep.equal({ + data: { + fieldWithDefaultArgumentValue: '"Hello World"', + }, + }); + }); + + it('when omitted variable provided', () => { + const result = executeQuery(` + query ($optional: String) { + fieldWithDefaultArgumentValue(input: $optional) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithDefaultArgumentValue: '"Hello World"', + }, + }); + }); + + it('not when argument cannot be coerced', () => { + const result = executeQuery(` + { + fieldWithDefaultArgumentValue(input: WRONG_TYPE) + } + `); + + expectJSON(result).toDeepEqual({ + data: { + fieldWithDefaultArgumentValue: null, + }, + errors: [ + { + message: + 'Argument "TestType.fieldWithDefaultArgumentValue(input:)" has invalid value: String cannot represent a non string value: WRONG_TYPE', + locations: [{ line: 3, column: 48 }], + path: ['fieldWithDefaultArgumentValue'], + }, + ], + }); + }); + + it('when no runtime value is provided to a non-null argument', () => { + const result = executeQuery(` + query optionalVariable($optional: String) { + fieldWithNonNullableStringInputAndDefaultArgumentValue(input: $optional) + } + `); + + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInputAndDefaultArgumentValue: + '"Hello World"', + }, + }); + }); + }); + + describe('getVariableValues: limit maximum number of coercion errors', () => { + const doc = parse(` + query ($input: [String!]) { + listNN(input: $input) + } + `); + + const operation = doc.definitions[0]; + assert(operation.kind === Kind.OPERATION_DEFINITION); + const { variableDefinitions } = operation; + assert(variableDefinitions != null); + + const inputValue = { input: [0, 1, 2] }; + + function invalidValueError(value: number, index: number) { + return { + message: `Variable "$input" has invalid value at [${index}]: String cannot represent a non string value: ${value}`, + locations: [{ line: 2, column: 14 }], + }; + } + + it('return all errors by default', () => { + const result = getVariableValues(schema, variableDefinitions, inputValue); + + expectJSON(result).toDeepEqual({ + errors: [ + invalidValueError(0, 0), + invalidValueError(1, 1), + invalidValueError(2, 2), + ], + }); + }); + + it('when maxErrors is equal to number of errors', () => { + const result = getVariableValues( + schema, + variableDefinitions, + inputValue, + { maxErrors: 3 }, + ); + + expectJSON(result).toDeepEqual({ + errors: [ + invalidValueError(0, 0), + invalidValueError(1, 1), + invalidValueError(2, 2), + ], + }); + }); + + it('when maxErrors is less than number of errors', () => { + const result = getVariableValues( + schema, + variableDefinitions, + inputValue, + { maxErrors: 2 }, + ); + + expectJSON(result).toDeepEqual({ + errors: [ + invalidValueError(0, 0), + invalidValueError(1, 1), + { + message: + 'Too many errors processing variables, error limit reached. Execution aborted.', + }, + ], + }); + }); + }); + + describe('using fragment arguments', () => { + it('when there are no fragment arguments', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a + } + fragment a on TestType { + fieldWithNonNullableStringInput(input: "A") + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"A"', + }, + }); + }); + + it('when a value is required and provided', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: "A") + } + fragment a($value: String!) on TestType { + fieldWithNonNullableStringInput(input: $value) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"A"', + }, + }); + }); + + it('when a value is required and not provided', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a + } + fragment a($value: String!) on TestType { + fieldWithNullableStringInput(input: $value) + } + `); + + expect(result).to.have.property('errors'); + expect(result.errors).to.have.length(1); + expect(result.errors?.at(0)?.message).to.match( + /Argument "value" of required type "String!"/, + ); + }); + + it('when the definition has a default and is provided', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: "A") + } + fragment a($value: String! = "B") on TestType { + fieldWithNonNullableStringInput(input: $value) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"A"', + }, + }); + }); + + it('when the definition has a default and is not provided', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a + } + fragment a($value: String! = "B") on TestType { + fieldWithNonNullableStringInput(input: $value) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"B"', + }, + }); + }); + + it('when a definition has a default, is not provided, and spreads another fragment', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a + } + fragment a($a: String! = "B") on TestType { + ...b(b: $a) + } + fragment b($b: String!) on TestType { + fieldWithNonNullableStringInput(input: $b) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInput: '"B"', + }, + }); + }); + + it('when the definition has a non-nullable default and is provided null', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: null) + } + fragment a($value: String! = "B") on TestType { + fieldWithNullableStringInput(input: $value) + } + `); + + expect(result).to.have.property('errors'); + expect(result.errors).to.have.length(1); + expect(result.errors?.at(0)?.message).to.match( + /Argument "value" has invalid value: Expected value of non-null type "String!" not to be null./, + ); + }); + + it('when the definition has no default and is not provided', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a + } + fragment a($value: String) on TestType { + fieldWithNonNullableStringInputAndDefaultArgumentValue(input: $value) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInputAndDefaultArgumentValue: + '"Hello World"', + }, + }); + }); + + it('when an argument is shadowed by an operation variable', () => { + const result = executeQueryWithFragmentArguments(` + query($x: String! = "A") { + ...a(x: "B") + } + fragment a($x: String) on TestType { + fieldWithNullableStringInput(input: $x) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: '"B"', + }, + }); + }); + + it('when a nullable argument without a field default is not provided and shadowed by an operation variable', () => { + const result = executeQueryWithFragmentArguments(` + query($x: String = "A") { + ...a + } + fragment a($x: String) on TestType { + fieldWithNullableStringInput(input: $x) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: null, + }, + }); + }); + + it('when a nullable argument with a field default is not provided and shadowed by an operation variable', () => { + const result = executeQueryWithFragmentArguments(` + query($x: String = "A") { + ...a + } + fragment a($x: String) on TestType { + fieldWithNonNullableStringInputAndDefaultArgumentValue(input: $x) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNonNullableStringInputAndDefaultArgumentValue: + '"Hello World"', + }, + }); + }); + + it('when a fragment-variable is shadowed by an intermediate fragment-spread but defined in the operation-variables', () => { + const result = executeQueryWithFragmentArguments(` + query($x: String = "A") { + ...a + } + fragment a($x: String) on TestType { + ...b + } + + fragment b on TestType { + fieldWithNullableStringInput(input: $x) + } + `); + expect(result).to.deep.equal({ + data: { + fieldWithNullableStringInput: '"A"', + }, + }); + }); + + it('when a fragment is used with different args', () => { + const result = executeQueryWithFragmentArguments(` + query($x: String = "Hello") { + a: nested { + ...a(x: "a") + } + b: nested { + ...a(x: "b", b: true) + } + hello: nested { + ...a(x: $x) + } + } + fragment a($x: String, $b: Boolean = false) on NestedType { + a: echo(input: $x) @skip(if: $b) + b: echo(input: $x) @include(if: $b) + } + `); + expect(result).to.deep.equal({ + data: { + a: { + a: '"a"', + }, + b: { + b: '"b"', + }, + hello: { + a: '"Hello"', + }, + }, + }); + }); + + it('when the argument variable is nested in a complex type', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: "C") + } + fragment a($value: String) on TestType { + list(input: ["A", "B", $value, "D"]) + } + `); + expect(result).to.deep.equal({ + data: { + list: '["A", "B", "C", "D"]', + }, + }); + }); + + it('when argument variables are used recursively', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(aValue: "C") + } + fragment a($aValue: String) on TestType { + ...b(bValue: $aValue) + } + fragment b($bValue: String) on TestType { + list(input: ["A", "B", $bValue, "D"]) + } + `); + expect(result).to.deep.equal({ + data: { + list: '["A", "B", "C", "D"]', + }, + }); + }); + + it('when argument variables with the same name are used directly and recursively', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: "A") + } + fragment a($value: String!) on TestType { + ...b(value: "B") + fieldInFragmentA: fieldWithNonNullableStringInput(input: $value) + } + fragment b($value: String!) on TestType { + fieldInFragmentB: fieldWithNonNullableStringInput(input: $value) + } + `); + expect(result).to.deep.equal({ + data: { + fieldInFragmentA: '"A"', + fieldInFragmentB: '"B"', + }, + }); + }); + + it('when argument passed in as list', () => { + const result = executeQueryWithFragmentArguments(` + query Q($opValue: String = "op") { + ...a(aValue: "A") + } + fragment a($aValue: String, $bValue: String) on TestType { + ...b(aValue: [$aValue, "B"], bValue: [$bValue, $opValue]) + } + fragment b($aValue: [String], $bValue: [String], $cValue: String) on TestType { + aList: list(input: $aValue) + bList: list(input: $bValue) + cList: list(input: [$cValue]) + } + `); + expect(result).to.deep.equal({ + data: { + aList: '["A", "B"]', + bList: '[null, "op"]', + cList: '[null]', + }, + }); + }); + + it('when argument passed to a directive', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: true) + } + fragment a($value: Boolean!) on TestType { + fieldWithNonNullableStringInput @skip(if: $value) + } + `); + expect(result).to.deep.equal({ + data: {}, + }); + }); + + it('when argument passed to a directive on a nested field', () => { + const result = executeQueryWithFragmentArguments(` + query { + ...a(value: true) + } + fragment a($value: Boolean!) on TestType { + nested { echo(input: "echo") @skip(if: $value) } + } + `); + expect(result).to.deep.equal({ + data: { nested: {} }, + }); + }); + + it('when a nullable argument to a directive with a field default is not provided and shadowed by an operation variable', () => { + // this test uses the @defer directive and incremental delivery because the `if` argument for skip/include have no field defaults + const document = parse( + ` + query($shouldDefer: Boolean = false) { + ...a + } + fragment a($shouldDefer: Boolean) on TestType { + ... @defer(if: $shouldDefer) { + fieldWithDefaultArgumentValue + } + } + `, + { experimentalFragmentArguments: true }, + ); + const result = execute({ schema, document }); + expect(result).to.include.keys('initialResult', 'subsequentResults'); + }); + }); +}); diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 4f17c3ad12..9b38002eae 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -53,16 +53,17 @@ export function completeValue( rootType, fieldName, ); - invariant(fieldDef != null); - data[responseName] = completeSubValue( - context, - errors, - fieldDef.type, - fieldDetailsList, - rootValue[responseName], - addPath(path, responseName, undefined), - ); + if (fieldDef) { + data[responseName] = completeSubValue( + context, + errors, + fieldDef.type, + fieldDetailsList, + rootValue[responseName], + addPath(path, responseName, undefined), + ); + } } return data; @@ -132,46 +133,49 @@ function completeObjectType( const typeName = result[prefix]; - invariant(typeof typeName === 'string'); - - const runtimeType = context.transformedArgs.schema.getType(typeName); - - invariant(isObjectType(runtimeType)); - const completed = Object.create(null); - const groupedFieldSetTree = collectSubfields( - context.transformedArgs, - runtimeType, - fieldDetailsList, - ); + if (typeName != null) { + invariant(typeof typeName === 'string'); - const groupedFieldSet = groupedFieldSetFromTree( - context, - groupedFieldSetTree, - path, - ); + const runtimeType = context.transformedArgs.schema.getType(typeName); - for (const [responseName, subFieldDetailsList] of groupedFieldSet) { - if (responseName === context.prefix) { - continue; - } + invariant(isObjectType(runtimeType)); - const fieldName = subFieldDetailsList[0].node.name.value; - const fieldDef = context.transformedArgs.schema.getField( + const groupedFieldSetTree = collectSubfields( + context.transformedArgs, runtimeType, - fieldName, + fieldDetailsList, ); - invariant(fieldDef != null); - completed[responseName] = completeSubValue( + const groupedFieldSet = groupedFieldSetFromTree( context, - errors, - fieldDef.type, - subFieldDetailsList, - result[responseName], - addPath(path, responseName, undefined), + groupedFieldSetTree, + 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, + ); + + if (fieldDef) { + completed[responseName] = completeSubValue( + context, + errors, + fieldDef.type, + subFieldDetailsList, + result[responseName], + addPath(path, responseName, undefined), + ); + } + } } return completed; From 2b4dac9440ebee9b13ef03f084dda6ebad061ed8 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 14 Jan 2025 14:07:38 +0200 Subject: [PATCH 06/16] streamline test code --- src/transform/__tests__/stream-test.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/transform/__tests__/stream-test.ts b/src/transform/__tests__/stream-test.ts index fd2c3207e6..215616ca4c 100644 --- a/src/transform/__tests__/stream-test.ts +++ b/src/transform/__tests__/stream-test.ts @@ -19,12 +19,13 @@ import { 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'; +import { execute } from './execute.js'; + const friendType = new GraphQLObjectType({ fields: { id: { type: GraphQLID }, @@ -90,7 +91,7 @@ async function complete( rootValue: unknown = {}, enableEarlyExecution = false, ) { - const result = await legacyExecuteIncrementally({ + const result = await execute({ schema, document, rootValue, @@ -115,7 +116,7 @@ async function completeAsync( numCalls: number, rootValue: unknown = {}, ) { - const result = await legacyExecuteIncrementally({ + const result = await execute({ schema, document, rootValue, @@ -1791,7 +1792,7 @@ describe('Execute: legacy stream directive', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { @@ -1971,7 +1972,7 @@ describe('Execute: legacy stream directive', () => { } } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { @@ -2072,7 +2073,7 @@ describe('Execute: legacy stream directive', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { @@ -2182,7 +2183,7 @@ describe('Execute: legacy stream directive', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { @@ -2292,7 +2293,7 @@ describe('Execute: legacy stream directive', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { @@ -2344,7 +2345,7 @@ describe('Execute: legacy stream directive', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { @@ -2404,7 +2405,7 @@ describe('Execute: legacy stream directive', () => { } `); - const executeResult = await legacyExecuteIncrementally({ + const executeResult = await execute({ schema, document, rootValue: { From 462afcfc71ece76428dba4aae6e794ea4764df51 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 14 Jan 2025 14:39:58 +0200 Subject: [PATCH 07/16] add additional specific test for transformation of new null bubbling from latest format --- .../legacyExecuteIncrementally-test.ts | 90 ++++++++++++++++++- src/transform/completeValue.ts | 4 +- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/transform/__tests__/legacyExecuteIncrementally-test.ts b/src/transform/__tests__/legacyExecuteIncrementally-test.ts index 7ef70d3f06..a9ea0b614f 100644 --- a/src/transform/__tests__/legacyExecuteIncrementally-test.ts +++ b/src/transform/__tests__/legacyExecuteIncrementally-test.ts @@ -2,6 +2,11 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON.js'; +import { invariant } from '../../jsutils/invariant.js'; +import { isPromise } from '../../jsutils/isPromise.js'; +import type { ObjMap } from '../../jsutils/ObjMap.js'; + +import type { DocumentNode } from '../../language/ast.js'; import { Kind } from '../../language/kinds.js'; import { parse } from '../../language/parser.js'; @@ -10,14 +15,52 @@ import { GraphQLString } from '../../type/scalars.js'; import { GraphQLSchema } from '../../type/schema.js'; import { legacyExecuteIncrementally } from '../legacyExecuteIncrementally.js'; +import type { + LegacyInitialIncrementalExecutionResult, + LegacySubsequentIncrementalExecutionResult, +} from '../transformResult.js'; + +const someObjectType = new GraphQLObjectType({ + name: 'SomeObject', + fields: { + someField: { type: GraphQLString }, + anotherField: { type: GraphQLString }, + nonNullableField: { type: new GraphQLNonNull(GraphQLString) }, + }, +}); const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', - fields: { someField: { type: new GraphQLNonNull(GraphQLString) } }, + fields: { + someField: { type: new GraphQLNonNull(GraphQLString) }, + someObjectField: { type: someObjectType }, + }, }), }); +async function complete(document: DocumentNode, rootValue: ObjMap) { + const result = legacyExecuteIncrementally({ + schema, + document, + rootValue, + }); + + invariant(!isPromise(result)); + + 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('legacyExecuteIncrementally', () => { it('handles invalid document', () => { const result = legacyExecuteIncrementally({ @@ -52,4 +95,49 @@ describe('legacyExecuteIncrementally', () => { ], }); }); + + it('handles null-bubbling from latest format', async () => { + const document = parse(` + query { + someObjectField { + ... @defer { someField anotherField } + ... @defer { someField nonNullableField } + } + } + `); + const result = await complete(document, { + someObjectField: { + someField: 'someField', + anotherField: 'anotherField', + nonNullableField: null, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { someObjectField: {} }, + hasNext: true, + }, + { + incremental: [ + { + data: { someField: 'someField', anotherField: 'anotherField' }, + path: ['someObjectField'], + }, + { + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field SomeObject.nonNullableField.', + locations: [{ line: 5, column: 11 }], + path: ['someObjectField', 'nonNullableField'], + }, + ], + path: ['someObjectField'], + }, + ], + hasNext: false, + }, + ]); + }); }); diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 9b38002eae..16477f507d 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -119,10 +119,10 @@ export function completeSubValue( } invariant(isObjectLike(result)); - return completeObjectType(context, errors, fieldDetailsList, result, path); + return completeObjectValue(context, errors, fieldDetailsList, result, path); } -function completeObjectType( +function completeObjectValue( context: TransformationContext, errors: Array, fieldDetailsList: ReadonlyArray, From 6987b5a381b44f3cd9ccb57f68c2e5f989d94c26 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 14 Jan 2025 14:42:56 +0200 Subject: [PATCH 08/16] tweak error message --- src/transform/__tests__/legacyExecuteIncrementally-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transform/__tests__/legacyExecuteIncrementally-test.ts b/src/transform/__tests__/legacyExecuteIncrementally-test.ts index a9ea0b614f..bd4c75929b 100644 --- a/src/transform/__tests__/legacyExecuteIncrementally-test.ts +++ b/src/transform/__tests__/legacyExecuteIncrementally-test.ts @@ -129,7 +129,7 @@ describe('legacyExecuteIncrementally', () => { { message: 'Cannot return null for non-nullable field SomeObject.nonNullableField.', - locations: [{ line: 5, column: 11 }], + locations: [{ line: 5, column: 34 }], path: ['someObjectField', 'nonNullableField'], }, ], From 89a7ae37235edc79618efeda2803c85056da208c Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 14 Jan 2025 14:44:54 +0200 Subject: [PATCH 09/16] coverage --- .../legacyExecuteIncrementally-test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/transform/__tests__/legacyExecuteIncrementally-test.ts b/src/transform/__tests__/legacyExecuteIncrementally-test.ts index bd4c75929b..9ed4c06ce1 100644 --- a/src/transform/__tests__/legacyExecuteIncrementally-test.ts +++ b/src/transform/__tests__/legacyExecuteIncrementally-test.ts @@ -47,18 +47,16 @@ async function complete(document: DocumentNode, rootValue: ObjMap) { }); invariant(!isPromise(result)); + invariant('initialResult' in result); - if ('initialResult' in result) { - const results: Array< - | LegacyInitialIncrementalExecutionResult - | LegacySubsequentIncrementalExecutionResult - > = [result.initialResult]; - for await (const patch of result.subsequentResults) { - results.push(patch); - } - return results; + const results: Array< + | LegacyInitialIncrementalExecutionResult + | LegacySubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); } - return result; + return results; } describe('legacyExecuteIncrementally', () => { From ab90d443817f3061aa495306919ac478050e5fea Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 20 Jan 2025 13:13:09 +0200 Subject: [PATCH 10/16] make sure to use correct fragment variables for deferred fragments --- src/transform/__tests__/collectFields-test.ts | 66 ++++++++++++--- src/transform/buildTransformationContext.ts | 28 ++----- src/transform/collectFields.ts | 81 +++++++++++-------- src/transform/completeValue.ts | 17 ++-- src/transform/groupedFieldSetFromTree.ts | 47 +++++++---- src/transform/transformResult.ts | 47 +++-------- 6 files changed, 156 insertions(+), 130 deletions(-) diff --git a/src/transform/__tests__/collectFields-test.ts b/src/transform/__tests__/collectFields-test.ts index b9167d34a2..ab3b65208c 100644 --- a/src/transform/__tests__/collectFields-test.ts +++ b/src/transform/__tests__/collectFields-test.ts @@ -15,6 +15,7 @@ import { GraphQLSchema } from '../../type/schema.js'; import { validateExecutionArgs } from '../../execution/execute.js'; +import { buildTransformationContext } from '../buildTransformationContext.js'; import { collectFields, collectSubfields } from '../collectFields.js'; describe('collectFields', () => { @@ -54,8 +55,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -78,8 +84,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -97,8 +108,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -121,8 +137,13 @@ describe('collectFields', () => { invariant(inlineFragment.kind === Kind.INLINE_FRAGMENT); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -148,8 +169,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -172,8 +198,13 @@ describe('collectFields', () => { invariant(inlineFragment.kind === Kind.INLINE_FRAGMENT); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -198,8 +229,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -209,7 +245,7 @@ describe('collectFields', () => { invariant(fieldDetailsList != null); const { groupedFieldSet: nestedGroupedFieldSet } = collectSubfields( - validatedExecutionArgs, + transformationContext, someObjectType, fieldDetailsList, ); @@ -240,8 +276,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); @@ -267,8 +308,13 @@ describe('collectFields', () => { invariant('schema' in validatedExecutionArgs); - const { groupedFieldSet } = collectFields( + const transformationContext = buildTransformationContext( validatedExecutionArgs, + '__prefix__', + ); + + const { groupedFieldSet } = collectFields( + transformationContext, query, validatedExecutionArgs.operation.selectionSet, ); diff --git a/src/transform/buildTransformationContext.ts b/src/transform/buildTransformationContext.ts index 3dd6084589..153530c919 100644 --- a/src/transform/buildTransformationContext.ts +++ b/src/transform/buildTransformationContext.ts @@ -18,19 +18,15 @@ import { import type { GraphQLOutputType } from '../type/index.js'; import { TypeNameMetaFieldDef } from '../type/introspection.js'; -import { collectSubfields as _collectSubfields } from '../execution/collectFields.js'; +import type { GroupedFieldSet } from '../execution/collectFields.js'; import type { ValidatedExecutionArgs } from '../execution/execute.js'; import type { PendingResult } from '../execution/types.js'; import type { FieldDetails } from './collectFields.js'; -type SelectionSetNodeOrFragmentName = - | { node: SelectionSetNode; fragmentName?: never } - | { node?: never; fragmentName: string }; - -interface DeferUsageContext { +export interface DeferUsageContext { originalLabel: string | undefined; - selectionSet: SelectionSetNodeOrFragmentName; + groupedFieldSet?: GroupedFieldSet | undefined; } export interface Stream { @@ -171,18 +167,11 @@ function transformSelection( ), }; } else if (selection.kind === Kind.INLINE_FRAGMENT) { - const transformedSelectionSet = transformRootSelectionSet( - context, - selection.selectionSet, - ); - return { ...selection, - selectionSet: transformedSelectionSet, + selectionSet: transformRootSelectionSet(context, selection.selectionSet), directives: selection.directives?.map((directive) => - transformMaybeDeferDirective(context, directive, { - node: transformedSelectionSet, - }), + transformMaybeDeferDirective(context, directive), ), }; } @@ -190,9 +179,7 @@ function transformSelection( return { ...selection, directives: selection.directives?.map((directive) => - transformMaybeDeferDirective(context, directive, { - fragmentName: selection.name.value, - }), + transformMaybeDeferDirective(context, directive), ), }; } @@ -200,7 +187,6 @@ function transformSelection( function transformMaybeDeferDirective( context: RequestTransformationContext, directive: DirectiveNode, - selectionSet: SelectionSetNodeOrFragmentName, ): DirectiveNode { const name = directive.name.value; @@ -223,7 +209,6 @@ function transformMaybeDeferDirective( const prefixedLabel = `${context.prefix}defer${context.incrementalCounter++}__${originalLabel}`; context.deferUsageMap.set(prefixedLabel, { originalLabel, - selectionSet, }); newArgs.push({ ...arg, @@ -242,7 +227,6 @@ function transformMaybeDeferDirective( const newLabel = `${context.prefix}defer${context.incrementalCounter++}`; context.deferUsageMap.set(newLabel, { originalLabel: undefined, - selectionSet, }); newArgs.push({ kind: Kind.ARGUMENT, diff --git a/src/transform/collectFields.ts b/src/transform/collectFields.ts index b5a9cfe950..8a75ee1252 100644 --- a/src/transform/collectFields.ts +++ b/src/transform/collectFields.ts @@ -24,7 +24,6 @@ import type { FragmentDetails, GroupedFieldSet, } from '../execution/collectFields.js'; -import type { ValidatedExecutionArgs } from '../execution/execute.js'; import type { VariableValues } from '../execution/values.js'; import { getDirectiveValues, @@ -33,12 +32,18 @@ import { import { typeFromAST } from '../utilities/typeFromAST.js'; +import type { + DeferUsageContext, + TransformationContext, +} from './buildTransformationContext.js'; + export interface FieldDetails { node: FieldNode; fragmentVariableValues?: VariableValues | undefined; } interface CollectFieldsContext { + deferUsageMap: Map; schema: GraphQLSchema; fragments: ObjMap; variableValues: VariableValues; @@ -47,10 +52,8 @@ interface CollectFieldsContext { hideSuggestions: boolean; } -export interface GroupedFieldSetTree { - groupedFieldSet: GroupedFieldSet; - deferredGroupedFieldSets: Map; -} +export type DeferredFragmentTree = Map; + /** * Given a selectionSet, collects all of the fields and returns them. * @@ -62,25 +65,29 @@ export interface GroupedFieldSetTree { */ export function collectFields( - validateExecutionArgs: ValidatedExecutionArgs, + transformationContext: TransformationContext, runtimeType: GraphQLObjectType, selectionSet: SelectionSetNode, -): GroupedFieldSetTree { +): { + groupedFieldSet: GroupedFieldSet; + deferredFragmentTree: DeferredFragmentTree; +} { const context: CollectFieldsContext = { - ...validateExecutionArgs, + deferUsageMap: transformationContext.deferUsageMap, + ...transformationContext.transformedArgs, runtimeType, visitedFragmentNames: new Set(), }; const groupedFieldSet = new AccumulatorMap(); - const deferredGroupedFieldSets = new Map(); + const deferredFragmentTree = new Map(); collectFieldsImpl( context, selectionSet, groupedFieldSet, - deferredGroupedFieldSets, + deferredFragmentTree, ); - return { groupedFieldSet, deferredGroupedFieldSets }; + return { groupedFieldSet, deferredFragmentTree }; } /** @@ -94,17 +101,21 @@ export function collectFields( * @internal */ export function collectSubfields( - validatedExecutionArgs: ValidatedExecutionArgs, + transformationContext: TransformationContext, returnType: GraphQLObjectType, fieldDetailsList: ReadonlyArray, -): GroupedFieldSetTree { +): { + groupedFieldSet: GroupedFieldSet; + deferredFragmentTree: DeferredFragmentTree; +} { const context: CollectFieldsContext = { - ...validatedExecutionArgs, + deferUsageMap: transformationContext.deferUsageMap, + ...transformationContext.transformedArgs, runtimeType: returnType, visitedFragmentNames: new Set(), }; const groupedFieldSet = new AccumulatorMap(); - const deferredGroupedFieldSets = new Map(); + const deferredFragmentTree = new Map(); for (const fieldDetail of fieldDetailsList) { const selectionSet = fieldDetail.node.selectionSet; @@ -114,23 +125,24 @@ export function collectSubfields( context, selectionSet, groupedFieldSet, - deferredGroupedFieldSets, + deferredFragmentTree, fragmentVariableValues, ); } } - return { groupedFieldSet, deferredGroupedFieldSets }; + return { groupedFieldSet, deferredFragmentTree }; } function collectFieldsImpl( context: CollectFieldsContext, selectionSet: SelectionSetNode, groupedFieldSet: AccumulatorMap, - deferredGroupedFieldSets: Map, + deferredFragmentTree: DeferredFragmentTree, fragmentVariableValues?: VariableValues, ): void { const { + deferUsageMap, schema, fragments, variableValues, @@ -160,20 +172,20 @@ function collectFieldsImpl( string, FieldDetails >(); - const nestedDeferredGroupedFieldSets = new Map< + const nestedDeferredFragmentTree = new Map< string, - GroupedFieldSetTree + DeferredFragmentTree >(); collectFieldsImpl( context, selection.selectionSet, deferredGroupedFieldSet, - nestedDeferredGroupedFieldSets, + nestedDeferredFragmentTree, ); - deferredGroupedFieldSets.set(deferLabel, { - groupedFieldSet: deferredGroupedFieldSet, - deferredGroupedFieldSets: nestedDeferredGroupedFieldSets, - }); + const deferUsageContext = deferUsageMap.get(deferLabel); + invariant(deferUsageContext != null); + deferUsageContext.groupedFieldSet = deferredGroupedFieldSet; + deferredFragmentTree.set(deferLabel, nestedDeferredFragmentTree); continue; } @@ -192,7 +204,7 @@ function collectFieldsImpl( context, selection.selectionSet, groupedFieldSet, - deferredGroupedFieldSets, + deferredFragmentTree, fragmentVariableValues, ); @@ -222,20 +234,21 @@ function collectFieldsImpl( string, FieldDetails >(); - const nestedDeferredGroupedFieldSets = new Map< + const nestedDeferredFragmentTree = new Map< string, - GroupedFieldSetTree + DeferredFragmentTree >(); collectFieldsImpl( context, fragment.definition.selectionSet, deferredGroupedFieldSet, - nestedDeferredGroupedFieldSets, + nestedDeferredFragmentTree, ); - deferredGroupedFieldSets.set(deferLabel, { - groupedFieldSet: deferredGroupedFieldSet, - deferredGroupedFieldSets: nestedDeferredGroupedFieldSets, - }); + const deferUsageContext = deferUsageMap.get(deferLabel); + invariant(deferUsageContext != null); + deferUsageContext.groupedFieldSet = deferredGroupedFieldSet; + invariant(deferUsageContext != null); + deferredFragmentTree.set(deferLabel, nestedDeferredFragmentTree); continue; } @@ -256,7 +269,7 @@ function collectFieldsImpl( context, fragment.definition.selectionSet, groupedFieldSet, - deferredGroupedFieldSets, + deferredFragmentTree, newFragmentVariableValues, ); break; diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 16477f507d..8df9a77ff2 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -22,7 +22,6 @@ import { import { GraphQLStreamDirective } from '../type/directives.js'; import type { GroupedFieldSet } from '../execution/collectFields.js'; -import type { ValidatedExecutionArgs } from '../execution/execute.js'; import type { TransformationContext } from './buildTransformationContext.js'; import type { FieldDetails } from './collectFields.js'; @@ -31,10 +30,10 @@ import { groupedFieldSetFromTree } from './groupedFieldSetFromTree.js'; const collectSubfields = memoize3( ( - validatedExecutionArgs: ValidatedExecutionArgs, + transformationContext: TransformationContext, returnType: GraphQLObjectType, fieldDetailsList: ReadonlyArray, - ) => _collectSubfields(validatedExecutionArgs, returnType, fieldDetailsList), + ) => _collectSubfields(transformationContext, returnType, fieldDetailsList), ); // eslint-disable-next-line @typescript-eslint/max-params @@ -142,15 +141,15 @@ function completeObjectValue( invariant(isObjectType(runtimeType)); - const groupedFieldSetTree = collectSubfields( - context.transformedArgs, - runtimeType, - fieldDetailsList, - ); + const { + groupedFieldSet: groupedFieldSetWithoutInlinedDefers, + deferredFragmentTree, + } = collectSubfields(context, runtimeType, fieldDetailsList); const groupedFieldSet = groupedFieldSetFromTree( context, - groupedFieldSetTree, + groupedFieldSetWithoutInlinedDefers, + deferredFragmentTree, path, ); diff --git a/src/transform/groupedFieldSetFromTree.ts b/src/transform/groupedFieldSetFromTree.ts index b34af0af3f..146d7510f1 100644 --- a/src/transform/groupedFieldSetFromTree.ts +++ b/src/transform/groupedFieldSetFromTree.ts @@ -1,4 +1,5 @@ import { AccumulatorMap } from '../jsutils/AccumulatorMap.js'; +import { invariant } from '../jsutils/invariant.js'; import type { Path } from '../jsutils/Path.js'; import { pathToArray } from '../jsutils/Path.js'; @@ -8,51 +9,63 @@ import type { } from '../execution/collectFields.js'; import type { TransformationContext } from './buildTransformationContext.js'; -import type { GroupedFieldSetTree } from './collectFields.js'; +import type { DeferredFragmentTree } from './collectFields.js'; export function groupedFieldSetFromTree( context: TransformationContext, - groupedFieldSetTree: GroupedFieldSetTree, + groupedFieldSet: GroupedFieldSet, + deferredFragmentTree: DeferredFragmentTree, path: Path | undefined, ): GroupedFieldSet { const groupedFieldSetWithInlinedDefers = new AccumulatorMap< string, FieldDetails >(); - groupedFieldSetFromTreeImpl( + + for (const [responseName, fieldDetailsList] of groupedFieldSet) { + for (const fieldDetails of fieldDetailsList) { + groupedFieldSetWithInlinedDefers.add(responseName, fieldDetails); + } + } + + maybeAddDefers( context, groupedFieldSetWithInlinedDefers, - groupedFieldSetTree, + deferredFragmentTree, path, ); + return groupedFieldSetWithInlinedDefers; } -function groupedFieldSetFromTreeImpl( +function maybeAddDefers( context: TransformationContext, groupedFieldSetWithInlinedDefers: AccumulatorMap, - groupedFieldSetTree: GroupedFieldSetTree, + deferredFragmentTree: DeferredFragmentTree, path: Path | undefined, ): void { - const { groupedFieldSet, deferredGroupedFieldSets } = groupedFieldSetTree; - - for (const [responseName, fieldDetailsList] of groupedFieldSet) { - for (const fieldDetails of fieldDetailsList) { - groupedFieldSetWithInlinedDefers.add(responseName, fieldDetails); - } - } - - for (const [label, childGroupedFieldSetTree] of deferredGroupedFieldSets) { + for (const [label, nestedDeferredFragmentTree] of deferredFragmentTree) { const pathStr = pathToArray(path).join('.'); const labels = context.pendingLabelsByPath.get(pathStr); if (labels?.has(label)) { continue; } - groupedFieldSetFromTreeImpl( + const deferUsageContext = context.deferUsageMap.get(label); + invariant(deferUsageContext != null); + const groupedFieldSet = deferUsageContext.groupedFieldSet; + invariant(groupedFieldSet != null); + + for (const [responseName, fieldDetailsList] of groupedFieldSet) { + for (const fieldDetails of fieldDetailsList) { + groupedFieldSetWithInlinedDefers.add(responseName, fieldDetails); + } + } + + maybeAddDefers( context, groupedFieldSetWithInlinedDefers, - childGroupedFieldSetTree, + nestedDeferredFragmentTree, path, ); } diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts index 722b0ba38f..6b19beb9a8 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -1,18 +1,13 @@ import { invariant } from '../jsutils/invariant.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; -import { memoize3 } from '../jsutils/memoize3.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import type { Path } from '../jsutils/Path.js'; import { addPath, pathToArray } from '../jsutils/Path.js'; import type { 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 type { ValidatedExecutionArgs } from '../execution/execute.js'; import { mapAsyncIterable } from '../execution/mapAsyncIterable.js'; import type { CompletedResult, @@ -25,7 +20,7 @@ import type { } from '../execution/types.js'; import type { TransformationContext } from './buildTransformationContext.js'; -import { collectFields as _collectFields } from './collectFields.js'; +import { collectFields } from './collectFields.js'; import { completeSubValue, completeValue } from './completeValue.js'; import { embedErrors } from './embedErrors.js'; import { getObjectAtPath } from './getObjectAtPath.js'; @@ -67,14 +62,6 @@ type LegacyIncrementalResult = | LegacyIncrementalDeferResult | LegacyIncrementalStreamResult; -const collectFields = memoize3( - ( - validatedExecutionArgs: ValidatedExecutionArgs, - returnType: GraphQLObjectType, - selectionSet: SelectionSetNode, - ) => _collectFields(validatedExecutionArgs, returnType, selectionSet), -); - export function transformResult( context: TransformationContext, result: ExecutionResult | ExperimentalIncrementalExecutionResults, @@ -273,26 +260,11 @@ function processCompleted( const errors: Array = []; - const selectionSet = deferUsageContext.selectionSet; - const selectionSetNode = selectionSet.node - ? selectionSet.node - : context.transformedArgs.fragments[selectionSet.fragmentName] - .definition.selectionSet; + const groupedFieldSet = deferUsageContext.groupedFieldSet; + invariant(groupedFieldSet != null); const objectPath = pathFromArray(pendingResult.path); - const groupedFieldSetTree = collectFields( - context.transformedArgs, - runtimeType, - selectionSetNode, - ); - - const groupedFieldSet = groupedFieldSetFromTree( - context, - groupedFieldSetTree, - objectPath, - ); - const data = completeValue( context, object, @@ -353,16 +325,15 @@ function transformInitialResult< processPending(context, pending); } - // no need to memoize for the initial result as will be called only once - const groupedFieldSetTree = _collectFields( - context.transformedArgs, - rootType, - operation.selectionSet, - ); + const { + groupedFieldSet: groupedFieldSetWithoutInlinedDefers, + deferredFragmentTree, + } = collectFields(context, rootType, operation.selectionSet); const groupedFieldSet = groupedFieldSetFromTree( context, - groupedFieldSetTree, + groupedFieldSetWithoutInlinedDefers, + deferredFragmentTree, undefined, ); From ee6e80a04ac40e3fa2d50fc2c422401c394ae082 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 20 Jan 2025 13:21:51 +0200 Subject: [PATCH 11/16] remove confusing DeferUsageContext --- .../buildTransformationContext-test.ts | 3 +- src/transform/buildTransformationContext.ts | 23 ++++++--------- src/transform/collectFields.ts | 28 ++++++++----------- src/transform/groupedFieldSetFromTree.ts | 4 +-- src/transform/transformResult.ts | 7 ++--- 5 files changed, 24 insertions(+), 41 deletions(-) diff --git a/src/transform/__tests__/buildTransformationContext-test.ts b/src/transform/__tests__/buildTransformationContext-test.ts index aa6d5cab4b..e062937d79 100644 --- a/src/transform/__tests__/buildTransformationContext-test.ts +++ b/src/transform/__tests__/buildTransformationContext-test.ts @@ -34,7 +34,8 @@ describe('buildTransformationContext', () => { '__prefix__', ); - expect(context.deferUsageMap instanceof Map).to.equal(true); + expect(context.originalDeferLabels instanceof Map).to.equal(true); + expect(context.deferredGroupedFieldSets instanceof Map).to.equal(true); expect(context.streamUsageMap instanceof Map).to.equal(true); expect(context.prefix).to.equal('__prefix__'); expect(context.pendingLabelsByPath instanceof Map).to.equal(true); diff --git a/src/transform/buildTransformationContext.ts b/src/transform/buildTransformationContext.ts index 153530c919..cc58a4ae76 100644 --- a/src/transform/buildTransformationContext.ts +++ b/src/transform/buildTransformationContext.ts @@ -24,11 +24,6 @@ import type { PendingResult } from '../execution/types.js'; import type { FieldDetails } from './collectFields.js'; -export interface DeferUsageContext { - originalLabel: string | undefined; - groupedFieldSet?: GroupedFieldSet | undefined; -} - export interface Stream { path: Path; itemType: GraphQLOutputType; @@ -43,7 +38,8 @@ interface StreamUsageContext { export interface TransformationContext { transformedArgs: ValidatedExecutionArgs; - deferUsageMap: Map; + originalDeferLabels: Map; + deferredGroupedFieldSets: Map; streamUsageMap: Map; prefix: string; pendingResultsById: Map; @@ -54,7 +50,7 @@ export interface TransformationContext { interface RequestTransformationContext { prefix: string; incrementalCounter: number; - deferUsageMap: Map; + originalDeferLabels: Map; streamUsageMap: Map; } @@ -67,7 +63,7 @@ export function buildTransformationContext( const context: RequestTransformationContext = { prefix, incrementalCounter: 0, - deferUsageMap: new Map(), + originalDeferLabels: new Map(), streamUsageMap: new Map(), }; @@ -97,7 +93,8 @@ export function buildTransformationContext( return { transformedArgs, - deferUsageMap: context.deferUsageMap, + originalDeferLabels: context.originalDeferLabels, + deferredGroupedFieldSets: new Map(), streamUsageMap: context.streamUsageMap, prefix, pendingResultsById: new Map(), @@ -207,9 +204,7 @@ function transformMaybeDeferDirective( const originalLabel = value.value; const prefixedLabel = `${context.prefix}defer${context.incrementalCounter++}__${originalLabel}`; - context.deferUsageMap.set(prefixedLabel, { - originalLabel, - }); + context.originalDeferLabels.set(prefixedLabel, originalLabel); newArgs.push({ ...arg, value: { @@ -225,9 +220,7 @@ function transformMaybeDeferDirective( if (!foundLabel) { const newLabel = `${context.prefix}defer${context.incrementalCounter++}`; - context.deferUsageMap.set(newLabel, { - originalLabel: undefined, - }); + context.originalDeferLabels.set(newLabel, undefined); newArgs.push({ kind: Kind.ARGUMENT, name: { diff --git a/src/transform/collectFields.ts b/src/transform/collectFields.ts index 8a75ee1252..8d08d7851d 100644 --- a/src/transform/collectFields.ts +++ b/src/transform/collectFields.ts @@ -32,10 +32,7 @@ import { import { typeFromAST } from '../utilities/typeFromAST.js'; -import type { - DeferUsageContext, - TransformationContext, -} from './buildTransformationContext.js'; +import type { TransformationContext } from './buildTransformationContext.js'; export interface FieldDetails { node: FieldNode; @@ -43,7 +40,7 @@ export interface FieldDetails { } interface CollectFieldsContext { - deferUsageMap: Map; + deferredGroupedFieldSets: Map; schema: GraphQLSchema; fragments: ObjMap; variableValues: VariableValues; @@ -72,9 +69,10 @@ export function collectFields( groupedFieldSet: GroupedFieldSet; deferredFragmentTree: DeferredFragmentTree; } { + const { transformedArgs, deferredGroupedFieldSets } = transformationContext; const context: CollectFieldsContext = { - deferUsageMap: transformationContext.deferUsageMap, - ...transformationContext.transformedArgs, + deferredGroupedFieldSets, + ...transformedArgs, runtimeType, visitedFragmentNames: new Set(), }; @@ -108,9 +106,10 @@ export function collectSubfields( groupedFieldSet: GroupedFieldSet; deferredFragmentTree: DeferredFragmentTree; } { + const { transformedArgs, deferredGroupedFieldSets } = transformationContext; const context: CollectFieldsContext = { - deferUsageMap: transformationContext.deferUsageMap, - ...transformationContext.transformedArgs, + deferredGroupedFieldSets, + ...transformedArgs, runtimeType: returnType, visitedFragmentNames: new Set(), }; @@ -142,7 +141,7 @@ function collectFieldsImpl( fragmentVariableValues?: VariableValues, ): void { const { - deferUsageMap, + deferredGroupedFieldSets, schema, fragments, variableValues, @@ -182,9 +181,7 @@ function collectFieldsImpl( deferredGroupedFieldSet, nestedDeferredFragmentTree, ); - const deferUsageContext = deferUsageMap.get(deferLabel); - invariant(deferUsageContext != null); - deferUsageContext.groupedFieldSet = deferredGroupedFieldSet; + deferredGroupedFieldSets.set(deferLabel, deferredGroupedFieldSet); deferredFragmentTree.set(deferLabel, nestedDeferredFragmentTree); continue; } @@ -244,10 +241,7 @@ function collectFieldsImpl( deferredGroupedFieldSet, nestedDeferredFragmentTree, ); - const deferUsageContext = deferUsageMap.get(deferLabel); - invariant(deferUsageContext != null); - deferUsageContext.groupedFieldSet = deferredGroupedFieldSet; - invariant(deferUsageContext != null); + deferredGroupedFieldSets.set(deferLabel, deferredGroupedFieldSet); deferredFragmentTree.set(deferLabel, nestedDeferredFragmentTree); continue; } diff --git a/src/transform/groupedFieldSetFromTree.ts b/src/transform/groupedFieldSetFromTree.ts index 146d7510f1..ed84312eea 100644 --- a/src/transform/groupedFieldSetFromTree.ts +++ b/src/transform/groupedFieldSetFromTree.ts @@ -51,9 +51,7 @@ function maybeAddDefers( continue; } - const deferUsageContext = context.deferUsageMap.get(label); - invariant(deferUsageContext != null); - const groupedFieldSet = deferUsageContext.groupedFieldSet; + const groupedFieldSet = context.deferredGroupedFieldSets.get(label); invariant(groupedFieldSet != null); for (const [responseName, fieldDetailsList] of groupedFieldSet) { diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts index 6b19beb9a8..57cef413dc 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -240,9 +240,6 @@ function processCompleted( continue; } - const deferUsageContext = context.deferUsageMap.get(label); - invariant(deferUsageContext != null); - let incrementalResult: LegacyIncrementalDeferResult; if ('errors' in completedResult) { incrementalResult = { @@ -260,7 +257,7 @@ function processCompleted( const errors: Array = []; - const groupedFieldSet = deferUsageContext.groupedFieldSet; + const groupedFieldSet = context.deferredGroupedFieldSets.get(label); invariant(groupedFieldSet != null); const objectPath = pathFromArray(pendingResult.path); @@ -284,7 +281,7 @@ function processCompleted( } } - const originalLabel = deferUsageContext.originalLabel; + const originalLabel = context.originalDeferLabels.get(label); if (originalLabel != null) { incrementalResult.label = originalLabel; } From 251c4b3834759cbe1bbebc87fb2a9cab6653e753 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 20 Jan 2025 13:41:39 +0200 Subject: [PATCH 12/16] removing confusing StreamUsageContext --- .../buildTransformationContext-test.ts | 3 +- src/transform/buildTransformationContext.ts | 27 +++---- src/transform/completeValue.ts | 42 +++++------ src/transform/transformResult.ts | 70 +++++++++---------- 4 files changed, 65 insertions(+), 77 deletions(-) diff --git a/src/transform/__tests__/buildTransformationContext-test.ts b/src/transform/__tests__/buildTransformationContext-test.ts index e062937d79..7669329092 100644 --- a/src/transform/__tests__/buildTransformationContext-test.ts +++ b/src/transform/__tests__/buildTransformationContext-test.ts @@ -35,8 +35,9 @@ describe('buildTransformationContext', () => { ); expect(context.originalDeferLabels instanceof Map).to.equal(true); + expect(context.originalStreamLabels instanceof Map).to.equal(true); expect(context.deferredGroupedFieldSets instanceof Map).to.equal(true); - expect(context.streamUsageMap instanceof Map).to.equal(true); + expect(context.streams instanceof Map).to.equal(true); expect(context.prefix).to.equal('__prefix__'); expect(context.pendingLabelsByPath instanceof Map).to.equal(true); expect(context.pendingResultsById instanceof Map).to.equal(true); diff --git a/src/transform/buildTransformationContext.ts b/src/transform/buildTransformationContext.ts index cc58a4ae76..7e07b9ae43 100644 --- a/src/transform/buildTransformationContext.ts +++ b/src/transform/buildTransformationContext.ts @@ -28,19 +28,15 @@ export interface Stream { path: Path; itemType: GraphQLOutputType; fieldDetailsList: ReadonlyArray; -} - -interface StreamUsageContext { - originalLabel: string | undefined; - streams: Set; nextIndex: number; } export interface TransformationContext { transformedArgs: ValidatedExecutionArgs; originalDeferLabels: Map; + originalStreamLabels: Map; deferredGroupedFieldSets: Map; - streamUsageMap: Map; + streams: Map; prefix: string; pendingResultsById: Map; pendingLabelsByPath: Map>; @@ -51,7 +47,7 @@ interface RequestTransformationContext { prefix: string; incrementalCounter: number; originalDeferLabels: Map; - streamUsageMap: Map; + originalStreamLabels: Map; } export function buildTransformationContext( @@ -64,7 +60,7 @@ export function buildTransformationContext( prefix, incrementalCounter: 0, originalDeferLabels: new Map(), - streamUsageMap: new Map(), + originalStreamLabels: new Map(), }; const transformedFragments = mapValue(fragments, (details) => ({ @@ -94,8 +90,9 @@ export function buildTransformationContext( return { transformedArgs, originalDeferLabels: context.originalDeferLabels, + originalStreamLabels: context.originalStreamLabels, deferredGroupedFieldSets: new Map(), - streamUsageMap: context.streamUsageMap, + streams: new Map(), prefix, pendingResultsById: new Map(), pendingLabelsByPath: new Map(), @@ -263,11 +260,7 @@ function transformMaybeStreamDirective( const originalLabel = value.value; const prefixedLabel = `${context.prefix}stream${context.incrementalCounter++}__${originalLabel}`; - context.streamUsageMap.set(prefixedLabel, { - originalLabel, - streams: new Set(), - nextIndex: 0, - }); + context.originalStreamLabels.set(prefixedLabel, originalLabel); newArgs.push({ ...arg, value: { @@ -283,11 +276,7 @@ function transformMaybeStreamDirective( if (!foundLabel) { const newLabel = `${context.prefix}stream${context.incrementalCounter++}`; - context.streamUsageMap.set(newLabel, { - originalLabel: undefined, - streams: new Set(), - nextIndex: 0, - }); + context.originalStreamLabels.set(newLabel, undefined); newArgs.push({ kind: Kind.ARGUMENT, name: { diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 8df9a77ff2..9a26a8dd58 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -230,41 +230,41 @@ function maybeAddStream( return; } - let stream; + let pendingStreamLabel; for (const fieldDetails of fieldDetailsList) { const directives = fieldDetails.node.directives; if (!directives) { continue; } - stream = directives.find( + const stream = directives.find( (directive) => directive.name.value === GraphQLStreamDirective.name, ); - if (stream != null) { - break; + if (stream == null) { + continue; } - } - if (stream == null) { - return; + const labelArg = stream.arguments?.find( + (arg) => arg.name.value === 'label', + ); + invariant(labelArg != null); + const labelValue = labelArg.value; + invariant(labelValue.kind === Kind.STRING); + const label = labelValue.value; + invariant(label != null); + const pendingLabels = context.pendingLabelsByPath.get( + pathToArray(path).join('.'), + ); + if (pendingLabels?.has(label)) { + pendingStreamLabel = label; + } } - const labelArg = stream.arguments?.find((arg) => arg.name.value === 'label'); - invariant(labelArg != null); - const labelValue = labelArg.value; - invariant(labelValue.kind === Kind.STRING); - const label = labelValue.value; - invariant(label != null); - const pendingLabels = context.pendingLabelsByPath.get( - pathToArray(path).join('.'), - ); - if (pendingLabels?.has(label)) { - const streamUsage = context.streamUsageMap.get(label); - invariant(streamUsage != null); - streamUsage.nextIndex = nextIndex; - streamUsage.streams.add({ + if (pendingStreamLabel != null) { + context.streams.set(pendingStreamLabel, { path, itemType, fieldDetailsList, + nextIndex, }); } } diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts index 57cef413dc..6381b7b18b 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -166,40 +166,38 @@ function processIncremental( const incremental: Array = []; for (const label of streamLabels) { - const streamUsageContext = context.streamUsageMap.get(label); - invariant(streamUsageContext != null); - const { originalLabel, nextIndex, streams } = streamUsageContext; - for (const stream of streams) { - const { path, itemType, fieldDetailsList } = stream; - const list = getObjectAtPath(context.mergedResult, pathToArray(path)); - invariant(Array.isArray(list)); - const items: Array = []; - const errors: Array = []; - for (let i = nextIndex; i < list.length; i++) { - const item = completeSubValue( - context, - errors, - itemType, - fieldDetailsList, - list[i], - addPath(path, i, undefined), - 1, - ); - items.push(item); - } - streamUsageContext.nextIndex = list.length; - const newIncrementalResult: LegacyIncrementalStreamResult = { - items, - path: [...pathToArray(path), nextIndex], - }; - if (errors.length > 0) { - newIncrementalResult.errors = errors; - } - if (originalLabel != null) { - newIncrementalResult.label = originalLabel; - } - incremental.push(newIncrementalResult); + const stream = context.streams.get(label); + invariant(stream != null); + const { path, itemType, fieldDetailsList, nextIndex } = stream; + const list = getObjectAtPath(context.mergedResult, pathToArray(path)); + invariant(Array.isArray(list)); + const items: Array = []; + const errors: Array = []; + for (let i = nextIndex; i < list.length; i++) { + const item = completeSubValue( + context, + errors, + itemType, + fieldDetailsList, + list[i], + addPath(path, i, undefined), + 1, + ); + items.push(item); + } + stream.nextIndex = list.length; + const newIncrementalResult: LegacyIncrementalStreamResult = { + items, + path: [...pathToArray(path), nextIndex], + }; + if (errors.length > 0) { + newIncrementalResult.errors = errors; + } + const originalLabel = context.originalStreamLabels.get(label); + if (originalLabel != null) { + newIncrementalResult.label = originalLabel; } + incremental.push(newIncrementalResult); } return incremental; } @@ -215,9 +213,9 @@ function processCompleted( const label = pendingResult.label; invariant(label != null); - const streamUsageContext = context.streamUsageMap.get(label); - if (streamUsageContext) { - context.streamUsageMap.delete(label); + const stream = context.streams.get(label); + if (stream) { + context.streams.delete(label); if ('errors' in completedResult) { const list = getObjectAtPath(context.mergedResult, pendingResult.path); invariant(Array.isArray(list)); From 8cd9da3b8fdb76788416befd821ba4b580851e16 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 20 Jan 2025 15:20:56 +0200 Subject: [PATCH 13/16] introduce complex logic to re-separate streams --- src/transform/__tests__/collectFields-test.ts | 4 + src/transform/__tests__/stream-test.ts | 70 ++++++++++ src/transform/buildTransformationContext.ts | 10 +- src/transform/collectFields.ts | 59 ++++---- src/transform/completeValue.ts | 131 ++++++++++-------- src/transform/groupedFieldSetFromTree.ts | 7 +- src/transform/transformResult.ts | 43 +++--- 7 files changed, 213 insertions(+), 111 deletions(-) diff --git a/src/transform/__tests__/collectFields-test.ts b/src/transform/__tests__/collectFields-test.ts index ab3b65208c..b5a9b08b02 100644 --- a/src/transform/__tests__/collectFields-test.ts +++ b/src/transform/__tests__/collectFields-test.ts @@ -69,6 +69,7 @@ describe('collectFields', () => { expect(groupedFieldSet.get('someField')).to.deep.equal([ { node: validatedExecutionArgs.operation.selectionSet.selections[0], + deferLabel: undefined, fragmentVariableValues: undefined, }, ]); @@ -151,6 +152,7 @@ describe('collectFields', () => { expect(groupedFieldSet.get('someField')).to.deep.equal([ { node: inlineFragment.selectionSet.selections[0], + deferLabel: undefined, fragmentVariableValues: undefined, }, ]); @@ -212,6 +214,7 @@ describe('collectFields', () => { expect(groupedFieldSet.get('someField')).to.deep.equal([ { node: inlineFragment.selectionSet.selections[0], + deferLabel: undefined, fragmentVariableValues: undefined, }, ]); @@ -261,6 +264,7 @@ describe('collectFields', () => { expect(nestedGroupedFieldSet.get('someField')).to.deep.equal([ { node: inlineFragment.selectionSet.selections[0], + deferLabel: undefined, fragmentVariableValues: undefined, }, ]); diff --git a/src/transform/__tests__/stream-test.ts b/src/transform/__tests__/stream-test.ts index 215616ca4c..5584926d93 100644 --- a/src/transform/__tests__/stream-test.ts +++ b/src/transform/__tests__/stream-test.ts @@ -1935,6 +1935,10 @@ describe('Execute: legacy stream directive', () => { }, { incremental: [ + { + items: [{ id: '1' }], + path: ['nestedObject', 'nestedFriendList', 0], + }, { items: [{ id: '1', name: 'Luke' }], path: ['nestedObject', 'nestedFriendList', 0], @@ -1944,6 +1948,72 @@ describe('Execute: legacy stream directive', () => { }, { incremental: [ + { + items: [{ id: '2' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + { + items: [{ id: '2', name: 'Han' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + ], + hasNext: true, + }, + { + hasNext: false, + }, + ]); + }); + it('Handles overlapping deferred and non-deferred streams with a non-zero initial count', async () => { + const document = parse(` + query { + nestedObject { + nestedFriendList @stream(initialCount: 1) { + id + } + } + nestedObject { + ... @defer { + nestedFriendList @stream(initialCount: 1) { + 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: [{ id: '1' }], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { nestedFriendList: [{ id: '1', name: 'Luke' }] }, + path: ['nestedObject'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, { items: [{ id: '2', name: 'Han' }], path: ['nestedObject', 'nestedFriendList', 1], diff --git a/src/transform/buildTransformationContext.ts b/src/transform/buildTransformationContext.ts index 7e07b9ae43..aa76b5f93a 100644 --- a/src/transform/buildTransformationContext.ts +++ b/src/transform/buildTransformationContext.ts @@ -18,16 +18,20 @@ import { import type { GraphQLOutputType } from '../type/index.js'; import { TypeNameMetaFieldDef } from '../type/introspection.js'; -import type { GroupedFieldSet } from '../execution/collectFields.js'; import type { ValidatedExecutionArgs } from '../execution/execute.js'; import type { PendingResult } from '../execution/types.js'; -import type { FieldDetails } from './collectFields.js'; +import type { FieldDetails, GroupedFieldSet } from './collectFields.js'; + +export interface OriginalStream { + originalLabel: string | undefined; + fieldDetailsList: ReadonlyArray; +} export interface Stream { path: Path; itemType: GraphQLOutputType; - fieldDetailsList: ReadonlyArray; + originalStreams: Array; nextIndex: number; } diff --git a/src/transform/collectFields.ts b/src/transform/collectFields.ts index 8d08d7851d..66b767b9a2 100644 --- a/src/transform/collectFields.ts +++ b/src/transform/collectFields.ts @@ -20,10 +20,7 @@ import { } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; -import type { - FragmentDetails, - GroupedFieldSet, -} from '../execution/collectFields.js'; +import type { FragmentDetails } from '../execution/collectFields.js'; import type { VariableValues } from '../execution/values.js'; import { getDirectiveValues, @@ -36,9 +33,12 @@ import type { TransformationContext } from './buildTransformationContext.js'; export interface FieldDetails { node: FieldNode; + deferLabel: string | undefined; fragmentVariableValues?: VariableValues | undefined; } +export type GroupedFieldSet = Map>; + interface CollectFieldsContext { deferredGroupedFieldSets: Map; schema: GraphQLSchema; @@ -117,6 +117,7 @@ export function collectSubfields( const deferredFragmentTree = new Map(); for (const fieldDetail of fieldDetailsList) { + const deferLabel = fieldDetail.deferLabel; const selectionSet = fieldDetail.node.selectionSet; if (selectionSet) { const { fragmentVariableValues } = fieldDetail; @@ -125,6 +126,7 @@ export function collectSubfields( selectionSet, groupedFieldSet, deferredFragmentTree, + deferLabel, fragmentVariableValues, ); } @@ -133,11 +135,13 @@ export function collectSubfields( return { groupedFieldSet, deferredFragmentTree }; } +// eslint-disable-next-line @typescript-eslint/max-params function collectFieldsImpl( context: CollectFieldsContext, selectionSet: SelectionSetNode, groupedFieldSet: AccumulatorMap, deferredFragmentTree: DeferredFragmentTree, + deferLabel?: string | undefined, fragmentVariableValues?: VariableValues, ): void { const { @@ -160,13 +164,14 @@ function collectFieldsImpl( } groupedFieldSet.add(getFieldEntryKey(selection), { node: selection, + deferLabel, fragmentVariableValues, }); break; } case Kind.INLINE_FRAGMENT: { - const deferLabel = isDeferred(selection); - if (deferLabel !== undefined) { + const newDeferLabel = isDeferred(selection); + if (newDeferLabel !== undefined) { const deferredGroupedFieldSet = new AccumulatorMap< string, FieldDetails @@ -180,9 +185,11 @@ function collectFieldsImpl( selection.selectionSet, deferredGroupedFieldSet, nestedDeferredFragmentTree, + newDeferLabel, + fragmentVariableValues, ); - deferredGroupedFieldSets.set(deferLabel, deferredGroupedFieldSet); - deferredFragmentTree.set(deferLabel, nestedDeferredFragmentTree); + deferredGroupedFieldSets.set(newDeferLabel, deferredGroupedFieldSet); + deferredFragmentTree.set(newDeferLabel, nestedDeferredFragmentTree); continue; } @@ -202,6 +209,7 @@ function collectFieldsImpl( selection.selectionSet, groupedFieldSet, deferredFragmentTree, + deferLabel, fragmentVariableValues, ); @@ -225,8 +233,20 @@ function collectFieldsImpl( continue; } - const deferLabel = isDeferred(selection); - if (deferLabel !== undefined) { + const fragmentVariableSignatures = fragment.variableSignatures; + let newFragmentVariableValues: VariableValues | undefined; + if (fragmentVariableSignatures) { + newFragmentVariableValues = getFragmentVariableValues( + selection, + fragmentVariableSignatures, + variableValues, + fragmentVariableValues, + hideSuggestions, + ); + } + + const newDeferLabel = isDeferred(selection); + if (newDeferLabel !== undefined) { const deferredGroupedFieldSet = new AccumulatorMap< string, FieldDetails @@ -240,30 +260,21 @@ function collectFieldsImpl( fragment.definition.selectionSet, deferredGroupedFieldSet, nestedDeferredFragmentTree, + newDeferLabel, + newFragmentVariableValues, ); - deferredGroupedFieldSets.set(deferLabel, deferredGroupedFieldSet); - deferredFragmentTree.set(deferLabel, nestedDeferredFragmentTree); + deferredGroupedFieldSets.set(newDeferLabel, deferredGroupedFieldSet); + deferredFragmentTree.set(newDeferLabel, nestedDeferredFragmentTree); 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, deferredFragmentTree, + deferLabel, newFragmentVariableValues, ); break; diff --git a/src/transform/completeValue.ts b/src/transform/completeValue.ts index 9a26a8dd58..dde083685c 100644 --- a/src/transform/completeValue.ts +++ b/src/transform/completeValue.ts @@ -1,3 +1,4 @@ +import { AccumulatorMap } from '../jsutils/AccumulatorMap.js'; import { invariant } from '../jsutils/invariant.js'; import { isObjectLike } from '../jsutils/isObjectLike.js'; import { memoize3 } from '../jsutils/memoize3.js'; @@ -21,10 +22,11 @@ import { } from '../type/definition.js'; import { GraphQLStreamDirective } from '../type/directives.js'; -import type { GroupedFieldSet } from '../execution/collectFields.js'; - -import type { TransformationContext } from './buildTransformationContext.js'; -import type { FieldDetails } from './collectFields.js'; +import type { + OriginalStream, + TransformationContext, +} from './buildTransformationContext.js'; +import type { FieldDetails, GroupedFieldSet } from './collectFields.js'; import { collectSubfields as _collectSubfields } from './collectFields.js'; import { groupedFieldSetFromTree } from './groupedFieldSetFromTree.js'; @@ -69,14 +71,13 @@ export function completeValue( } // eslint-disable-next-line @typescript-eslint/max-params -export function completeSubValue( +function completeSubValue( context: TransformationContext, errors: Array, returnType: GraphQLOutputType, fieldDetailsList: ReadonlyArray, result: unknown, path: Path, - listDepth = 0, ): unknown { if (isNonNullType(returnType)) { return completeSubValue( @@ -106,15 +107,21 @@ export function completeSubValue( if (isListType(returnType)) { invariant(Array.isArray(result)); - return completeListValue( + + const itemType = returnType.ofType; + + const completed = completeListValue( context, errors, - returnType.ofType, + itemType, fieldDetailsList, result, path, - listDepth, ); + + maybeAddStream(context, itemType, fieldDetailsList, path, result.length); + + return completed; } invariant(isObjectLike(result)); @@ -181,18 +188,18 @@ function completeObjectValue( } // eslint-disable-next-line @typescript-eslint/max-params -function completeListValue( +export function completeListValue( context: TransformationContext, errors: Array, itemType: GraphQLOutputType, fieldDetailsList: ReadonlyArray, result: Array, path: Path, - listDepth: number, + initialIndex = 0, ): Array { const completedItems = []; - for (let index = 0; index < result.length; index++) { + for (let index = initialIndex; index < result.length; index++) { const completed = completeSubValue( context, errors, @@ -200,71 +207,81 @@ function completeListValue( fieldDetailsList, result[index], addPath(path, index, undefined), - listDepth + 1, ); completedItems.push(completed); } - maybeAddStream( - context, - itemType, - fieldDetailsList, - listDepth, - path, - result.length, - ); - return completedItems; } -// eslint-disable-next-line @typescript-eslint/max-params function maybeAddStream( context: TransformationContext, itemType: GraphQLOutputType, fieldDetailsList: ReadonlyArray, - listDepth: number, path: Path, nextIndex: number, ): void { - if (listDepth > 0) { + const pendingLabels = context.pendingLabelsByPath.get( + pathToArray(path).join('.'), + ); + if (pendingLabels == null) { return; } - - let pendingStreamLabel; + const pendingLabel = pendingLabels.values().next().value; + invariant(pendingLabel != null); + + const streamLabelByDeferLabel = new Map< + string | undefined, + string | undefined + >(); + const fieldDetailsListByDeferLabel = new AccumulatorMap< + string | undefined, + FieldDetails + >(); for (const fieldDetails of fieldDetailsList) { const directives = fieldDetails.node.directives; - if (!directives) { - continue; - } - const stream = directives.find( - (directive) => directive.name.value === GraphQLStreamDirective.name, - ); - if (stream == null) { - continue; - } - - const labelArg = stream.arguments?.find( - (arg) => arg.name.value === 'label', - ); - invariant(labelArg != null); - const labelValue = labelArg.value; - invariant(labelValue.kind === Kind.STRING); - const label = labelValue.value; - invariant(label != null); - const pendingLabels = context.pendingLabelsByPath.get( - pathToArray(path).join('.'), - ); - if (pendingLabels?.has(label)) { - pendingStreamLabel = label; + if (directives) { + const stream = directives.find( + (directive) => directive.name.value === GraphQLStreamDirective.name, + ); + if (stream != null) { + const labelArg = stream.arguments?.find( + (arg) => arg.name.value === 'label', + ); + invariant(labelArg != null); + const labelValue = labelArg.value; + invariant(labelValue.kind === Kind.STRING); + const label = labelValue.value; + const originalLabel = context.originalStreamLabels.get(label); + const deferLabel = fieldDetails.deferLabel; + streamLabelByDeferLabel.set(deferLabel, originalLabel); + fieldDetailsListByDeferLabel.add(deferLabel, fieldDetails); + } } } - if (pendingStreamLabel != null) { - context.streams.set(pendingStreamLabel, { - path, - itemType, - fieldDetailsList, - nextIndex, - }); + if (fieldDetailsListByDeferLabel.size > 0) { + const originalStreams: Array = []; + for (const [ + deferLabel, + fieldDetailsListForDeferLabel, + ] of fieldDetailsListByDeferLabel) { + const originalLabel = streamLabelByDeferLabel.get(deferLabel); + originalStreams.push({ + originalLabel, + fieldDetailsList: fieldDetailsListForDeferLabel, + }); + } + const streamsForPendingLabel = context.streams.get(pendingLabel); + if (streamsForPendingLabel == null) { + context.streams.set(pendingLabel, { + path, + itemType, + originalStreams, + nextIndex, + }); + } else { + streamsForPendingLabel.originalStreams.push(...originalStreams); + } } } diff --git a/src/transform/groupedFieldSetFromTree.ts b/src/transform/groupedFieldSetFromTree.ts index ed84312eea..56c2ef0f98 100644 --- a/src/transform/groupedFieldSetFromTree.ts +++ b/src/transform/groupedFieldSetFromTree.ts @@ -3,13 +3,12 @@ import { invariant } from '../jsutils/invariant.js'; import type { Path } from '../jsutils/Path.js'; import { pathToArray } from '../jsutils/Path.js'; +import type { TransformationContext } from './buildTransformationContext.js'; import type { + DeferredFragmentTree, FieldDetails, GroupedFieldSet, -} from '../execution/collectFields.js'; - -import type { TransformationContext } from './buildTransformationContext.js'; -import type { DeferredFragmentTree } from './collectFields.js'; +} from './collectFields.js'; export function groupedFieldSetFromTree( context: TransformationContext, diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts index 6381b7b18b..89c6364540 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -21,7 +21,7 @@ import type { import type { TransformationContext } from './buildTransformationContext.js'; import { collectFields } from './collectFields.js'; -import { completeSubValue, completeValue } from './completeValue.js'; +import { completeListValue, completeValue } from './completeValue.js'; import { embedErrors } from './embedErrors.js'; import { getObjectAtPath } from './getObjectAtPath.js'; import { groupedFieldSetFromTree } from './groupedFieldSetFromTree.js'; @@ -168,36 +168,33 @@ function processIncremental( for (const label of streamLabels) { const stream = context.streams.get(label); invariant(stream != null); - const { path, itemType, fieldDetailsList, nextIndex } = stream; + const { path, itemType, originalStreams, nextIndex } = stream; const list = getObjectAtPath(context.mergedResult, pathToArray(path)); invariant(Array.isArray(list)); - const items: Array = []; - const errors: Array = []; - for (let i = nextIndex; i < list.length; i++) { - const item = completeSubValue( + for (const { originalLabel, fieldDetailsList } of originalStreams) { + const errors: Array = []; + const items = completeListValue( context, errors, itemType, fieldDetailsList, - list[i], - addPath(path, i, undefined), - 1, + list, + path, + nextIndex, ); - items.push(item); - } - stream.nextIndex = list.length; - const newIncrementalResult: LegacyIncrementalStreamResult = { - items, - path: [...pathToArray(path), nextIndex], - }; - if (errors.length > 0) { - newIncrementalResult.errors = errors; - } - const originalLabel = context.originalStreamLabels.get(label); - if (originalLabel != null) { - newIncrementalResult.label = originalLabel; + stream.nextIndex = list.length; + const newIncrementalResult: LegacyIncrementalStreamResult = { + items, + path: [...pathToArray(path), nextIndex], + }; + if (errors.length > 0) { + newIncrementalResult.errors = errors; + } + if (originalLabel != null) { + newIncrementalResult.label = originalLabel; + } + incremental.push(newIncrementalResult); } - incremental.push(newIncrementalResult); } return incremental; } From 0a8e4764e5c10321b90375ad3ed73b06a5252706 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 21 Jan 2025 00:33:08 +0200 Subject: [PATCH 14/16] add additional test --- src/transform/__tests__/stream-test.ts | 75 ++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/transform/__tests__/stream-test.ts b/src/transform/__tests__/stream-test.ts index 5584926d93..7417192955 100644 --- a/src/transform/__tests__/stream-test.ts +++ b/src/transform/__tests__/stream-test.ts @@ -2026,6 +2026,81 @@ describe('Execute: legacy stream directive', () => { }, ]); }); + it('Handles overlapping deferred and non-deferred streams with a non-zero initial count with a slow defer', async () => { + const document = parse(` + query { + nestedObject { + nestedFriendList @stream(initialCount: 1) { + id + } + } + nestedObject { + ... @defer { + nestedFriendList @stream(initialCount: 1) { + id + name + } + } + } + } + `); + const result = await complete(document, { + nestedObject: { + async *nestedFriendList() { + yield await Promise.resolve({ + ...friends[0], + name: Promise.resolve(friends[0].name), + }); + yield await Promise.resolve(friends[1]); + yield await Promise.resolve(friends[2]); + }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + nestedObject: { + nestedFriendList: [{ id: '1' }], + }, + }, + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + path: ['nestedObject', 'nestedFriendList', 1], + }, + { + data: { + nestedFriendList: [ + { id: '1', name: 'Luke' }, + { id: '2', name: 'Han' }, + ], + }, + path: ['nestedObject'], + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3' }], + path: ['nestedObject', 'nestedFriendList', 2], + }, + { + items: [{ id: '3', name: 'Leia' }], + path: ['nestedObject', 'nestedFriendList', 2], + }, + ], + 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(); From 19bda889667bf1a08b887a5e5909ca9ea7d47419 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 21 Jan 2025 00:36:20 +0200 Subject: [PATCH 15/16] lint --- src/transform/collectFields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transform/collectFields.ts b/src/transform/collectFields.ts index 66b767b9a2..d3f90d918c 100644 --- a/src/transform/collectFields.ts +++ b/src/transform/collectFields.ts @@ -141,7 +141,7 @@ function collectFieldsImpl( selectionSet: SelectionSetNode, groupedFieldSet: AccumulatorMap, deferredFragmentTree: DeferredFragmentTree, - deferLabel?: string | undefined, + deferLabel?: string, fragmentVariableValues?: VariableValues, ): void { const { From 6099c10d9fab8826247e5fcbcf964f0519fed26d Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 21 Jan 2025 00:45:26 +0200 Subject: [PATCH 16/16] add some dry to clean-up --- src/transform/transformResult.ts | 33 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/transform/transformResult.ts b/src/transform/transformResult.ts index 89c6364540..590c6bb358 100644 --- a/src/transform/transformResult.ts +++ b/src/transform/transformResult.ts @@ -224,14 +224,7 @@ function processCompleted( 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); - } + deletePendingResult(context, pendingResult, label); continue; } @@ -283,18 +276,26 @@ function processCompleted( 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); - } + deletePendingResult(context, pendingResult, label); } return incremental; } +function deletePendingResult( + context: TransformationContext, + pendingResult: PendingResult, + label: string, +): void { + context.pendingResultsById.delete(pendingResult.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); + } +} + function transformInitialResult< T extends ExecutionResult | InitialIncrementalExecutionResult, >(context: TransformationContext, result: T): T {