Yapf.net

Record historie bijhouden met HSTORE

Het is verbazend hoe vaak het handig zou zijn om bij te kunnen houden wat er allemaal met een record gebeurt. Auditing blijkt heel eenvoudig met een HSTORE veld en een trigger.

Bijhouden wat er in een record verandert is opzich eenvoudig te doen met een kopie van de originele tabel en een trigger die BEFORE UPDATE het OLD record in de historietabel zet. Maar als je dat doet dan moet je aparte tabellen maken voor elke tabel die je audit en je moet structurele aanpassingen aan de tabellen die je audit ook doorvoeren in de historietabel.

Het kan eenvoudiger met één historie tabel met een HSTORE veld waar de inhoud van het originele record in wordt opgeslagen. Er is dan nog steeds een trigger nodig, maar de function die wordt uitgevoerd kan gewoon het hele OLD record omzeten naar HSTORE en dat opslaan in één centrale historietabel met een kolom voor de naam van de originele schema en tabel waar het record uit kwam. Als een tabel wordt aangepast dan wordt dat automatisch in OLD doorgevoerd en dus ook in de HSTORE. Het maakt op deze manier ook niet meer uit welk proces voor de verandering van het record zorgt, het is niet meer aan de applicatie om de historie bij te houden, de database doet het zelf.

Let op dat het OLD record wordt opgeslagen, niet NEW, het is immers een historie van hoe het record er uit zag in het verleden. De huidige staat van het record zit al in de brontabel.

Proof of concept

code
-- For testing purposes, create a schema and put everything in there so it can be dropped at cleanup.
-- Cleanup with:
--
DROP SCHEMA IF EXISTS temp CASCADE;


CREATE SCHEMA temp;

-- Two tables that wil be audited
CREATE TABLE temp.products
(
    id    SERIAL,
    title text,
    price NUMERIC(8, 2)
);

CREATE TABLE temp.users
(
    id   INT,
    name TEXT
);

-- Create a single history table where everything is logged.
CREATE TABLE temp.audit_history
(
    id           serial,
    date_created timestamp DEFAULT now(),
    schemaname   text,
    tablename    text,
    operation    text,
    whodidit     text      DEFAULT current_user,
    old_val      hstore
);

-- Create the trigger function that will do the auditing.
CREATE FUNCTION temp.change_trigger() RETURNS trigger AS
$$
BEGIN
    IF TG_OP = 'INSERT' THEN
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE'
    THEN
        INSERT INTO temp.audit_history (tablename, schemaname, operation, old_val)
        VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP,
                hstore(OLD));
        RETURN NEW;
    ELSIF TG_OP = 'DELETE'
    THEN
        INSERT INTO temp.audit_history (tablename, schemaname, operation, old_val)
        VALUES (TG_RELNAME, TG_TABLE_SCHEMA, TG_OP, row_to_json(OLD));
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE 'plpgsql' SECURITY DEFINER;


-- Create triggers for the tables that are going to be audited.
CREATE TRIGGER trig_audit_products
    BEFORE INSERT OR UPDATE OR DELETE
    ON temp.products
    FOR EACH ROW
EXECUTE PROCEDURE temp.change_trigger();

CREATE TRIGGER trig_audit_users
    BEFORE INSERT OR UPDATE OR DELETE
    ON temp.users
    FOR EACH ROW
EXECUTE PROCEDURE temp.change_trigger();
code
-- Insert some data and make some changes.

INSERT INTO temp.users (id, name)
VALUES (43, 'Benny');

INSERT INTO temp.products (id, title, price)
VALUES (1, 'Funky shoes', 12);

UPDATE temp.users SET name='Benny' WHERE id = 43;

UPDATE temp.products SET title = 'Funky tapas' WHERE id = 1;

UPDATE temp.products SET price = 15    WHERE id = 1;
UPDATE temp.products SET price = 15.25 WHERE id = 1;
UPDATE temp.products SET price = 15.75 WHERE id = 1;
UPDATE temp.products SET price = 18.15 WHERE id = 1;
UPDATE temp.products SET price = 18.00 WHERE id = 1;
UPDATE temp.products SET price = 18.10 WHERE id = 1;
code
-- The history table now contains HSTORE values of the states of each record that has been updated.
SELECT * FROM temp.audit_history;

id	date_created	                schemaname  tablename	operation  whodidit	old_val
1	2020-01-08 12:05:13.274925	temp	    users       UPDATE	   vinny	name => Benny, id => 43
2	2020-01-08 12:05:13.298814	temp	    products	UPDATE	   vinny	price => 12.00, id => 1, title => "Funky shoes"
3	2020-01-08 12:05:13.321988	temp	    products	UPDATE	   vinny	price => 12.00, id => 1, title => "Funky tapas"
4	2020-01-08 12:05:13.351825	temp	    products	UPDATE	   vinny	price => 15.00, id => 1, title => "Funky tapas"
5	2020-01-08 12:08:21.224172	temp	    products	UPDATE	   vinny	price => 15.25, id => 1, title => "Funky tapas"
6	2020-01-08 12:08:23.311041	temp	    products	UPDATE	   vinny	price => 15.75, id => 1, title => "Funky tapas"
7	2020-01-08 12:08:46.849261	temp	    products	UPDATE	   vinny	price => 18.15, id => 1, title => "Funky tapas"
8	2020-01-08 12:08:46.858744	temp	    products	UPDATE	   vinny	price => 18.00, id => 1, title => "Funky tapas"


-- Now you can select specific details from the history
SELECT date_created, old_val->'price' AS price
FROM temp.audit_history
WHERE schemaname='temp' AND tablename='products' ORDER BY date_created;


date_created	                price
2020-01-08 12:05:13.298814	12.00
2020-01-08 12:05:13.321988	12.00
2020-01-08 12:05:13.351825	15.00
2020-01-08 12:08:21.224172	15.25
2020-01-08 12:08:23.311041	15.75
2020-01-08 12:08:46.849261	18.15
2020-01-08 12:08:46.858744	18.00


-- Using Windowing you can now show how a value changed over time
SELECT date_created,
       old_val -> 'price'                                              AS price,
       (old_val -> 'price')::NUMERIC -
       LAG((old_val -> 'price')::NUMERIC) OVER (ORDER BY date_created) AS price_change
FROM temp.audit_history
WHERE schemaname = 'temp'
  AND tablename = 'products'
ORDER BY date_created;


date_created	                price   price_change
2020-01-08 12:05:13.298814	12.00
2020-01-08 12:05:13.321988	12.00	0
2020-01-08 12:05:13.351825	15.00	3
2020-01-08 12:08:21.224172	15.25	0.25
2020-01-08 12:08:23.311041	15.75	0.5
2020-01-08 12:08:46.849261	18.15	2.4
2020-01-08 12:08:46.858744	18.00	-0.15

Q&A

Wordt de historietabel zo niet heel snel heel groot en heeft dat geen invloed op performance?

Ja de tabel wordt heel snel heel groot maar je doet uitsluitend INSERT, geen UPDATE, dus de performance impact is minimaal.
Mocht er toch een issue zijn dan heb je heel PostgreSQL te beschikking om het op te lossen. Je hebt in de triggerfunction ook de naam van de tabel waar de trigger uit is aangeroepen dus je zou ook aparte historytabellen kunnen gebruiken op basis van die tabelnaam. Je zou dat ook kunnen opdelen op datum zodat er een nieuwe historietabel komt per week. Je kunt de tabel unlogged maken zodat de historietabel weliswaar niet meer crash-safe is, maar ook geen tijd meer verdoet aan het crash-safe maken. Je kunt in de triggerfunction instellen dat onveranderede kolommen uit de HSTORE worden weggelaten. etc etc.

Hoe zit het met referentiële integriteit?

Dat is tegelijk ingewikkeld en eenvoudig. In principe gaat het om een historie dus dat het niet meer klopt met de huidige staat is precies de reden waarom je het opslaat.
Maar je hebt in principe voor elk record altijd elke staat beschikbaar; de huidige staat staat in de tabellen, de vorige staat staat in de historie. Een nieuwsartikel waarvan de auteur is veranderd heeft een historie-record voor de datum van de wijziging en dat record verwijst naar de oude auteur en die oude auteur staat ofwel nog steeds in de usertabel, of hij is aangepast en staat in de historietabel. Als hij in de historietabel staat dan is het een beetje opletten want dan moet je dat record dus opzoeken aan de hand van de datum; het gaat om het oudste userrecord dat is aangemaakt na het nieuwsartikelrecord. Windowing is your friend hierbij.