Skip to content

Commit db6b6b7

Browse files
committed
sqlite: use row template for result rows
Rows returned by get(), all() and iterate() were created with Object::New() and a null prototype. That produces a dictionary-mode object with its own property dictionary for every row. Cache a DictionaryTemplate per statement, rebuilt when the statement is re-prepared, so rows get fast properties and share a map. Column names that DictionaryTemplate cannot represent keep using Object::New(): array indices, duplicates, and non-ASCII names, which it would intern as Latin-1. Assisted-by: Claude Code Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
1 parent ae9c25a commit db6b6b7

4 files changed

Lines changed: 238 additions & 43 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use strict';
2+
const common = require('../common.js');
3+
const sqlite = require('node:sqlite');
4+
const assert = require('assert');
5+
6+
const bench = common.createBenchmark(main, {
7+
n: [20],
8+
rows: [100, 10000],
9+
method: ['all', 'iterate'],
10+
access: ['named', 'keyed'],
11+
});
12+
13+
const keys = ['text_column', 'integer_column', 'real_column', 'other_column'];
14+
15+
function readNamed(row) {
16+
return row.text_column.length + row.integer_column + row.real_column +
17+
row.other_column;
18+
}
19+
20+
function readKeyed(row) {
21+
let sum = 0;
22+
for (let k = 0; k < keys.length; k++) sum += row[keys[k]] === null ? 0 : 1;
23+
return sum;
24+
}
25+
26+
function main(conf) {
27+
const db = new sqlite.DatabaseSync(':memory:');
28+
db.exec(
29+
'CREATE TABLE foo (text_column TEXT, integer_column INTEGER, ' +
30+
'real_column REAL, blob_column BLOB, other_column INTEGER)',
31+
);
32+
const insert = db.prepare('INSERT INTO foo VALUES (?, ?, ?, ?, ?)');
33+
db.exec('BEGIN');
34+
for (let i = 0; i < conf.rows; i++) {
35+
insert.run(`text ${i}`, i, i / 3, Buffer.from('blob'), i * 2);
36+
}
37+
db.exec('COMMIT');
38+
39+
const stmt = db.prepare('SELECT * FROM foo');
40+
const read = conf.access === 'named' ? readNamed : readKeyed;
41+
const n = conf.n * (1e5 / conf.rows);
42+
let sink = 0;
43+
44+
bench.start();
45+
for (let i = 0; i < n; i++) {
46+
for (const row of conf.method === 'all' ? stmt.all() : stmt.iterate()) {
47+
sink += read(row);
48+
}
49+
}
50+
bench.end(n * conf.rows);
51+
52+
assert.ok(sink > 0);
53+
}

‎src/node_sqlite.cc‎

Lines changed: 79 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3759,6 +3759,7 @@ void Statement::Finalize() {
37593759
}
37603760

37613761
void Statement::InvalidateColumnNameCache() {
3762+
cached_row_template_.Reset();
37623763
cached_column_names_.clear();
37633764
cached_column_names_reprepare_count_ = -1;
37643765
}
@@ -3981,37 +3982,86 @@ MaybeLocal<Name> Statement::ColumnNameToName(const int column) {
39813982
.As<Name>();
39823983
}
39833984

3984-
// Populates `keys` with cached column names, rebuilding the cache if the
3985-
// statement was re-prepared.
3986-
bool Statement::GetCachedColumnNames(LocalVector<Name>* keys) {
3987-
Isolate* isolate = env()->isolate();
3985+
// Mirrors V8's String::AsArrayIndex(): canonical decimal below 2^32 - 1.
3986+
static bool IsArrayIndex(std::string_view name) {
3987+
uint32_t index;
3988+
const char* end = name.data() + name.size();
3989+
auto [ptr, ec] = std::from_chars(name.data(), end, index);
3990+
return ec == std::errc() && ptr == end &&
3991+
(name.size() == 1 || name[0] != '0') &&
3992+
index != std::numeric_limits<uint32_t>::max();
3993+
}
3994+
3995+
// DictionaryTemplate CHECKs that no key is an array index, interns keys as
3996+
// Latin-1 rather than UTF-8, and assumes keys are unique.
3997+
static bool CanUseRowTemplate(const std::vector<std::string_view>& names) {
3998+
std::unordered_set<std::string_view> seen;
3999+
for (std::string_view name : names) {
4000+
if (!simdutf::validate_ascii(name.data(), name.size()) ||
4001+
IsArrayIndex(name) || !seen.insert(name).second) {
4002+
return false;
4003+
}
4004+
}
4005+
return true;
4006+
}
39884007

4008+
// Rebuilds the row shape if the statement was re-prepared.
4009+
bool Statement::UpdateColumnNameCache() {
39894010
const int reprepare_count =
39904011
sqlite3_stmt_status(statement_.get(), SQLITE_STMTSTATUS_REPREPARE, false);
3991-
if (reprepare_count != cached_column_names_reprepare_count_) {
3992-
cached_column_names_.clear();
3993-
const int num_cols = sqlite3_column_count(statement_.get());
3994-
if (num_cols == 0) {
3995-
cached_column_names_reprepare_count_ = reprepare_count;
3996-
return true;
3997-
}
4012+
if (reprepare_count == cached_column_names_reprepare_count_) return true;
4013+
4014+
Isolate* isolate = env()->isolate();
4015+
InvalidateColumnNameCache();
4016+
const int num_cols = sqlite3_column_count(statement_.get());
4017+
std::vector<std::string_view> names;
4018+
names.reserve(num_cols);
4019+
for (int i = 0; i < num_cols; ++i) {
4020+
const char* name = sqlite3_column_name(statement_.get(), i);
4021+
// ColumnNameToName() below reports the error.
4022+
if (name == nullptr) break;
4023+
names.emplace_back(name);
4024+
}
4025+
4026+
if (names.size() == static_cast<size_t>(num_cols) &&
4027+
CanUseRowTemplate(names)) {
4028+
cached_row_template_.Reset(
4029+
isolate,
4030+
DictionaryTemplate::New(isolate, {names.data(), names.size()}));
4031+
} else {
39984032
cached_column_names_.reserve(num_cols);
39994033
for (int i = 0; i < num_cols; ++i) {
40004034
Local<Name> key;
40014035
if (!ColumnNameToName(i).ToLocal(&key)) {
40024036
InvalidateColumnNameCache();
40034037
return false;
40044038
}
4005-
cached_column_names_.emplace_back(Global<Name>(isolate, key));
4039+
cached_column_names_.emplace_back(isolate, key);
40064040
}
4007-
cached_column_names_reprepare_count_ = reprepare_count;
40084041
}
4042+
cached_column_names_reprepare_count_ = reprepare_count;
4043+
return true;
4044+
}
40094045

4010-
keys->reserve(cached_column_names_.size());
4046+
MaybeLocal<Object> Statement::NewRowObject(LocalVector<Value>* values) {
4047+
Isolate* isolate = env()->isolate();
4048+
if (!cached_row_template_.IsEmpty()) {
4049+
// NewInstance() may replace entries, so it needs a MaybeLocal copy.
4050+
MaybeStackBuffer<MaybeLocal<Value>, 16> maybe_values(values->size());
4051+
for (size_t i = 0; i < values->size(); ++i) maybe_values[i] = (*values)[i];
4052+
return NewDictionaryInstanceNullProto(env()->context(),
4053+
cached_row_template_.Get(isolate),
4054+
{maybe_values.out(), values->size()});
4055+
}
4056+
4057+
DCHECK_EQ(cached_column_names_.size(), values->size());
4058+
LocalVector<Name> keys(isolate);
4059+
keys.reserve(cached_column_names_.size());
40114060
for (const auto& name : cached_column_names_) {
4012-
keys->emplace_back(name.Get(isolate));
4061+
keys.emplace_back(name.Get(isolate));
40134062
}
4014-
return true;
4063+
return Object::New(
4064+
isolate, Null(isolate), keys.data(), values->data(), values->size());
40154065
}
40164066

40174067
MaybeLocal<Value> StatementExecutionHelper::ColumnToValue(Environment* env,
@@ -4056,12 +4106,15 @@ MaybeLocal<Value> StatementExecutionHelper::All(Environment* env,
40564106
int num_cols = 0;
40574107
LocalVector<Value> rows(isolate);
40584108
LocalVector<Value> row_values(isolate);
4059-
LocalVector<Name> row_keys(isolate);
40604109

40614110
SteppingStatementGuard stepping(db, stmt);
40624111
while ((r = sqlite3_step(stmt)) == SQLITE_ROW) {
40634112
if (num_cols == 0) {
40644113
num_cols = sqlite3_column_count(stmt);
4114+
// A running statement is only re-prepared before its first row.
4115+
if (!return_arrays && !statement->UpdateColumnNameCache()) {
4116+
return MaybeLocal<Value>();
4117+
}
40654118
}
40664119

40674120
if (ExtractRowValues(env, stmt, num_cols, use_big_ints, &row_values)
@@ -4074,16 +4127,10 @@ MaybeLocal<Value> StatementExecutionHelper::All(Environment* env,
40744127
Array::New(isolate, row_values.data(), row_values.size());
40754128
rows.emplace_back(row_array);
40764129
} else {
4077-
if (row_keys.size() == 0) {
4078-
// Reuses the statement's internalized column names instead of
4079-
// re-interning them on every call.
4080-
if (!statement->GetCachedColumnNames(&row_keys)) {
4081-
return MaybeLocal<Value>();
4082-
}
4130+
Local<Object> row_obj;
4131+
if (!statement->NewRowObject(&row_values).ToLocal(&row_obj)) {
4132+
return MaybeLocal<Value>();
40834133
}
4084-
DCHECK_EQ(row_keys.size(), row_values.size());
4085-
Local<Object> row_obj = Object::New(
4086-
isolate, Null(isolate), row_keys.data(), row_values.data(), num_cols);
40874134
rows.emplace_back(row_obj);
40884135
}
40894136
}
@@ -4221,16 +4268,10 @@ MaybeLocal<Value> StatementExecutionHelper::Get(Environment* env,
42214268
if (return_arrays) {
42224269
result = Array::New(isolate, row_values.data(), row_values.size());
42234270
} else {
4224-
LocalVector<Name> keys(isolate);
4225-
// Reuses the statement's internalized column names instead of
4226-
// re-interning them on every call.
4227-
if (!statement->GetCachedColumnNames(&keys)) {
4271+
if (!statement->UpdateColumnNameCache() ||
4272+
!statement->NewRowObject(&row_values).ToLocal(&result)) {
42284273
return MaybeLocal<Value>();
42294274
}
4230-
4231-
DCHECK_EQ(keys.size(), row_values.size());
4232-
result = Object::New(
4233-
isolate, Null(isolate), keys.data(), row_values.data(), num_cols);
42344275
}
42354276

42364277
RESET_AND_CHECK(isolate, db, stmt, needs_reset, MaybeLocal<Value>());
@@ -5031,7 +5072,6 @@ void StatementIterator::Next(const FunctionCallbackInfo<Value>& args) {
50315072

50325073
int num_cols = sqlite3_column_count(iter->stmt_->statement_.get());
50335074
Local<Value> row_value;
5034-
LocalVector<Name> row_keys(isolate);
50355075
LocalVector<Value> row_values(isolate);
50365076

50375077
if (ExtractRowValues(env,
@@ -5046,13 +5086,10 @@ void StatementIterator::Next(const FunctionCallbackInfo<Value>& args) {
50465086
if (iter->stmt_->return_arrays_) {
50475087
row_value = Array::New(isolate, row_values.data(), row_values.size());
50485088
} else {
5049-
// Use cached internalized column names to avoid repeated V8 string
5050-
// creation and enable hidden class sharing across row objects.
5051-
if (!iter->stmt_->GetCachedColumnNames(&row_keys)) return;
5052-
5053-
DCHECK_EQ(row_keys.size(), row_values.size());
5054-
row_value = Object::New(
5055-
isolate, Null(isolate), row_keys.data(), row_values.data(), num_cols);
5089+
if (!iter->stmt_->UpdateColumnNameCache() ||
5090+
!iter->stmt_->NewRowObject(&row_values).ToLocal(&row_value)) {
5091+
return;
5092+
}
50565093
}
50575094

50585095
MaybeLocal<Value> values[] = {Boolean::New(isolate, false), row_value};

‎src/node_sqlite.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,8 @@ class Statement : public BaseObject {
396396
static void Dispose(const v8::FunctionCallbackInfo<v8::Value>& args);
397397
v8::MaybeLocal<v8::Value> ColumnToValue(const int column);
398398
v8::MaybeLocal<v8::Name> ColumnNameToName(const int column);
399-
bool GetCachedColumnNames(v8::LocalVector<v8::Name>* keys);
399+
// Requires a successful UpdateColumnNameCache() for the current execution.
400+
v8::MaybeLocal<v8::Object> NewRowObject(v8::LocalVector<v8::Value>* values);
400401
void Finalize();
401402
bool IsFinalized();
402403

@@ -415,9 +416,13 @@ class Statement : public BaseObject {
415416
uint64_t reset_generation_ = 0;
416417
std::optional<std::map<std::string, std::string>> bare_named_params_;
417418
inline int ResetStatement();
419+
// Rows are built from the template, or from the names when they cannot be
420+
// template keys. At most one of the two is populated.
421+
v8::Global<v8::DictionaryTemplate> cached_row_template_;
418422
std::vector<v8::Global<v8::Name>> cached_column_names_;
419423
int cached_column_names_reprepare_count_ = -1;
420424
void InvalidateColumnNameCache();
425+
bool UpdateColumnNameCache();
421426
bool BindParams(const v8::FunctionCallbackInfo<v8::Value>& args);
422427
bool BindValue(const v8::Local<v8::Value>& value, const int index);
423428

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Flags: --allow-natives-syntax
2+
'use strict';
3+
const { skipIfSQLiteMissing } = require('../common');
4+
skipIfSQLiteMissing();
5+
const { DatabaseSync } = require('node:sqlite');
6+
const { suite, test } = require('node:test');
7+
8+
const hasFastProperties = eval('(o) => %HasFastProperties(o)');
9+
const haveSameMap = eval('(a, b) => %HaveSameMap(a, b)');
10+
11+
function assertSharedFastShape(t, rows) {
12+
t.assert.ok(rows.length >= 2);
13+
for (const row of rows) {
14+
t.assert.strictEqual(Object.getPrototypeOf(row), null);
15+
t.assert.ok(hasFastProperties(row));
16+
t.assert.ok(haveSameMap(rows[0], row));
17+
}
18+
}
19+
20+
function createDatabase() {
21+
const db = new DatabaseSync(':memory:');
22+
db.exec('CREATE TABLE t (a INTEGER, b TEXT); ' +
23+
"INSERT INTO t VALUES (1, 'x'), (2, 'y'), (3, 'z');");
24+
return db;
25+
}
26+
27+
suite('result rows share a fast map', () => {
28+
test('all()', (t) => {
29+
using db = createDatabase();
30+
using stmt = db.prepare('SELECT a, b FROM t');
31+
assertSharedFastShape(t, stmt.all());
32+
});
33+
34+
test('get()', (t) => {
35+
using db = createDatabase();
36+
using stmt = db.prepare('SELECT a, b FROM t WHERE a = ?');
37+
assertSharedFastShape(t, [stmt.get(1), stmt.get(2)]);
38+
});
39+
40+
test('iterate()', (t) => {
41+
using db = createDatabase();
42+
using stmt = db.prepare('SELECT a, b FROM t');
43+
assertSharedFastShape(t, [...stmt.iterate()]);
44+
});
45+
});
46+
47+
test('rows with differing value types keep their values', (t) => {
48+
using db = new DatabaseSync(':memory:');
49+
db.exec('CREATE TABLE t (v); ' +
50+
"INSERT INTO t VALUES (NULL), (1), (1.5), ('s'), (x'01');");
51+
using stmt = db.prepare('SELECT v FROM t ORDER BY rowid');
52+
const expected = [
53+
{ __proto__: null, v: null },
54+
{ __proto__: null, v: 1 },
55+
{ __proto__: null, v: 1.5 },
56+
{ __proto__: null, v: 's' },
57+
{ __proto__: null, v: new Uint8Array([1]) },
58+
];
59+
t.assert.deepStrictEqual(stmt.all(), expected);
60+
t.assert.deepStrictEqual([...stmt.iterate()], expected);
61+
stmt.setReadBigInts(true);
62+
t.assert.deepStrictEqual(stmt.all()[1], { __proto__: null, v: 1n });
63+
});
64+
65+
test('column names that cannot be template keys', (t) => {
66+
using db = new DatabaseSync(':memory:');
67+
const cases = [
68+
['SELECT 1 AS "0"', { __proto__: null, 0: 1 }],
69+
['SELECT 1 AS "4294967294"', { __proto__: null, 4294967294: 1 }],
70+
['SELECT 1 AS a, 2 AS a', { __proto__: null, a: 2 }],
71+
['SELECT 1 AS "café"', { __proto__: null, café: 1 }],
72+
];
73+
for (const [sql, row] of cases) {
74+
using stmt = db.prepare(sql);
75+
t.assert.deepStrictEqual(stmt.get(), row);
76+
t.assert.deepStrictEqual(stmt.all(), [row]);
77+
t.assert.deepStrictEqual([...stmt.iterate()], [row]);
78+
}
79+
});
80+
81+
test('names that look numeric but are not array indices', (t) => {
82+
using db = new DatabaseSync(':memory:');
83+
using stmt = db.prepare(
84+
'SELECT 1 AS "01", 2 AS "4294967295", 3 AS "-1", 4 AS "1.5"');
85+
const row = {
86+
__proto__: null, ['01']: 1, 4294967295: 2, ['-1']: 3, ['1.5']: 4,
87+
};
88+
t.assert.deepStrictEqual(stmt.get(), row);
89+
t.assert.deepStrictEqual([...stmt.iterate()], [row]);
90+
});
91+
92+
test('iterate() picks up columns added by a re-prepare', (t) => {
93+
using db = new DatabaseSync(':memory:');
94+
db.exec("CREATE TABLE t (a); INSERT INTO t VALUES ('x');");
95+
using stmt = db.prepare('SELECT * FROM t');
96+
t.assert.deepStrictEqual([...stmt.iterate()], [{ __proto__: null, a: 'x' }]);
97+
db.exec("ALTER TABLE t ADD COLUMN b DEFAULT 'y';");
98+
t.assert.deepStrictEqual([...stmt.iterate()],
99+
[{ __proto__: null, a: 'x', b: 'y' }]);
100+
});

0 commit comments

Comments
 (0)