From 4c9602acd8b6b3cc02bc9d85eab2588b037d4fae Mon Sep 17 00:00:00 2001 From: Erik Soehnel Date: Tue, 11 Mar 2025 23:40:26 +0100 Subject: [PATCH 1/2] Add updateHook A wrapper around sqlite3_update_hook. For now only as a low-level operation to Database. To be useful in projects it will probably need some wrapping in the worker but right now I have no idea yet how that should look. --- src/api.js | 80 +++++++++++++++++++++++++++++++++++++ src/exported_functions.json | 3 +- test/test_update_hook.js | 43 ++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 test/test_update_hook.js diff --git a/src/api.js b/src/api.js index 10759d96..0ec7f8ec 100644 --- a/src/api.js +++ b/src/api.js @@ -71,6 +71,10 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { var SQLITE_BLOB = 4; // var - Encodings, used for registering functions. var SQLITE_UTF8 = 1; + // var - Authorizer Action Codes used to identify change types in updateHook + var SQLITE_INSERT = 18; + var SQLITE_UPDATE = 23; + var SQLITE_DELETE = 9; // var - cwrap function var sqlite3_open = cwrap("sqlite3_open", "number", ["string", "number"]); var sqlite3_close_v2 = cwrap("sqlite3_close_v2", "number", ["number"]); @@ -239,6 +243,12 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { ["number"] ); + var sqlite3_update_hook = cwrap( + "sqlite3_update_hook", + "number", + ["number", "number", "number"] + ); + /** * @classdesc * Represents a prepared statement. @@ -1383,6 +1393,76 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { return this; }; + /** Registers the update hook with SQLite + @param {function(operation, tableName, rowId)} callback + executed whenever a row in any rowid table is changed + + For each changed row, the callback is called once with the change + ('insert', 'update' or 'delete'), the table name where the change + happened and the rowid of the row that has been changed. + + rowid is cast to a plain number, if it exceeds Number.MAX_SAFE_INTEGER + an error will be thrown. + + The callback MUST NOT modify the database in any way. + + Only a single callback can be registered. Unregister the callback by + passing an empty function. + + Not called for some updates like ON REPLACE CONFLICT and TRUNCATE. + + See sqlite docs on sqlite3_update_hook for more details. + */ + Database.prototype["updateHook"] = function updateHook(callback) { + this.updateHookCallback = callback; + + // void(*)(void *,int ,char const *,char const *,sqlite3_int64) + function wrappedCallback( + ignored, + operationCode, + databaseNamePtr, + tableNamePtr, + rowIdBigInt + ) { + var operation; + + switch (operationCode) { + case SQLITE_INSERT: + operation = "insert"; + break; + case SQLITE_UPDATE: + operation = "update"; + break; + case SQLITE_DELETE: + operation = "delete"; + break; + default: + throw "unknown operationCode in updateHook callback: " + + operationCode; + } + + var tableName = UTF8ToString(tableNamePtr); + + if (rowIdBigInt > Number.MAX_SAFE_INTEGER) { + throw "rowId too big to fit inside a Number"; + } + + var rowId = Number(rowIdBigInt); + + if (this.updateHookCallback) { + this.updateHookCallback(operation, tableName, rowId); + } + } + + var funcPtr = addFunction(wrappedCallback.bind(this), "viiiij"); + + this.handleError(sqlite3_update_hook( + this.db, + funcPtr, + 0 // passed as the first arg to wrappedCallback + )); + }; + // export Database to Module Module.Database = Database; }; diff --git a/src/exported_functions.json b/src/exported_functions.json index 324017ae..3be25955 100644 --- a/src/exported_functions.json +++ b/src/exported_functions.json @@ -42,5 +42,6 @@ "_sqlite3_result_int64", "_sqlite3_result_error", "_sqlite3_aggregate_context", -"_RegisterExtensionFunctions" +"_RegisterExtensionFunctions", +"_sqlite3_update_hook" ] diff --git a/test/test_update_hook.js b/test/test_update_hook.js new file mode 100644 index 00000000..1cbe6476 --- /dev/null +++ b/test/test_update_hook.js @@ -0,0 +1,43 @@ +exports.test = function(SQL, assert){ + var db = new SQL.Database(); + + db.exec( + "CREATE TABLE consoles (id INTEGER PRIMARY KEY, company TEXT, name TEXT);" + + "INSERT INTO consoles VALUES (1, 'Sony', 'Playstation');" + + "INSERT INTO consoles VALUES (2, 'Microsoft', 'Xbox');" + ); + + // {operation: undefined, tableName: undefined, rowId: undefined}; + var updateHookCalls = [] + + db.updateHook(function(operation, tableName, rowId) { + updateHookCalls.push({operation, tableName, rowId}); + }); + + // INSERT + db.exec("INSERT INTO consoles VALUES (3, 'Sega', 'Saturn');"); + + assert.deepEqual(updateHookCalls, [{operation: 'insert', tableName: 'consoles', rowId: 3}], 'insert a single row'); + updateHookCalls = [] + + // UPDATE + db.exec("UPDATE consoles SET name = 'Playstation 5' WHERE id = 1"); + + assert.deepEqual(updateHookCalls, [{operation: 'update', tableName: 'consoles', rowId: 1}], 'update a single row'); + updateHookCalls = [] + + // UPDATE (multiple rows) + db.exec("UPDATE consoles SET name = name + ' [legacy]' WHERE id IN (2,3)"); + + assert.deepEqual(updateHookCalls, [ + {operation: 'update', tableName: 'consoles', rowId: 2}, + {operation: 'update', tableName: 'consoles', rowId: 3}, + ], 'update two rows'); + updateHookCalls = [] + + // DELETE + db.exec("DELETE FROM consoles WHERE company = 'Sega'"); + + assert.deepEqual(updateHookCalls, [{operation: 'delete', tableName: 'consoles', rowId: 3}], 'delete a single row'); + updateHookCalls = [] +} From f3cff2bb0f658ddc5a4c40bea70f26133d15f41c Mon Sep 17 00:00:00 2001 From: Erik Soehnel Date: Thu, 13 Mar 2025 09:29:42 +0100 Subject: [PATCH 2/2] Allow removing the updateHook callback Also release the callback function when the callback is removed or the database is closed. Include the previously omitted database name in the callback args as the sqlite callback does. --- src/api.js | 43 ++++++++++++++++++++++---------- test/test_update_hook.js | 53 +++++++++++++++++++++++++++++++--------- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/api.js b/src/api.js index 0ec7f8ec..7ba821f8 100644 --- a/src/api.js +++ b/src/api.js @@ -1124,6 +1124,12 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { }); Object.values(this.functions).forEach(removeFunction); this.functions = {}; + + if (this.updateHookFunctionPtr) { + removeFunction(this.updateHookFunctionPtr); + this.updateHookFunctionPtr = undefined; + } + this.handleError(sqlite3_close_v2(this.db)); FS.unlink("/" + this.filename); this.db = null; @@ -1394,12 +1400,13 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { }; /** Registers the update hook with SQLite - @param {function(operation, tableName, rowId)} callback + @param {function(operation, database, table, rowId) | null} callback executed whenever a row in any rowid table is changed For each changed row, the callback is called once with the change - ('insert', 'update' or 'delete'), the table name where the change - happened and the rowid of the row that has been changed. + ('insert', 'update' or 'delete'), the database name and table name + where the change happened and the rowid of the row that has been + changed. rowid is cast to a plain number, if it exceeds Number.MAX_SAFE_INTEGER an error will be thrown. @@ -1407,14 +1414,25 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { The callback MUST NOT modify the database in any way. Only a single callback can be registered. Unregister the callback by - passing an empty function. + passing null. - Not called for some updates like ON REPLACE CONFLICT and TRUNCATE. + Not called for some updates like ON REPLACE CONFLICT and TRUNCATE (a + DELETE FROM without a WHERE clause). See sqlite docs on sqlite3_update_hook for more details. */ Database.prototype["updateHook"] = function updateHook(callback) { - this.updateHookCallback = callback; + if (this.updateHookFunctionPtr) { + // unregister and cleanup a previously registered update hook + sqlite3_update_hook(this.db, 0, 0); + removeFunction(this.updateHookFunctionPtr); + this.updateHookFunctionPtr = undefined; + } + + if (!callback) { + // no new callback to register + return; + } // void(*)(void *,int ,char const *,char const *,sqlite3_int64) function wrappedCallback( @@ -1441,6 +1459,7 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { + operationCode; } + var databaseName = UTF8ToString(databaseNamePtr); var tableName = UTF8ToString(tableNamePtr); if (rowIdBigInt > Number.MAX_SAFE_INTEGER) { @@ -1449,18 +1468,16 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() { var rowId = Number(rowIdBigInt); - if (this.updateHookCallback) { - this.updateHookCallback(operation, tableName, rowId); - } + callback(operation, databaseName, tableName, rowId); } - var funcPtr = addFunction(wrappedCallback.bind(this), "viiiij"); + this.updateHookFunctionPtr = addFunction(wrappedCallback, "viiiij"); - this.handleError(sqlite3_update_hook( + sqlite3_update_hook( this.db, - funcPtr, + this.updateHookFunctionPtr, 0 // passed as the first arg to wrappedCallback - )); + ); }; // export Database to Module diff --git a/test/test_update_hook.js b/test/test_update_hook.js index 1cbe6476..ec875e25 100644 --- a/test/test_update_hook.js +++ b/test/test_update_hook.js @@ -7,37 +7,66 @@ exports.test = function(SQL, assert){ "INSERT INTO consoles VALUES (2, 'Microsoft', 'Xbox');" ); - // {operation: undefined, tableName: undefined, rowId: undefined}; + // {operation: undefined, databaseName: undefined, tableName: undefined, rowId: undefined}; var updateHookCalls = [] - db.updateHook(function(operation, tableName, rowId) { - updateHookCalls.push({operation, tableName, rowId}); + db.updateHook(function(operation, databaseName, tableName, rowId) { + updateHookCalls.push({operation, databaseName, tableName, rowId}); }); // INSERT db.exec("INSERT INTO consoles VALUES (3, 'Sega', 'Saturn');"); - assert.deepEqual(updateHookCalls, [{operation: 'insert', tableName: 'consoles', rowId: 3}], 'insert a single row'); - updateHookCalls = [] + assert.deepEqual(updateHookCalls, [ + {operation: "insert", databaseName: "main", tableName: "consoles", rowId: 3} + ], "insert a single row"); // UPDATE + updateHookCalls = [] db.exec("UPDATE consoles SET name = 'Playstation 5' WHERE id = 1"); - assert.deepEqual(updateHookCalls, [{operation: 'update', tableName: 'consoles', rowId: 1}], 'update a single row'); - updateHookCalls = [] + assert.deepEqual(updateHookCalls, [ + {operation: "update", databaseName: "main", tableName: "consoles", rowId: 1} + ], "update a single row"); // UPDATE (multiple rows) + updateHookCalls = [] db.exec("UPDATE consoles SET name = name + ' [legacy]' WHERE id IN (2,3)"); assert.deepEqual(updateHookCalls, [ - {operation: 'update', tableName: 'consoles', rowId: 2}, - {operation: 'update', tableName: 'consoles', rowId: 3}, - ], 'update two rows'); - updateHookCalls = [] + {operation: "update", databaseName: "main", tableName: "consoles", rowId: 2}, + {operation: "update", databaseName: "main", tableName: "consoles", rowId: 3}, + ], "update two rows"); // DELETE + updateHookCalls = [] db.exec("DELETE FROM consoles WHERE company = 'Sega'"); - assert.deepEqual(updateHookCalls, [{operation: 'delete', tableName: 'consoles', rowId: 3}], 'delete a single row'); + assert.deepEqual(updateHookCalls, [ + {operation: "delete", databaseName: "main", tableName: "consoles", rowId: 3} + ], "delete a single row"); + + // UNREGISTER updateHookCalls = [] + + db.updateHook(null); + + db.exec("DELETE FROM consoles WHERE company = 'Microsoft'"); + + assert.deepEqual(updateHookCalls, [], "unregister the update hook"); + + // REGISTER AGAIN + updateHookCalls = [] + + db.updateHook(function(operation, databaseName, tableName, rowId) { + updateHookCalls.push({operation, databaseName, tableName, rowId}); + }); + + // need a where clause, just running "DELETE FROM consoles" would result in + // a TRUNCATE and not yield any update hook callbacks + db.exec("DELETE FROM consoles WHERE id > 0"); + + assert.deepEqual(updateHookCalls, [ + {operation: 'delete', databaseName: 'main', tableName: 'consoles', rowId: 1} + ], "register the update hook again"); }