Skip to content

Commit 8fa6fec

Browse files
hoeckErik Soehnel
and
Erik Soehnel
authored
Add updateHook (#604)
* 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. * 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. --------- Co-authored-by: Erik Soehnel <[email protected]>
1 parent 1ae2bbe commit 8fa6fec

File tree

3 files changed

+171
-1
lines changed

3 files changed

+171
-1
lines changed

src/api.js

+97
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
7171
var SQLITE_BLOB = 4;
7272
// var - Encodings, used for registering functions.
7373
var SQLITE_UTF8 = 1;
74+
// var - Authorizer Action Codes used to identify change types in updateHook
75+
var SQLITE_INSERT = 18;
76+
var SQLITE_UPDATE = 23;
77+
var SQLITE_DELETE = 9;
7478
// var - cwrap function
7579
var sqlite3_open = cwrap("sqlite3_open", "number", ["string", "number"]);
7680
var sqlite3_close_v2 = cwrap("sqlite3_close_v2", "number", ["number"]);
@@ -239,6 +243,12 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
239243
["number"]
240244
);
241245

246+
var sqlite3_update_hook = cwrap(
247+
"sqlite3_update_hook",
248+
"number",
249+
["number", "number", "number"]
250+
);
251+
242252
/**
243253
* @classdesc
244254
* Represents a prepared statement.
@@ -1114,6 +1124,12 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
11141124
});
11151125
Object.values(this.functions).forEach(removeFunction);
11161126
this.functions = {};
1127+
1128+
if (this.updateHookFunctionPtr) {
1129+
removeFunction(this.updateHookFunctionPtr);
1130+
this.updateHookFunctionPtr = undefined;
1131+
}
1132+
11171133
this.handleError(sqlite3_close_v2(this.db));
11181134
FS.unlink("/" + this.filename);
11191135
this.db = null;
@@ -1383,6 +1399,87 @@ Module["onRuntimeInitialized"] = function onRuntimeInitialized() {
13831399
return this;
13841400
};
13851401

1402+
/** Registers the update hook with SQLite
1403+
@param {function(operation, database, table, rowId) | null} callback
1404+
executed whenever a row in any rowid table is changed
1405+
1406+
For each changed row, the callback is called once with the change
1407+
('insert', 'update' or 'delete'), the database name and table name
1408+
where the change happened and the rowid of the row that has been
1409+
changed.
1410+
1411+
rowid is cast to a plain number, if it exceeds Number.MAX_SAFE_INTEGER
1412+
an error will be thrown.
1413+
1414+
The callback MUST NOT modify the database in any way.
1415+
1416+
Only a single callback can be registered. Unregister the callback by
1417+
passing null.
1418+
1419+
Not called for some updates like ON REPLACE CONFLICT and TRUNCATE (a
1420+
DELETE FROM without a WHERE clause).
1421+
1422+
See sqlite docs on sqlite3_update_hook for more details.
1423+
*/
1424+
Database.prototype["updateHook"] = function updateHook(callback) {
1425+
if (this.updateHookFunctionPtr) {
1426+
// unregister and cleanup a previously registered update hook
1427+
sqlite3_update_hook(this.db, 0, 0);
1428+
removeFunction(this.updateHookFunctionPtr);
1429+
this.updateHookFunctionPtr = undefined;
1430+
}
1431+
1432+
if (!callback) {
1433+
// no new callback to register
1434+
return;
1435+
}
1436+
1437+
// void(*)(void *,int ,char const *,char const *,sqlite3_int64)
1438+
function wrappedCallback(
1439+
ignored,
1440+
operationCode,
1441+
databaseNamePtr,
1442+
tableNamePtr,
1443+
rowIdBigInt
1444+
) {
1445+
var operation;
1446+
1447+
switch (operationCode) {
1448+
case SQLITE_INSERT:
1449+
operation = "insert";
1450+
break;
1451+
case SQLITE_UPDATE:
1452+
operation = "update";
1453+
break;
1454+
case SQLITE_DELETE:
1455+
operation = "delete";
1456+
break;
1457+
default:
1458+
throw "unknown operationCode in updateHook callback: "
1459+
+ operationCode;
1460+
}
1461+
1462+
var databaseName = UTF8ToString(databaseNamePtr);
1463+
var tableName = UTF8ToString(tableNamePtr);
1464+
1465+
if (rowIdBigInt > Number.MAX_SAFE_INTEGER) {
1466+
throw "rowId too big to fit inside a Number";
1467+
}
1468+
1469+
var rowId = Number(rowIdBigInt);
1470+
1471+
callback(operation, databaseName, tableName, rowId);
1472+
}
1473+
1474+
this.updateHookFunctionPtr = addFunction(wrappedCallback, "viiiij");
1475+
1476+
sqlite3_update_hook(
1477+
this.db,
1478+
this.updateHookFunctionPtr,
1479+
0 // passed as the first arg to wrappedCallback
1480+
);
1481+
};
1482+
13861483
// export Database to Module
13871484
Module.Database = Database;
13881485
};

src/exported_functions.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@
4242
"_sqlite3_result_int64",
4343
"_sqlite3_result_error",
4444
"_sqlite3_aggregate_context",
45-
"_RegisterExtensionFunctions"
45+
"_RegisterExtensionFunctions",
46+
"_sqlite3_update_hook"
4647
]

test/test_update_hook.js

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
exports.test = function(SQL, assert){
2+
var db = new SQL.Database();
3+
4+
db.exec(
5+
"CREATE TABLE consoles (id INTEGER PRIMARY KEY, company TEXT, name TEXT);" +
6+
"INSERT INTO consoles VALUES (1, 'Sony', 'Playstation');" +
7+
"INSERT INTO consoles VALUES (2, 'Microsoft', 'Xbox');"
8+
);
9+
10+
// {operation: undefined, databaseName: undefined, tableName: undefined, rowId: undefined};
11+
var updateHookCalls = []
12+
13+
db.updateHook(function(operation, databaseName, tableName, rowId) {
14+
updateHookCalls.push({operation, databaseName, tableName, rowId});
15+
});
16+
17+
// INSERT
18+
db.exec("INSERT INTO consoles VALUES (3, 'Sega', 'Saturn');");
19+
20+
assert.deepEqual(updateHookCalls, [
21+
{operation: "insert", databaseName: "main", tableName: "consoles", rowId: 3}
22+
], "insert a single row");
23+
24+
// UPDATE
25+
updateHookCalls = []
26+
db.exec("UPDATE consoles SET name = 'Playstation 5' WHERE id = 1");
27+
28+
assert.deepEqual(updateHookCalls, [
29+
{operation: "update", databaseName: "main", tableName: "consoles", rowId: 1}
30+
], "update a single row");
31+
32+
// UPDATE (multiple rows)
33+
updateHookCalls = []
34+
db.exec("UPDATE consoles SET name = name + ' [legacy]' WHERE id IN (2,3)");
35+
36+
assert.deepEqual(updateHookCalls, [
37+
{operation: "update", databaseName: "main", tableName: "consoles", rowId: 2},
38+
{operation: "update", databaseName: "main", tableName: "consoles", rowId: 3},
39+
], "update two rows");
40+
41+
// DELETE
42+
updateHookCalls = []
43+
db.exec("DELETE FROM consoles WHERE company = 'Sega'");
44+
45+
assert.deepEqual(updateHookCalls, [
46+
{operation: "delete", databaseName: "main", tableName: "consoles", rowId: 3}
47+
], "delete a single row");
48+
49+
// UNREGISTER
50+
updateHookCalls = []
51+
52+
db.updateHook(null);
53+
54+
db.exec("DELETE FROM consoles WHERE company = 'Microsoft'");
55+
56+
assert.deepEqual(updateHookCalls, [], "unregister the update hook");
57+
58+
// REGISTER AGAIN
59+
updateHookCalls = []
60+
61+
db.updateHook(function(operation, databaseName, tableName, rowId) {
62+
updateHookCalls.push({operation, databaseName, tableName, rowId});
63+
});
64+
65+
// need a where clause, just running "DELETE FROM consoles" would result in
66+
// a TRUNCATE and not yield any update hook callbacks
67+
db.exec("DELETE FROM consoles WHERE id > 0");
68+
69+
assert.deepEqual(updateHookCalls, [
70+
{operation: 'delete', databaseName: 'main', tableName: 'consoles', rowId: 1}
71+
], "register the update hook again");
72+
}

0 commit comments

Comments
 (0)