Files
node/test/parallel/test-sqlite-virtual-table.js
Trivikram Kamat cfb2de3db8 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.

Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
Assisted-by: claude:opus-5.5
PR-URL: https://github.com/nodejs/node/pull/66519
Fixes: https://github.com/nodejs/node/issues/66518
Reviewed-By: Guilherme Araújo <arauujogui@gmail.com>
2026-10-25 06:39:02 +00:00

990 lines
30 KiB
JavaScript

// Flags: --expose-gc
'use strict';
const { skipIfSQLiteMissing, mustCallAtLeast } = require('../common');
skipIfSQLiteMissing();
const assert = require('node:assert');
const { Database } = require('node:sqlite');
const { suite, test } = require('node:test');
suite('Database.prototype.createModule()', () => {
suite('input validation', () => {
const db = new Database(':memory:');
test('throws if name is not a string', () => {
assert.throws(() => {
db.createModule();
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "name" argument must be a string/,
});
});
test('throws if options is not an object', () => {
assert.throws(() => {
db.createModule('mod', null);
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "options" argument must be an object/,
});
});
test('throws if options.columns is not an array', () => {
assert.throws(() => {
db.createModule('mod', { columns: 'bad', rows() {} });
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "options\.columns" argument must be an array/,
});
});
test('throws if options.columns is empty', () => {
assert.throws(() => {
db.createModule('mod', { columns: [], rows() {} });
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /The "options\.columns" array must not be empty/,
});
});
test('throws if options.rows is not a function', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'x', type: 'TEXT' }],
rows: 'bad',
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "options\.rows" argument must be a function/,
});
});
test('throws if column name is not a string', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 123, type: 'TEXT' }],
rows() {},
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The column "name" property must be a string/,
});
});
test('throws if column type is not a string', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'x', type: 123 }],
rows() {},
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The column "type" property must be a string/,
});
});
test('throws if column type is not a valid SQLite type', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'x', type: 'INVALID' }],
rows() {},
});
}, {
code: 'ERR_INVALID_ARG_VALUE',
message: /The column "type" property must be one of/,
});
});
test('throws if column hidden is not a boolean', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'x', type: 'TEXT', hidden: 'yes' }],
rows() {},
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The column "hidden" property must be a boolean/,
});
});
test('throws if options.directOnly is not a boolean', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'x', type: 'TEXT' }],
rows() {},
directOnly: 'yes',
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "options\.directOnly" argument must be a boolean/,
});
});
test('throws if options.useBigIntArguments is not a boolean', () => {
assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'x', type: 'TEXT' }],
rows() {},
useBigIntArguments: 'yes',
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "options\.useBigIntArguments" argument must be a boolean/,
});
});
test('throws if database is not open', () => {
const closedDb = new Database(':memory:');
closedDb.close();
assert.throws(() => {
closedDb.createModule('mod', {
columns: [{ name: 'x', type: 'TEXT' }],
rows() {},
});
}, {
code: 'ERR_INVALID_STATE',
message: /database is not open/,
});
});
});
suite('basic virtual table', () => {
test('creates a simple read-only virtual table', () => {
const db = new Database(':memory:');
db.createModule('simple', {
columns: [
{ name: 'id', type: 'INTEGER' },
{ name: 'name', type: 'TEXT' },
],
*rows() {
yield [1, 'Alice'];
yield [2, 'Bob'];
yield [3, 'Charlie'];
},
});
db.exec('CREATE VIRTUAL TABLE t1 USING simple');
const result = db.prepare('SELECT * FROM t1').all();
assert.deepStrictEqual(result, [
{ __proto__: null, id: 1, name: 'Alice' },
{ __proto__: null, id: 2, name: 'Bob' },
{ __proto__: null, id: 3, name: 'Charlie' },
]);
});
test('works as eponymous table (without CREATE VIRTUAL TABLE)', () => {
const db = new Database(':memory:');
db.createModule('eponymous', {
columns: [
{ name: 'value', type: 'TEXT' },
],
*rows() {
yield ['hello'];
yield ['world'];
},
});
const result = db.prepare('SELECT * FROM eponymous').all();
assert.deepStrictEqual(result, [
{ __proto__: null, value: 'hello' },
{ __proto__: null, value: 'world' },
]);
});
test('supports rows() returning an array', () => {
const db = new Database(':memory:');
db.createModule('array_mod', {
columns: [
{ name: 'val', type: 'INTEGER' },
],
rows() {
return [[10], [20], [30]];
},
});
const result = db.prepare('SELECT * FROM array_mod').all();
assert.deepStrictEqual(result, [
{ __proto__: null, val: 10 },
{ __proto__: null, val: 20 },
{ __proto__: null, val: 30 },
]);
});
test('supports empty result set', () => {
const db = new Database(':memory:');
db.createModule('empty_mod', {
columns: [
{ name: 'x', type: 'TEXT' },
],
*rows() {
// yields nothing
},
});
const result = db.prepare('SELECT * FROM empty_mod').all();
assert.deepStrictEqual(result, []);
});
});
suite('table-valued function with parameters', () => {
test('passes hidden column values as arguments to rows()', () => {
const db = new Database(':memory:');
db.createModule('gen_series', {
columns: [
{ name: 'value', type: 'INTEGER' },
{ name: 'start', type: 'INTEGER', hidden: true },
{ name: 'stop', type: 'INTEGER', hidden: true },
{ name: 'step', type: 'INTEGER', hidden: true },
],
*rows(start, stop, step) {
start ??= 0;
stop ??= 10;
step ??= 1;
for (let i = start; i <= stop; i += step) {
yield [i];
}
},
});
const result = db.prepare(
'SELECT value FROM gen_series(1, 5, 1)'
).all();
assert.deepStrictEqual(result, [
{ __proto__: null, value: 1 },
{ __proto__: null, value: 2 },
{ __proto__: null, value: 3 },
{ __proto__: null, value: 4 },
{ __proto__: null, value: 5 },
]);
});
test('passes step parameter', () => {
const db = new Database(':memory:');
db.createModule('gen_step', {
columns: [
{ name: 'value', type: 'INTEGER' },
{ name: 'start', type: 'INTEGER', hidden: true },
{ name: 'stop', type: 'INTEGER', hidden: true },
{ name: 'step', type: 'INTEGER', hidden: true },
],
*rows(start, stop, step) {
start ??= 0;
stop ??= 10;
step ??= 1;
for (let i = start; i <= stop; i += step) {
yield [i];
}
},
});
const result = db.prepare(
'SELECT value FROM gen_step(0, 10, 3)'
).all();
assert.deepStrictEqual(result, [
{ __proto__: null, value: 0 },
{ __proto__: null, value: 3 },
{ __proto__: null, value: 6 },
{ __proto__: null, value: 9 },
]);
});
test('handles partial parameters (some hidden cols unconstrained)', () => {
const db = new Database(':memory:');
db.createModule('partial_params', {
columns: [
{ name: 'value', type: 'INTEGER' },
{ name: 'count', type: 'INTEGER', hidden: true },
],
*rows(count) {
const n = count ?? 3;
for (let i = 0; i < n; i++) {
yield [i];
}
},
});
// Without parameter (uses default).
db.exec('CREATE VIRTUAL TABLE pp USING partial_params');
const result1 = db.prepare('SELECT * FROM pp').all();
assert.strictEqual(result1.length, 3);
// With parameter via table-valued function syntax.
const result2 = db.prepare('SELECT * FROM partial_params(5)').all();
assert.strictEqual(result2.length, 5);
});
test('maps parameters correctly beyond 32 hidden columns', () => {
const db = new Database(':memory:');
const paramCount = 40;
const columns = [{ name: 'value', type: 'INTEGER' }];
for (let i = 0; i < paramCount; i++) {
columns.push({ name: `p${i}`, type: 'INTEGER', hidden: true });
}
let received;
db.createModule('many_params', {
columns,
rows(...args) {
received = args;
return [[0]];
},
});
// Constrain only the last parameter. A bitmask in an int would not be
// able to represent an index this high.
db.prepare(`SELECT value FROM many_params(${
Array.from({ length: paramCount }, (_, i) => (i === paramCount - 1 ? '7' : 'NULL')).join(', ')
})`).all();
assert.strictEqual(received.length, paramCount);
assert.strictEqual(received[paramCount - 1], 7);
});
test('does not pass null for parameters that are unavailable in a plan', () => {
const db = new Database(':memory:');
db.createModule('join_params', {
columns: [
{ name: 'value', type: 'INTEGER' },
{ name: 'param', type: 'INTEGER', hidden: true },
],
rows: mustCallAtLeast(function*(param) {
assert.notStrictEqual(param, null,
'rows() must not be called with an unavailable ' +
'parameter');
if (param !== null) {
yield [param];
}
}),
});
db.exec('CREATE TABLE t (a INTEGER)');
db.exec('INSERT INTO t VALUES (1), (2), (3)');
// With DISTINCT, SQLite may consider a plan where the parameter is read
// from the inner table and is not yet available, which used to make
// xBestIndex accept it and call rows(null), producing an empty result.
const result = db.prepare(
'SELECT DISTINCT value FROM join_params, t WHERE join_params.param = t.a'
).all();
assert.deepStrictEqual(result, [
{ __proto__: null, value: 1 },
{ __proto__: null, value: 2 },
{ __proto__: null, value: 3 },
]);
});
});
suite('type conversions', () => {
test('handles various SQLite data types', () => {
const db = new Database(':memory:');
db.createModule('types_mod', {
columns: [
{ name: 'int_col', type: 'INTEGER' },
{ name: 'real_col', type: 'REAL' },
{ name: 'text_col', type: 'TEXT' },
{ name: 'blob_col', type: 'BLOB' },
{ name: 'null_col', type: 'TEXT' },
],
*rows() {
yield [42, 3.14, 'hello', new Uint8Array([1, 2, 3]), null];
},
});
const result = db.prepare('SELECT * FROM types_mod').get();
assert.strictEqual(result.int_col, 42);
assert.strictEqual(result.real_col, 3.14);
assert.strictEqual(result.text_col, 'hello');
assert.deepStrictEqual(
new Uint8Array(result.blob_col),
new Uint8Array([1, 2, 3])
);
assert.strictEqual(result.null_col, null);
});
});
suite('useBigIntArguments', () => {
test('passes INTEGER parameters as BigInts when enabled', () => {
const db = new Database(':memory:');
let receivedType;
db.createModule('bigint_mod', {
columns: [
{ name: 'result', type: 'TEXT' },
{ name: 'input', type: 'INTEGER', hidden: true },
],
useBigIntArguments: true,
*rows(input) {
receivedType = typeof input;
yield [String(input)];
},
});
db.prepare('SELECT * FROM bigint_mod(42)').get();
assert.strictEqual(receivedType, 'bigint');
});
test('passes INTEGER parameters as numbers by default', () => {
const db = new Database(':memory:');
let receivedType;
db.createModule('number_mod', {
columns: [
{ name: 'result', type: 'TEXT' },
{ name: 'input', type: 'INTEGER', hidden: true },
],
*rows(input) {
receivedType = typeof input;
yield [String(input)];
},
});
db.prepare('SELECT * FROM number_mod(42)').get();
assert.strictEqual(receivedType, 'number');
});
});
suite('re-entrancy', () => {
// Closing the database while SQLite is stepping the statement that owns
// the cursor would finalize that statement from under sqlite3_step().
for (const [name, makeRows] of [
['rows()', (db) => function* () { db.close(); yield [1]; }],
['iterator next()', (db) => () => ({
[Symbol.iterator]() { return this; },
next() { db.close(); return { value: [1], done: false }; },
})],
['a row getter', (db) => () => [{ get 0() { db.close(); return 1; } }]],
]) {
test(`throws when close() is called from ${name}`, () => {
const db = new Database(':memory:');
db.createModule('closer', {
columns: [{ name: 'value', type: 'INTEGER' }],
rows: makeRows(db),
});
assert.throws(() => {
db.prepare('SELECT value FROM closer').all();
}, {
code: 'ERR_INVALID_STATE',
message: /database cannot be closed while in a callback/,
});
});
}
test('throws when createModule() is called from an authorizer', () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE t(a)');
let err;
db.setAuthorizer(() => {
try {
db.createModule('from_authz', {
columns: [{ name: 'v', type: 'INTEGER' }],
*rows() { yield [1]; },
});
} catch (e) {
err = e;
}
return 0;
});
db.prepare('SELECT * FROM t').all();
assert.strictEqual(err?.code, 'ERR_INVALID_STATE');
assert.match(err.message, /cannot be accessed from an authorizer/);
});
});
suite('iterator cleanup', () => {
test('closes the iterator when SQLite stops stepping early', () => {
const db = new Database(':memory:');
let cleanedUp = false;
db.createModule('early_stop', {
columns: [{ name: 'value', type: 'INTEGER' }],
*rows() {
try {
for (let i = 0; i < 100; i++) {
yield [i];
}
} finally {
cleanedUp = true;
}
},
});
const rows = db.prepare('SELECT value FROM early_stop LIMIT 2').all();
assert.strictEqual(rows.length, 2);
assert.strictEqual(cleanedUp, true);
});
test('keeps the original error when rows() throws', () => {
const db = new Database(':memory:');
db.createModule('cleanup_throws', {
columns: [{ name: 'value', type: 'INTEGER' }],
*rows() {
try {
yield [1];
throw new Error('boom');
} finally {
// Cleanup must not mask the error above.
}
},
});
assert.throws(() => {
db.prepare('SELECT value FROM cleanup_throws').all();
}, /boom/);
});
test('closes the iterator on break out of a for...of loop', () => {
const db = new Database(':memory:');
let cleanedUp = false;
db.createModule('breaker', {
columns: [{ name: 'v', type: 'INTEGER' }],
*rows() {
try {
for (let i = 0; i < 1000; i++) {
yield [i];
}
} finally {
cleanedUp = true;
}
},
});
for (const row of db.prepare('SELECT v FROM breaker').iterate()) {
if (row.v === 1) break;
}
assert.strictEqual(cleanedUp, true);
});
test('surfaces an error thrown by the iterator\'s return()', () => {
const db = new Database(':memory:');
for (const [label, descriptor] of [
['method', { value() { throw new Error('return boom'); } }],
['getter', { get() { throw new Error('return boom'); } }],
]) {
db.createModule(`ret_${label}`, {
columns: [{ name: 'v', type: 'INTEGER' }],
rows() {
let i = 0;
const it = {
[Symbol.iterator]() { return this; },
next() { return { value: [i++], done: i > 50 }; },
};
Object.defineProperty(it, 'return', descriptor);
return it;
},
});
assert.throws(() => {
db.prepare(`SELECT v FROM ret_${label} LIMIT 2`).all();
}, /return boom/, `throwing return ${label} should surface`);
}
});
test('closes the iterator when a filter is reapplied to the cursor', () => {
// A correlated subquery re-invokes xFilter on the same cursor per outer
// row, abandoning the previous iterator; its `finally` must still run.
const db = new Database(':memory:');
const cleanedUp = [];
db.createModule('refilter_cleanup', {
columns: [
{ name: 'input', type: 'INTEGER', hidden: true },
],
*rows(input) {
try {
yield [input];
} finally {
cleanedUp.push(input);
}
},
});
db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1), (2)');
const rows = db.prepare(
'SELECT a FROM t WHERE EXISTS(SELECT 1 FROM refilter_cleanup(t.a))'
).all();
assert.deepStrictEqual(rows, [
{ __proto__: null, a: 1 },
{ __proto__: null, a: 2 },
]);
assert.deepStrictEqual(cleanedUp, [1, 2]);
});
test('does not call return() on a normally exhausted iterator', () => {
// Once next() reports `done: true` the iterator is finished, so no
// cleanup should run. A throwing return() must not fail an otherwise
// successful query. Matches `for...of`, which only calls return() on
// abrupt termination.
const db = new Database(':memory:');
let returnCalls = 0;
function cleanup() {
returnCalls++;
throw new Error('cleanup boom');
}
for (const [label, makeRows] of [
['empty', () => {
return {
[Symbol.iterator]() { return this; },
next() { return { value: [], done: true }; },
return: cleanup,
};
}],
['fully_consumed', () => {
let i = 0;
return {
[Symbol.iterator]() { return this; },
next() { return { value: [i++], done: i > 2 }; },
return: cleanup,
};
}],
]) {
db.createModule(`exhausted_${label}`, {
columns: [{ name: 'v', type: 'INTEGER' }],
rows: makeRows,
});
const rows = db.prepare(`SELECT v FROM exhausted_${label}`).all();
assert.deepStrictEqual(
rows.map((row) => row.v),
label === 'empty' ? [] : [0, 1]);
}
assert.strictEqual(returnCalls, 0);
});
test('does not call return() on an exhausted iterator when re-filtered', () => {
// A scalar subquery drains the inner iterator for each outer row, so
// xFilter re-filters a cursor whose iterator already reported done.
const db = new Database(':memory:');
let returnCalls = 0;
db.createModule('exhausted_refilter', {
columns: [
{ name: 'v', type: 'INTEGER' },
{ name: 'input', type: 'INTEGER', hidden: true },
],
rows() {
let i = 0;
return {
[Symbol.iterator]() { return this; },
next() { return { value: [i++], done: i > 2 }; },
return() {
returnCalls++;
throw new Error('cleanup boom');
},
};
},
});
db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1), (2), (3)');
const rows = db.prepare(
'SELECT a, (SELECT count(*) FROM exhausted_refilter(t.a)) AS c FROM t'
).all();
assert.deepStrictEqual(rows, [
{ __proto__: null, a: 1, c: 2 },
{ __proto__: null, a: 2, c: 2 },
{ __proto__: null, a: 3, c: 2 },
]);
assert.strictEqual(returnCalls, 0);
});
test('does not call return() after an iterator protocol error', () => {
// `for...of` does not close an iterator whose next() is invalid, so the
// protocol error is reported and return() is never called.
const db = new Database(':memory:');
let returnCalls = 0;
for (const [label, next, message] of [
['next_not_function', 5,
/iterator must have a next\(\) method/],
['next_non_object', () => 42,
/iterator's next\(\) method must return an object/],
]) {
db.createModule(`protocol_${label}`, {
columns: [{ name: 'v', type: 'INTEGER' }],
rows() {
return {
[Symbol.iterator]() { return this; },
next,
return() {
returnCalls++;
throw new Error('cleanup boom');
},
};
},
});
assert.throws(
() => db.prepare(`SELECT v FROM protocol_${label}`).all(),
{ code: 'ERR_SQLITE_ERROR', message });
}
assert.strictEqual(returnCalls, 0);
});
test('does not run cleanup when the statement is collected', () => {
// The destructor runs from a GC callback, where JavaScript cannot be
// executed. An abandoned generator does not run `finally` in JavaScript
// either, so the expected outcome is no crash and no cleanup.
const db = new Database(':memory:');
let cleanedUp = false;
db.createModule('abandoned', {
columns: [{ name: 'v', type: 'INTEGER' }],
*rows() {
try {
for (let i = 0; i < 1000; i++) {
yield [i];
}
} finally {
cleanedUp = true;
}
},
});
(function abandon() {
db.prepare('SELECT v FROM abandoned').iterate().next();
})();
for (let i = 0; i < 5; i++) {
globalThis.gc({ execution: 'sync' });
}
assert.strictEqual(cleanedUp, false);
});
test('a throwing cleanup does not swallow the next SQLite error', () => {
// SQLite discards xClose's return value, so a cleanup error there has no
// SQLite error to pair with. Suppressing one would leave the suppression
// flag set for the next unrelated statement.
const db = new Database(':memory:');
db.createModule('cleanup_leak', {
columns: [{ name: 'value', type: 'INTEGER' }],
rows() {
let i = 0;
return {
[Symbol.iterator]() { return this; },
next() { return { value: [i++], done: i > 50 }; },
return() { throw new Error('cleanup boom'); },
};
},
});
assert.throws(() => {
db.prepare('SELECT value FROM cleanup_leak LIMIT 1').all();
}, /cleanup boom/);
assert.throws(() => {
db.prepare('SELECT * FROM no_such_table');
}, { code: 'ERR_SQLITE_ERROR', message: /no such table: no_such_table/ });
});
test('a SQLite error outranks a throwing cleanup', () => {
// Both fail at once: the statement aborts on a constraint violation
// while the cursor is open, and the cursor's return() then throws. Only
// one can surface, and xClose cannot choose based on the statement's
// state, because SQLite closes cursors before transferring the error to
// the connection. The constraint violation is the actionable one.
const db = new Database(':memory:');
db.createModule('both_fail', {
columns: [{ name: 'value', type: 'INTEGER' }],
rows() {
let i = 0;
return {
[Symbol.iterator]() { return this; },
next() { return { value: [i++], done: i > 50 }; },
return() { throw new Error('cleanup boom'); },
};
},
});
db.exec('CREATE TABLE t(v INTEGER PRIMARY KEY)');
db.exec('INSERT INTO t VALUES (5)');
assert.throws(() => {
db.prepare('INSERT INTO t SELECT value FROM both_fail').run();
}, { code: 'ERR_SQLITE_ERROR', message: /UNIQUE constraint failed: t\.v/ });
});
});
suite('iteration protocol violations', () => {
// Each of these is a misuse with no pending JavaScript exception, so the
// module has to report a SQLite error of its own rather than relying on one.
for (const [name, rows, expected] of [
[
'rows() returns a non-object',
() => 42,
/must return an iterable object/,
],
[
'Symbol.iterator returns a non-object',
() => ({ [Symbol.iterator]() { return 7; } }),
/Symbol\.iterator method must return an object/,
],
[
'iterator has no next()',
() => ({ [Symbol.iterator]() { return this; } }),
/must have a next\(\) method/,
],
[
'next() returns a non-object',
() => ({ [Symbol.iterator]() { return this; }, next() { return 42; } }),
/next\(\) method must return an object/,
],
]) {
test(`reports an error when ${name}`, () => {
const db = new Database(':memory:');
db.createModule('bad', {
columns: [{ name: 'v', type: 'INTEGER' }],
rows,
});
// Both entry points must fail; exec() has no return value to inspect,
// so a missing error there would look like success.
assert.throws(() => {
db.prepare('SELECT v FROM bad').all();
}, { code: 'ERR_SQLITE_ERROR', message: expected });
assert.throws(() => {
db.exec('SELECT v FROM bad');
}, { code: 'ERR_SQLITE_ERROR', message: expected });
});
}
});
suite('hidden column constraints', () => {
const makeDb = () => {
const db = new Database(':memory:');
db.createModule('gs', {
columns: [
{ name: 'value', type: 'INTEGER' },
{ name: 'start', type: 'INTEGER', hidden: true },
{ name: 'stop', type: 'INTEGER', hidden: true },
],
*rows(start, stop) {
start ??= 1;
stop ??= 3;
for (let i = start; i <= stop; i++) {
yield [i];
}
},
});
return db;
};
test('reports the constrained value back from a hidden column', () => {
const db = makeDb();
assert.deepStrictEqual(
db.prepare('SELECT start, value FROM gs(1, 3)').all(),
[
{ __proto__: null, start: 1, value: 1 },
{ __proto__: null, start: 1, value: 2 },
{ __proto__: null, start: 1, value: 3 },
]);
});
test('survives a recheck of a constraint it already consumed', () => {
// SQLite treats `omit` as a hint, so it may recheck `start = 1` against
// whatever xColumn reports. Returning NULL there rejects every row.
const db = makeDb();
assert.deepStrictEqual(
db.prepare('SELECT value FROM gs(1, 3) WHERE start = 1').all(),
[
{ __proto__: null, value: 1 },
{ __proto__: null, value: 2 },
{ __proto__: null, value: 3 },
]);
});
test('uses the constrained plan when parameters come from a join', () => {
const db = makeDb();
db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1), (2)');
assert.deepStrictEqual(
db.prepare(
'SELECT t.a, gs.value FROM t, gs(t.a, t.a + 1) AS gs ORDER BY t.a, gs.value'
).all(),
[
{ __proto__: null, a: 1, value: 1 },
{ __proto__: null, a: 1, value: 2 },
{ __proto__: null, a: 2, value: 2 },
{ __proto__: null, a: 2, value: 3 },
]);
});
});
suite('error handling', () => {
test('propagates errors thrown in rows()', () => {
const db = new Database(':memory:');
db.createModule('error_mod', {
columns: [
{ name: 'x', type: 'TEXT' },
],
rows() {
throw new Error('rows error');
},
});
assert.throws(() => {
db.prepare('SELECT * FROM error_mod').all();
}, {
message: /rows error/,
});
});
test('propagates errors thrown during iteration', () => {
const db = new Database(':memory:');
db.createModule('iter_error_mod', {
columns: [
{ name: 'x', type: 'INTEGER' },
],
*rows() {
yield [1];
throw new Error('iteration error');
},
});
assert.throws(() => {
db.prepare('SELECT * FROM iter_error_mod').all();
}, {
message: /iteration error/,
});
});
});
suite('multiple queries', () => {
test('supports querying the virtual table multiple times', () => {
const db = new Database(':memory:');
let callCount = 0;
db.createModule('multi_mod', {
columns: [
{ name: 'value', type: 'INTEGER' },
],
*rows() {
callCount++;
yield [callCount];
},
});
db.exec('CREATE VIRTUAL TABLE m USING multi_mod');
const r1 = db.prepare('SELECT * FROM m').get();
const r2 = db.prepare('SELECT * FROM m').get();
assert.strictEqual(r1.value, 1);
assert.strictEqual(r2.value, 2);
assert.strictEqual(callCount, 2);
});
});
});