Skip to content

Commit fd5654c

Browse files
committed
sqlite: expose prepared statement statistics
Signed-off-by: geeksilva97 <edigleyssonsilva@gmail.com>
1 parent 050fc39 commit fd5654c

4 files changed

Lines changed: 184 additions & 0 deletions

File tree

doc/api/sqlite.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,39 @@ added: v22.5.0
12021202
The source SQL text of the prepared statement. This property is a
12031203
wrapper around [`sqlite3_sql()`][].
12041204

1205+
### `statement.stat(counter)`
1206+
1207+
<!-- YAML
1208+
added: REPLACEME
1209+
-->
1210+
1211+
* `counter` {string} The name of the counter to read. One of:
1212+
1213+
* `'fullscanStep'` The number of times SQLite has stepped forward in a table
1214+
as part of a full table scan.
1215+
* `'sort'` The number of sort operations that have occurred.
1216+
* `'autoindex'` The number of rows inserted into transient indices that were
1217+
created automatically to help joins run faster.
1218+
* `'vmStep'` The number of virtual machine operations executed by the
1219+
prepared statement.
1220+
* `'reprepare'` The number of times the statement has been automatically
1221+
reprepared due to schema changes or changes to bound parameters.
1222+
* `'run'` The number of times the statement has run to completion.
1223+
* `'filterMiss'` The number of times the Bloom filter returned a result that
1224+
required the join step to be processed as normal.
1225+
* `'filterHit'` The number of times a join step was bypassed because a Bloom
1226+
filter returned not-found.
1227+
* `'memused'` The approximate number of bytes of heap memory used to store
1228+
the prepared statement.
1229+
1230+
* Returns: {number} The current value of the requested counter.
1231+
1232+
Returns one of the runtime counters that SQLite tracks for this prepared
1233+
statement. This method is a wrapper around [`sqlite3_stmt_status()`][] and does
1234+
not reset the counter. Asserting that a statement does not perform a full table
1235+
scan (`statement.stat('fullscanStep') === 0`) is a useful check to guard
1236+
against degenerate performance.
1237+
12051238
## Class: `SQLTagStore`
12061239

12071240
<!-- YAML
@@ -1689,6 +1722,7 @@ callback function to indicate what type of operation is being authorized.
16891722
[`sqlite3_serialize()`]: https://sqlite.org/c3ref/serialize.html
16901723
[`sqlite3_set_authorizer()`]: https://sqlite.org/c3ref/set_authorizer.html
16911724
[`sqlite3_sql()`]: https://www.sqlite.org/c3ref/expanded_sql.html
1725+
[`sqlite3_stmt_status()`]: https://www.sqlite.org/c3ref/stmt_status.html
16921726
[`sqlite3changeset_apply()`]: https://www.sqlite.org/session/sqlite3changeset_apply.html
16931727
[`sqlite3session_attach()`]: https://www.sqlite.org/session/sqlite3session_attach.html
16941728
[`sqlite3session_changeset()`]: https://www.sqlite.org/session/sqlite3session_changeset.html

src/node_sqlite.cc

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ static constexpr const LimitInfo* GetLimitInfoFromName(std::string_view name) {
170170
return nullptr;
171171
}
172172

173+
static constexpr const StatusInfo* GetStatusInfoFromName(
174+
std::string_view name) {
175+
for (const auto& info : kStatusMapping) {
176+
if (name == info.js_name) {
177+
return &info;
178+
}
179+
}
180+
return nullptr;
181+
}
182+
173183
inline MaybeLocal<Object> CreateSQLiteError(Isolate* isolate,
174184
const char* message) {
175185
Local<String> js_msg;
@@ -3202,6 +3212,34 @@ void StatementSync::ExpandedSQLGetter(const FunctionCallbackInfo<Value>& args) {
32023212
args.GetReturnValue().Set(result);
32033213
}
32043214

3215+
void StatementSync::Stat(const FunctionCallbackInfo<Value>& args) {
3216+
StatementSync* stmt;
3217+
ASSIGN_OR_RETURN_UNWRAP(&stmt, args.This());
3218+
Environment* env = Environment::GetCurrent(args);
3219+
THROW_AND_RETURN_ON_BAD_STATE(
3220+
env, stmt->IsFinalized(), "statement has been finalized");
3221+
Isolate* isolate = env->isolate();
3222+
3223+
if (!args[0]->IsString()) {
3224+
THROW_ERR_INVALID_ARG_TYPE(isolate,
3225+
"The \"counter\" argument must be a string.");
3226+
return;
3227+
}
3228+
3229+
Utf8Value counter(isolate, args[0].As<String>());
3230+
const StatusInfo* status_info = GetStatusInfoFromName(counter.ToStringView());
3231+
if (status_info == nullptr) {
3232+
THROW_ERR_INVALID_ARG_VALUE(
3233+
isolate, "The \"counter\" argument is not a valid statistic name.");
3234+
return;
3235+
}
3236+
3237+
// The reset flag is always false; the counter is read without being cleared.
3238+
int value = sqlite3_stmt_status(
3239+
stmt->statement_, status_info->sqlite_status_id, false);
3240+
args.GetReturnValue().Set(Integer::New(isolate, value));
3241+
}
3242+
32053243
void StatementSync::SetAllowBareNamedParameters(
32063244
const FunctionCallbackInfo<Value>& args) {
32073245
StatementSync* stmt;
@@ -3617,6 +3655,7 @@ Local<FunctionTemplate> StatementSync::GetConstructorTemplate(
36173655
tmpl,
36183656
FIXED_ONE_BYTE_STRING(isolate, "expandedSQL"),
36193657
StatementSync::ExpandedSQLGetter);
3658+
SetProtoMethodNoSideEffect(isolate, tmpl, "stat", StatementSync::Stat);
36203659
SetProtoMethod(isolate,
36213660
tmpl,
36223661
"setAllowBareNamedParameters",

src/node_sqlite.h

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ static_assert(
5252
CheckLimitIndices(),
5353
"Each kLimitMapping entry's sqlite_limit_id must match its index");
5454

55+
// Mapping from JavaScript counter names to SQLite statement status constants
56+
struct StatusInfo {
57+
std::string_view js_name;
58+
int sqlite_status_id;
59+
};
60+
61+
inline constexpr std::array<StatusInfo, 9> kStatusMapping = {{
62+
{"fullscanStep", SQLITE_STMTSTATUS_FULLSCAN_STEP},
63+
{"sort", SQLITE_STMTSTATUS_SORT},
64+
{"autoindex", SQLITE_STMTSTATUS_AUTOINDEX},
65+
{"vmStep", SQLITE_STMTSTATUS_VM_STEP},
66+
{"reprepare", SQLITE_STMTSTATUS_REPREPARE},
67+
{"run", SQLITE_STMTSTATUS_RUN},
68+
{"filterMiss", SQLITE_STMTSTATUS_FILTER_MISS},
69+
{"filterHit", SQLITE_STMTSTATUS_FILTER_HIT},
70+
{"memused", SQLITE_STMTSTATUS_MEMUSED},
71+
}};
72+
5573
class DatabaseOpenConfiguration {
5674
public:
5775
explicit DatabaseOpenConfiguration(std::string&& location)
@@ -272,6 +290,7 @@ class StatementSync : public BaseObject {
272290
static void SourceSQLGetter(const v8::FunctionCallbackInfo<v8::Value>& args);
273291
static void ExpandedSQLGetter(
274292
const v8::FunctionCallbackInfo<v8::Value>& args);
293+
static void Stat(const v8::FunctionCallbackInfo<v8::Value>& args);
275294
static void SetAllowBareNamedParameters(
276295
const v8::FunctionCallbackInfo<v8::Value>& args);
277296
static void SetAllowUnknownNamedParameters(

test/parallel/test-sqlite-statement-sync.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,98 @@ suite('StatementSync.prototype.expandedSQL', () => {
429429
});
430430
});
431431

432+
suite('StatementSync.prototype.stat()', () => {
433+
const counters = [
434+
'fullscanStep', 'sort', 'autoindex', 'vmStep', 'reprepare',
435+
'run', 'filterMiss', 'filterHit', 'memused',
436+
];
437+
438+
test('returns a number for every valid counter', (t) => {
439+
using db = new DatabaseSync(nextDb());
440+
db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;');
441+
const stmt = db.prepare('SELECT * FROM data');
442+
for (const counter of counters) {
443+
t.assert.strictEqual(typeof stmt.stat(counter), 'number');
444+
}
445+
});
446+
447+
test('counts virtual machine steps and runs after execution', (t) => {
448+
using db = new DatabaseSync(nextDb());
449+
db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;');
450+
const insert = db.prepare('INSERT INTO data (key, val) VALUES (?, ?)');
451+
for (let i = 1; i <= 5; i++) {
452+
insert.run(i, `val-${i}`);
453+
}
454+
const stmt = db.prepare('SELECT * FROM data');
455+
t.assert.strictEqual(stmt.stat('run'), 0);
456+
t.assert.strictEqual(stmt.stat('vmStep'), 0);
457+
stmt.all();
458+
t.assert.strictEqual(stmt.stat('run'), 1);
459+
t.assert.ok(stmt.stat('vmStep') > 0);
460+
t.assert.ok(stmt.stat('memused') > 0);
461+
});
462+
463+
test('detects full table scans', (t) => {
464+
using db = new DatabaseSync(nextDb());
465+
db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;');
466+
const insert = db.prepare('INSERT INTO data (key, val) VALUES (?, ?)');
467+
for (let i = 1; i <= 10; i++) {
468+
insert.run(i, `val-${i}`);
469+
}
470+
471+
// Filtering on a non-indexed column forces a full table scan.
472+
const scan = db.prepare('SELECT * FROM data WHERE val = ?');
473+
scan.all('val-5');
474+
t.assert.ok(scan.stat('fullscanStep') > 0);
475+
476+
// Filtering on the primary key uses the index; no full scan occurs.
477+
const indexed = db.prepare('SELECT * FROM data WHERE key = ?');
478+
indexed.all(5);
479+
t.assert.strictEqual(indexed.stat('fullscanStep'), 0);
480+
});
481+
482+
test('reading a counter does not reset it', (t) => {
483+
using db = new DatabaseSync(nextDb());
484+
db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;');
485+
const stmt = db.prepare('SELECT * FROM data');
486+
stmt.all();
487+
const first = stmt.stat('run');
488+
t.assert.strictEqual(stmt.stat('run'), first);
489+
});
490+
491+
test('throws if the counter argument is not a string', (t) => {
492+
using db = new DatabaseSync(nextDb());
493+
const stmt = db.prepare('SELECT 1');
494+
t.assert.throws(() => stmt.stat(), {
495+
code: 'ERR_INVALID_ARG_TYPE',
496+
message: /The "counter" argument must be a string/,
497+
});
498+
t.assert.throws(() => stmt.stat(42), {
499+
code: 'ERR_INVALID_ARG_TYPE',
500+
message: /The "counter" argument must be a string/,
501+
});
502+
});
503+
504+
test('throws if the counter name is unknown', (t) => {
505+
using db = new DatabaseSync(nextDb());
506+
const stmt = db.prepare('SELECT 1');
507+
t.assert.throws(() => stmt.stat('nope'), {
508+
code: 'ERR_INVALID_ARG_VALUE',
509+
message: /The "counter" argument is not a valid statistic name/,
510+
});
511+
});
512+
513+
test('throws if the statement is finalized', (t) => {
514+
const db = new DatabaseSync(nextDb());
515+
const stmt = db.prepare('SELECT 1');
516+
db.close();
517+
t.assert.throws(() => stmt.stat('run'), {
518+
code: 'ERR_INVALID_STATE',
519+
message: /statement has been finalized/,
520+
});
521+
});
522+
});
523+
432524
suite('StatementSync.prototype.setReadBigInts()', () => {
433525
test('BigInts support can be toggled', (t) => {
434526
const db = new DatabaseSync(nextDb());

0 commit comments

Comments
 (0)