Simple method to add historic feature to SQLite
Abstract: Make a database which can be store historic modification is often considered a “secondary” activity, but bad design leads to databases that are difficult to optimize and often not very understandable. In this article we illustrate a simple method that respects the dictates of relational theory & is easy to understand. As a plus we will show it on SQLite, a small but powerful database system.
Authors: Ernesto Ghetti (idea), Giovanni Giorgi (Exhibition and demo trigger)
Let’s suppose we have a table called “CUSTOMER” and we want to log any changes made to its fields, for example on a daily basis.
It is usually tempting to add a couple of START & END DATE columns, only to realize that querying this table to find the current data becomes a nightmare, especially if there are other related tables. Another approach is to use a CUSTOMER_HISTORY table, but duplicating all the tables and leading to a cumbersome system of updating a group of related tables.
Lets start with a E-R model like this:
We are going to create a CUSTOMER table with a logical primary key NDG and a physical primary key XD. Then we define a relationship with the same table using XDS (iDentifer hiStory) as a foreign key. Let’s add some data (like customer name) and a couple of dates to register when the record is inserted / updated, as well as the author (editor) of the change in question:
create table CUSTOMER(
xd number not null,
NDG number not null,
xds number,
d_start timestamp,
d_end timestamp,
editor varchar2(40),
name varchar2(80),
Primary key (xd),
CONSTRAINT FK_HISTORY_CUSTOMER
FOREIGN KEY (XDS)
REFERENCES CUSTOMER (XD)
);
-- Forbids different CUSTOMERS with same NDG:
create unique index CUSTOMER_UQ_NDG ON CUSTOMER( case when xd=xds then NDG END );
-- View on active data
create view VCUSTOMER as
select * from CUSTOMER where xd=xds;
Then let’s define a couple of rules:
- The current record is the one with XD = XDS. It could have END_DATE = null
- A historicized record always has XD> XDS and must have defined the END_DATE
- Also, we want to avoid different customers have the same logical primary key, called NDG (line 16 above).
Creating a new record is easy:
-- Insert record example (unique id are fixed for simplicity)
INSERT INTO CUSTOMER(XD,XDS,d_start,d_end,ndg,name,editor) values(1 ,1,date('now'),null,100,'Zeno Parisi','BOT');
select * from customer order by XD desc;
+----+-----+-----+------------+-------+--------+-------------+
| xd | NDG | xds | d_start | d_end | editor | name |
+----+-----+-----+------------+-------+--------+-------------+
| 1 | 100 | 1 | 2021-03-31 | | BOT | Zeno Parisi |
+----+-----+-----+------------+-------+--------+-------------+
Now the tricky part: how we update a record?
We use a “shrimp” strategy. First of all we create a copy of the current record, and we let point it to the new one with XD pointer.
-- Copy old record XD > XDS
-- On SQLITE you got a rowid special unique id column for every row: we use it here to make
-- the copy
INSERT INTO CUSTOMER(XD,XDS,d_start,d_end,editor,name,ndg)
select rowid+1 /*trick to get a new one */, XDS,d_start,date('now'),editor,name,ndg
from CUSTOMER where xd=xds and NDG=100;
-- Update XD=XDS
update CUSTOMER set name='Zeno Paris', editor='GG', d_start=date('now') where xd=xds and ndg=100;
Then we update the current record with the new value we want to insert (line 7 above). The result is below:
-- After update select * from customer order by XD desc; +----+-----+-----+------------+------------+--------+-------------+ | xd | NDG | xds | d_start | d_end | editor | name | +----+-----+-----+------------+------------+--------+-------------+ | 2 | 100 | 1 | 2021-03-31 | 2021-03-31 | BOT | Zeno Parisi | | 1 | 100 | 1 | 2021-03-31 | | GG | Zeno Paris | +----+-----+-----+------------+------------+--------+-------------+
External relation support
This strategy supports external relations too, as you can see in the owned_service table model.
Trigger support
-- New record (see below)
INSERT INTO customer(XD,XDS,d_start,d_end,ndg,name,editor)
select max(rowid)+1,max(rowid)+1, date('now'),null,200,'Scott Tiger','BOT'
from customer;
/* Trigger support for trasparent updates on vcustomer
*
*/
create trigger auto_history_customer_update
instead of update on vcustomer
FOR EACH ROW
begin
INSERT INTO CUSTOMER(XD,XDS,d_start,d_end,editor,name,ndg)
select max(rowid)+1 /*trick to get a new one */, XDS,d_start,date(’now’),editor,name,ndg
from CUSTOMER where xd=xds and NDG =OLD.ndg;
update CUSTOMER set name=NEW.name, editor=NEW.editor, d_start=date(’now’) where xd=xds and xd=OLD.XD;
end;
/* This trigger only manage rowids */
create trigger auto_history_customer_insert
instead of insert on vcustomer
FOR EACH ROW
begin
INSERT INTO CUSTOMER(XD,XDS, d_start, d_end, editor, name, ndg)
select max(rowid)+1,max(rowid)+1, NEW.d_start, NEW.d_end,NEW.editor, NEW.name, NEW.NDG
from customer;
end;
– Trigger support example
update vcustomer set editor=‘Changed via trigger’ where NDG=200;
– select * from customer order by xd desc;
select * from vcustomer;
+—-+—–+—–+————+——-+———————+————-+
| xd | NDG | xds | d_start | d_end | editor | name |
+—-+—–+—–+————+——-+———————+————-+
| 1 | 100 | 1 | 2021-03-31 | | GG | Zeno Paris |
| 3 | 200 | 3 | 2021-03-31 | | Changed via trigger | Scott Tiger |
+—-+—–+—–+————+——-+———————+————-+
.echo off
Real table data:
+—-+—–+—–+————+————+———————+————-+
| xd | NDG | xds | d_start | d_end | editor | name |
+—-+—–+—–+————+————+———————+————-+
| 1 | 100 | 1 | 2021-03-31 | | GG | Zeno Paris |
| 2 | 100 | 1 | 2021-03-31 | 2021-03-31 | BOT | Zeno Parisi |
| 3 | 200 | 3 | 2021-03-31 | | Changed via trigger | Scott Tiger |
| 4 | 200 | 3 | 2021-03-31 | 2021-03-31 | BOT | Scott Tiger |
+—-+—–+—–+————+————+———————+————-+
