sqlite: add virtual table support via createModule()

Expose SQLite's virtual table API through a new
`database.createModule(name, options)` method, wrapping
`sqlite3_create_module_v2()`. This enables read-only virtual tables
backed by JavaScript data sources, usable either as an eponymous table
(`SELECT * FROM module_name`) or via `CREATE VIRTUAL TABLE t USING
module_name`. Hidden columns pass parameters using table-valued
function syntax (`SELECT * FROM module_name(param1, param2)`).

`options` accepts `columns`, `rows`, `directOnly`, and
`useBigIntArguments`. Column types are validated against INTEGER,
TEXT, REAL, BLOB, and ANY, and column names are quoted when building
the `sqlite3_declare_vtab()` schema.

Rebased from https://github.com/nodejs/node/pull/61544, which was
opened by byteforge38 and became inactive. Changes on top of that
work:

- xColumn reports the value each hidden column was constrained to,
  rather than NULL. SQLite treats xBestIndex's `omit` as a hint, so it
  may recheck a constraint it already handed to xFilter; against NULL
  that recheck rejected every row, and `gs(1, 3) WHERE start = 1`
  returned no rows.
- xBestIndex lowers estimatedCost as it consumes constraints. With a
  constant cost the planner was free to pick the unconstrained plan and
  recheck afterwards, so a correlated parameter such as
  `FROM t, gs(t.a, t.a + 1)` also returned no rows.
- Violations of the iteration protocol report a SQLite error instead of
  calling PropagateJSError with no JavaScript exception pending. That
  left `.all()` returning undefined and `exec()` reporting success.
- xBestIndex passes the constrained hidden-column indices to xFilter
  through idxStr rather than an int bitmask, which previously aliased
  for parameter indices at or above the width of an int.
- xFilter, xNext, and xColumn take a CallbackDepthGuard. Without it
  close() from inside rows(), an iterator's next(), or a row getter
  finalized the statement that SQLite was still stepping, crashing the
  process.
- xClose calls the iterator's return() method so generator `finally`
  blocks run when SQLite stops stepping early, as it does for LIMIT or
  a `break` out of a for...of loop. It is skipped while tearing down
  from ~StatementSync or ~DatabaseSync, which run from garbage
  collection callbacks where JavaScript cannot be executed; an
  abandoned generator does not run `finally` in JavaScript either. It
  is also skipped when an error is already pending, so that error still
  reaches the caller.
- VirtualTableModule holds a BaseObjectWeakPtr<DatabaseSync> to match
  UserDefinedFunction instead of a raw pointer.
- createModule() rejects being called from an authorizer callback.
- Documents that values yielded by rows() follow the usual conversion
  rules, so a number is stored as REAL and a BigInt as INTEGER even
  when a column declares INTEGER, since virtual tables do not apply
  column affinity to the values they return.

Refs: https://github.com/nodejs/node/pull/61544
Refs: https://github.com/nodejs/node/issues/63826
Fixes: https://github.com/nodejs/node/issues/61539

Co-authored-by: byteforge38 <stormcraft318@gmail.com>
Signed-off-by: Trevor Burnham <trevorburnham@gmail.com>
Assisted-by: Claude Opus 5
PR-URL: https://github.com/nodejs/node/pull/65787
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
Trevor Burnhamandbyteforge38 authored and GitHub committed 2026-09-22 03:57:34 +00:00
1 parent 8cb3355e92
commit dd9f4dac32
5 files changed
+1785

No files matched your search

+109
View File
@@ -870,6 +870,114 @@ console.log(allUsers);
// ]
```
### `database.createModule(name, options)`
<!-- YAML
added: REPLACEME
-->
* `name` {string} The name of the virtual table module. This name is used in
`CREATE VIRTUAL TABLE ... USING name` statements and as an eponymous table
name.
* `options` {Object} Module configuration settings.
* `columns` {Array} An array of column definitions. Each element is an object
with the following properties:
* `name` {string} The name of the column.
* `type` {string} The declared type of the column. Must be one of
`'INTEGER'`, `'TEXT'`, `'REAL'`, `'BLOB'`, or `'ANY'`.
* `hidden` {boolean} If `true`, the column is hidden and acts as a
parameter for table-valued function usage. **Default:** `false`.
* `rows` {Function} A function called to produce rows when the virtual table
is queried. The function receives values for hidden columns (parameters) as
arguments, in the order they are defined. Must return an iterable (such as
an array or generator) where each element is an array of column values.
* `directOnly` {boolean} If `true`, the virtual table can only be used in
top-level SQL statements and cannot be used inside triggers or views.
**Default:** `false`.
* `useBigIntArguments` {boolean} If `true`, integer parameters passed to
`rows` are converted to `BigInt`s. **Default:** `false`.
Registers a virtual table module with the database. This method is a wrapper
around [`sqlite3_create_module_v2()`][]. Virtual tables allow JavaScript code
to provide the backing data for SQL tables. The registered module can be used
in two ways:
* **Eponymous table**: Query the module name directly without creating a table
(e.g., `SELECT * FROM module_name`).
* **Named virtual table**: Use `CREATE VIRTUAL TABLE t USING module_name` to
create a persistent virtual table.
Hidden columns can be used to pass parameters to the `rows` function using
table-valued function syntax (e.g., `SELECT * FROM module_name(param1, param2)`).
Values yielded by `rows` follow the conversion rules in [Type conversion between
JavaScript and SQLite][]: a {number} is stored as `REAL` and a {bigint} is
stored as `INTEGER`, regardless of the column's declared `type`. Unlike an
ordinary table, a virtual table does not apply column affinity to the values it
returns, so yield a {bigint} when a column needs `INTEGER` storage:
```js
db.createModule('counter', {
columns: [{ name: 'value', type: 'INTEGER' }],
*rows() {
yield [1]; // typeof(value) is 'real'
yield [2n]; // typeof(value) is 'integer'
},
});
```
```cjs
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.createModule('generate_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];
}
},
});
console.log(db.prepare('SELECT * FROM generate_series(1, 5, 1)').all());
// Prints: [ { value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }, { value: 5 } ]
```
```mjs
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.createModule('generate_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];
}
},
});
console.log(db.prepare('SELECT * FROM generate_series(1, 5, 1)').all());
// Prints: [ { value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }, { value: 5 } ]
```
### `database.createSession([options])`
<!-- YAML
@@ -2010,6 +2118,7 @@ callback function to indicate what type of operation is being authorized.
[`sqlite3_column_origin_name()`]: https://www.sqlite.org/c3ref/column_database_name.html
[`sqlite3_column_table_name()`]: https://www.sqlite.org/c3ref/column_database_name.html
[`sqlite3_create_function_v2()`]: https://www.sqlite.org/c3ref/create_function.html
[`sqlite3_create_module_v2()`]: https://www.sqlite.org/c3ref/create_module.html
[`sqlite3_create_window_function()`]: https://www.sqlite.org/c3ref/create_function.html
[`sqlite3_db_filename()`]: https://sqlite.org/c3ref/db_filename.html
[`sqlite3_deserialize()`]: https://sqlite.org/c3ref/deserialize.html
+718
View File
@@ -16,9 +16,12 @@
#include "util-inl.h"
#include <array>
#include <charconv>
#include <cinttypes>
#include <cmath>
#include <cstring>
#include <limits>
#include <string>
namespace node {
namespace sqlite {
@@ -1022,6 +1025,483 @@ BaseObjectPtr<DatabaseSyncLimits> DatabaseSyncLimits::Create(
return MakeBaseObject<DatabaseSyncLimits>(env, obj, std::move(database));
}
// ---------------------------------------------------------------------------
// VirtualTableModule
// ---------------------------------------------------------------------------
VirtualTableModule::VirtualTableModule(Environment* env,
BaseObjectWeakPtr<DatabaseSync> db,
Local<Function> rows_fn,
std::string&& schema_sql,
int num_columns,
std::vector<int>&& hidden_col_indices,
bool use_bigint_args,
bool direct_only)
: env_(env),
db_(std::move(db)),
rows_fn_(env->isolate(), rows_fn),
schema_sql_(std::move(schema_sql)),
num_columns_(num_columns),
hidden_col_indices_(std::move(hidden_col_indices)),
use_bigint_args_(use_bigint_args),
direct_only_(direct_only),
module_def_({}) {
// Initialize the sqlite3_module definition. Each VirtualTableModule instance
// gets its own copy to avoid thread-safety issues with worker threads.
module_def_.iVersion = 1;
module_def_.xCreate = VirtualTableModule::xCreate;
module_def_.xConnect = VirtualTableModule::xCreate;
module_def_.xBestIndex = VirtualTableModule::xBestIndex;
module_def_.xDisconnect = VirtualTableModule::xDisconnect;
module_def_.xDestroy = VirtualTableModule::xDestroy;
module_def_.xOpen = VirtualTableModule::xOpen;
module_def_.xClose = VirtualTableModule::xClose;
module_def_.xFilter = VirtualTableModule::xFilter;
module_def_.xNext = VirtualTableModule::xNext;
module_def_.xEof = VirtualTableModule::xEof;
module_def_.xColumn = VirtualTableModule::xColumn;
module_def_.xRowid = VirtualTableModule::xRowid;
// Build mapping from schema column index to row array index.
// Visible columns are numbered sequentially; hidden columns map to -1.
col_index_map_.assign(num_columns, 0);
for (int idx : hidden_col_indices_) {
col_index_map_[idx] = -1;
}
int visible_idx = 0;
for (int i = 0; i < num_columns; i++) {
if (col_index_map_[i] < 0) {
continue;
}
col_index_map_[i] = visible_idx++;
}
}
VirtualTableModule::~VirtualTableModule() {}
int VirtualTableModule::PropagateJSError() {
if (db_) {
db_->SetIgnoreNextSQLiteError(true);
}
return SQLITE_ERROR;
}
int VirtualTableModule::ReportProtocolError(sqlite3_vtab* vtab,
const char* message) {
sqlite3_free(vtab->zErrMsg);
vtab->zErrMsg = sqlite3_mprintf("%s", message);
return SQLITE_ERROR;
}
bool VirtualTableModule::CanCallIntoJS() const {
return db_ && !db_->IsInDestructor();
}
void VirtualTableModule::ReleaseHiddenValues(NodeVTabCursor* cursor) {
for (sqlite3_value*& value : cursor->hidden_values) {
if (value != nullptr) {
sqlite3_value_free(value);
value = nullptr;
}
}
}
int VirtualTableModule::xCreate(sqlite3* db,
void* pAux,
int argc,
const char* const* argv,
sqlite3_vtab** ppVTab,
char** pzErr) {
VirtualTableModule* mod = static_cast<VirtualTableModule*>(pAux);
int rc = sqlite3_declare_vtab(db, mod->schema_sql_.c_str());
if (rc != SQLITE_OK) {
*pzErr = sqlite3_mprintf("%s", sqlite3_errmsg(db));
return rc;
}
if (mod->direct_only_) {
sqlite3_vtab_config(db, SQLITE_VTAB_DIRECTONLY);
}
NodeVTab* vtab = new NodeVTab();
memset(&vtab->base, 0, sizeof(vtab->base));
vtab->module = mod;
*ppVTab = &vtab->base;
return SQLITE_OK;
}
int VirtualTableModule::xBestIndex(sqlite3_vtab* pVTab,
sqlite3_index_info* pInfo) {
NodeVTab* vtab = reinterpret_cast<NodeVTab*>(pVTab);
VirtualTableModule* mod = vtab->module;
int num_hidden = static_cast<int>(mod->hidden_col_indices_.size());
int argv_index = 0;
// Comma-separated list of the hidden column indices that received a
// constraint, in argv order. Passed to xFilter via idxStr so it can map each
// argv value back to the right parameter. A bitmask in idxNum would cap the
// number of parameters at the width of an int.
std::string idx_str;
// For each hidden column (parameter), look for a usable EQ constraint.
for (int hidden_idx = 0; hidden_idx < num_hidden; hidden_idx++) {
int col = mod->hidden_col_indices_[hidden_idx];
for (int i = 0; i < pInfo->nConstraint; i++) {
if (pInfo->aConstraint[i].iColumn == col &&
pInfo->aConstraint[i].usable &&
pInfo->aConstraint[i].op == SQLITE_INDEX_CONSTRAINT_EQ) {
argv_index++;
pInfo->aConstraintUsage[i].argvIndex = argv_index;
pInfo->aConstraintUsage[i].omit = 1;
if (!idx_str.empty()) {
idx_str += ',';
}
idx_str += std::to_string(hidden_idx);
break;
}
}
}
if (!idx_str.empty()) {
pInfo->idxStr = sqlite3_mprintf("%s", idx_str.c_str());
if (pInfo->idxStr == nullptr) {
return SQLITE_NOMEM;
}
pInfo->needToFreeIdxStr = 1;
}
pInfo->idxNum = argv_index;
// Each consumed constraint has to make the plan look cheaper, or the planner
// is free to choose the unconstrained plan and recheck the constraints
// afterwards. That recheck is what turns an unpicked plan into an empty
// result for table-valued syntax.
double estimated_rows = 1000.0;
for (int i = 0; i < argv_index; i++) {
estimated_rows /= 10.0;
}
estimated_rows = std::max(estimated_rows, 1.0);
pInfo->estimatedRows = static_cast<sqlite3_int64>(estimated_rows);
pInfo->estimatedCost = estimated_rows;
return SQLITE_OK;
}
int VirtualTableModule::xDisconnect(sqlite3_vtab* pVTab) {
NodeVTab* vtab = reinterpret_cast<NodeVTab*>(pVTab);
delete vtab;
return SQLITE_OK;
}
int VirtualTableModule::xDestroy(sqlite3_vtab* pVTab) {
return xDisconnect(pVTab);
}
int VirtualTableModule::xOpen(sqlite3_vtab* pVTab,
sqlite3_vtab_cursor** ppCursor) {
NodeVTab* vtab = reinterpret_cast<NodeVTab*>(pVTab);
NodeVTabCursor* cursor = new NodeVTabCursor();
memset(&cursor->base, 0, sizeof(cursor->base));
cursor->module = vtab->module;
cursor->hidden_values.assign(vtab->module->num_columns_, nullptr);
cursor->rowid = 0;
cursor->done = true;
*ppCursor = &cursor->base;
return SQLITE_OK;
}
int VirtualTableModule::xClose(sqlite3_vtab_cursor* pCursor) {
NodeVTabCursor* cursor = reinterpret_cast<NodeVTabCursor*>(pCursor);
VirtualTableModule* mod = cursor->module;
// Close the iterator so generator `finally` blocks still run when SQLite
// stops stepping early, as it does for LIMIT or a `break` out of a for...of
// loop. Skipped in two cases:
//
// - While the database is being torn down from a destructor, because those
// run from a garbage collection callback where JavaScript cannot be
// executed. An abandoned generator does not run `finally` in JavaScript
// either, so skipping matches the language.
// - When an error is already pending, because calling into JavaScript would
// discard it and the caller would see an empty result instead of the error.
// A generator whose own body threw has already run its `finally` as part of
// that throw, so this only affects an iterator abandoned while suspended
// because something else failed.
if (!cursor->iterator.IsEmpty() && mod->CanCallIntoJS() &&
!mod->env_->isolate()->HasPendingException()) {
Environment* env = mod->env_;
Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);
CallbackDepthGuard callback_guard(mod->db_.get());
// Scoped above the property lookup so a throwing `return` getter is
// handled the same way as a throwing `return()` method.
TryCatch try_catch(isolate);
Local<Object> iterator = cursor->iterator.Get(isolate);
Local<Value> return_method;
if (iterator->Get(env->context(), FIXED_ONE_BYTE_STRING(isolate, "return"))
.ToLocal(&return_method) &&
return_method->IsFunction()) {
USE(return_method.As<Function>()->Call(
env->context(), iterator, 0, nullptr));
}
// Re-throw so that a throwing `finally` is not silently discarded. SQLite
// discards xClose's return value, so there is no SQLite error here to
// suppress; calling PropagateJSError would leave the suppression flag set
// and swallow the next unrelated SQLite error.
if (try_catch.HasCaught() && !try_catch.HasTerminated()) {
try_catch.ReThrow();
}
}
ReleaseHiddenValues(cursor);
cursor->iterator.Reset();
cursor->current_row.Reset();
delete cursor;
return SQLITE_OK;
}
int VirtualTableModule::xFilter(sqlite3_vtab_cursor* pCursor,
int idxNum,
const char* idxStr,
int argc,
sqlite3_value** argv) {
NodeVTabCursor* cursor = reinterpret_cast<NodeVTabCursor*>(pCursor);
VirtualTableModule* mod = cursor->module;
Environment* env = mod->env_;
Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);
if (!mod->CanCallIntoJS()) {
return SQLITE_ERROR;
}
CallbackDepthGuard callback_guard(mod->db_.get());
cursor->rowid = 0;
cursor->done = false;
cursor->iterator.Reset();
cursor->current_row.Reset();
ReleaseHiddenValues(cursor);
// Build arguments for rows() from hidden column constraint values.
// idxStr (set in xBestIndex) lists the hidden column indices that received
// an EQ constraint, in argv order. Unconstrained parameters stay null.
int num_hidden = static_cast<int>(mod->hidden_col_indices_.size());
LocalVector<Value> js_args(isolate, num_hidden);
for (int i = 0; i < num_hidden; i++) {
js_args[i] = Null(isolate);
}
const char* p = idxStr;
const char* idx_end = p == nullptr ? nullptr : p + std::strlen(p);
for (int argv_pos = 0; argv_pos < argc && p != nullptr && p != idx_end;
argv_pos++) {
int hidden_idx = 0;
auto [next, ec] = std::from_chars(p, idx_end, hidden_idx);
if (ec != std::errc() || hidden_idx >= num_hidden) {
return SQLITE_ERROR;
}
p = (next != idx_end && *next == ',') ? next + 1 : next;
MaybeLocal<Value> js_val;
SQLITE_VALUE_TO_JS(
value, isolate, mod->use_bigint_args_, js_val, argv[argv_pos]);
Local<Value> local;
if (!js_val.ToLocal(&local)) {
return mod->PropagateJSError();
}
js_args[hidden_idx] = local;
// Keep a copy so xColumn can report what the column was constrained to.
// SQLite treats `omit` as a hint, so it may still recheck the constraint
// against the value xColumn returns.
int schema_idx = mod->hidden_col_indices_[hidden_idx];
cursor->hidden_values[schema_idx] = sqlite3_value_dup(argv[argv_pos]);
if (cursor->hidden_values[schema_idx] == nullptr) {
return SQLITE_NOMEM;
}
}
// Call the rows() function.
auto recv = Undefined(isolate);
auto fn = mod->rows_fn_.Get(isolate);
MaybeLocal<Value> retval =
fn->Call(env->context(), recv, js_args.size(), js_args.data());
Local<Value> result;
if (!retval.ToLocal(&result)) {
return mod->PropagateJSError();
}
// Get an iterator from the result. If the result has Symbol.iterator,
// call it. Otherwise, assume the result is already an iterator.
Local<Object> iterator_obj;
if (result->IsObject()) {
Local<Object> result_obj = result.As<Object>();
Local<Value> iter_method_val;
if (!result_obj->Get(env->context(), v8::Symbol::GetIterator(isolate))
.ToLocal(&iter_method_val)) {
return mod->PropagateJSError();
}
if (iter_method_val->IsFunction()) {
MaybeLocal<Value> iter_result = iter_method_val.As<Function>()->Call(
env->context(), result_obj, 0, nullptr);
Local<Value> iter_val;
if (!iter_result.ToLocal(&iter_val)) {
return mod->PropagateJSError();
}
if (!iter_val->IsObject()) {
return ReportProtocolError(
pCursor->pVtab,
"The \"options.rows\" iterable's Symbol.iterator method must "
"return an object");
}
iterator_obj = iter_val.As<Object>();
} else {
// Assume result is already an iterator (has .next()).
iterator_obj = result_obj;
}
} else {
return ReportProtocolError(
pCursor->pVtab,
"The \"options.rows\" function must return an iterable object");
}
cursor->iterator.Reset(isolate, iterator_obj);
// Advance to the first row.
return xNext(pCursor);
}
int VirtualTableModule::xNext(sqlite3_vtab_cursor* pCursor) {
NodeVTabCursor* cursor = reinterpret_cast<NodeVTabCursor*>(pCursor);
VirtualTableModule* mod = cursor->module;
Environment* env = mod->env_;
Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);
if (!mod->CanCallIntoJS()) {
return SQLITE_ERROR;
}
CallbackDepthGuard callback_guard(mod->db_.get());
Local<Object> iterator = cursor->iterator.Get(isolate);
// Call iterator.next().
Local<Value> next_method_val;
if (!iterator->Get(env->context(), FIXED_ONE_BYTE_STRING(isolate, "next"))
.ToLocal(&next_method_val)) {
return mod->PropagateJSError();
}
if (!next_method_val->IsFunction()) {
return ReportProtocolError(
pCursor->pVtab,
"The \"options.rows\" iterator must have a next() method");
}
MaybeLocal<Value> next_result = next_method_val.As<Function>()->Call(
env->context(), iterator, 0, nullptr);
Local<Value> next_val;
if (!next_result.ToLocal(&next_val)) {
return mod->PropagateJSError();
}
if (!next_val->IsObject()) {
return ReportProtocolError(
pCursor->pVtab,
"The \"options.rows\" iterator's next() method must return an object");
}
Local<Object> next_obj = next_val.As<Object>();
// Read "done" property.
Local<Value> done_val;
if (!next_obj->Get(env->context(), env->done_string()).ToLocal(&done_val)) {
return mod->PropagateJSError();
}
if (done_val->BooleanValue(isolate)) {
cursor->done = true;
cursor->current_row.Reset();
} else {
cursor->done = false;
cursor->rowid++;
// Read "value" property.
Local<Value> value_val;
if (!next_obj->Get(env->context(), env->value_string())
.ToLocal(&value_val)) {
return mod->PropagateJSError();
}
cursor->current_row.Reset(isolate, value_val);
}
return SQLITE_OK;
}
int VirtualTableModule::xEof(sqlite3_vtab_cursor* pCursor) {
NodeVTabCursor* cursor = reinterpret_cast<NodeVTabCursor*>(pCursor);
return cursor->done ? 1 : 0;
}
int VirtualTableModule::xColumn(sqlite3_vtab_cursor* pCursor,
sqlite3_context* ctx,
int i) {
NodeVTabCursor* cursor = reinterpret_cast<NodeVTabCursor*>(pCursor);
VirtualTableModule* mod = cursor->module;
if (i < 0 || i >= mod->num_columns_) {
sqlite3_result_null(ctx);
return SQLITE_OK;
}
// Hidden columns are parameters rather than data, so they are not present in
// the row array. Report the value the query constrained the column to, so
// that a recheck of that constraint still matches.
if (mod->col_index_map_[i] < 0) {
if (cursor->hidden_values[i] != nullptr) {
sqlite3_result_value(ctx, cursor->hidden_values[i]);
} else {
sqlite3_result_null(ctx);
}
return SQLITE_OK;
}
Environment* env = mod->env_;
Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);
if (!mod->CanCallIntoJS()) {
return SQLITE_ERROR;
}
CallbackDepthGuard callback_guard(mod->db_.get());
Local<Value> row = cursor->current_row.Get(isolate);
if (!row->IsObject()) {
sqlite3_result_null(ctx);
return SQLITE_OK;
}
Local<Object> row_obj = row.As<Object>();
Local<Value> col_val;
if (!row_obj->Get(env->context(), mod->col_index_map_[i]).ToLocal(&col_val)) {
sqlite3_result_error(ctx, "", 0);
return mod->PropagateJSError();
}
JSValueToSQLiteResult(isolate, ctx, col_val);
return SQLITE_OK;
}
int VirtualTableModule::xRowid(sqlite3_vtab_cursor* pCursor,
sqlite3_int64* pRowid) {
NodeVTabCursor* cursor = reinterpret_cast<NodeVTabCursor*>(pCursor);
*pRowid = cursor->rowid;
return SQLITE_OK;
}
void VirtualTableModule::xDestroyModule(void* pAux) {
delete static_cast<VirtualTableModule*>(pAux);
}
DatabaseSync::DatabaseSync(Environment* env,
Local<Object> object,
DatabaseOpenConfiguration&& open_config,
@@ -1067,6 +1547,10 @@ void DatabaseSync::DeleteSessions() {
}
DatabaseSync::~DatabaseSync() {
// See the note in ~StatementSync: closing the connection here must not reach
// back into JavaScript.
DestructorScope destructor_scope(this);
BindingData* binding =
env()->principal_realm()->GetBindingData<BindingData>();
if (binding != nullptr) binding->open_databases.erase(this);
@@ -2338,6 +2822,235 @@ void DatabaseSync::AggregateFunction(const FunctionCallbackInfo<Value>& args) {
CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void());
}
void DatabaseSync::CreateModule(const FunctionCallbackInfo<Value>& args) {
DatabaseSync* db;
ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
Environment* env = Environment::GetCurrent(args);
THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open");
THROW_AND_RETURN_IF_IN_AUTHORIZER(env, db);
if (!args[0]->IsString()) {
THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
"The \"name\" argument must be a string.");
return;
}
if (!args[1]->IsObject()) {
THROW_ERR_INVALID_ARG_TYPE(env->isolate(),
"The \"options\" argument must be an object.");
return;
}
Utf8Value name(env->isolate(), args[0].As<String>());
Local<Object> options = args[1].As<Object>();
// Extract columns array.
Local<Value> columns_v;
if (!options
->Get(env->context(),
FIXED_ONE_BYTE_STRING(env->isolate(), "columns"))
.ToLocal(&columns_v)) {
return;
}
if (!columns_v->IsArray()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(), "The \"options.columns\" argument must be an array.");
return;
}
Local<Array> columns = columns_v.As<Array>();
uint32_t num_columns = columns->Length();
if (num_columns == 0) {
THROW_ERR_INVALID_ARG_VALUE(
env->isolate(), "The \"options.columns\" array must not be empty.");
return;
}
// Extract rows function.
Local<Value> rows_v;
if (!options
->Get(env->context(), FIXED_ONE_BYTE_STRING(env->isolate(), "rows"))
.ToLocal(&rows_v)) {
return;
}
if (!rows_v->IsFunction()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(), "The \"options.rows\" argument must be a function.");
return;
}
Local<Function> rows_fn = rows_v.As<Function>();
// Extract optional boolean options.
bool direct_only = false;
Local<Value> direct_only_v;
if (!options
->Get(env->context(),
FIXED_ONE_BYTE_STRING(env->isolate(), "directOnly"))
.ToLocal(&direct_only_v)) {
return;
}
if (!direct_only_v->IsUndefined()) {
if (!direct_only_v->IsBoolean()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(),
"The \"options.directOnly\" argument must be a boolean.");
return;
}
direct_only = direct_only_v.As<Boolean>()->Value();
}
bool use_bigint_args = false;
Local<Value> use_bigint_args_v;
if (!options
->Get(env->context(),
FIXED_ONE_BYTE_STRING(env->isolate(), "useBigIntArguments"))
.ToLocal(&use_bigint_args_v)) {
return;
}
if (!use_bigint_args_v->IsUndefined()) {
if (!use_bigint_args_v->IsBoolean()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(),
"The \"options.useBigIntArguments\" argument must be a boolean.");
return;
}
use_bigint_args = use_bigint_args_v.As<Boolean>()->Value();
}
// Build CREATE TABLE schema SQL from columns.
std::string schema_sql = "CREATE TABLE x(";
std::vector<int> hidden_col_indices;
for (uint32_t i = 0; i < num_columns; i++) {
Local<Value> col_v;
if (!columns->Get(env->context(), i).ToLocal(&col_v)) {
return;
}
if (!col_v->IsObject()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(),
"Each column in \"options.columns\" must be an object.");
return;
}
Local<Object> col = col_v.As<Object>();
// Get column name.
Local<Value> col_name_v;
if (!col->Get(env->context(), env->name_string()).ToLocal(&col_name_v)) {
return;
}
if (!col_name_v->IsString()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(), "The column \"name\" property must be a string.");
return;
}
Utf8Value col_name(env->isolate(), col_name_v.As<String>());
// Get column type.
Local<Value> col_type_v;
if (!col->Get(env->context(), env->type_string()).ToLocal(&col_type_v)) {
return;
}
if (!col_type_v->IsString()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(), "The column \"type\" property must be a string.");
return;
}
Utf8Value col_type(env->isolate(), col_type_v.As<String>());
// Get optional hidden flag.
bool hidden = false;
Local<Value> hidden_v;
if (!col->Get(env->context(),
FIXED_ONE_BYTE_STRING(env->isolate(), "hidden"))
.ToLocal(&hidden_v)) {
return;
}
if (!hidden_v->IsUndefined()) {
if (!hidden_v->IsBoolean()) {
THROW_ERR_INVALID_ARG_TYPE(
env->isolate(),
"The column \"hidden\" property must be a boolean.");
return;
}
hidden = hidden_v.As<Boolean>()->Value();
}
if (hidden) {
hidden_col_indices.push_back(static_cast<int>(i));
}
if (i > 0) {
schema_sql += ", ";
}
// Validate column type against allowed SQLite type names.
std::string type_str = col_type.ToString();
if (type_str != "INTEGER" && type_str != "TEXT" && type_str != "REAL" &&
type_str != "BLOB" && type_str != "ANY") {
THROW_ERR_INVALID_ARG_VALUE(
env->isolate(),
"The column \"type\" property must be one of "
"'INTEGER', 'TEXT', 'REAL', 'BLOB', or 'ANY'.");
return;
}
// Quote column name to prevent SQL injection.
schema_sql += "\"";
std::string name_str = col_name.ToString();
for (char c : name_str) {
if (c == '"') {
schema_sql += "\"\"";
} else {
schema_sql += c;
}
}
schema_sql += "\" ";
schema_sql += type_str;
if (hidden) {
schema_sql += " HIDDEN";
}
}
schema_sql += ")";
// Reading the options bag and the column definitions above can run user
// JavaScript through a property getter, which may have closed the database
// since it was checked.
THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open");
VirtualTableModule* vtab_mod =
new VirtualTableModule(env,
BaseObjectWeakPtr<DatabaseSync>(db),
rows_fn,
std::move(schema_sql),
num_columns,
std::move(hidden_col_indices),
use_bigint_args,
direct_only);
int r = sqlite3_create_module_v2(db->connection_.get(),
*name,
&vtab_mod->module_def_,
vtab_mod,
VirtualTableModule::xDestroyModule);
CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void());
}
void DatabaseSync::CreateSession(const FunctionCallbackInfo<Value>& args) {
DatabaseSync* db;
ASSIGN_OR_RETURN_UNWRAP(&db, args.This());
@@ -3009,6 +3722,10 @@ StatementSync::StatementSync(Environment* env,
}
StatementSync::~StatementSync() {
// Runs from a garbage collection callback, so finalizing the statement here
// must not reach back into JavaScript. The scope has to live here rather than
// in Close(), which is shared with the JS-facing close() and dispose().
DestructorScope destructor_scope(db_.get());
Close();
}
@@ -4613,6 +5330,7 @@ static void Initialize(Local<Object> target,
SetProtoMethod(isolate, db_tmpl, "deserialize", DatabaseSync::Deserialize);
SetProtoMethod(
isolate, db_tmpl, "setAuthorizer", DatabaseSync::SetAuthorizer);
SetProtoMethod(isolate, db_tmpl, "createModule", DatabaseSync::CreateModule);
SetSideEffectFreeGetter(isolate,
db_tmpl,
FIXED_ONE_BYTE_STRING(isolate, "isOpen"),
+108
View File
@@ -250,6 +250,7 @@ class DatabaseSync : public BaseObject {
static void Serialize(const v8::FunctionCallbackInfo<v8::Value>& args);
static void Deserialize(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetAuthorizer(const v8::FunctionCallbackInfo<v8::Value>& args);
static void CreateModule(const v8::FunctionCallbackInfo<v8::Value>& args);
static int AuthorizerCallback(void* user_data,
int action_code,
const char* param1,
@@ -293,6 +294,10 @@ class DatabaseSync : public BaseObject {
void DecrementCallbackDepth() { --callback_depth_; }
bool IsInCallback() const { return callback_depth_ > 0; }
void IncrementDestructorDepth() { ++destructor_depth_; }
void DecrementDestructorDepth() { --destructor_depth_; }
bool IsInDestructor() const { return destructor_depth_ > 0; }
// SQLite reaches back into JavaScript from inside its pre-update hook, while
// it is still walking this connection's session list. Session objects are
// weak, so a garbage collection during such a callback could collect one and
@@ -340,6 +345,7 @@ class DatabaseSync : public BaseObject {
std::unique_ptr<sqlite3, ConnectionDeleter> connection_;
bool ignore_next_sqlite_error_;
int callback_depth_ = 0;
int destructor_depth_ = 0;
int authorizer_depth_ = 0;
int trace_suppression_depth_ = 0;
std::vector<sqlite3_stmt*> stepping_statements_;
@@ -358,6 +364,7 @@ class DatabaseSync : public BaseObject {
friend class Session;
friend class SQLTagStore;
friend class StatementExecutionHelper;
friend class VirtualTableModule;
};
class StatementSync : public BaseObject {
@@ -539,6 +546,23 @@ class CallbackDepthGuard {
std::vector<BaseObjectPtr<Session>> pinned_sessions_;
};
// Marks a window in which SQLite is being torn down from a C++ destructor.
// Those destructors run from V8 garbage collection callbacks, where executing
// JavaScript is forbidden, so callbacks reached through them must not call back
// into JavaScript.
class DestructorScope {
public:
explicit DestructorScope(DatabaseSync* db) : db_(db) {
db_->IncrementDestructorDepth();
}
~DestructorScope() { db_->DecrementDestructorDepth(); }
DestructorScope(const DestructorScope&) = delete;
DestructorScope& operator=(const DestructorScope&) = delete;
private:
DatabaseSync* db_;
};
class TraceEventSuppressionGuard {
public:
explicit TraceEventSuppressionGuard(DatabaseSync* db) : db_(db) {
@@ -627,6 +651,90 @@ class DatabaseSyncLimits : public BaseObject {
BaseObjectWeakPtr<DatabaseSync> database_;
};
struct NodeVTab {
sqlite3_vtab base;
class VirtualTableModule* module;
};
struct NodeVTabCursor {
sqlite3_vtab_cursor base;
class VirtualTableModule* module;
v8::Global<v8::Object> iterator;
v8::Global<v8::Value> current_row;
// The value each hidden column was constrained to, indexed by schema column
// index and empty for visible columns. These are owned copies, since the
// values SQLite passes to xFilter are only valid for that call.
std::vector<sqlite3_value*> hidden_values;
sqlite3_int64 rowid;
bool done;
};
class VirtualTableModule {
public:
VirtualTableModule(Environment* env,
BaseObjectWeakPtr<DatabaseSync> db,
v8::Local<v8::Function> rows_fn,
std::string&& schema_sql,
int num_columns,
std::vector<int>&& hidden_col_indices,
bool use_bigint_args,
bool direct_only);
~VirtualTableModule();
static int xCreate(sqlite3* db,
void* pAux,
int argc,
const char* const* argv,
sqlite3_vtab** ppVTab,
char** pzErr);
static int xBestIndex(sqlite3_vtab* pVTab, sqlite3_index_info* pInfo);
static int xDisconnect(sqlite3_vtab* pVTab);
static int xDestroy(sqlite3_vtab* pVTab);
static int xOpen(sqlite3_vtab* pVTab, sqlite3_vtab_cursor** ppCursor);
static int xClose(sqlite3_vtab_cursor* pCursor);
static int xFilter(sqlite3_vtab_cursor* pCursor,
int idxNum,
const char* idxStr,
int argc,
sqlite3_value** argv);
static int xNext(sqlite3_vtab_cursor* pCursor);
static int xEof(sqlite3_vtab_cursor* pCursor);
static int xColumn(sqlite3_vtab_cursor* pCursor, sqlite3_context* ctx, int i);
static int xRowid(sqlite3_vtab_cursor* pCursor, sqlite3_int64* pRowid);
static void xDestroyModule(void* pAux);
private:
// Suppresses the SQLite error text so the pending JavaScript exception is
// what surfaces to the caller. Always returns SQLITE_ERROR.
int PropagateJSError();
// Reports a violation of the iteration protocol by the `rows` function.
// Unlike PropagateJSError there is no JavaScript exception to surface, so an
// error message has to be supplied. Always returns SQLITE_ERROR.
static int ReportProtocolError(sqlite3_vtab* vtab, const char* message);
// False while the database is being torn down from a destructor, which runs
// from a garbage collection callback where JavaScript cannot be executed.
bool CanCallIntoJS() const;
static void ReleaseHiddenValues(NodeVTabCursor* cursor);
Environment* env_;
BaseObjectWeakPtr<DatabaseSync> db_;
v8::Global<v8::Function> rows_fn_;
std::string schema_sql_;
int num_columns_;
std::vector<int> hidden_col_indices_;
// Maps schema column index to row array index for visible columns.
// Hidden columns are mapped to -1.
std::vector<int> col_index_map_;
bool use_bigint_args_;
bool direct_only_;
sqlite3_module module_def_;
friend class DatabaseSync;
};
} // namespace sqlite
} // namespace node
@@ -84,6 +84,40 @@ suite('closing the database from an options getter', () => {
}, invalidState);
});
test('createModule() throws instead of using a closed connection', (t) => {
const db = new DatabaseSync(':memory:');
t.assert.throws(() => {
db.createModule('mod', {
columns: [{ name: 'value', type: 'INTEGER' }],
*rows() {
yield [1];
},
get directOnly() {
db.close();
return false;
},
});
}, invalidState);
});
test('createModule() throws when a column getter closes the database', (t) => {
const db = new DatabaseSync(':memory:');
t.assert.throws(() => {
db.createModule('mod', {
columns: [{
name: 'value',
get type() {
db.close();
return 'INTEGER';
},
}],
*rows() {
yield [1];
},
});
}, invalidState);
});
test('deserialize() throws instead of using a closed connection', (t) => {
const source = new DatabaseSync(':memory:');
source.exec('CREATE TABLE data(value TEXT)');
+816
View File
@@ -0,0 +1,816 @@
// Flags: --expose-gc
'use strict';
const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();
const assert = require('node:assert');
const { DatabaseSync } = require('node:sqlite');
const { suite, test } = require('node:test');
suite('DatabaseSync.prototype.createModule()', () => {
suite('input validation', () => {
const db = new DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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);
});
});
suite('type conversions', () => {
test('handles various SQLite data types', () => {
const db = new DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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('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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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 DatabaseSync(':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);
});
});
});