From 5123e4e31927ef2d179eee999dbfcb0f7b4ea481 Mon Sep 17 00:00:00 2001 From: Shaun Smith <51304449+smith-xyz@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:35:42 -0400 Subject: [PATCH] Add expandedSql property to Statement for debugging --- lib/sqlite3.d.ts | 2 + src/statement.cc | 31 ++++- src/statement.h | 1 + test/expandedSql.test.js | 244 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 test/expandedSql.test.js diff --git a/lib/sqlite3.d.ts b/lib/sqlite3.d.ts index 15e66230e..e8e1abae4 100644 --- a/lib/sqlite3.d.ts +++ b/lib/sqlite3.d.ts @@ -91,6 +91,8 @@ export class Statement extends events.EventEmitter { each(callback?: (err: Error | null, row: T) => void, complete?: (err: Error | null, count: number) => void): this; each(params: any, callback?: (this: RunResult, err: Error | null, row: T) => void, complete?: (err: Error | null, count: number) => void): this; each(...params: any[]): this; + + readonly expandedSql: string | undefined; } export class Database extends events.EventEmitter { diff --git a/src/statement.cc b/src/statement.cc index fc49b90f1..092d7b9a5 100644 --- a/src/statement.cc +++ b/src/statement.cc @@ -22,6 +22,7 @@ Napi::Object Statement::Init(Napi::Env env, Napi::Object exports) { InstanceMethod("each", &Statement::Each, napi_default_method), InstanceMethod("reset", &Statement::Reset, napi_default_method), InstanceMethod("finalize", &Statement::Finalize_, napi_default_method), + InstanceAccessor("expandedSql", &Statement::ExpandedSQLGetter, nullptr, napi_default_method), }); exports.Set("Statement", t); @@ -159,6 +160,7 @@ void Statement::Work_AfterPrepare(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + if (stmt->status != SQLITE_OK) { Error(baton.get()); stmt->Finalize_(); @@ -364,6 +366,7 @@ void Statement::Work_AfterBind(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + if (stmt->status != SQLITE_OK) { Error(baton.get()); } @@ -431,6 +434,7 @@ void Statement::Work_AfterGet(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + if (stmt->status != SQLITE_ROW && stmt->status != SQLITE_DONE) { Error(baton.get()); } @@ -505,6 +509,7 @@ void Statement::Work_AfterRun(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + if (stmt->status != SQLITE_ROW && stmt->status != SQLITE_DONE) { Error(baton.get()); } @@ -575,6 +580,7 @@ void Statement::Work_AfterAll(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + if (stmt->status != SQLITE_DONE) { Error(baton.get()); } @@ -740,10 +746,10 @@ void Statement::Work_AfterEach(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + if (stmt->status != SQLITE_DONE) { Error(baton.get()); } - STATEMENT_END(); } @@ -777,6 +783,7 @@ void Statement::Work_AfterReset(napi_env e, napi_status status, void* data) { auto env = stmt->Env(); Napi::HandleScope scope(env); + // Fire callbacks. Napi::Function cb = baton->callback.Value(); if (IS_FUNCTION(cb)) { @@ -937,3 +944,25 @@ void Statement::CleanQueue() { delete call->baton; } } + +Napi::Value Statement::ExpandedSQLGetter(const Napi::CallbackInfo& info) { + auto env = info.Env(); + + if (!_handle) { + return env.Undefined(); + } + + // call SQLite's sqlite3_expanded_sql() to get the SQL with bound parameters + char* expanded = sqlite3_expanded_sql(_handle); + + // handle memory allocation failure + if (!expanded) { + return env.Undefined(); + } + + // create JS string and free the C string + Napi::String result = Napi::String::New(env, expanded); + sqlite3_free(expanded); + + return result; +} diff --git a/src/statement.h b/src/statement.h index c522c0fdf..2988de0fb 100644 --- a/src/statement.h +++ b/src/statement.h @@ -203,6 +203,7 @@ class Statement : public Napi::ObjectWrap { WORK_DEFINITION(Reset) Napi::Value Finalize_(const Napi::CallbackInfo& info); + Napi::Value ExpandedSQLGetter(const Napi::CallbackInfo& info); protected: static void Work_BeginPrepare(Database::Baton* baton); diff --git a/test/expandedSql.test.js b/test/expandedSql.test.js new file mode 100644 index 000000000..43e65d5f0 --- /dev/null +++ b/test/expandedSql.test.js @@ -0,0 +1,244 @@ +var sqlite3 = require('..'); +var assert = require('assert'); + +describe('expandedSql', function() { + var db; + before(function(done) { + db = new sqlite3.Database(':memory:'); + db.serialize(function() { + db.run("CREATE TABLE foo (id INT, txt TEXT)"); + db.run("CREATE TABLE complex (a INT, b INT, c INT, d INT, e INT)", done); + }); + }); + after(function(done) { + db.wait(function() { + db.close(done); + }); + }); + + it('should be accessible as a property on Statement', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(NULL,NULL)"); + stmt.finalize(done); + }); + }); + + it('should show bound parameters in callbacks', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.run(1, "test", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(1,'test')"); + stmt.finalize(done); + }); + }); + + it('should work with get callback', function(done) { + var stmt = db.prepare("SELECT * FROM foo WHERE id = ?"); + stmt.get(1, function(err, row) { + if (err) throw err; + assert.equal(stmt.expandedSql, "SELECT * FROM foo WHERE id = 1"); + assert.equal(row.id, 1); + stmt.finalize(done); + }); + }); + + it('should work with all callback', function(done) { + var stmt = db.prepare("SELECT * FROM foo WHERE id > ?"); + stmt.all(0, function(err, rows) { + if (err) throw err; + assert.equal(stmt.expandedSql, "SELECT * FROM foo WHERE id > 0"); + assert.ok(rows.length > 0); + stmt.finalize(done); + }); + }); + + it('should work with each callback', function(done) { + var stmt = db.prepare("SELECT * FROM foo WHERE id < ?"); + var count = 0; + stmt.each(5, function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "SELECT * FROM foo WHERE id < 5"); + count++; + }, function(err) { + if (err) throw err; + assert.ok(count > 0); + stmt.finalize(done); + }); + }); + + it('should work with bind callback', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.bind(10, "hello", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(10,'hello')"); + stmt.run(function(err) { + if (err) throw err; + stmt.finalize(done); + }); + }); + }); + + it('should handle string escaping correctly', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.run(20, "test'quote", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(20,'test''quote')"); + stmt.finalize(done); + }); + }); + + it('should handle incomplete bindings', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.run(20, function(err) { + if (err) throw err; + // sqlite fills unbound parameters with NULL, not ? + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(20,NULL)"); + stmt.finalize(done); + }); + }); + + it('should work with named parameters', function(done) { + var stmt = db.prepare("SELECT * FROM foo WHERE id = $id"); + stmt.get({ $id: 1 }, function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "SELECT * FROM foo WHERE id = 1"); + stmt.finalize(done); + }); + }); + + it('should handle NULL values', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.run(200, null, function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(200,NULL)"); + stmt.finalize(done); + }); + }); + + it('should handle empty strings', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.run(1, "", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(1,'')"); + stmt.finalize(done); + }); + }); + + it('should handle large integers', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + var maxSafeInt = Number.MAX_SAFE_INTEGER; + stmt.run(maxSafeInt, "large", function(err) { + if (err) throw err; + // sqlite formats large numbers - accept various scientific notations + var expanded = stmt.expandedSql; + assert.ok( + expanded.match(/INSERT INTO foo VALUES\(9\.00719.*[eE]\+1[45],'large'\)/), + 'Expected large number in scientific notation, got: ' + expanded + ); + stmt.finalize(done); + }); + }); + + it('should return undefined before statement is prepared', function(done) { + var stmt = db.prepare("SELECT 1"); + assert.equal(stmt.expandedSql, undefined); + stmt.finalize(done); + }); + + it('should return undefined after finalize', function(done) { + var stmt = db.prepare("SELECT 1"); + stmt.finalize(function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, undefined); + done(); + }); + }); + + it('should update after reset and rebind', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + + stmt.bind(5, "first", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(5,'first')"); + + stmt.run(function(err) { + if (err) throw err; + + stmt.reset(function(err) { + if (err) throw err; + + stmt.bind(6, "second", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(6,'second')"); + stmt.finalize(done); + }); + }); + }); + }); + }); + + it('should work with batch inserts', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + var inserted = 0; + var batchSize = 5; + + for (var i = 300; i < 300 + batchSize; i++) { + (function(id) { + stmt.run(id, 'batch' + id, function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(" + id + ",'batch" + id + "')"); + inserted++; + if (inserted === batchSize) { + stmt.finalize(done); + } + }); + })(i); + } + }); + + it('should work with complex queries with many parameters', function(done) { + var stmt = db.prepare("INSERT INTO complex VALUES(?,?,?,?,?)"); + stmt.run(1, 2, 3, 4, 5, function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO complex VALUES(1,2,3,4,5)"); + stmt.finalize(done); + }); + }); + + it('should work within serialized transactions', function(done) { + db.serialize(function() { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + stmt.run(500, "transaction", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(500,'transaction')"); + stmt.finalize(done); + }); + }); + }); + + it('should be accessible outside callbacks after operations complete', function(done) { + var stmt = db.prepare("INSERT INTO foo VALUES(?,?)"); + + stmt.run(600, "outside", function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "INSERT INTO foo VALUES(600,'outside')"); + stmt.finalize(done); + }); + }); + + it('demonstrates the async queue behavior', function(done) { + var stmt = db.prepare("SELECT * FROM foo WHERE id = ?"); + + stmt.bind(1); + + // worker thread still hasn't completed, so it isn't available + assert.equal(stmt.expandedSql, undefined); + + stmt.bind(1, function(err) { + if (err) throw err; + assert.equal(stmt.expandedSql, "SELECT * FROM foo WHERE id = 1"); + stmt.finalize(done); + }); + }); +});