I'm fairly certain I'm probably just overlooking something simple but here's the situation: I'm trying to create a "reusable" historical logging trigger for a production database. The intent is that, when I attach the trigger to a table in one of the production database schemas, it will verify the existence of (and create if necessary) a historical logging table in the History
schema to where any changes (INSERT/UPDATE/DELETE) in the production table will be logged.
I have most of it working, but it seems that something in it is generating duplication on inserted records. Here are the definitions (please, no comments about the mixed-case definitions - this is how our database has been designed):
TABLE DEFINITION
-- Table: Company.Rate
-- DROP TABLE IF EXISTS "Company"."Rate";
CREATE TABLE IF NOT EXISTS "Company"."Rate"
(
"RateID" integer NOT NULL DEFAULT nextval('"Company"."Rate_seq"'::regclass),
"TypeCode" character(2) COLLATE pg_catalog."default" NOT NULL,
"Rate" numeric(9,5) NOT NULL DEFAULT 0,
"Factor" numeric(7,5) NOT NULL DEFAULT 1,
"Fee" numeric(6,2) NOT NULL DEFAULT 0,
"EffectiveDate" date NOT NULL,
"CancellationDate" date,
CONSTRAINT rate_pk PRIMARY KEY ("RateID", "TypeCode"),
CONSTRAINT "Rate_CollateralType_FK" FOREIGN KEY ("TypeCode")
REFERENCES "Production"."Type" ("TypeCode") MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
)
TABLESPACE pg_default;
-- Trigger: LogRateDelete
-- DROP TRIGGER IF EXISTS "LogRateDelete" ON "Company"."Rate";
CREATE OR REPLACE TRIGGER "LogRateDelete"
BEFORE DELETE
ON "Company"."Rate"
FOR EACH ROW
EXECUTE FUNCTION "History"."LogHistory"('true');
-- Trigger: LogRateInsert
-- DROP TRIGGER IF EXISTS "LogRateInsert" ON "Company"."Rate";
CREATE OR REPLACE TRIGGER "LogRateInsert"
BEFORE INSERT
ON "Company"."Rate"
FOR EACH ROW
EXECUTE FUNCTION "History"."LogHistory"('false');
-- Trigger: LogRateUpdate
-- DROP TRIGGER IF EXISTS "LogRateUpdate" ON "Company"."Rate";
CREATE OR REPLACE TRIGGER "LogRateUpdate"
BEFORE UPDATE
ON "Company"."Rate"
FOR EACH ROW
EXECUTE FUNCTION "History"."LogHistory"('false');
LogHistory
TRIGGER FUNCTION FOR HISTORICAL LOGGING
-- FUNCTION: History.LogHistory()
-- DROP FUNCTION IF EXISTS "History"."LogHistory"();
CREATE OR REPLACE FUNCTION "History"."LogHistory"()
RETURNS trigger
LANGUAGE 'plpgsql'
COST 100
VOLATILE NOT LEAKPROOF
AS $BODY$
DECLARE
rec record;
cols text;
vals text;
colname text;
curval text;
curtype text;
sqlcmd text;
recdelete boolean;
tableexists boolean;
BEGIN
cols := '';
vals := '';
colname := '';
curval := '';
curtype := '';
sqlcmd := '';
sqlcmd := 'SELECT tableexists FROM "History"."VerifyHistoryTableExists"(''' || TG_TABLE_SCHEMA || ''',''' || TG_TABLE_NAME || ''');';
EXECUTE sqlcmd INTO tableexists;
IF NOT tableexists THEN
RAISE EXCEPTION 'History table for %1 does not exist', '"' || TG_TABLE_SCHEMA || '"."' || TG_TABLE_NAME || '"';
ELSE
IF TG_ARGV[0] IS NOT NULL THEN
recdelete := TG_ARGV[0];
ELSE
recdelete := FALSE;
END IF;
FOR rec IN
-- ORIGINAL QUERY: Could cause issues if the user doesn't have access to information_schema
-- SELECT column_name, data_type from information_schema.columns WHERE table_name=TG_TABLE_NAME AND table_schema=TG_TABLE_SCHEMA)
-- Querying pg_catalog instead of information_schema due to potential issues with user access
(SELECT a.attname AS column_name,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type
FROM pg_catalog.pg_attribute a
WHERE a.attrelid =
(SELECT c.oid
FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = TG_TABLE_NAME
AND n.nspname = TG_TABLE_SCHEMA)
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum)
LOOP
colname := rec.column_name;
curtype := lower(rec.data_type);
EXECUTE format('SELECT $1.%1$I', colname) INTO curval USING NEW;
IF curval IS NOT NULL THEN
CASE WHEN curtype LIKE 'character%' THEN
curval := '''' || curval || '''';
WHEN curtype = 'cidr' THEN
curval := '''' || curval || '''';
WHEN curtype = 'date' THEN
curval := '''' || curval || '''';
WHEN curtype = 'inet' THEN
curval := '''' || curval || '''';
WHEN curtype = 'json' THEN
curval := '''' || curval || '''';
WHEN curtype = 'macaddr' THEN
curval := '''' || curval || '''';
WHEN curtype = 'text' THEN
curval := '''' || curval || '''';
WHEN curtype LIKE 'time%' THEN
curval := '''' || curval || '''';
WHEN curtype = 'xml' THEN
curval := '''' || curval || '''';
ELSE
curval := curval;
END CASE;
ELSE
curval := 'NULL';
END IF;
IF cols = '' THEN
cols := '"' || colname || '"';
ELSE
cols := cols || ', "' || colname || '"';
END IF;
IF vals = '' THEN
vals := curval;
ELSE
vals := vals || ', ' || curval;
END IF;
END LOOP;
sqlcmd := 'INSERT INTO "History"."' || TG_TABLE_NAME || 'History" (' || cols || ', "EventUser", "EventOccurred", "RecordDeleted") VALUES (' || vals || ', ''' || SESSION_USER || ''', ''' || CURRENT_TIMESTAMP || ''', ' || recdelete || ');';
EXECUTE sqlcmd USING NEW;
RETURN NEW;
END IF;
END;
$BODY$;
VerifyHistoryTableExists
FUNCTION
-- FUNCTION: History.VerifyHistoryTableExists(text, text)
-- DROP FUNCTION IF EXISTS "History"."VerifyHistoryTableExists"(text, text);
CREATE OR REPLACE FUNCTION "History"."VerifyHistoryTableExists"(
prodschemaname text,
prodtablename text,
OUT tableexists boolean)
RETURNS boolean
LANGUAGE 'plpgsql'
COST 100
VOLATILE SECURITY DEFINER PARALLEL UNSAFE
AS $BODY$
DECLARE
createtable boolean;
sqlcmd text;
rec record;
colname text;
histcols text;
prodcols text;
BEGIN
createtable := FALSE;
colname := '';
histcols := '';
prodcols := '';
-- Query pg_catalog to determine if a history table currently exists for the production table calling the function
sqlcmd := 'SELECT NOT EXISTS (SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE nspname = ''History'' AND c.relname = ''' || $2 || 'History'');';
EXECUTE sqlcmd INTO createtable;
IF createtable THEN
-- If the History table for a production table using this trigger function doesn't exist, then create the history table.
-- Use inheritance to ensure that all of the original table's columns are created and available in the historical table.
-- Inheritance also helps to keep the tables in sync if the production table structure is modified in some way.
sqlcmd := 'CREATE TABLE "History"."' || $2 || 'History"
(
"EventUser" character varying(60) NOT NULL DEFAULT SESSION_USER,
"EventOccurred" timestamp without time zone NOT NULL DEFAULT NOW(),
"RecordDeleted" boolean NOT NULL DEFAULT FALSE
)
INHERITS ("' || $1 || '"."' || $2 || '")
WITH (
OIDS=FALSE
);
ALTER TABLE "History"."' || $2 || 'History"
OWNER TO "Developers";
GRANT ALL ON TABLE "History"."' || $2 || 'History" TO "Developers";
GRANT SELECT ON TABLE "History"."' || $2 || 'History" TO public;';
EXECUTE sqlcmd;
FOR rec IN
-- Querying pg_catalog instead of information_schema due to potential issues with user access
(SELECT a.attname AS column_name
FROM pg_catalog.pg_attribute a
WHERE a.attrelid =
(SELECT c.oid
FROM pg_catalog.pg_class c
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = $2
AND n.nspname = $1)
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum)
LOOP
colname := rec.column_name;
IF histcols = '' THEN
histcols := '"' || colname || '"';
ELSE
histcols := histcols || ', "' || colname || '"';
END IF;
IF prodcols = '' THEN
prodcols := 'prod."' || colname || '"';
ELSE
prodcols := prodcols || ', prod."' || colname || '"';
END IF;
END LOOP;
sqlcmd := 'INSERT INTO "History"."' || $2 || 'History" (' || histcols || ', "EventUser", "EventOccurred", "RecordDeleted")
SELECT ' || prodcols || ', ''SYSTEM'', NOW(), FALSE FROM ONLY "' || $1 || '"."' || $2 || '" prod;';
EXECUTE sqlcmd;
END IF;
sqlcmd := 'SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE nspname = ''History'' AND c.relname = ''' || $2 || 'History'');';
EXECUTE sqlcmd INTO tableexists;
END;
$BODY$;
As mentioned, it seems to be working in the fact that, when I inserted my first record into the production Rate
table, it automatically created the RateHistory
table in the History
schema and it inserted a historical entry for that record. However, when I went back to look at the original Rate
table, there were two identical records instead of the one that I inserted.
I TRUNCATE
d both tables to try and start fresh, but when I INSERT
ed another new record, I got the same result. The RateHistory
table showed a single entry, while the "source" Rate
table had two identical rows.
I've gone over and over the functions and the table definition, but I'm just not seeing what would cause it to feed a duplicate record back into the source table - especially because it actually causes a violation the Primary Key constraint. Could someone point out what I'm obviously too dense to pick up on?