Postgres Audit Trigger Individual Rows - postgresql

I would like to create a basic audit log trigger in postgres that I can use across various tables.
My only requirement is that the audit log show each updated value as a separate entry.
i.e. if an INSERT is performed into a table with 5 rows, I receive 5 entries in the audit log table (one for each value added) or if that row is deleted from the table, I receive 5 entries (one for each value removed)
I have looked at various examples and am still having trouble getting the output correct, especially when the operation is UPDATE.
Here is the basic trigger flow.
-- 1. Making the Audit Table
CREATE TABLE audit_table
-- The audit table which will show:
-- Table, the ID of whats being changed, new columns, old columns,
-- user, operation(insert update delete), and timestamp
(
"src_table" TEXT NOT NULL,
"src_id" INT NOT NULL,
"col_new" TEXT,
"col_old" TEXT,
"user" TEXT DEFAULT current_user,
"action" TEXT NOT NULL,
"when_changed" TIMESTAMP
);
-- 2. Creating the base audit trigger function, the engine for processing changes --
CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS trigger AS
$$
BEGIN
IF TG_OP = 'INSERT' --Only shows new values
THEN
INSERT INTO audit_table ( "src_table", "src_id", "col_new", "user", "action", "when_changed")
VALUES(TG_TABLE_NAME, TG_RELID, row_to_json(NEW), current_user, TG_OP, current_timestamp);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' --Shows new and old values
THEN
INSERT INTO audit_table ("src_table", "src_id", "col_new", "col_old", "user", "action", "when_changed")
VALUES(TG_TABLE_NAME, TG_RELID, row_to_json(NEW), row_to_json(OLD), current_user, TG_OP, current_timestamp);
RETURN NEW;
ELSIF TG_OP = 'DELETE' --Only shows old values
THEN
INSERT INTO audit_table ("src_table", "src_id", "col_old", "user", "action", "when_changed")
VALUES(TG_TABLE_NAME, TG_RELID, row_to_json(OLD), current_user, TG_OP, current_timestamp);
RETURN OLD;
END IF;
END
$$
LANGUAGE 'plpgsql';
-- 3. Basic logic for calling audit trigger on tables, works for any insert, update, delete
CREATE TRIGGER test_audit_trigger
BEFORE INSERT OR UPDATE OR DELETE
ON test_table
FOR EACH ROW EXECUTE PROCEDURE audit_trigger();
The issue:
row_to_json returns the entire old or new payload as one column. I would like to return each change as a separate entry row in the audit_table, using for example column_name old_value new_value as a schema. What is the simplest way of achieving this? Having it be json is not a requirement.
Also not required:
Having it be BEFORE keyword, if AFTER or a combination of the two is better.
Having one trigger do all 3 functions, it can be a separate trigger or function per action
Returning values that are not changed, for example if an UPDATE only changes 1 value, I do not need the unchanged values in the log.

Here is a possible solution based on yours. One AFTER row trigger for insert, update and delete. The structure of audit_table is a bit different. Old and new values are saved as their text representation. You may wish to change the behaviour under insert and delete as they seem to be too verbose.
Unrelated but under your requirements the audit table has to implement the EAV (entity, attribute, value) antipattern.
drop table if exists audit_table;
create table audit_table
(
src_table text not null,
col_name text not null,
v_old text,
v_new text,
"user" text not null default current_user,
action text not null,
when_changed timestamptz not null default current_timestamp
);
create or replace function audit_tf() returns trigger language plpgsql as
$$
declare
k text;
v text;
j_new jsonb := to_jsonb(new);
j_old jsonb := to_jsonb(old);
begin
if TG_OP = 'INSERT' then -- only shows new values
for k, v in select * from jsonb_each_text(j_new) loop
insert into audit_table (src_table, col_name, v_new, action)
values (TG_TABLE_NAME, k, v, TG_OP);
end loop;
elsif TG_OP = 'UPDATE' then -- shows new and old values
for k, v in select * from jsonb_each_text(j_new) loop
if (v <> j_old ->> k) then
insert into audit_table (src_table, col_name, v_new, v_old, action)
values (TG_TABLE_NAME, k, v, j_old ->> k, TG_OP);
end if;
end loop;
elsif TG_OP = 'DELETE' then -- only shows old values
for k, v in select * from jsonb_each_text(j_old) loop
insert into audit_table (src_table, col_name, v_old, action)
values (TG_TABLE_NAME, k, v, TG_OP);
end loop;
end if;
return null;
end;
$$;
LANGUAGE 'plpgsql';
-- Demo
drop table if exists delme;
create table delme (x integer, y integer, z text, t timestamptz default now());
create trigger test_audit_trigger
after insert or update or delete on delme
for each row execute procedure audit_tf();
insert into delme (x, y, z) values (2, 2, 'two');
update delme set x = 1, y = 10 where x = 2;
delete from delme where x = 1;
select src_table, col_name, v_old, v_new, action from audit_table;
src_table
col_name
v_old
v_new
action
delme
t
2021-12-28T00:21:03.966621
INSERT
delme
x
2
INSERT
delme
y
2
INSERT
delme
z
two
INSERT
delme
x
2
1
UPDATE
delme
y
2
10
UPDATE
delme
t
2021-12-28T00:21:03.966621
DELETE
delme
x
1
DELETE
delme
y
10
DELETE
delme
z
two
DELETE

Related

Unique constraint across multiple postgres tables

Let's say I have the following two postgres tables with the same field:
CREATE TABLE table1 (
label VARCHAR(50)
);
CREATE TABLE table2 (
label VARCHAR(50)
);
I want label to be unique across both tables. That is, the following data should raise an error:
INSERT INTO table1 (label) VALUES ('hello');
INSERT INTO table2 (label) VALUES ('hello');
Is there any way to enforce this at the database level?
You cannot create a unique constraint across table, but a trigger on both tables can. One way: (see demo)
create or replace function table1_table2_cross_check()
returns trigger
language plpgsql
as $$
begin
if tg_table_name = 'table1'
then
if exists (select null
from table2
where label = new.label
)
then
raise exception 'Executing: % table1, Label Value: ''%'', already exists in table2',tg_op,new.label;
end if;
else
if exists (select null
from table1
where label = new.label
)
then
raise exception 'Executing: % table2, Label Value: ''%'', already exists in table1',tg_op,new.label;
end if;
end if;
return new;
end;
$$;
create trigger table1_biur
before insert or update
on table1
for each row
execute procedure table1_table2_cross_check();
create trigger table2_biur
before insert or update
on table2
for each row
execute procedure table1_table2_cross_check();

How to DELETE/INSERT rows in the same table using a UPDATE Trigger?

I want to create a trigger function, which copies certain columns of an recent updated row and deletes the old data. After that I want to insert the copied columns in exact the same table in the same row (overwrite). I need the data to be INSERTED because this function will be embedded in an existing program, with predefined Triggers.
That's what I have so far:
CREATE OR REPLACE FUNCTION update_table()
RETURNS TRIGGER AS
$func$
BEGIN
WITH tmp AS (DELETE FROM table
WHERE table.id = NEW.id
RETURNING id, geom )
INSERT INTO table (id, geom) SELECT * FROM tmp;
END;
$func$ language plpgsql;
CREATE TRIGGER T_update
AFTER UPDATE OF geom ON table
EXECUTE PROCEDURE update_table();
But I get the Error message:
ERROR: cannot perform DELETE RETURNING on relation "table"
HINT: You need an unconditional ON DELETE DO INSTEAD rule with a RETURNING clause.
Why I should use a rule here?
I'm using PostgreSQL 9.6
UPDATE:
A little bit of clarification. When I have two columns in my table (id, geom), after I updated geom I want to make a copy of this (new)row and insert it into the same table, while overwriting the updated row. (I'm not interested in any value before the update) I know that this is odd but I need this row to be inserted again because the program i embed this function in, listens to a INSERT statement and cannot be changed by me.
Right after you update a row, its old values will no longer be available. So, if you simply want to preserve the old row in case of an update you need to create a BEFORE UPDATE trigger, so that you can still access the OLD values and create a new row, e.g.
CREATE TABLE t (id int, geom geometry(point,4326));
CREATE OR REPLACE FUNCTION update_table() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO t (id, geom) VALUES (OLD.id,OLD.geom);
RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER t_update
BEFORE UPDATE OF geom ON t FOR EACH ROW EXECUTE PROCEDURE update_table();
INSERT INTO t VALUES (1,'SRID=4326;POINT(1 1)');
If you update the record 1 ..
UPDATE t SET geom = 'SRID=4326;POINT(2 2)', id = 2 WHERE id = 1;
UPDATE t SET geom = 'SRID=4326;POINT(3 3)', id = 3 WHERE id = 2;
.. you get a new record in the same table as you wished
SELECT id, ST_AsText(geom) FROM t;
id | st_astext
----+------------
1 | POINT(1 1)
2 | POINT(2 2)
3 | POINT(3 3)
Demo: db<>fiddle
Unrelated note: consider upgrading your PostgreSQL version! 9.6 will reach EOL in November, 2021.
First thanks to #JimJones for the answer. I´d like to post his answer modified for this purpose. This code "overwrites" the updated row by inserting a copy of itself and then deleting the old duplicate. That way I can Trigger on INSERT.
CREATE TABLE t (Unique_id SERIAL,id int, geom geometry(point,4326));
CREATE OR REPLACE FUNCTION update_table() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO t (id, geom) VALUES (NEW.id,NEW.geom);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER t_update
BEFORE UPDATE OF geom ON t FOR EACH ROW EXECUTE PROCEDURE update_table();
CREATE OR REPLACE FUNCTION delete_table() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM t a
USING t b
WHERE a.Unique_id < b.Unique_id
AND a.geom = b.geom;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER t_delete
AFTER UPDATE OF geom ON t FOR EACH ROW EXECUTE PROCEDURE delete_table();
INSERT INTO t VALUES (1,1,'SRID=4326;POINT(1 1)');
UPDATE t SET geom = 'SRID=4326;POINT(2 2)' WHERE id = 1;

Postgres Count records inserted/ updated

I'm trying to keey track of a clients database with which we sync. I need to record records_added (INSERTs) and records_updated (UPDATEs) to our table.
I'm using an UPSERT to handle the sync, and a trigger to update a table keeping track of insert/updates.
The issue is counting records that have are updated. I have 40+ columns to check, do I have to put all these in my check logic? Is there a more elegant way?
Section of code in question:
select
case
when old.uuid = new.uuid
and (
old.another_field != new.another_field,
old.and_another_field != new.and_another_field,
-- many more columns here << This is particularly painful
) then 1
else 0
end into update_count;
Reproducible example:
-- create tables
CREATE TABLE IF NOT EXISTS example (uuid serial primary key, another_field int, and_another_field int);
CREATE TABLE IF NOT EXISTS tracker_table (
records_added integer DEFAULT 0,
records_updated integer DEFAULT 0,
created_at date unique
);
-- create function
CREATE OR REPLACE FUNCTION update_records_inserted () RETURNS TRIGGER AS $body$
DECLARE update_count INT;
DECLARE insert_count INT;
BEGIN
-- ---------------- START OF BLOCK IN QUESTION -----------------
select
case
when old.uuid = new.uuid
and (
old.another_field != new.another_field
-- many more columns here
) then 1
else 0
end into update_count;
-- ------------------ END OF BLOCK IN QUESTION ------------------
-- count INSERTs
select
case
when old.uuid is null
and new.uuid is not null then 1
else 0
end into insert_count;
-- --log the counts
-- raise notice 'update %', update_count;
-- raise notice 'insert %', insert_count;
-- insert or update count to tracker table
insert into
tracker_table(
created_at,
records_added,
records_updated
)
VALUES
(CURRENT_DATE, insert_count, update_count) ON CONFLICT (created_at) DO
UPDATE
SET
records_added = tracker_table.records_added + insert_count,
records_updated = tracker_table.records_updated + update_count;
RETURN NEW;
END;
$body$ LANGUAGE plpgsql;
-- Trigger
DROP TRIGGER IF EXISTS example_trigger ON example;
CREATE TRIGGER example_trigger
AFTER
INSERT
OR
UPDATE
ON example FOR EACH ROW EXECUTE PROCEDURE update_records_inserted ();
-- A query to insert, then update when number of uses > 1
insert into example(whatever) values (2, 3) ON CONFLICT(uuid) DO UPDATE SET another_field=excluded.another_field+1;

Handle NULL values ​in trigger of PostgreSQL views using DEFAULT?

I wanted to have an explanation on triggers of Postgres views.
To make clear what I want to ask, I'll give you a very simplified example of my case.
In this example we have two tables (table_a, table_b) that joined together make the view in the example (vw_table_ab).
In this example I will use trivial names and simple DDLs/DMLs.
-- TABLE table_a
CREATE TABLE table_a
(
id serial PRIMARY KEY,
timestamp_field timestamp DEFAULT now() NOT NULL,
boolean_field boolean DEFAULT FALSE NOT NULL
);
-- TABLE table_b
CREATE TABLE table_b
(
id serial PRIMARY KEY,
timestamp_field timestamp DEFAULT now() NOT NULL,
boolean_field boolean DEFAULT FALSE NOT NULL,
id_table_a integer NOT NULL,
CONSTRAINT "fk_table_a" FOREIGN KEY (id_table_a) REFERENCES table_a (id) ON DELETE CASCADE NOT DEFERRABLE,
CONSTRAINT "u_table_a" UNIQUE (id_table_a)
);
-- VIEW vw_table_ab
CREATE VIEW vw_table_ab AS (
SELECT a.timestamp_field AS timestamp_a,
a.boolean_field AS boolean_a,
b.timestamp_field AS timestamp_b,
b.boolean_field AS boolean_b
FROM table_a a
JOIN table_b b ON a.id = b.id_table_a
);
A trigger function on standard actions (INSERT, UPDATE and DELETE) is linked to this view through an INSTEAD OF trigger.
-- TRIGGER FUNCTION fn_trigger
CREATE FUNCTION fn_trigger() RETURNS trigger LANGUAGE plpgsql AS
$_$
DECLARE
sql TEXT;
BEGIN
sql = 'SELECT ' || TG_TABLE_NAME || '_' || lower(TG_OP) || '($1);';
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
EXECUTE (sql) USING NEW;
RAISE NOTICE '%', sql;
RETURN NEW;
ELSE
EXECUTE (sql) USING OLD;
RAISE NOTICE '%', sql;
RETURN OLD;
END IF;
END;
$_$;
-- TRIGGER tr_table_ab
CREATE TRIGGER tr_table_ab
INSTEAD OF INSERT OR UPDATE OR DELETE ON vw_table_ab
FOR EACH ROW EXECUTE PROCEDURE fn_trigger();
The example I bring has a trigger called only on the insert action, and the function that is executed is this:
-- INSERT FUNCTION vw_table_ab_insert
CREATE FUNCTION vw_table_ab_insert(new vw_table_ab) RETURNS void LANGUAGE plpgsql AS
$_$
DECLARE
id_table_a integer;
BEGIN
INSERT INTO table_a (timestamp_field, boolean_field) VALUES (new.timestamp_a, new.boolean_a)
RETURNING id
INTO id_table_a;
INSERT INTO table_b (timestamp_field, boolean_field, id_table_a) VALUES (new.timestamp_a, new.boolean_b, id_table_a);
END;
$_$;
Now we can get to my problem. I make an insert on the view, and when the action is triggered, I get a "Not null violation" error becouse I have some NOT NULL constraints on table_a and table_b like in this case:
INSERT INTO vw_table_ab (timestamp_a, boolean_a, timestamp_b, boolean_b) VALUES (now(), NULL, now(), NULL);
Suppose that the previous statement is generated through a programming language framework and I don't want to handle this case in backend code, but I want handle this case in PostgreSQL in the insert function vw_table_ab_insert. So at this point my problem is bound to the new parameter of the function because I have fields of the view that are NULL. But these fields have a DEFAULT value in the definition of the base table, and I want to use that.
...
timestamp_field timestamp DEFAULT now() NOT NULL,
boolean_field boolean DEFAULT FALSE NOT NULL
...
My question:
How can I manage the NULL values ​​in trigger of the views using the DEFAULT defined in the tables?
Initially I thought of putting IF ... THEN ... inside the function and override null values ​​with DEFAULT expression but I do not really like that.
For example, the function would become like this:
CREATE FUNCTION vw_table_ab_insert(new vw_table_ab) RETURNS void LANGUAGE plpgsql AS
$_$
DECLARE
id_table_a integer;
BEGIN
IF new.timestamp_a IS NULL THEN
new.timestamp_a = DEFAULT;
END IF;
IF new.boolean_a IS NULL THEN
new.boolean_a = DEFAULT;
END IF;
IF new.timestamp_b IS NULL THEN
new.timestamp_b = DEFAULT;
END IF;
IF new.boolean_b IS NULL THEN
new.boolean_b = DEFAULT;
END IF;
INSERT INTO table_a (timestamp_field, boolean_field)
VALUES (new.timestamp_a, new.boolean_a)
RETURNING id
INTO id_table_a;
INSERT INTO table_b (timestamp_field, boolean_field, id_table_a)
VALUES (new.timestamp_a, new.boolean_b, id_table_a);
END;
$_$;
Someone can help me? Is there another method for handling this case?
The easiest way would be to use ALTER VIEW ... ALTER col SET DEFAULT to define default values on the view that are the same as the default values on the base table.
Then instead of inserting explicit NULLs, omit the columns from the INSERT statement or insert DEFAULT explicitly. Your resulting view will behave just like a real table.

Postgres create universal function on all table

I have a few db tables.
I want write universtal postgres function on copy rows to history tables
I have tables:
table1
table1_h
table2
table2_h
I wrote function (with help stackoverflow)
CREATE OR REPLACE FUNCTION copy_history_f() RETURNS TRIGGER AS
$BODY$
DECLARE
tablename_h text:= TG_TABLE_NAME || '_h';
BEGIN
EXECUTE 'INSERT INTO ' || quote_ident(TG_TABLE_SCHEMA) || '.' || quote_ident(tablename_h) || ' VALUES (' || OLD.* ||')';
RETURN NULL;
END;
$BODY$
LANGUAGE plpgsql VOLATILE;
And functions was create, but after update is error.
ERROR: syntax error at or near ","
ROW 1: ...RT INTO table1_h VALUES ((12,,,0,,"Anto...
I know where is error in this insert but I don't know how I repair that.
Structure tables table1 and table1_h are identical but table1_h has one more column (id_h)
Can you help me, how I have create psql function?
Thnak you.
drop table if exists t;
drop table if exists t_h;
drop function if exists ftg();
create table t(i serial, x numeric);
insert into t(x) values(1.1),(2.2);
create table t_h(i int, x numeric);
create function ftg() returns trigger language plpgsql as $ftg$
declare
tablename_h text:= TG_TABLE_NAME || '_h';
begin
execute format($q$ insert into %I.%I select $1.*; $q$, TG_TABLE_SCHEMA, tablename_h) using old;
return null;
end $ftg$;
create trigger tg_t after delete on t for each row execute procedure ftg();
delete from t where i = 1;
select * from t_h;
dbfiddle
Update It solves your problem, but I think that you want to have a bit more info in your history tables. It will be more complex a bit:
drop table if exists t;
drop table if exists t_h;
drop function if exists ftg();
create table t(i serial, x numeric);
insert into t(x) values(1.1),(2.2);
create table t_h(
hi serial, -- just ID
hd timestamp, -- timestamp
hu text, -- user who made changes
ha text, -- action
i int, x numeric
);
create function ftg() returns trigger language plpgsql as $ftg$
declare
tablename_h text:= TG_TABLE_NAME || '_h';
begin
execute format(
$q$
insert into %I.%I
select
nextval(%L || '_hi_seq'),
clock_timestamp(),
current_user,
%L,
$1.*
$q$, TG_TABLE_SCHEMA, tablename_h, tablename_h, TG_OP) using old;
return null;
end $ftg$;
create trigger tg_t after delete or update on t for each row execute procedure ftg();
update t set x = x * 2;
update t set x = x * 2 where i = 2;
delete from t where i = 1;
select * from t_h;
dbfiddle
I assume you are inserting the 'old' values from table1 into table1_h.
The additional column is your problem. When you using an insert without naming columns you must use a matching number and type for the insert.
You must use column referencing.
eg.
Insert into table1_h(column1, column2, column3)
values (a,b,c)
Consider a default value for the additional column in table table1_h.