Skip to content

Commit 431dc8b

Browse files
authored
add abrupt return handling for sync iterables (#4533)
in parallel to handling for async iterables
1 parent ecd9046 commit 431dc8b

2 files changed

Lines changed: 197 additions & 59 deletions

File tree

src/execution/__tests__/lists-test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,123 @@ describe('Execute: Accepts any iterable as list value', () => {
7878
});
7979
});
8080

81+
describe('Execute: Handles abrupt completion in synchronous iterables', () => {
82+
function complete(rootValue: unknown, as: string = '[String]') {
83+
return execute({
84+
schema: buildSchema(`type Query { listField: ${as} }`),
85+
document: parse('{ listField }'),
86+
rootValue,
87+
});
88+
}
89+
90+
it('closes the iterator when `next` throws', async () => {
91+
let returned = false;
92+
let nextCalls = 0;
93+
94+
const listField: IterableIterator<string> = {
95+
[Symbol.iterator](): IterableIterator<string> {
96+
return this;
97+
},
98+
next(): IteratorResult<string> {
99+
nextCalls++;
100+
if (nextCalls === 1) {
101+
return { done: false, value: 'ok' };
102+
}
103+
throw new Error('bad');
104+
},
105+
return(): IteratorResult<string> {
106+
returned = true;
107+
return { done: true, value: undefined };
108+
},
109+
};
110+
111+
expectJSON(await complete({ listField })).toDeepEqual({
112+
data: { listField: null },
113+
errors: [
114+
{
115+
message: 'bad',
116+
locations: [{ line: 1, column: 3 }],
117+
path: ['listField'],
118+
},
119+
],
120+
});
121+
expect(nextCalls).to.equal(2);
122+
expect(returned).to.equal(true);
123+
});
124+
125+
it('closes the iterator when a null bubbles up from a non-null item', async () => {
126+
const values = [1, null, 2];
127+
let index = 0;
128+
let returned = false;
129+
130+
const listField: IterableIterator<number | null> = {
131+
[Symbol.iterator](): IterableIterator<number | null> {
132+
return this;
133+
},
134+
next(): IteratorResult<number | null> {
135+
const value = values[index++];
136+
if (value === undefined) {
137+
return { done: true, value: undefined };
138+
}
139+
return { done: false, value };
140+
},
141+
return(): IteratorResult<number | null> {
142+
returned = true;
143+
return { done: true, value: undefined };
144+
},
145+
};
146+
147+
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
148+
data: { listField: null },
149+
errors: [
150+
{
151+
message: 'Cannot return null for non-nullable field Query.listField.',
152+
locations: [{ line: 1, column: 3 }],
153+
path: ['listField', 1],
154+
},
155+
],
156+
});
157+
expect(index).to.equal(2);
158+
expect(returned).to.equal(true);
159+
});
160+
161+
it('ignores errors thrown by the iterator `return` method', async () => {
162+
const values = [1, null, 2];
163+
let index = 0;
164+
let returned = false;
165+
166+
const listField: IterableIterator<number | null> = {
167+
[Symbol.iterator](): IterableIterator<number | null> {
168+
return this;
169+
},
170+
next(): IteratorResult<number | null> {
171+
const value = values[index++];
172+
if (value === undefined) {
173+
return { done: true, value: undefined };
174+
}
175+
return { done: false, value };
176+
},
177+
return(): IteratorResult<number | null> {
178+
returned = true;
179+
throw new Error('ignored return error');
180+
},
181+
};
182+
183+
expectJSON(await complete({ listField }, '[Int!]')).toDeepEqual({
184+
data: { listField: null },
185+
errors: [
186+
{
187+
message: 'Cannot return null for non-nullable field Query.listField.',
188+
locations: [{ line: 1, column: 3 }],
189+
path: ['listField', 1],
190+
},
191+
],
192+
});
193+
expect(index).to.equal(2);
194+
expect(returned).to.equal(true);
195+
});
196+
});
197+
81198
describe('Execute: Accepts async iterables as list value', () => {
82199
function complete(rootValue: unknown, as: string = '[String]') {
83200
return execute({

src/execution/execute.ts

Lines changed: 80 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,13 +1018,13 @@ function getStreamUsage(
10181018
* Complete a async iterator value by completing the result and calling
10191019
* recursively until all the results are completed.
10201020
*/
1021-
async function completeAsyncIteratorValue(
1021+
async function completeAsyncIterable(
10221022
exeContext: ExecutionContext,
10231023
itemType: GraphQLOutputType,
10241024
fieldDetailsList: FieldDetailsList,
10251025
info: GraphQLResolveInfo,
10261026
path: Path,
1027-
asyncIterator: AsyncIterator<unknown>,
1027+
items: AsyncIterable<unknown>,
10281028
incrementalContext: IncrementalContext | undefined,
10291029
deferMap: ReadonlyMap<DeferUsage, DeferredFragmentRecord> | undefined,
10301030
): Promise<GraphQLWrappedResult<ReadonlyArray<unknown>>> {
@@ -1035,6 +1035,7 @@ async function completeAsyncIteratorValue(
10351035
newDeferredFragmentRecords: undefined,
10361036
incrementalDataRecords: undefined,
10371037
};
1038+
const asyncIterator = items[Symbol.asyncIterator]();
10381039
let index = 0;
10391040
const streamUsage = getStreamUsage(
10401041
exeContext.validatedExecutionArgs,
@@ -1131,10 +1132,7 @@ async function completeAsyncIteratorValue(
11311132
index++;
11321133
}
11331134
} catch (error) {
1134-
asyncIterator.return?.().catch(() => {
1135-
/* c8 ignore next 1 */
1136-
// ignore error
1137-
});
1135+
returnIteratorIgnoringErrors(asyncIterator);
11381136
throw error;
11391137
}
11401138

@@ -1169,15 +1167,14 @@ function completeListValue(
11691167
const maybeCancellableIterable = abortSignalListener
11701168
? cancellableIterable(result, abortSignalListener)
11711169
: result;
1172-
const asyncIterator = maybeCancellableIterable[Symbol.asyncIterator]();
11731170

1174-
return completeAsyncIteratorValue(
1171+
return completeAsyncIterable(
11751172
exeContext,
11761173
itemType,
11771174
fieldDetailsList,
11781175
info,
11791176
path,
1180-
asyncIterator,
1177+
maybeCancellableIterable,
11811178
incrementalContext,
11821179
deferMap,
11831180
);
@@ -1211,8 +1208,6 @@ function completeIterableValue(
12111208
incrementalContext: IncrementalContext | undefined,
12121209
deferMap: ReadonlyMap<DeferUsage, DeferredFragmentRecord> | undefined,
12131210
): PromiseOrValue<GraphQLWrappedResult<ReadonlyArray<unknown>>> {
1214-
// This is specified as a simple map, however we're optimizing the path
1215-
// where the list contains no Promises by avoiding creating another Promise.
12161211
let containsPromise = false;
12171212
const completedResults: Array<unknown> = [];
12181213
const graphqlWrappedResult: GraphQLWrappedResult<Array<unknown>> = {
@@ -1227,38 +1222,62 @@ function completeIterableValue(
12271222
path,
12281223
);
12291224
const iterator = items[Symbol.iterator]();
1230-
let iteration = iterator.next();
1231-
while (!iteration.done) {
1232-
const item = iteration.value;
1225+
try {
1226+
while (true) {
1227+
if (streamUsage && index >= streamUsage.initialCount) {
1228+
const iteration = iterator.next();
1229+
if (!iteration.done) {
1230+
const item = iteration.value;
1231+
const syncStreamRecord: StreamRecord = {
1232+
label: streamUsage.label,
1233+
path,
1234+
streamItemQueue: buildSyncStreamItemQueue(
1235+
item,
1236+
index,
1237+
path,
1238+
iterator,
1239+
exeContext,
1240+
streamUsage.fieldDetailsList,
1241+
info,
1242+
itemType,
1243+
),
1244+
};
12331245

1234-
if (streamUsage && index >= streamUsage.initialCount) {
1235-
const syncStreamRecord: StreamRecord = {
1236-
label: streamUsage.label,
1237-
path,
1238-
streamItemQueue: buildSyncStreamItemQueue(
1239-
item,
1240-
index,
1241-
path,
1242-
iterator,
1243-
exeContext,
1244-
streamUsage.fieldDetailsList,
1245-
info,
1246-
itemType,
1247-
),
1248-
};
1246+
addIncrementalDataRecords(graphqlWrappedResult, [syncStreamRecord]);
1247+
}
1248+
break;
1249+
}
12491250

1250-
addIncrementalDataRecords(graphqlWrappedResult, [syncStreamRecord]);
1251-
break;
1252-
}
1251+
const iteration = iterator.next();
1252+
if (iteration.done) {
1253+
break;
1254+
}
12531255

1254-
// No need to modify the info object containing the path,
1255-
// since from here on it is not ever accessed by resolver functions.
1256-
const itemPath = addPath(path, index, undefined);
1256+
const item = iteration.value;
12571257

1258-
if (isPromise(item)) {
1259-
completedResults.push(
1260-
completePromisedListItemValue(
1258+
// No need to modify the info object containing the path,
1259+
// since from here on it is not ever accessed by resolver functions.
1260+
const itemPath = addPath(path, index, undefined);
1261+
1262+
if (isPromise(item)) {
1263+
completedResults.push(
1264+
completePromisedListItemValue(
1265+
item,
1266+
graphqlWrappedResult,
1267+
exeContext,
1268+
itemType,
1269+
fieldDetailsList,
1270+
info,
1271+
itemPath,
1272+
incrementalContext,
1273+
deferMap,
1274+
),
1275+
);
1276+
containsPromise = true;
1277+
} else if (
1278+
completeListItemValue(
12611279
item,
1280+
completedResults,
12621281
graphqlWrappedResult,
12631282
exeContext,
12641283
itemType,
@@ -1267,28 +1286,15 @@ function completeIterableValue(
12671286
itemPath,
12681287
incrementalContext,
12691288
deferMap,
1270-
),
1271-
);
1272-
containsPromise = true;
1273-
} else if (
1274-
completeListItemValue(
1275-
item,
1276-
completedResults,
1277-
graphqlWrappedResult,
1278-
exeContext,
1279-
itemType,
1280-
fieldDetailsList,
1281-
info,
1282-
itemPath,
1283-
incrementalContext,
1284-
deferMap,
1285-
)
1286-
) {
1287-
containsPromise = true;
1289+
)
1290+
) {
1291+
containsPromise = true;
1292+
}
1293+
index++;
12881294
}
1289-
index++;
1290-
1291-
iteration = iterator.next();
1295+
} catch (error) {
1296+
returnIteratorIgnoringErrors(iterator);
1297+
throw error;
12921298
}
12931299

12941300
return containsPromise
@@ -2442,3 +2448,18 @@ function buildStreamItemResult(
24422448
incrementalDataRecords,
24432449
};
24442450
}
2451+
2452+
function returnIteratorIgnoringErrors(
2453+
iterator: Iterator<unknown> | AsyncIterator<unknown>,
2454+
) {
2455+
try {
2456+
const result = iterator.return?.();
2457+
if (isPromise(result)) {
2458+
result.catch(() => {
2459+
// ignore errors
2460+
});
2461+
}
2462+
} catch {
2463+
// ignore errors
2464+
}
2465+
}

0 commit comments

Comments
 (0)