Skip to content

Commit a61d7f8

Browse files
committed
sqlite: use shared-shape objects for result rows
`all()`, `get()` and `iterate()` built each row with the `Object::New()` overload that takes names and values. That overload always returns a dictionary-mode object, so no two rows shared a map and every property read was a hash lookup. Build rows from a `DictionaryTemplate` cached on the statement in place of the column names, invalidated on re-prepare. Rows keep their null prototype. Statements whose column names a template cannot express (array indices, duplicates, non-ASCII names, which the template interns as Latin-1) or with more than 64 columns keep the previous path. Add a benchmark that reads every column of each row, since the existing ones only measure building rows. Fixes: #65799 Assisted-by: Claude Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
1 parent ae9c25a commit a61d7f8

4 files changed

Lines changed: 207 additions & 54 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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: [1e4],
8+
rows: [1, 100],
9+
method: ['all', 'iterate', 'get'],
10+
returnArrays: [0, 1],
11+
});
12+
13+
function main(conf) {
14+
const db = new sqlite.Database(':memory:');
15+
db.exec(
16+
'CREATE TABLE foo (text_column TEXT, integer_column INTEGER, real_column REAL, blob_column BLOB)',
17+
);
18+
const insert = db.prepare('INSERT INTO foo VALUES (?, ?, ?, ?)');
19+
for (let i = 0; i < conf.rows; i++) {
20+
insert.run(`text ${i}`, i, i / 3, Buffer.from('example blob data'));
21+
}
22+
23+
const stmt = db.prepare(`SELECT * FROM foo LIMIT ${conf.rows}`);
24+
stmt.setReturnArrays(conf.returnArrays === 1);
25+
26+
// Each row is built and then has every column read once.
27+
const read = conf.returnArrays ?
28+
(row) => row[0].length + row[1] + row[2] + row[3].length :
29+
(row) => row.text_column.length + row.integer_column +
30+
row.real_column + row.blob_column.length;
31+
32+
let sum = 0;
33+
bench.start();
34+
for (let i = 0; i < conf.n; i++) {
35+
if (conf.method === 'get') {
36+
sum += read(stmt.get());
37+
} else {
38+
for (const row of stmt[conf.method]()) sum += read(row);
39+
}
40+
}
41+
bench.end(conf.n);
42+
43+
assert.ok(sum > 0);
44+
}

‎src/node_sqlite.cc‎

Lines changed: 79 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <cstring>
2323
#include <limits>
2424
#include <string>
25+
#include <string_view>
2526

2627
namespace node {
2728
namespace sqlite {
@@ -3760,6 +3761,7 @@ void Statement::Finalize() {
37603761

37613762
void Statement::InvalidateColumnNameCache() {
37623763
cached_column_names_.clear();
3764+
cached_row_template_.Reset();
37633765
cached_column_names_reprepare_count_ = -1;
37643766
}
37653767

@@ -3981,37 +3983,79 @@ MaybeLocal<Name> Statement::ColumnNameToName(const int column) {
39813983
.As<Name>();
39823984
}
39833985

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();
3986+
// Size of the stack array RowToObject passes to the template.
3987+
static constexpr int kMaxRowTemplateColumns = 64;
3988+
3989+
// Templates read names as Latin-1 and reject indices and duplicates.
3990+
static bool CanUseInRowTemplate(Local<Context> context,
3991+
Local<Name> key,
3992+
std::string_view name,
3993+
const std::vector<std::string_view>& seen) {
3994+
return simdutf::validate_ascii(name.data(), name.size()) &&
3995+
std::find(seen.begin(), seen.end(), name) == seen.end() &&
3996+
key->ToArrayIndex(context).IsEmpty();
3997+
}
39883998

3999+
// Fills either cached_row_template_ or cached_column_names_.
4000+
bool Statement::UpdateRowCache() {
39894001
const int reprepare_count =
39904002
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-
}
4003+
if (reprepare_count == cached_column_names_reprepare_count_) return true;
4004+
4005+
InvalidateColumnNameCache();
4006+
Isolate* isolate = env()->isolate();
4007+
Local<Context> context = env()->context();
4008+
const int num_cols = sqlite3_column_count(statement_.get());
4009+
LocalVector<Name> keys(isolate);
4010+
std::vector<std::string_view> names;
4011+
keys.reserve(num_cols);
4012+
names.reserve(num_cols);
4013+
bool use_template = num_cols <= kMaxRowTemplateColumns;
4014+
for (int i = 0; i < num_cols; ++i) {
4015+
Local<Name> key;
4016+
if (!ColumnNameToName(i).ToLocal(&key)) return false;
4017+
std::string_view name = sqlite3_column_name(statement_.get(), i);
4018+
use_template =
4019+
use_template && CanUseInRowTemplate(context, key, name, names);
4020+
keys.push_back(key);
4021+
names.push_back(name);
4022+
}
4023+
4024+
if (use_template) {
4025+
cached_row_template_.Reset(
4026+
isolate,
4027+
DictionaryTemplate::New(isolate, {names.data(), names.size()}));
4028+
} else {
39984029
cached_column_names_.reserve(num_cols);
3999-
for (int i = 0; i < num_cols; ++i) {
4000-
Local<Name> key;
4001-
if (!ColumnNameToName(i).ToLocal(&key)) {
4002-
InvalidateColumnNameCache();
4003-
return false;
4004-
}
4005-
cached_column_names_.emplace_back(Global<Name>(isolate, key));
4030+
for (Local<Name> key : keys) {
4031+
cached_column_names_.emplace_back(isolate, key);
40064032
}
4007-
cached_column_names_reprepare_count_ = reprepare_count;
40084033
}
4034+
cached_column_names_reprepare_count_ = reprepare_count;
4035+
return true;
4036+
}
40094037

4010-
keys->reserve(cached_column_names_.size());
4011-
for (const auto& name : cached_column_names_) {
4012-
keys->emplace_back(name.Get(isolate));
4038+
MaybeLocal<Object> Statement::RowToObject(LocalVector<Value>* values) {
4039+
DCHECK_EQ(
4040+
sqlite3_stmt_status(statement_.get(), SQLITE_STMTSTATUS_REPREPARE, false),
4041+
cached_column_names_reprepare_count_);
4042+
Isolate* isolate = env()->isolate();
4043+
if (cached_row_template_.IsEmpty()) {
4044+
LocalVector<Name> keys(isolate);
4045+
keys.reserve(cached_column_names_.size());
4046+
for (const auto& name : cached_column_names_) {
4047+
keys.emplace_back(name.Get(isolate));
4048+
}
4049+
DCHECK_EQ(keys.size(), values->size());
4050+
return Object::New(
4051+
isolate, Null(isolate), keys.data(), values->data(), keys.size());
40134052
}
4014-
return true;
4053+
DCHECK_LE(values->size(), kMaxRowTemplateColumns);
4054+
MaybeLocal<Value> template_values[kMaxRowTemplateColumns];
4055+
std::copy(values->begin(), values->end(), template_values);
4056+
return NewDictionaryInstanceNullProto(env()->context(),
4057+
cached_row_template_.Get(isolate),
4058+
{template_values, values->size()});
40154059
}
40164060

40174061
MaybeLocal<Value> StatementExecutionHelper::ColumnToValue(Environment* env,
@@ -4056,12 +4100,14 @@ MaybeLocal<Value> StatementExecutionHelper::All(Environment* env,
40564100
int num_cols = 0;
40574101
LocalVector<Value> rows(isolate);
40584102
LocalVector<Value> row_values(isolate);
4059-
LocalVector<Name> row_keys(isolate);
40604103

40614104
SteppingStatementGuard stepping(db, stmt);
40624105
while ((r = sqlite3_step(stmt)) == SQLITE_ROW) {
40634106
if (num_cols == 0) {
40644107
num_cols = sqlite3_column_count(stmt);
4108+
if (!return_arrays && !statement->UpdateRowCache()) {
4109+
return MaybeLocal<Value>();
4110+
}
40654111
}
40664112

40674113
if (ExtractRowValues(env, stmt, num_cols, use_big_ints, &row_values)
@@ -4074,16 +4120,10 @@ MaybeLocal<Value> StatementExecutionHelper::All(Environment* env,
40744120
Array::New(isolate, row_values.data(), row_values.size());
40754121
rows.emplace_back(row_array);
40764122
} 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-
}
4123+
Local<Object> row_obj;
4124+
if (!statement->RowToObject(&row_values).ToLocal(&row_obj)) {
4125+
return MaybeLocal<Value>();
40834126
}
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);
40874127
rows.emplace_back(row_obj);
40884128
}
40894129
}
@@ -4220,17 +4260,9 @@ MaybeLocal<Value> StatementExecutionHelper::Get(Environment* env,
42204260
Local<Value> result;
42214261
if (return_arrays) {
42224262
result = Array::New(isolate, row_values.data(), row_values.size());
4223-
} 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)) {
4228-
return MaybeLocal<Value>();
4229-
}
4230-
4231-
DCHECK_EQ(keys.size(), row_values.size());
4232-
result = Object::New(
4233-
isolate, Null(isolate), keys.data(), row_values.data(), num_cols);
4263+
} else if (!statement->UpdateRowCache() ||
4264+
!statement->RowToObject(&row_values).ToLocal(&result)) {
4265+
return MaybeLocal<Value>();
42344266
}
42354267

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

50325064
int num_cols = sqlite3_column_count(iter->stmt_->statement_.get());
50335065
Local<Value> row_value;
5034-
LocalVector<Name> row_keys(isolate);
50355066
LocalVector<Value> row_values(isolate);
50365067

50375068
if (ExtractRowValues(env,
@@ -5045,14 +5076,9 @@ void StatementIterator::Next(const FunctionCallbackInfo<Value>& args) {
50455076

50465077
if (iter->stmt_->return_arrays_) {
50475078
row_value = Array::New(isolate, row_values.data(), row_values.size());
5048-
} 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);
5079+
} else if (!iter->stmt_->UpdateRowCache() ||
5080+
!iter->stmt_->RowToObject(&row_values).ToLocal(&row_value)) {
5081+
return;
50565082
}
50575083

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

‎src/node_sqlite.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,9 @@ 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+
// Call after sqlite3_step(), which may re-prepare, and before RowToObject.
400+
bool UpdateRowCache();
401+
v8::MaybeLocal<v8::Object> RowToObject(v8::LocalVector<v8::Value>* values);
400402
void Finalize();
401403
bool IsFinalized();
402404

@@ -417,6 +419,8 @@ class Statement : public BaseObject {
417419
inline int ResetStatement();
418420
std::vector<v8::Global<v8::Name>> cached_column_names_;
419421
int cached_column_names_reprepare_count_ = -1;
422+
// Set instead of cached_column_names_ when the names fit a template.
423+
v8::Global<v8::DictionaryTemplate> cached_row_template_;
420424
void InvalidateColumnNameCache();
421425
bool BindParams(const v8::FunctionCallbackInfo<v8::Value>& args);
422426
bool BindValue(const v8::Local<v8::Value>& value, const int index);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Flags: --allow-natives-syntax
2+
'use strict';
3+
const { skipIfSQLiteMissing } = require('../common');
4+
skipIfSQLiteMissing();
5+
const assert = require('node:assert');
6+
const { Database } = require('node:sqlite');
7+
const { suite, test } = require('node:test');
8+
9+
function assertSharedShape(rows) {
10+
for (const row of rows) {
11+
assert.strictEqual(Object.getPrototypeOf(row), null);
12+
assert(eval('%HasFastProperties(row)'));
13+
assert(eval('%HaveSameMap(row, rows[0])'));
14+
}
15+
}
16+
17+
suite('result row objects', () => {
18+
const db = new Database(':memory:');
19+
db.exec('CREATE TABLE t (a INTEGER, b TEXT, c REAL)');
20+
const insert = db.prepare('INSERT INTO t VALUES (?, ?, ?)');
21+
insert.run(1, 'x', 1.5);
22+
insert.run(2, 'y', 2.5);
23+
insert.run(3, null, null);
24+
25+
test('all() rows share a fast map', () => {
26+
const rows = db.prepare('SELECT * FROM t ORDER BY a').all();
27+
assert.deepStrictEqual(rows, [
28+
{ __proto__: null, a: 1, b: 'x', c: 1.5 },
29+
{ __proto__: null, a: 2, b: 'y', c: 2.5 },
30+
{ __proto__: null, a: 3, b: null, c: null },
31+
]);
32+
assertSharedShape(rows);
33+
});
34+
35+
test('get() rows share a fast map', () => {
36+
const stmt = db.prepare('SELECT * FROM t WHERE a = ?');
37+
assertSharedShape([stmt.get(1), stmt.get(2), stmt.get(3)]);
38+
});
39+
40+
test('iterate() rows share a fast map', () => {
41+
assertSharedShape(db.prepare('SELECT * FROM t ORDER BY a').iterate().toArray());
42+
});
43+
44+
test('a re-prepared statement picks up the new columns', () => {
45+
using db = new Database(':memory:');
46+
db.exec('CREATE TABLE s (a INTEGER)');
47+
db.exec('INSERT INTO s VALUES (1)');
48+
const stmt = db.prepare('SELECT * FROM s');
49+
assert.deepStrictEqual(stmt.get(), { __proto__: null, a: 1 });
50+
db.exec('ALTER TABLE s ADD COLUMN b INTEGER DEFAULT 2');
51+
assert.deepStrictEqual(stmt.get(), { __proto__: null, a: 1, b: 2 });
52+
});
53+
54+
test('array-index column names', () => {
55+
const stmt = db.prepare('SELECT 1 AS "0", 2 AS b');
56+
assert.deepStrictEqual(stmt.get(), { __proto__: null, 0: 1, b: 2 });
57+
assert.deepStrictEqual(stmt.all(), [{ __proto__: null, 0: 1, b: 2 }]);
58+
});
59+
60+
test('duplicate column names keep the last value', () => {
61+
const stmt = db.prepare('SELECT 1 AS a, 2 AS a');
62+
assert.deepStrictEqual(stmt.get(), { __proto__: null, a: 2 });
63+
assert.deepStrictEqual(stmt.iterate().toArray(),
64+
[{ __proto__: null, a: 2 }]);
65+
});
66+
67+
test('non-ASCII column names', () => {
68+
const row = db.prepare('SELECT 1 AS "café", 2 AS "名前"').get();
69+
assert.deepStrictEqual(row, { __proto__: null, café: 1, 名前: 2 });
70+
});
71+
72+
test('rows wider than the template limit', () => {
73+
const cols = Array.from({ length: 100 }, (_, i) => `${i} AS c${i}`);
74+
const row = db.prepare(`SELECT ${cols.join(', ')}`).get();
75+
const expected = { __proto__: null };
76+
for (let i = 0; i < 100; i++) expected[`c${i}`] = i;
77+
assert.deepStrictEqual(row, expected);
78+
});
79+
});

0 commit comments

Comments
 (0)