Skip to content

Commit 8e01e23

Browse files
yaacovCRrobrichardlilianammmatosglasserCito
committed
feat(execution): add IncrementalExecutor with defer/stream validation and exports
Co-authored-by: Rob Richard <rob@1stdibs.com> Co-authored-by: Liliana Matos <liliana@1stdibs.com> Co-authored-by: David Glasser <glasser@davidglasser.net> Co-authored-by: Christoph Zwerschke <cito@online.de> Co-authored-by: Jovi De Croock <decroockjovi@gmail.com> Co-authored-by: Jerel Miller <jerelmiller@gmail.com>
1 parent 881571e commit 8e01e23

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+13864
-214
lines changed

src/execution/Executor.ts

Lines changed: 244 additions & 87 deletions
Large diffs are not rendered by default.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { invariant } from '../jsutils/invariant.js';
2+
import type { ObjMap } from '../jsutils/ObjMap.js';
3+
import type { Path } from '../jsutils/Path.js';
4+
import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js';
5+
6+
import { OperationTypeNode } from '../language/ast.js';
7+
8+
import type {
9+
GraphQLList,
10+
GraphQLObjectType,
11+
GraphQLOutputType,
12+
GraphQLResolveInfo,
13+
} from '../type/index.js';
14+
15+
import type {
16+
DeferUsage,
17+
FieldDetailsList,
18+
GroupedFieldSet,
19+
} from './collectFields.js';
20+
import { Executor, getStreamUsage } from './Executor.js';
21+
22+
const UNEXPECTED_MULTIPLE_PAYLOADS =
23+
'Executing this GraphQL operation would unexpectedly produce multiple payloads (due to @defer or @stream directive)';
24+
25+
/** @internal */
26+
export class ExecutorThrowingOnIncremental extends Executor {
27+
override executeCollectedRootFields(
28+
operation: OperationTypeNode,
29+
rootType: GraphQLObjectType,
30+
rootValue: unknown,
31+
originalGroupedFieldSet: GroupedFieldSet,
32+
newDeferUsages: ReadonlyArray<DeferUsage>,
33+
): PromiseOrValue<ObjMap<unknown>> {
34+
if (newDeferUsages.length > 0) {
35+
invariant(
36+
this.validatedExecutionArgs.operation.operation !==
37+
OperationTypeNode.SUBSCRIPTION,
38+
'`@defer` directive not supported on subscription operations. Disable `@defer` by setting the `if` argument to `false`.',
39+
);
40+
const reason = new Error(UNEXPECTED_MULTIPLE_PAYLOADS);
41+
this.cancel(reason);
42+
throw reason;
43+
}
44+
return this.executeRootGroupedFieldSet(
45+
operation,
46+
rootType,
47+
rootValue,
48+
originalGroupedFieldSet,
49+
undefined,
50+
);
51+
}
52+
53+
override executeCollectedSubfields(
54+
parentType: GraphQLObjectType,
55+
sourceValue: unknown,
56+
path: Path | undefined,
57+
originalGroupedFieldSet: GroupedFieldSet,
58+
newDeferUsages: ReadonlyArray<DeferUsage>,
59+
): PromiseOrValue<ObjMap<unknown>> {
60+
if (newDeferUsages.length > 0) {
61+
invariant(
62+
this.validatedExecutionArgs.operation.operation !==
63+
OperationTypeNode.SUBSCRIPTION,
64+
'`@defer` directive not supported on subscription operations. Disable `@defer` by setting the `if` argument to `false`.',
65+
);
66+
const reason = new Error(UNEXPECTED_MULTIPLE_PAYLOADS);
67+
this.cancel(reason);
68+
throw reason;
69+
}
70+
71+
return this.executeFields(
72+
parentType,
73+
sourceValue,
74+
path,
75+
originalGroupedFieldSet,
76+
undefined,
77+
);
78+
}
79+
80+
// eslint-disable-next-line max-params
81+
override completeListValue(
82+
returnType: GraphQLList<GraphQLOutputType>,
83+
fieldDetailsList: FieldDetailsList,
84+
info: GraphQLResolveInfo,
85+
path: Path,
86+
result: unknown,
87+
positionContext: undefined,
88+
): PromiseOrValue<ReadonlyArray<unknown>> {
89+
const streamUsage = getStreamUsage(
90+
this.validatedExecutionArgs,
91+
fieldDetailsList,
92+
);
93+
if (streamUsage !== undefined) {
94+
invariant(
95+
this.validatedExecutionArgs.operation.operation !==
96+
OperationTypeNode.SUBSCRIPTION,
97+
'`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.',
98+
);
99+
100+
const reason = new Error(UNEXPECTED_MULTIPLE_PAYLOADS);
101+
this.cancel(reason);
102+
throw reason;
103+
}
104+
105+
return super.completeListValue(
106+
returnType,
107+
fieldDetailsList,
108+
info,
109+
path,
110+
result,
111+
positionContext,
112+
);
113+
}
114+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { invariant } from '../../jsutils/invariant.js';
5+
6+
import { parse } from '../../language/parser.js';
7+
8+
import { buildSchema } from '../../utilities/buildASTSchema.js';
9+
10+
import { collectFields } from '../collectFields.js';
11+
import { validateExecutionArgs } from '../execute.js';
12+
13+
const schema = buildSchema(`
14+
type Query {
15+
field: String
16+
}
17+
`);
18+
19+
function collectRootFields(query: string) {
20+
const validatedExecutionArgs = validateExecutionArgs({
21+
schema,
22+
document: parse(query),
23+
});
24+
25+
invariant('operation' in validatedExecutionArgs);
26+
27+
const { operation, fragments, variableValues } = validatedExecutionArgs;
28+
29+
const queryType = schema.getQueryType();
30+
31+
invariant(queryType != null);
32+
33+
return collectFields(
34+
schema,
35+
fragments,
36+
variableValues,
37+
queryType,
38+
operation.selectionSet,
39+
false,
40+
);
41+
}
42+
43+
describe('collectFields', () => {
44+
describe('overlapping fragment spreads', () => {
45+
it('should not collect a deferred spread after a non-deferred spread has been collected', () => {
46+
const { newDeferUsages } = collectRootFields(`
47+
query {
48+
...FragmentName
49+
...FragmentName @defer
50+
}
51+
fragment FragmentName on Query {
52+
field
53+
}
54+
`);
55+
56+
expect(newDeferUsages).to.have.lengthOf(0);
57+
});
58+
59+
it('should not collect a deferred spread after a deferred spread has been collected', () => {
60+
const { newDeferUsages } = collectRootFields(`
61+
query {
62+
...FragmentName @defer
63+
...FragmentName @defer
64+
}
65+
fragment FragmentName on Query {
66+
field
67+
}
68+
`);
69+
70+
expect(newDeferUsages).to.have.lengthOf(1);
71+
});
72+
73+
it('should collect a non-deferred spread after a deferred spread has been collected', () => {
74+
const { groupedFieldSet } = collectRootFields(`
75+
query {
76+
...FragmentName @defer
77+
...FragmentName
78+
}
79+
fragment FragmentName on Query {
80+
field
81+
}
82+
`);
83+
84+
const fieldDetailsList = groupedFieldSet.get('field');
85+
86+
invariant(fieldDetailsList != null);
87+
88+
expect(fieldDetailsList).to.have.lengthOf(2);
89+
});
90+
91+
it('should not collect a non-deferred spread after a non-deferred spread has been collected', () => {
92+
const { groupedFieldSet } = collectRootFields(`
93+
query {
94+
...FragmentName
95+
...FragmentName
96+
}
97+
fragment FragmentName on Query {
98+
field
99+
}
100+
`);
101+
102+
const fieldDetailsList = groupedFieldSet.get('field');
103+
104+
invariant(fieldDetailsList != null);
105+
106+
expect(fieldDetailsList).to.have.lengthOf(1);
107+
});
108+
});
109+
});

src/execution/__tests__/executor-test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { assert, expect } from 'chai';
22
import { describe, it } from 'mocha';
33

4+
import { expectEqualPromisesOrValues } from '../../__testUtils__/expectEqualPromisesOrValues.js';
45
import { expectJSON } from '../../__testUtils__/expectJSON.js';
56
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
67

78
import { inspect } from '../../jsutils/inspect.js';
9+
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
810
import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js';
911

1012
import type { FieldNode } from '../../language/ast.js';
@@ -21,6 +23,7 @@ import {
2123
GraphQLScalarType,
2224
GraphQLUnionType,
2325
} from '../../type/definition.js';
26+
import { GraphQLStreamDirective } from '../../type/directives.js';
2427
import {
2528
GraphQLBoolean,
2629
GraphQLInt,
@@ -29,8 +32,32 @@ import {
2932
import { GraphQLSchema } from '../../type/schema.js';
3033

3134
import type { FieldDetailsList } from '../collectFields.js';
32-
import { execute, executeSync, validateExecutionArgs } from '../execute.js';
33-
import { collectSubfields } from '../Executor.js';
35+
import type { ExecutionArgs } from '../execute.js';
36+
import {
37+
execute as executeThrowingOnIncremental,
38+
executeIgnoringIncremental,
39+
executeSync as executeSyncWrappingThrowingOnIncremental,
40+
experimentalExecuteIncrementally,
41+
validateExecutionArgs,
42+
} from '../execute.js';
43+
import type { ExecutionResult } from '../Executor.js';
44+
import { collectSubfields, getStreamUsage } from '../Executor.js';
45+
46+
function execute(args: ExecutionArgs): PromiseOrValue<ExecutionResult> {
47+
return expectEqualPromisesOrValues([
48+
executeThrowingOnIncremental(args),
49+
executeIgnoringIncremental(args),
50+
experimentalExecuteIncrementally(args),
51+
]) as PromiseOrValue<ExecutionResult>;
52+
}
53+
54+
function executeSync(args: ExecutionArgs): ExecutionResult {
55+
return expectEqualPromisesOrValues([
56+
executeSyncWrappingThrowingOnIncremental(args),
57+
executeIgnoringIncremental(args),
58+
experimentalExecuteIncrementally(args),
59+
]) as ExecutionResult;
60+
}
3461

3562
describe('Execute: Handles basic execution tasks', () => {
3663
it('executes arbitrary code', async () => {
@@ -1483,4 +1510,45 @@ describe('Execute: Handles basic execution tasks', () => {
14831510

14841511
expect(third).to.not.equal(first);
14851512
});
1513+
1514+
it('memoizes getStreamUsage results', () => {
1515+
const itemType = new GraphQLObjectType({
1516+
name: 'Item',
1517+
fields: {
1518+
id: { type: GraphQLString },
1519+
},
1520+
});
1521+
const schema = new GraphQLSchema({
1522+
query: new GraphQLObjectType({
1523+
name: 'Query',
1524+
fields: {
1525+
items: { type: new GraphQLList(itemType) },
1526+
},
1527+
}),
1528+
directives: [GraphQLStreamDirective],
1529+
});
1530+
const document = parse('{ items @stream(initialCount: 1) { id } }');
1531+
const validatedExecutionArgs = validateExecutionArgs({
1532+
schema,
1533+
document,
1534+
});
1535+
1536+
assert('schema' in validatedExecutionArgs);
1537+
1538+
const operation = validatedExecutionArgs.operation;
1539+
const node = operation.selectionSet.selections[0] as FieldNode;
1540+
1541+
const fieldDetailsList = [{ node }];
1542+
const first = getStreamUsage(validatedExecutionArgs, fieldDetailsList);
1543+
1544+
expect(first).to.not.equal(undefined);
1545+
1546+
const second = getStreamUsage(validatedExecutionArgs, fieldDetailsList);
1547+
1548+
expect(second).to.equal(first);
1549+
1550+
const third = getStreamUsage(validatedExecutionArgs, [{ node }]);
1551+
1552+
expect(third).to.not.equal(first);
1553+
});
14861554
});

0 commit comments

Comments
 (0)