Skip to content

Commit 60cc525

Browse files
committed
add abrupt return handling for sync iterables (#4533)
in parallel to handling for async iterables
1 parent 74fdca6 commit 60cc525

2 files changed

Lines changed: 172 additions & 38 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: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -703,16 +703,17 @@ async function completePromisedValue(
703703
* Complete a async iterator value by completing the result and calling
704704
* recursively until all the results are completed.
705705
*/
706-
async function completeAsyncIteratorValue(
706+
async function completeAsyncIterableValue(
707707
exeContext: ExecutionContext,
708708
itemType: GraphQLOutputType,
709709
fieldDetailsList: FieldDetailsList,
710710
info: GraphQLResolveInfo,
711711
path: Path,
712-
asyncIterator: AsyncIterator<unknown>,
712+
items: AsyncIterable<unknown>,
713713
): Promise<ReadonlyArray<unknown>> {
714714
let containsPromise = false;
715715
const completedResults: Array<unknown> = [];
716+
const asyncIterator = items[Symbol.asyncIterator]();
716717
let index = 0;
717718
try {
718719
while (true) {
@@ -762,10 +763,7 @@ async function completeAsyncIteratorValue(
762763
index++;
763764
}
764765
} catch (error) {
765-
asyncIterator.return?.().catch(() => {
766-
/* c8 ignore next 1 */
767-
// ignore error
768-
});
766+
returnIteratorIgnoringErrors(asyncIterator);
769767
throw error;
770768
}
771769
return containsPromise ? Promise.all(completedResults) : completedResults;
@@ -790,15 +788,14 @@ function completeListValue(
790788
const maybeCancellableIterable = abortSignalListener
791789
? cancellableIterable(result, abortSignalListener)
792790
: result;
793-
const asyncIterator = maybeCancellableIterable[Symbol.asyncIterator]();
794791

795-
return completeAsyncIteratorValue(
792+
return completeAsyncIterableValue(
796793
exeContext,
797794
itemType,
798795
fieldDetailsList,
799796
info,
800797
path,
801-
asyncIterator,
798+
maybeCancellableIterable,
802799
);
803800
}
804801

@@ -826,48 +823,53 @@ function completeIterableValue(
826823
path: Path,
827824
items: Iterable<unknown>,
828825
): PromiseOrValue<ReadonlyArray<unknown>> {
829-
// This is specified as a simple map, however we're optimizing the path
830-
// where the list contains no Promises by avoiding creating another Promise.
831826
let containsPromise = false;
832827
const completedResults: Array<unknown> = [];
833828
let index = 0;
834829
const iterator = items[Symbol.iterator]();
835-
let iteration = iterator.next();
836-
while (!iteration.done) {
837-
const item = iteration.value;
830+
try {
831+
while (true) {
832+
const iteration = iterator.next();
833+
if (iteration.done) {
834+
break;
835+
}
836+
837+
const item = iteration.value;
838838

839-
// No need to modify the info object containing the path,
840-
// since from here on it is not ever accessed by resolver functions.
841-
const itemPath = addPath(path, index, undefined);
839+
// No need to modify the info object containing the path,
840+
// since from here on it is not ever accessed by resolver functions.
841+
const itemPath = addPath(path, index, undefined);
842842

843-
if (isPromise(item)) {
844-
completedResults.push(
845-
completePromisedListItemValue(
843+
if (isPromise(item)) {
844+
completedResults.push(
845+
completePromisedListItemValue(
846+
item,
847+
exeContext,
848+
itemType,
849+
fieldDetailsList,
850+
info,
851+
itemPath,
852+
),
853+
);
854+
containsPromise = true;
855+
} else if (
856+
completeListItemValue(
846857
item,
858+
completedResults,
847859
exeContext,
848860
itemType,
849861
fieldDetailsList,
850862
info,
851863
itemPath,
852-
),
853-
);
854-
containsPromise = true;
855-
} else if (
856-
completeListItemValue(
857-
item,
858-
completedResults,
859-
exeContext,
860-
itemType,
861-
fieldDetailsList,
862-
info,
863-
itemPath,
864-
)
865-
) {
866-
containsPromise = true;
864+
)
865+
) {
866+
containsPromise = true;
867+
}
868+
index++;
867869
}
868-
869-
index++;
870-
iteration = iterator.next();
870+
} catch (error) {
871+
returnIteratorIgnoringErrors(iterator);
872+
throw error;
871873
}
872874

873875
return containsPromise ? Promise.all(completedResults) : completedResults;
@@ -1360,3 +1362,18 @@ function assertEventStream(result: unknown): AsyncIterable<unknown> {
13601362

13611363
return result;
13621364
}
1365+
1366+
function returnIteratorIgnoringErrors(
1367+
iterator: Iterator<unknown> | AsyncIterator<unknown>,
1368+
) {
1369+
try {
1370+
const result = iterator.return?.();
1371+
if (isPromise(result)) {
1372+
result.catch(() => {
1373+
// ignore errors
1374+
});
1375+
}
1376+
} catch {
1377+
// ignore errors
1378+
}
1379+
}

0 commit comments

Comments
 (0)