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.