Skip to content

Commit aa5cfc6

Browse files
committed
amend! sqlite: skip return() on exhausted virtual table iterators
sqlite: skip return() on exhausted virtual table iterators Closing a virtual table cursor invoked the iterator's return() even after next() had already reported done: true, deviating from for...of semantics where return() only runs on abrupt termination. An empty custom iterable with a throwing return() made an otherwise successful query fail. Only close the iterator when the scan stopped early, as with LIMIT, a break, or a cursor re-filter; a normally finished iterator already ran its cleanup. Likewise, skip return() after an iterator protocol error, where next() is missing or returns a non-object, as for...of does not close the iterator in that case. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5.5
1 parent b18715a commit aa5cfc6

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

‎src/node_sqlite.cc‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,7 +1449,10 @@ int VirtualTableModule::xNext(sqlite3_vtab_cursor* pCursor) {
14491449
.ToLocal(&next_method_val)) {
14501450
return mod->PropagateJSError();
14511451
}
1452+
// On a protocol error, drop the iterator so it is not closed later.
1453+
// `for...of` does not call return() when next() is invalid either.
14521454
if (!next_method_val->IsFunction()) {
1455+
cursor->iterator.Reset();
14531456
return ReportProtocolError(
14541457
pCursor->pVtab,
14551458
"The \"options.rows\" iterator must have a next() method");
@@ -1462,6 +1465,7 @@ int VirtualTableModule::xNext(sqlite3_vtab_cursor* pCursor) {
14621465
return mod->PropagateJSError();
14631466
}
14641467
if (!next_val->IsObject()) {
1468+
cursor->iterator.Reset();
14651469
return ReportProtocolError(
14661470
pCursor->pVtab,
14671471
"The \"options.rows\" iterator's next() method must return an object");

‎test/parallel/test-sqlite-virtual-table.js‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,20 +628,25 @@ suite('Database.prototype.createModule()', () => {
628628
// successful query. Matches `for...of`, which only calls return() on
629629
// abrupt termination.
630630
const db = new Database(':memory:');
631+
let returnCalls = 0;
632+
function cleanup() {
633+
returnCalls++;
634+
throw new Error('cleanup boom');
635+
}
631636
for (const [label, makeRows] of [
632637
['empty', () => {
633638
return {
634639
[Symbol.iterator]() { return this; },
635640
next() { return { value: [], done: true }; },
636-
return() { throw new Error('cleanup boom'); },
641+
return: cleanup,
637642
};
638643
}],
639644
['fully_consumed', () => {
640645
let i = 0;
641646
return {
642647
[Symbol.iterator]() { return this; },
643648
next() { return { value: [i++], done: i > 2 }; },
644-
return() { throw new Error('cleanup boom'); },
649+
return: cleanup,
645650
};
646651
}],
647652
]) {
@@ -655,6 +660,75 @@ suite('Database.prototype.createModule()', () => {
655660
rows.map((row) => row.v),
656661
label === 'empty' ? [] : [0, 1]);
657662
}
663+
assert.strictEqual(returnCalls, 0);
664+
});
665+
666+
test('does not call return() on an exhausted iterator when re-filtered', () => {
667+
// A scalar subquery drains the inner iterator for each outer row, so
668+
// xFilter re-filters a cursor whose iterator already reported done.
669+
const db = new Database(':memory:');
670+
let returnCalls = 0;
671+
672+
db.createModule('exhausted_refilter', {
673+
columns: [
674+
{ name: 'v', type: 'INTEGER' },
675+
{ name: 'input', type: 'INTEGER', hidden: true },
676+
],
677+
rows() {
678+
let i = 0;
679+
return {
680+
[Symbol.iterator]() { return this; },
681+
next() { return { value: [i++], done: i > 2 }; },
682+
return() {
683+
returnCalls++;
684+
throw new Error('cleanup boom');
685+
},
686+
};
687+
},
688+
});
689+
690+
db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1), (2), (3)');
691+
const rows = db.prepare(
692+
'SELECT a, (SELECT count(*) FROM exhausted_refilter(t.a)) AS c FROM t'
693+
).all();
694+
assert.deepStrictEqual(rows, [
695+
{ __proto__: null, a: 1, c: 2 },
696+
{ __proto__: null, a: 2, c: 2 },
697+
{ __proto__: null, a: 3, c: 2 },
698+
]);
699+
assert.strictEqual(returnCalls, 0);
700+
});
701+
702+
test('does not call return() after an iterator protocol error', () => {
703+
// `for...of` does not close an iterator whose next() is invalid, so the
704+
// protocol error is reported and return() is never called.
705+
const db = new Database(':memory:');
706+
let returnCalls = 0;
707+
for (const [label, next, message] of [
708+
['next_not_function', 5,
709+
/iterator must have a next\(\) method/],
710+
['next_non_object', () => 42,
711+
/iterator's next\(\) method must return an object/],
712+
]) {
713+
db.createModule(`protocol_${label}`, {
714+
columns: [{ name: 'v', type: 'INTEGER' }],
715+
rows() {
716+
return {
717+
[Symbol.iterator]() { return this; },
718+
next,
719+
return() {
720+
returnCalls++;
721+
throw new Error('cleanup boom');
722+
},
723+
};
724+
},
725+
});
726+
727+
assert.throws(
728+
() => db.prepare(`SELECT v FROM protocol_${label}`).all(),
729+
{ code: 'ERR_SQLITE_ERROR', message });
730+
}
731+
assert.strictEqual(returnCalls, 0);
658732
});
659733

660734
test('does not run cleanup when the statement is collected', () => {

0 commit comments

Comments
 (0)