Newsletter
TechAnV Blog
Get updates on security engineering, Rust, eBPF, and DevSecOps. No spam, unsubscribe anytime.
Check your inbox and click the confirmation link to complete your subscription.
Track timestamped changes to a SQLite table using triggers#
This is more of a “today I figured out” than a TIL.
I have an idea to implement the Denormalized Query Engine design pattern using SQLite triggers.
My goal is that any time an insert or update occurs on a table, a matching row will be written to a _dqe_changes table recording the rowid of the affected row and the unix timestamp (in milliseconds) that the change occurred.
If a row is deleted, a record will be written into tha table with a 1 in the deleted column.
This will allow subscribing scripts to poll that table for changes made since their last poll, based on the milliseconds timestamp field. They can then apply those changes to external data stores, such as an Elasticsearch index.
Here’s the recipe I came up with:
1-- This table exists just for table -> integer lookups,2-- to save space in the _dqe_changes table3CREATE TABLE _dqe_tables(4 id integer primary key,5 [table] text unique6);7-- This table records the timestamped changes8CREATE TABLE _dqe_changes(9 table_id integer,10 rowid integer,11 deleted integer, -- treated as a null or 1 boolean12 updated_ms integer,13 PRIMARY KEY (table_id, rowid),14 FOREIGN KEY (table_id) REFERENCES _dqe_tables(id)15);16-- Create an index for polling against17CREATE INDEX _dqe_changes_updated_ms ON _dqe_changes(updated_ms);18
19-- An example table:20CREATE TABLE foo (name text);21
22-- Each table needs to have these triggers created on it:23CREATE TRIGGER IF NOT EXISTS [foo_dqe_insert] AFTER INSERT ON [foo]24BEGIN25 INSERT OR IGNORE INTO _dqe_tables([table]) VALUES ('foo');26 INSERT OR REPLACE INTO _dqe_changes(table_id, rowid, updated_ms)27 VALUES (28 (select id from _dqe_tables where [table] = 'foo'),29 new.rowid,30 -- This is a recipe for timestamp in milliseconds, from31 -- https://stackoverflow.com/a/56895050/608332 strftime('%s','now') || substr(strftime('%f','now'),4)33 );34END;35CREATE TRIGGER IF NOT EXISTS [foo_dqe_update] AFTER UPDATE ON [foo]36BEGIN37 INSERT OR IGNORE INTO _dqe_tables([table]) VALUES ('foo');38 INSERT OR REPLACE INTO _dqe_changes(table_id, rowid, updated_ms)39 VALUES (40 (select id from _dqe_tables where [table] = 'foo'),41 new.rowid,42 strftime('%s','now') || substr(strftime('%f','now'),4)43 );44END;45CREATE TRIGGER IF NOT EXISTS [foo_dqe_delete] AFTER DELETE ON [foo]46BEGIN47 INSERT OR IGNORE INTO _dqe_tables([table]) VALUES ('foo');48 INSERT OR REPLACE INTO _dqe_changes(table_id, rowid, deleted, updated_ms)49 VALUES (50 (select id from _dqe_tables where [table] = 'foo'),51 old.rowid,52 1,53 strftime('%s','now') || substr(strftime('%f','now'),4)54 );55END;56
57INSERT INTO foo VALUES ('hello');58INSERT INTO foo VALUES ('hello2');59INSERT INTO foo VALUES ('hello3');60INSERT INTO foo VALUES ('hello4');To test this I ran sqlite3 (with no arguments, which provides an in-memory database to play with), pasted in the above and then ran this:
1sqlite> .headers on2sqlite> select rowid, * from foo;3rowid|name41|hello52|hello263|hello374|hello48sqlite> select * from _dqe_changes;9table_id|rowid|deleted|updated_ms101|1||1629399072866111|2||1629399072867121|3||1629399072867131|4||162939907297414sqlite> select * from _dqe_tables;15id|table161|foo17sqlite> delete from foo where name = 'hello3';18sqlite> select * from _dqe_changes;19table_id|rowid|deleted|updated_ms201|1||1629399072866211|2||1629399072867221|4||1629399072974231|3|1|162939910316924sqlite> update foo set name = 'hello6' where name = 'hello';25sqlite> select * from _dqe_changes;26table_id|rowid|deleted|updated_ms271|2||1629399072867281|4||1629399072974291|3|1|1629399103169301|1||162939911588631sqlite> select rowid, * from foo;32rowid|name331|hello6342|hello2354|hello4There’s one catch with the above recipe: if you delete ALL of the rows from a table (delete * from foo) SQLite defaults to reusing rowids in that table, starting again from 1. This means that your accompanying records in the _dqe_changes table will have stale rowids, which could lead to surprising behaviour.
If tables have a id integer primary key column SQLite does NOT reuse rowids, as explained here.
Initializing for a new table#
The above recipe expects the triggers to be installed on the table before any rows are written to it.
If applying this recipe to an already-populated table, the following SQL should initialize _dqe_changes with a row for every row:
1INSERT OR IGNORE INTO _dqe_tables([table]) VALUES ('foo');2INSERT OR REPLACE INTO _dqe_changes(table_id, rowid, deleted, updated_ms)3 with table_id as (select id from _dqe_tables where [table] = 'foo')4 select5 table_id.id,6 foo.rowid,7 null as deleted,8 strftime('%s','now') || substr(strftime('%f','now'),4) as updated_ms9 from foo, table_id;