mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
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>
990 lines
30 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|