feat: cache parameter bindings

This commit is contained in:
Fedor Indutny 2025-09-05 10:00:29 -07:00 committed by GitHub
parent e528fa8aaa
commit ac72ab5354
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 141 additions and 52 deletions

2
.nvmrc
View File

@ -1 +1 @@
20.18.2
22.18.0

View File

@ -2,7 +2,8 @@ import { Buffer } from 'node:buffer';
import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3';
import Database from '../lib/index.js';
import { DatabaseSync as NDatabase } from 'node:sqlite';
import Database from '../dist/index.mjs';
const PREPARE = `
CREATE TABLE t (
@ -21,12 +22,15 @@ const DELETE = 'DELETE FROM t';
describe('INSERT INTO t', () => {
const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE);
bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
bench(
'@signalapp/sqlcipher',
@ -51,4 +55,16 @@ describe('INSERT INTO t', () => {
},
},
);
bench(
'node:sqlite',
() => {
ninsert.run({ b: BLOB });
},
{
teardown: () => {
ndb.exec(DELETE);
},
},
);
});

View File

@ -1,7 +1,8 @@
import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3';
import Database from '../lib/index.js';
import { DatabaseSync as NDatabase } from 'node:sqlite';
import Database from '../dist/index.mjs';
const PREPARE = `
CREATE TABLE t (
@ -24,12 +25,15 @@ const DELETE = 'DELETE FROM t';
describe('INSERT INTO t', () => {
const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE);
bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
bench(
'@signalapp/sqlcipher',
@ -54,4 +58,16 @@ describe('INSERT INTO t', () => {
},
},
);
bench(
'node:sqlite',
() => {
ninsert.run({ a1: 1, a2: 2, a3: 3, b1: 'b1', b2: 'b2', b3: 'b3' });
},
{
teardown: () => {
ndb.exec(DELETE);
},
},
);
});

View File

@ -1,7 +1,8 @@
import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3';
import Database from '../lib/index.js';
import { DatabaseSync as NDatabase } from 'node:sqlite';
import Database from '../dist/index.mjs';
const PREPARE = `
CREATE TABLE t (
@ -36,12 +37,15 @@ const SELECT = 'SELECT * FROM t LIMIT 1000';
describe('SELECT * FROM t', () => {
const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE);
bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
sdb.transaction(() => {
for (const value of VALUES) {
@ -55,6 +59,12 @@ describe('SELECT * FROM t', () => {
}
})();
ndb.exec('BEGIN');
for (const value of VALUES) {
ninsert.run(value);
}
ndb.exec('COMMIT');
const sselect = sdb.prepare(SELECT);
const bselect = bdb.prepare(SELECT);
@ -65,4 +75,10 @@ describe('SELECT * FROM t', () => {
bench('@signalapp/better-sqlite', () => {
bselect.all();
});
bench('node:sqlite', () => {
// Node.js seems to finalize the statement after `.all()`
const nselect = ndb.prepare(SELECT);
nselect.all();
});
});

View File

@ -27,15 +27,16 @@ const addon = bindings<{
persistent: boolean,
pluck: boolean,
bigint: boolean,
paramNames: Array<string | null>,
): NativeStatement;
statementRun<Options extends StatementOptions>(
stmt: NativeStatement,
params: StatementParameters<Options> | undefined,
params: NativeParameters<Options> | undefined,
result: [number, number],
): void;
statementStep<Options extends StatementOptions>(
stmt: NativeStatement,
params: StatementParameters<Options> | null | undefined,
params: NativeParameters<Options> | null | undefined,
cache: Array<SqliteValue<Options>> | undefined,
isGet: boolean,
): Array<SqliteValue<Options>>;
@ -85,11 +86,15 @@ export type StatementOptions = Readonly<{
bigint?: true;
}>;
export type NativeParameters<Options extends StatementOptions> = ReadonlyArray<
SqliteValue<Options>
>;
/**
* Parameters accepted by `.run()`/`.get()`/`.all()` methods of the statement.
*/
export type StatementParameters<Options extends StatementOptions> =
| ReadonlyArray<SqliteValue<Options>>
| NativeParameters<Options>
| Readonly<Record<string, SqliteValue<Options>>>;
/**
@ -119,6 +124,9 @@ class Statement<Options extends StatementOptions = object> {
#cache: Array<SqliteValue<Options>> | undefined;
#createRow: undefined | ((result: unknown) => RowType<Options>);
#translateParams: (
params: StatementParameters<Options>,
) => NativeParameters<Options>;
#native: NativeStatement | undefined;
#onClose: (() => void) | undefined;
@ -131,14 +139,47 @@ class Statement<Options extends StatementOptions = object> {
) {
this.#needsTranslation = persistent === true && !pluck;
const paramNames = new Array<string | null>();
this.#native = addon.statementNew(
db,
query,
persistent === true,
pluck === true,
bigint === true,
paramNames,
);
const isArrayParams = paramNames.every((name) => name === null);
const isObjectParams =
!isArrayParams && paramNames.every((name) => typeof name === 'string');
if (!isArrayParams && !isObjectParams) {
throw new TypeError('Cannot mix named and anonymous params in query');
}
if (isArrayParams) {
this.#translateParams = (params) => {
if (!Array.isArray(params)) {
throw new TypeError('Query requires an array of anonymous params');
}
return params;
};
} else {
this.#translateParams = runInThisContext(`
(function translateParams(params) {
if (Array.isArray(params)) {
throw new TypeError('Query requires an object of named params');
}
return [
${paramNames
.map((name) => `params[${JSON.stringify(name)}]`)
.join(',\n')}
];
})
`);
}
this.#onClose = onClose;
}
@ -154,8 +195,8 @@ class Statement<Options extends StatementOptions = object> {
throw new Error('Statement closed');
}
const result: [number, number] = [0, 0];
this.#checkParams(params);
addon.statementRun(this.#native, params, result);
const nativeParams = this.#checkParams(params);
addon.statementRun(this.#native, nativeParams, result);
return { changes: result[0], lastInsertRowid: result[1] };
}
@ -174,8 +215,13 @@ class Statement<Options extends StatementOptions = object> {
if (this.#native === undefined) {
throw new Error('Statement closed');
}
this.#checkParams(params);
const result = addon.statementStep(this.#native, params, this.#cache, true);
const nativeParams = this.#checkParams(params);
const result = addon.statementStep(
this.#native,
nativeParams,
this.#cache,
true,
);
if (result === undefined) {
return undefined;
}
@ -202,9 +248,8 @@ class Statement<Options extends StatementOptions = object> {
throw new Error('Statement closed');
}
const result = [];
this.#checkParams(params);
let singleUseParams: StatementParameters<Options> | undefined | null =
params;
const nativeParams = this.#checkParams(params);
let singleUseParams: typeof nativeParams | undefined | null = nativeParams;
while (true) {
const single = addon.statementStep(
this.#native,
@ -282,9 +327,11 @@ class Statement<Options extends StatementOptions = object> {
}
/** @internal */
#checkParams(params: StatementParameters<Options> | undefined): void {
#checkParams(
params: StatementParameters<Options> | undefined,
): NativeParameters<Options> | undefined {
if (params === undefined) {
return;
return undefined;
}
if (typeof params !== 'object') {
throw new TypeError('Params must be either object or array');
@ -292,6 +339,7 @@ class Statement<Options extends StatementOptions = object> {
if (params === null) {
throw new TypeError('Params cannot be null');
}
return this.#translateParams(params);
}
}

View File

@ -406,12 +406,14 @@ Napi::Value Statement::New(const Napi::CallbackInfo& info) {
auto is_persistent = info[2].As<Napi::Boolean>();
auto is_pluck = info[3].As<Napi::Boolean>();
auto is_bigint = info[4].As<Napi::Boolean>();
auto param_names = info[5].As<Napi::Array>();
assert(db_external.IsExternal());
assert(query.IsString());
assert(is_persistent.IsBoolean());
assert(is_pluck.IsBoolean());
assert(is_bigint.IsBoolean());
assert(param_names.IsArray());
auto db = db_external.Data();
@ -440,6 +442,18 @@ Napi::Value Statement::New(const Napi::CallbackInfo& info) {
auto stmt = new Statement(db, db_external, handle, is_persistent, is_pluck,
is_bigint);
int key_count = sqlite3_bind_parameter_count(handle);
for (int i = 1; i <= key_count; i++) {
auto name = sqlite3_bind_parameter_name(handle, i);
if (name == nullptr) {
param_names[i - 1] = env.Null();
} else {
// Skip "$"
param_names[i - 1] = name + 1;
}
}
return Napi::External<Statement>::New(
env, stmt, [](Napi::Env env, Statement* stmt) { delete stmt; });
}
@ -733,36 +747,18 @@ bool Statement::BindParams(Napi::Env env, Napi::Value params) {
for (int i = 1; i <= list_len; i++) {
auto name = sqlite3_bind_parameter_name(handle_, i);
if (name != nullptr) {
NAPI_THROW(FormatError(env, "Unexpected named param %s at %d", name, i),
false);
}
auto error = BindParam(env, i, list[i - 1]);
if (error != nullptr) {
NAPI_THROW(
FormatError(env, "Failed to bind param %d, error %s", i, error),
false);
}
}
} else {
auto obj = params.As<Napi::Object>();
for (int i = 1; i <= key_count; i++) {
auto name = sqlite3_bind_parameter_name(handle_, i);
if (name == nullptr) {
NAPI_THROW(FormatError(env, "Unexpected anonymous param at %d", i),
false);
}
// Skip "$"
name = name + 1;
auto value = obj[name];
auto error = BindParam(env, i, value);
if (error != nullptr) {
NAPI_THROW(
FormatError(env, "Failed to bind param %s, error %s", name, error),
false);
if (name == nullptr) {
NAPI_THROW(
FormatError(env, "Failed to bind param %d, error %s", i, error),
false);
} else {
NAPI_THROW(FormatError(env, "Failed to bind param %s, error %s",
name + 1, error),
false);
}
}
}
}

View File

@ -212,12 +212,16 @@ describe('list parameters', () => {
test('object parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > ?');
expect(() => stmt.get({})).toThrowError('Unexpected anonymous param at 1');
expect(() => stmt.get({})).toThrowError(
'Query requires an array of anonymous params',
);
});
test('against named parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > $a');
expect(() => stmt.get([2])).toThrowError('Unexpected named param $a at 1');
expect(() => stmt.get([2])).toThrowError(
'Query requires an object of named params',
);
});
});
@ -239,13 +243,6 @@ describe('object parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > $a');
expect(() => stmt.get()).toThrowError('Expected 1 parameters, got 0');
});
test('against anonymous parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > ?');
expect(() => stmt.get({ a: 1 })).toThrowError(
'Unexpected anonymous param at 1',
);
});
});
describe('tail', () => {