Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

incremental: add helpers to resolvers #3911

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
50 changes: 49 additions & 1 deletion src/execution/IncrementalPublisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,12 +663,16 @@ export class IncrementalPublisher {
}

this._introduce(subsequentResultRecord);
subsequentResultRecord.publish();
return;
}

if (subsequentResultRecord._pending.size === 0) {
this._push(subsequentResultRecord);
} else {
for (const deferredGroupedFieldSetRecord of subsequentResultRecord.deferredGroupedFieldSetRecords) {
deferredGroupedFieldSetRecord.publish();
}
this._introduce(subsequentResultRecord);
}
}
Expand Down Expand Up @@ -749,33 +753,56 @@ function isStreamItemsRecord(
export class InitialResultRecord {
errors: Array<GraphQLError>;
children: Set<SubsequentResultRecord>;
priority: number;
deferPriority: number;
published: true;
constructor() {
this.errors = [];
this.children = new Set();
this.priority = 0;
this.deferPriority = 0;
this.published = true;
}
}

/** @internal */
export class DeferredGroupedFieldSetRecord {
path: ReadonlyArray<string | number>;
priority: number;
deferPriority: number;
deferredFragmentRecords: ReadonlyArray<DeferredFragmentRecord>;
groupedFieldSet: GroupedFieldSet;
shouldInitiateDefer: boolean;
errors: Array<GraphQLError>;
data: ObjMap<unknown> | undefined;
published: true | Promise<void>;
publish: () => void;
sent: boolean;

constructor(opts: {
path: Path | undefined;
priority: number;
deferPriority: number;
deferredFragmentRecords: ReadonlyArray<DeferredFragmentRecord>;
groupedFieldSet: GroupedFieldSet;
shouldInitiateDefer: boolean;
}) {
this.path = pathToArray(opts.path);
this.priority = opts.priority;
this.deferPriority = opts.deferPriority;
this.deferredFragmentRecords = opts.deferredFragmentRecords;
this.groupedFieldSet = opts.groupedFieldSet;
this.shouldInitiateDefer = opts.shouldInitiateDefer;
this.errors = [];
// promiseWithResolvers uses void only as a generic type parameter
// see: https://typescript-eslint.io/rules/no-invalid-void-type/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const { promise: published, resolve } = promiseWithResolvers<void>();
this.published = published;
this.publish = () => {
resolve();
this.published = true;
};
this.sent = false;
}
}
Expand Down Expand Up @@ -828,20 +855,41 @@ export class StreamItemsRecord {
errors: Array<GraphQLError>;
streamRecord: StreamRecord;
path: ReadonlyArray<string | number>;
priority: number;
deferPriority: number;
items: Array<unknown> | undefined;
children: Set<SubsequentResultRecord>;
isFinalRecord?: boolean;
isCompletedAsyncIterator?: boolean;
isCompleted: boolean;
filtered: boolean;
published: true | Promise<void>;
publish: () => void;
sent: boolean;

constructor(opts: { streamRecord: StreamRecord; path: Path | undefined }) {
constructor(opts: {
streamRecord: StreamRecord;
path: Path | undefined;
priority: number;
}) {
this.streamRecord = opts.streamRecord;
this.path = pathToArray(opts.path);
this.priority = opts.priority;
this.deferPriority = 0;
this.children = new Set();
this.errors = [];
this.isCompleted = false;
this.filtered = false;
// promiseWithResolvers uses void only as a generic type parameter
// see: https://typescript-eslint.io/rules/no-invalid-void-type/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const { promise: published, resolve } = promiseWithResolvers<void>();
this.published = published;
this.publish = () => {
resolve();
this.published = true;
};
this.sent = false;
}
}

Expand Down
174 changes: 173 additions & 1 deletion src/execution/__tests__/defer-test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { expect } from 'chai';
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 { isPromise } from '../../jsutils/isPromise.js';

import type { DocumentNode } from '../../language/ast.js';
import { Kind } from '../../language/kinds.js';
import { parse } from '../../language/parser.js';

import type { FieldDetails } from '../../type/definition.js';
import {
GraphQLList,
GraphQLNonNull,
Expand Down Expand Up @@ -216,6 +220,174 @@ describe('Execute: defer directive', () => {
},
});
});
it('Can provides correct info about deferred execution state when resolver could defer', async () => {
let fieldDetails: ReadonlyArray<FieldDetails> | undefined;
let deferPriority;
let published;
let resumed;

const SomeType = new GraphQLObjectType({
name: 'SomeType',
fields: {
someField: {
type: GraphQLString,
resolve: () => Promise.resolve('someField'),
},
deferredField: {
type: GraphQLString,
resolve: async (_parent, _args, _context, info) => {
fieldDetails = info.fieldDetails;
deferPriority = info.deferPriority;
published = info.published;
await published;
resumed = true;
},
},
},
});

const someSchema = new GraphQLSchema({ query: SomeType });

const document = parse(`
query {
someField
... @defer {
deferredField
}
}
`);

const operation = document.definitions[0];
assert(operation.kind === Kind.OPERATION_DEFINITION);
const fragment = operation.selectionSet.selections[1];
assert(fragment.kind === Kind.INLINE_FRAGMENT);
const field = fragment.selectionSet.selections[0];

const result = experimentalExecuteIncrementally({
schema: someSchema,
document,
});

expect(fieldDetails).to.equal(undefined);
expect(deferPriority).to.equal(undefined);
expect(published).to.equal(undefined);
expect(resumed).to.equal(undefined);

const initialPayload = await result;
assert('initialResult' in initialPayload);
const iterator = initialPayload.subsequentResults[Symbol.asyncIterator]();
await iterator.next();

assert(fieldDetails !== undefined);
expect(fieldDetails[0].node).to.equal(field);
expect(fieldDetails[0].target?.deferPriority).to.equal(1);
expect(deferPriority).to.equal(1);
expect(isPromise(published)).to.equal(true);
expect(resumed).to.equal(true);
});
it('Can provides correct info about deferred execution state when deferred field is masked by non-deferred field', async () => {
let fieldDetails: ReadonlyArray<FieldDetails> | undefined;
let deferPriority;
let published;

const SomeType = new GraphQLObjectType({
name: 'SomeType',
fields: {
someField: {
type: GraphQLString,
resolve: (_parent, _args, _context, info) => {
fieldDetails = info.fieldDetails;
deferPriority = info.deferPriority;
published = info.published;
return 'someField';
},
},
},
});

const someSchema = new GraphQLSchema({ query: SomeType });

const document = parse(`
query {
someField
... @defer {
someField
}
}
`);

const operation = document.definitions[0];
assert(operation.kind === Kind.OPERATION_DEFINITION);
const node1 = operation.selectionSet.selections[0];
const fragment = operation.selectionSet.selections[1];
assert(fragment.kind === Kind.INLINE_FRAGMENT);
const node2 = fragment.selectionSet.selections[0];

const result = experimentalExecuteIncrementally({
schema: someSchema,
document,
});

const initialPayload = await result;
assert('initialResult' in initialPayload);
expect(initialPayload.initialResult).to.deep.equal({
data: {
someField: 'someField',
},
pending: [{ id: '0', path: [] }],
hasNext: true,
});

assert(fieldDetails !== undefined);
expect(fieldDetails[0].node).to.equal(node1);
expect(fieldDetails[0].target).to.equal(undefined);
expect(fieldDetails[1].node).to.equal(node2);
expect(fieldDetails[1].target?.deferPriority).to.equal(1);
expect(deferPriority).to.equal(0);
expect(published).to.equal(true);
});
it('Can provides correct info about deferred execution state when resolver need not defer', async () => {
let deferPriority;
let published;
const SomeType = new GraphQLObjectType({
name: 'SomeType',
fields: {
deferredField: {
type: GraphQLString,
resolve: (_parent, _args, _context, info) => {
deferPriority = info.deferPriority;
published = info.published;
},
},
},
});

const someSchema = new GraphQLSchema({ query: SomeType });

const document = parse(`
query {
... @defer {
deferredField
}
}
`);

const result = experimentalExecuteIncrementally({
schema: someSchema,
document,
});

expect(deferPriority).to.equal(undefined);
expect(published).to.equal(undefined);

const initialPayload = await result;
assert('initialResult' in initialPayload);
const iterator = initialPayload.subsequentResults[Symbol.asyncIterator]();
await iterator.next();

expect(deferPriority).to.equal(1);
expect(published).to.equal(true);
});
it('Does not disable defer with null if argument', async () => {
const document = parse(`
query HeroNameQuery($shouldDefer: Boolean) {
Expand Down
22 changes: 18 additions & 4 deletions src/execution/__tests__/executor-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { inspect } from '../../jsutils/inspect.js';
import { Kind } from '../../language/kinds.js';
import { parse } from '../../language/parser.js';

import type { GraphQLResolveInfo } from '../../type/definition.js';
import {
GraphQLInterfaceType,
GraphQLList,
Expand Down Expand Up @@ -191,7 +192,7 @@ describe('Execute: Handles basic execution tasks', () => {
});

it('provides info about current execution state', () => {
let resolvedInfo;
let resolvedInfo: GraphQLResolveInfo | undefined;
const testType = new GraphQLObjectType({
name: 'Test',
fields: {
Expand All @@ -213,7 +214,7 @@ describe('Execute: Handles basic execution tasks', () => {

expect(resolvedInfo).to.have.all.keys(
'fieldName',
'fieldNodes',
'fieldDetails',
'returnType',
'parentType',
'path',
Expand All @@ -222,6 +223,9 @@ describe('Execute: Handles basic execution tasks', () => {
'rootValue',
'operation',
'variableValues',
'priority',
'deferPriority',
'published',
);

const operation = document.definitions[0];
Expand All @@ -234,14 +238,24 @@ describe('Execute: Handles basic execution tasks', () => {
schema,
rootValue,
operation,
priority: 0,
deferPriority: 0,
published: true,
});

const field = operation.selectionSet.selections[0];
expect(resolvedInfo).to.deep.include({
fieldNodes: [field],
path: { prev: undefined, key: 'result', typename: 'Test' },
variableValues: { var: 'abc' },
});

const fieldDetails = resolvedInfo?.fieldDetails;
assert(fieldDetails !== undefined);

const field = operation.selectionSet.selections[0];
expect(fieldDetails[0]).to.deep.include({
node: field,
target: undefined,
});
});

it('populates path correctly with complex types', () => {
Expand Down