Audit trigger (Statement Level) storing identifier of inserted/updated/deleted rows - postgresql

I have the following small MVWE for a basic micro-auditing system which works fine but lacks a functionality:
DROP TABLE IF EXISTS audit CASCADE;
CREATE TABLE audit(
Id BIGSERIAL NOT NULL
,TimeValue TIMESTAMP NOT NULL
,RoleName NAME NOT NULL
,Operation NAME NOT NULL
,SchemaName NAME NOT NULL
,TableName NAME NOT NULL
,Identifiers BIGINT[]
---
,PRIMARY KEY(Id)
);
-- Audit Trigger:
DROP FUNCTION IF EXISTS audit_trigger() CASCADE;
CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS
$BODY$
BEGIN
INSERT INTO audit(TimeValue, RoleName, Operation, SchemaName, TableName) VALUES
(now()::TIMESTAMP, current_user, TG_OP, TG_TABLE_SCHEMA, TG_RELNAME);
RETURN NULL;
END;
$BODY$
LANGUAGE plpgsql SECURITY DEFINER;
-- Channels:
DROP TABLE IF EXISTS channels CASCADE;
CREATE TABLE channels(
Id INTEGER NOT NULL
,UserKey TEXT NOT NULL
,Active BOOLEAN NOT NULL DEFAULT(TRUE)
---
,PRIMARY KEY(Id)
,UNIQUE(UserKey)
);
CREATE TRIGGER channel_audit_trigger BEFORE INSERT OR UPDATE OR DELETE ON channels
FOR EACH STATEMENT EXECUTE PROCEDURE audit_trigger();
-- Perform some operations:
INSERT INTO channels(
SELECT C.Id, 'Channel-' || C.Id
FROM generate_series(1, 300, 10) AS C(Id)
);
DELETE FROM channels WHERE id < 10;
UPDATE channels
SET UserKey = 'wild channel'
WHERE id = 21;
I would like to add into the last column of audit table, identifiers of rows that have been inserted/updated/deleted in channels.
I have used STATEMENT level because I just need to collects identifiers in a array. But I do not find how to access DML statistics. Conversely at the ROW level I must handle OLD and NEW cases and I cannot succeed in aggregate all touched identifier.
How can I proceed in order to fill the last column of audit table with touched identifiers?
Update
Finally I reached my goal, but this solution might not be scalable and may have some unwanted drawbacks (I am open to any constructive feedback and advice).
Basically, how I have solved my problem:
Log at ROW level BEFORE DML is performed into a table audit_rowlevel;
Aggregate the freshly added content of audit_rowlevel into audit_statementlevel at STATEMENT level AFTER DML is performed;
Minimal Working Example is now:
DROP TABLE IF EXISTS audit_rowlevel CASCADE;
CREATE TABLE audit_rowlevel(
Id BIGSERIAL NOT NULL
,Aggregated BOOLEAN NOT NULL DEFAULT(FALSE)
,TimeValue TIMESTAMP NOT NULL
-- https://www.postgresql.org/docs/current/static/functions-info.html
,RoleName NAME NOT NULL
,ClientIP INET NOT NULL
,ClientPid INTEGER NOT NULL
-- https://www.postgresql.org/docs/current/static/plpgsql-trigger.html
,Operation TEXT NOT NULL
,SchemaName NAME NOT NULL
,TableName NAME NOT NULL
,RowId BIGINT NOT NULL
-- https://www.postgresql.org/docs/current/static/functions-json.html
,OldValue JSONB
,NewValue JSONB
---
,PRIMARY KEY(Id)
);
-- Row Level Trigger:
DROP FUNCTION IF EXISTS audit_rowlevel_trigger() CASCADE;
CREATE OR REPLACE FUNCTION audit_rowlevel_trigger()
RETURNS TRIGGER AS
$BODY$
DECLARE
history BOOLEAN := (TG_NARGS > 0) AND (TG_ARGV[0]::BOOLEAN);
rowid BIGINT;
oldvalue JSONB;
newvalue JSONB;
BEGIN
-- Handle NEW:
IF TG_OP = ANY('{INSERT,UPDATE}') THEN
IF history THEN
newvalue := to_jsonb(NEW);
END IF;
rowid := NEW.Id::BIGINT;
END IF;
-- Handle OLD:
IF TG_OP = ANY('{UPDATE,DELETE}') THEN
IF history THEN
oldvalue := to_jsonb(OLD);
END IF;
rowid := OLD.Id::BIGINT;
END IF;
-- INSERT:
INSERT INTO audit_rowlevel(TimeValue, RoleName, ClientIP, ClientPID, Operation, SchemaName, TableName, RowId, NewValue, OldValue) VALUES
(now()::TIMESTAMP, current_user, inet_client_addr(), pg_backend_pid(), TG_OP, TG_TABLE_SCHEMA, TG_RELNAME, RowId, NewValue, OldValue);
-- RETURN:
IF TG_OP = ANY('{INSERT,UPDATE}') THEN
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NULL;
END IF;
END;
$BODY$
LANGUAGE plpgsql SECURITY DEFINER;
-- Statement Level Trigger:
DROP TABLE IF EXISTS audit_statementlevel CASCADE;
CREATE TABLE audit_statementlevel(
Id BIGSERIAL NOT NULL
,TimeValue TIMESTAMP NOT NULL
,RoleName NAME NOT NULL
,ClientIP INET NOT NULL
,ClientPid INTEGER NOT NULL
,Operation TEXT NOT NULL
,SchemaName NAME NOT NULL
,TableName NAME NOT NULL
,RowCount BIGINT NOT NULL
,RowIds BIGINT[] NOT NULL
,AuditIds BIGINT[] NOT NULL
---
,PRIMARY KEY(Id)
);
-- Row Level Trigger:
DROP FUNCTION IF EXISTS audit_statementlevel_trigger() CASCADE;
CREATE OR REPLACE FUNCTION audit_statementlevel_trigger()
RETURNS TRIGGER AS
$BODY$
DECLARE
rowcount BIGINT;
BEGIN
WITH
A AS (
SELECT
TimeValue, RoleName, ClientIP, ClientPid, Operation, SchemaName, TableName
,COUNT(*)
,array_agg(RowId)
,array_agg(Id)
FROM
audit_rowlevel
WHERE
NOT Aggregated
GROUP BY
TimeValue, RoleName, ClientIP, ClientPid, Operation, SchemaName, TableName
ORDER BY
TimeValue
),
B AS (
INSERT INTO audit_statementlevel(TimeValue, RoleName, ClientIP, ClientPid, Operation, SchemaName, TableName, RowCount, RowIds, AuditIds)
(SELECT * FROM A)
RETURNING AuditIds
),
C AS (
SELECT array_agg(DISTINCT T.id) AS Ids FROM B, unnest(B.AuditIds) AS T(id)
)
UPDATE
audit_rowlevel
SET
Aggregated = TRUE
FROM
C
WHERE
Id = ANY(C.Ids);
RETURN NULL;
END;
$BODY$
LANGUAGE plpgsql SECURITY DEFINER;
-- Channels:
DROP TABLE IF EXISTS channels CASCADE;
CREATE TABLE channels(
Id INTEGER NOT NULL
,UserKey TEXT NOT NULL
,Active BOOLEAN NOT NULL DEFAULT(TRUE)
---
,PRIMARY KEY(Id)
,UNIQUE(UserKey)
);
CREATE TRIGGER channel_audit_rowlevel_trigger BEFORE INSERT OR UPDATE OR DELETE ON channels
FOR EACH ROW EXECUTE PROCEDURE audit_rowlevel_trigger(TRUE);
CREATE TRIGGER channel_audit_statementlevel_trigger AFTER INSERT OR UPDATE OR DELETE ON channels
FOR EACH STATEMENT EXECUTE PROCEDURE audit_statementlevel_trigger();
-- Perform some operations:
INSERT INTO channels(
SELECT C.Id, 'Channel-' || C.Id
FROM generate_series(1, 300, 10) AS C(Id)
);
DELETE FROM channels WHERE id < 10;
UPDATE channels
SET UserKey = 'wild channel'
WHERE id = 21;
I am interested knowing if this solution looks right to professional developers.
Am I going into the good direction or is this solution evil?

Related

trigger in postgresql old value and new value

how to write trigger in postgresql which maintain old value, new value and table name.
I have 5 tables and each tables is different data structure i want to maintain audit details in single table with old value new value and table name.old value and new value contain multiple columns in json format with column name and value.
example
audit_details
----------------------------------------------------------------
date_time|table_name|old_data|new_data|user|primary_key_of_table
----------------------------------------------------------------
I wrote sample trigger function for you. You can make additional changes yourself.
CREATE TABLE test.log_table_data (
id serial not null,
schema_name varchar(100) NOT NULL,
table_name varchar(100) NOT NULL,
action_date timestamp NOT NULL DEFAULT now(),
action_type varchar(10) NOT NULL,
table_id int4 NOT NULL,
old_data jsonb NULL,
new_data jsonb NULL,
CONSTRAINT log_table_data_pk PRIMARY KEY (id)
);
create or replace function test.register_as_log()
returns trigger
language plpgsql
security definer
as $function$
declare
v_old_data json;
v_new_data json;
shemaname varchar;
tablename varchar;
begin
shemaname = tg_table_schema;
tablename = tg_table_name;
/* json_strip_nulls - removes null values */
if (tg_op = 'UPDATE') then
v_old_data = (select json_strip_nulls(row_to_json(old.*)));
v_new_data = (select json_strip_nulls(row_to_json(new.*)));
insert into test.log_table_data (schema_name, table_name, action_type, table_id, old_data, new_data)
values (shemaname, tablename, 'update', old.id, v_old_data, v_new_data);
return new;
end if;
if (tg_op = 'DELETE') then
v_old_data = (select json_strip_nulls(row_to_json(old.*)));
insert into test.log_table_data (schema_name, table_name, action_type, table_id, old_data, new_data)
values (shemaname, tablename, 'delete', old.id, v_old_data, null);
return old;
end if;
end;
$function$
;

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.

PL/pgSQL: How to use IF NEW.<variable_column_name> <> OLD.<variable_column_name>

I am pretty new to PL/pgSQL programming. I have a requirement of audit logging updated columns in my table
Table
create table sample_table(name varchar(15),city varchar(15),age int,mail varchar(20) primary key);
Audit table
create table sample_table__audits_dynamicols(mail varchar(20), columnchanged varchar(10), oldvalue varchar(10), changed_on timestamp(6) NOT NULL)
Trigger Function
CREATE FUNCTION public.log_sample_table_allchanges() RETURNS trigger AS $BODY$DECLARE
_colname text;
_tablename varchar(15) := 'sample_table';
_schema varchar(15) := 'public';
_changed_on time := now();
BEGIN
FOR _colname IN SELECT column_name FROM information_schema.Columns WHERE table_schema = _schema AND table_name = _tablename LOOP
IF NEW._colname <> OLD._colname THEN
INSERT INTO sample_table__audits_dynamicols(mail,columnchanged, oldvalue ,changed_on)
VALUES(OLD.mail,_colname,OLD.:_colname,_changed_on);
END IF;
END LOOP;
RETURN NEW;
END$BODY$
LANGUAGE plpgsql VOLATILE NOT LEAKPROOF;
Trigger
create TRIGGER log_sample_table_allchanges
BEFORE UPDATE
ON SAMPLE_TABLE
FOR EACH ROW
EXECUTE PROCEDURE log_sample_table_allchanges();
Requirement: Whenever a column value is changed i want to log it as
(mail, columnname, columnvalue, date)
E.g:
insert into sample_table (name, mail, city, age) values('kanta','mk#foo.com','hyd',23);
insert into sample_table (name, mail, city, age) values('kmk','mk#gmail.com','hyd',23);
So when i update like the following
update sample_table set age=24 where mail='mk#foo.com';
update sample_table set city='bza' where mail='mk#gmail.com'
I want audit table to record like
(mk#foo.com,age,23, timestamp)
(mk#gmail.com, city, hyd, timestamp)
Right now I am facing issue with column comparison in my Trigger function. Please help me rectifying my Trigger function to meet my requirement.
You may use EXECUTE to get the values of columns dynamically and do the comparison.
CREATE OR REPLACE FUNCTION public.log_sample_table_allchanges() RETURNS trigger AS
$BODY$
DECLARE
_colname text;
_tablename varchar(15) := 'sample_table';
_schema varchar(15) := 'public';
_changed_on timestamp := now();
_old_val text;
_new_val text;
BEGIN
FOR _colname IN SELECT column_name FROM information_schema.Columns WHERE table_schema = _schema AND table_name = _tablename
LOOP
EXECUTE 'SELECT $1.' || _colname || ', $2.' || _colname
USING OLD,NEW
INTO _old_val, _new_val; --get the old and new values for the column.
IF _new_val <> _old_val THEN
INSERT INTO sample_table__audits_dynamicols(mail,columnchanged, oldvalue ,changed_on)
VALUES(OLD.mail,_colname,_old_val,_changed_on);
END IF;
END LOOP;
RETURN NEW;
END$BODY$
LANGUAGE plpgsql VOLATILE NOT LEAKPROOF;
I'm not sure why you have defined mail as a PRIMARY KEY in the audits table, it will cause unique constraint violation if the same mail gets updated twice.

Postgresql dynamic function with current table name

I have a function (audit.create_audit_table()) that accepts an array of table names. It creates a single function audit.if_modified_func() and then loops through each table name and creates an audit table and applies a trigger to the main table. The function compiles and is created with no errors. When I run the function
select audit.create_audit_table(ARRAY['organization'])
I keep getting the following error and I am not sure why because I thought that TG_TABLE_NAME is a automatic variable which will give me access to the current table that is executing audit.if_modified_func()
ERROR:
ERROR: column "tg_table_name" does not exist
LINE 3: audit_row audit.' || quote_ident(TG_TABLE_NAME::TEXT)||';
^
Here's the function:
CREATE OR REPLACE FUNCTION audit.create_audit_table(table_names character varying[])
RETURNS character varying AS
$BODY$
DECLARE
table_name varchar;
i int;
BEGIN
EXECUTE 'CREATE OR REPLACE FUNCTION audit.if_modified_func() RETURNS TRIGGER AS $$
DECLARE
audit_row audit.' || quote_ident(TG_TABLE_NAME::TEXT)||';
include_values boolean;
log_diffs boolean;
h_old hstore;
h_new hstore;
excluded_cols text[] = ARRAY[]::text[];
BEGIN
IF TG_WHEN <> ''AFTER'' THEN
RAISE EXCEPTION ''audit.if_modified_func() may only run as an AFTER trigger'';
END IF;
audit_row = ROW(
nextval(''audit.'|| quote_ident(TG_TABLE_NAME::text) ||'_event_id_seq''), -- event_id
TG_TABLE_SCHEMA::text, -- schema_name
TG_TABLE_NAME::text, -- table_name
TG_RELID, -- relation OID for much quicker searches
session_user::text, -- session_user_name
current_timestamp, -- action_tstamp_tx
statement_timestamp(), -- action_tstamp_stm
clock_timestamp(), -- action_tstamp_clk
txid_current(), -- transaction ID
current_setting(''application_name''), -- client application
inet_client_addr(), -- client_addr
inet_client_port(), -- client_port
current_query(), -- top-level query or queries (if multistatement) from client
substring(TG_OP,1,1), -- action
NULL, NULL, -- row_data, changed_fields
''f'' -- statement_only
);
IF NOT TG_ARGV[0]::boolean IS DISTINCT FROM ''f''::boolean THEN
audit_row.client_query = NULL;
END IF;
IF TG_ARGV[1] IS NOT NULL THEN
excluded_cols = TG_ARGV[1]::text[];
END IF;
IF (TG_OP = ''UPDATE'' AND TG_LEVEL = ''ROW'') THEN
audit_row.row_data = hstore(OLD.*) - excluded_cols;
audit_row.changed_fields = (hstore(NEW.*) - audit_row.row_data) - excluded_cols;
IF audit_row.changed_fields = hstore('''') THEN
-- All changed fields are ignored. Skip this update.
RETURN NULL;
END IF;
ELSIF (TG_OP = ''DELETE'' AND TG_LEVEL = ''ROW'') THEN
audit_row.row_data = hstore(OLD.*) - excluded_cols;
ELSIF (TG_OP = ''INSERT'' AND TG_LEVEL = ''ROW'') THEN
audit_row.row_data = hstore(NEW.*) - excluded_cols;
ELSIF (TG_LEVEL = ''STATEMENT'' AND TG_OP IN (''INSERT'',''UPDATE'',''DELETE'',''TRUNCATE'')) THEN
audit_row.statement_only = ''t'';
ELSE
RAISE EXCEPTION ''[audit.if_modified_func] - Trigger func added as trigger for unhandled case: %%, %%'',TG_OP, TG_LEVEL;
RETURN NULL;
END IF;
INSERT INTO audit.'|| quote_ident(TG_TABLE_NAME::TEXT) ||' VALUES (audit_row.*);
RETURN null;
END;
$$
LANGUAGE plpgsql;
ALTER FUNCTION audit.if_modified_func()
OWNER TO postgres;';
FOR i in 1..array_upper(table_names, 1) LOOP
EXECUTE format('
DROP TABLE IF EXISTS audit.%1$s;
CREATE TABLE audit.%1$s (
event_id bigserial primary key,
schema_name text not null,
table_name text not null,
relid oid not null,
session_user_name text,
action_tstamp_tx TIMESTAMP WITH TIME ZONE NOT NULL,
action_tstamp_stm TIMESTAMP WITH TIME ZONE NOT NULL,
action_tstamp_clk TIMESTAMP WITH TIME ZONE NOT NULL,
transaction_id bigint,
application_name text,
client_addr inet,
client_port integer,
client_query text,
action TEXT NOT NULL CHECK (action IN (''I'',''D'',''U'', ''T'')),
row_data hstore,
changed_fields hstore,
statement_only boolean not null
);
REVOKE ALL ON audit.%1$s FROM public;
COMMENT ON TABLE audit.%1$s IS ''History of auditable actions on audited tables, from audit.if_modified_func()'';
COMMENT ON COLUMN audit.%1$s.event_id IS ''Unique identifier for each auditable event'';
COMMENT ON COLUMN audit.%1$s.schema_name IS ''Database schema audited table for this event is in'';
COMMENT ON COLUMN audit.%1$s.table_name IS ''Non-schema-qualified table name of table event occured in'';
COMMENT ON COLUMN audit.%1$s.relid IS ''Table OID. Changes with drop/create. Get with ''''tablename''''::regclass'';
COMMENT ON COLUMN audit.%1$s.session_user_name IS ''Login / session user whose statement caused the audited event'';
COMMENT ON COLUMN audit.%1$s.action_tstamp_tx IS ''Transaction start timestamp for tx in which audited event occurred'';
COMMENT ON COLUMN audit.%1$s.action_tstamp_stm IS ''Statement start timestamp for tx in which audited event occurred'';
COMMENT ON COLUMN audit.%1$s.action_tstamp_clk IS ''Wall clock time at which audited event''''s trigger call occurred'';
COMMENT ON COLUMN audit.%1$s.transaction_id IS ''Identifier of transaction that made the change. May wrap, but unique paired with action_tstamp_tx.'';
COMMENT ON COLUMN audit.%1$s.client_addr IS ''IP address of client that issued query. Null for unix domain socket.'';
COMMENT ON COLUMN audit.%1$s.client_port IS ''Remote peer IP port address of client that issued query. Undefined for unix socket.'';
COMMENT ON COLUMN audit.%1$s.client_query IS ''Top-level query that caused this auditable event. May be more than one statement.'';
COMMENT ON COLUMN audit.%1$s.application_name IS ''Application name set when this audit event occurred. Can be changed in-session by client.'';
COMMENT ON COLUMN audit.%1$s.action IS ''Action type; I = insert, D = delete, U = update, T = truncate'';
COMMENT ON COLUMN audit.%1$s.row_data IS ''Record value. Null for statement-level trigger. For INSERT this is the new tuple. For DELETE and UPDATE it is the old tuple.'';
COMMENT ON COLUMN audit.%1$s.changed_fields IS ''New values of fields changed by UPDATE. Null except for row-level UPDATE events.'';
COMMENT ON COLUMN audit.%1$s.statement_only IS ''''''t'''' if audit event is from an FOR EACH STATEMENT trigger, ''''f'''' for FOR EACH ROW'';
CREATE INDEX %1$s_relid_idx ON audit.%1$s(relid);
CREATE INDEX %1$s_action_tstamp_tx_stm_idx ON audit.%1$s(action_tstamp_stm);
CREATE INDEX %1$s_action_idx ON audit.%1$s(action);
', table_names[i]);
EXECUTE format('
DROP TRIGGER IF EXISTS audit_trigger_row ON %1$s;
CREATE TRIGGER audit_trigger_row
AFTER INSERT OR UPDATE OR DELETE
ON public.%1$s
FOR EACH ROW
EXECUTE PROCEDURE audit.if_modified_func();', table_names[i]);
EXECUTE format('
DROP TRIGGER IF EXISTS audit_trigger_stm ON %1$s;
CREATE TRIGGER audit_trigger_stm
AFTER TRUNCATE
ON public.%1$s
FOR EACH STATEMENT
EXECUTE PROCEDURE audit.if_modified_func();', table_names[i]);
END LOOP;
RETURN 'SUCCESS';
END;
$BODY$
LANGUAGE plpgsql;
ALTER FUNCTION audit.create_audit_table(character varying[])
OWNER TO postgres;
UPDATE 03/31:
Ok, so I created the if_modified_func() function without the dynamic sql and I declared the audit_row as audit_row RECORD; I am not sure about the part of "needing a cast upon inserting the values". I am also not sure if this is the correct way to do the insert
EXECUTE format($string$INSERT INTO audit.%1$s VALUES (audit_row.*);$string$, TG_TABLE_NAME::text);
I am now getting this error when I run select audit.create_audit_table(ARRAY['organization'])
ERROR:
ERROR: record "audit_row" has no field "row_data"
CONTEXT: PL/pgSQL function audit.if_modified_func() line 42 at assignment
Here's the updated function:
CREATE OR REPLACE FUNCTION audit.if_modified_func() RETURNS TRIGGER AS $$
DECLARE
audit_row RECORD;
include_values boolean;
log_diffs boolean;
h_old hstore;
h_new hstore;
excluded_cols text[] = ARRAY[]::text[];
BEGIN
IF TG_WHEN <> 'AFTER' THEN
RAISE EXCEPTION 'audit.if_modified_func() may only run as an AFTER trigger';
END IF;
audit_row = ROW(
nextval(format('audit.%1$s_event_id_seq',TG_TABLE_NAME::text)), -- event_id
TG_TABLE_SCHEMA::text, -- schema_name
TG_TABLE_NAME::text, -- table_name
TG_RELID, -- relation OID for much quicker searches
session_user::text, -- session_user_name
current_timestamp, -- action_tstamp_tx
statement_timestamp(), -- action_tstamp_stm
clock_timestamp(), -- action_tstamp_clk
txid_current(), -- transaction ID
current_setting('application_name'), -- client application
inet_client_addr(), -- client_addr
inet_client_port(), -- client_port
current_query(), -- top-level query or queries (if multistatement) from client
substring(TG_OP,1,1), -- action
NULL, NULL, -- row_data, changed_fields
'f' -- statement_only
);
IF NOT TG_ARGV[0]::boolean IS DISTINCT FROM 'f'::boolean THEN
audit_row.client_query = NULL;
END IF;
IF TG_ARGV[1] IS NOT NULL THEN
excluded_cols = TG_ARGV[1]::text[];
END IF;
IF (TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW') THEN
audit_row.row_data = hstore(OLD.*) - excluded_cols;
audit_row.changed_fields = (hstore(NEW.*) - audit_row.row_data) - excluded_cols;
IF audit_row.changed_fields = hstore('') THEN
-- All changed fields are ignored. Skip this update.
RETURN NULL;
END IF;
ELSIF (TG_OP = 'DELETE' AND TG_LEVEL = 'ROW') THEN
audit_row.row_data = hstore(OLD.*) - excluded_cols;
ELSIF (TG_OP = 'INSERT' AND TG_LEVEL = 'ROW') THEN
audit_row.row_data = hstore(NEW.*) - excluded_cols;
ELSIF (TG_LEVEL = 'STATEMENT' AND TG_OP IN ('INSERT','UPDATE','DELETE','TRUNCATE')) THEN
audit_row.statement_only = 't';
ELSE
RAISE EXCEPTION '[audit.if_modified_func] - Trigger func added as trigger for unhandled case: %, %',TG_OP, TG_LEVEL;
RETURN NULL;
END IF;
EXECUTE format('INSERT INTO audit.%1$s VALUES (audit_row.*)', TG_TABLE_NAME::text);
RETURN null;
END;
$$
LANGUAGE plpgsql;
ALTER FUNCTION audit.if_modified_func()
OWNER TO postgres;
TG_TABLE_NAME is a special, trigger variable, which is only available inside trigger functions. Your create_audit_table() is not a trigger function.
Also, you constantly redefining your real trigger function (if_modified_func()), which "invalidates" any earlier created triggers.
Create your trigger function without the dynamic SQL magic (dynamic SQL will only need to insert values to these audit tables). Then, you can add your audit logic to a table with:
CREATE TRIGGER audit_trigger_row
AFTER INSERT OR UPDATE OR DELETE
ON public.<your_table_name>
FOR EACH ROW
EXECUTE PROCEDURE <your_audit_trigger_function_name>();
You can put this (but only this -- maybe with drop if exists) inside a function, to allow attaching this audit logic more easily.
Notes:
Inside the trigger function, you cannot use a %ROWTYPE variable (because you don't know the exact table. you only have its name). The solution is simple: just use the RECORD type instead (you will need a cast upon inserting the values though).
Don't use single quotes for such long strings. Use the $your_keyword$<string_value>$your_keyword$ format instead. With possibly the format() function instead of just concatenating values. Your code will be much more readable.
Edit: to utilize your RECORD variable, you should either:
Initialize it with a structure. You can do this in your case with f.ex.
SELECT nextval('audit.'|| quote_ident(TG_TABLE_NAME) || '_event_id_seq') AS event_id,
TG_TABLE_SCHEMA AS schema_name,
TG_TABLE_NAME AS table_name,
TG_RELID AS relid,
session_user AS session_user_name,
current_timestamp AS action_tstamp_tx,
statement_timestamp() AS action_tstamp_stm,
clock_timestamp() AS action_tstamp_clk,
txid_current() AS transaction_id,
current_setting('application_name') AS application_name,
inet_client_addr() AS client_addr,
inet_client_port() AS client_port,
current_query() AS client_query,
substring(TG_OP, 1, 1) AS action,
NULL::hstore AS row_data,
NULL::hstore AS changed_fields,
FALSE AS statement_only
INTO audit_row;
Use the predefined names of the ROW() constructor. The first column's name if f1, the second's is f2, etc.
audit_row.f15 = hstore(OLD.*) - excluded_cols;
After choosing one of the above methods, you should insert the row like:
EXECUTE format('INSERT INTO audit.%1$s VALUES (($1::text::audit.%1$s).*)', quote_ident(TG_TABLE_NAME)) USING audit_row;
Note: even the cast to text is required due to the fact that EXECUTE cannot know the actual structure of audit_row.
http://rextester.com/GUAJ1339
quote_ident(TG_TABLE_NAME::TEXT) will apply necessary actions to correcty quote the argument as relation name.
I would recommend using execute format('statement') instead of concatinations, eg:
t=# do $$ begin raise info '%',format('I am %I, now is %L',current_user,now()); end;$$;
INFO: I am postgres, now is '2017-03-30 07:33:53.579476+00'
DO
Instead of:
t=# do $$ begin raise info '%','I am '||quote_ident(current_user)||', now is '||quote_ident(now()::text); end;$$;
INFO: I am postgres, now is "2017-03-30 07:36:20.495887+00"
DO

Insert record dynamically inside of Procedural Trigger

We are looking to convert our database over to Postgres (9.3.5), which I have no experience with, and I am trying to get our audit tables up and running. I understand that each table will need its own trigger, but all triggers can call a single function.
The trigger on the table is passing a list of the columns that need to be audited since some of our columns are not tracked.
Here are some of the posts I followed:
- https://stackoverflow.com/a/7915100/229897
- http://www.postgresql.org/docs/9.3/static/plpgsql-statements.html
- http://www.postgresql.org/docs/9.4/static/plpgsql-trigger.html
When I run this I get the error: ERROR: syntax error at or near "$1"
DROP TABLE IF EXISTS people;
DROP TABLE IF EXISTS a_people;
CREATE TABLE IF NOT EXISTS people (
record_id SERIAL PRIMARY KEY NOT NULL,
first_name VARCHAR NOT NULL,
last_name VARCHAR NOT NULL,
last_updated_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS a_people (
record_id SERIAL PRIMARY KEY NOT NULL,
a_record_id INT,
first_name VARCHAR NULL,
last_name VARCHAR NULL,
last_updated_on TIMESTAMP
);
/******************************************************/
--the function
CREATE OR REPLACE FUNCTION audit_func()
RETURNS TRIGGER AS
$BODY$
DECLARE
audit TEXT := TG_TABLE_SCHEMA || '.a_' || TG_TABLE_NAME;
cols TEXT := TG_ARGV[0];
BEGIN
EXECUTE format('INSERT INTO %1$s(a_%2$s) SELECT %2$s FROM ($1)', audit, cols) USING OLD;
NEW.last_updated_on = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$BODY$
LANGUAGE plpgsql;
/******************************************************/
--the trigger calling the function to update inbound records
CREATE TRIGGER build_user_full_name_trg
BEFORE UPDATE
ON people
FOR EACH ROW WHEN (OLD.* IS DISTINCT FROM NEW.*)
EXECUTE PROCEDURE audit_func('record_id,first_name,last_name');
/******************************************************/
INSERT INTO people (first_name, last_name) VALUES ('George','Lincoln');
UPDATE people SET last_name = 'Washington' WHERE first_name = 'George';
SELECT * FROM people;
I welcome your assistance (and patience)!
This subselect should work:
EXECUTE format('INSERT INTO %1$s(a_%2$s) SELECT %2$s FROM (select ($1).*) XX', audit, cols) USING OLD;