How to maintain cross-table consistency with triggers in PostgreSQL? - sql

Some interdependencies between database tables cannot be (easily) modeled with only foreign keys and check constraints. In my current project, I've begun writing constraint triggers for all these conditions, but it looks like I'm going to end up with hundreds of triggers if I go this route.
My main questions are:
Do the triggers and constraints in the scenario outlined below actually cover all the bases, or is it still possible to add/modify data in such a way that the result would be inconsistent?
Is writing all those triggers really the right way to go? I rarely see constraint triggers in 3rd-party DB schemas. Do other people just trust the application not to mess up?
Minimal example scenario
A central "inventory" table contains all tracked items. Some inventory items are of a specific type with specialized dimensions; these extra dimensions are stored in separate tables ("books", "pictures"). This basic table layout cannot be changed (this is just an example; the actual DB obviously has a lot more tables and columns).
Extra requirements:
(A) Every row in the "inventory" table whose type is "book" must have a matching row in "books" (same goes for "pictures")
(B) Every row in the "books" table must point to a unique row in "inventory" whose type is "book" (same goes for "pictures")
(C) Once inserted, an "inventory" record can never change its type
Complete database contents:
"inventory": id | type | name
----+------+----------------------
a | pic | panda.jpg
b | book | How to do stuff
c | misc | ball of wool
d | book | The life of π
e | pic | Self portrait (1889)
"pictures": inv_id | quality
--------+----------------------------
a | b/w photo?
e | nice, but missing right ear
"books": inv_id | author
--------+--------
b | Hiro P
d | Yann M
Create and populate the schema:
CREATE TABLE inventory (
id CHAR(1) PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('pic', 'book', 'misc')),
name TEXT NOT NULL
);
CREATE TABLE pictures (
inv_id CHAR(1) PRIMARY KEY REFERENCES inventory(id) ON UPDATE CASCADE ON DELETE CASCADE,
quality TEXT
);
CREATE TABLE books (
inv_id CHAR(1) PRIMARY KEY REFERENCES inventory(id) ON UPDATE CASCADE ON DELETE CASCADE,
author TEXT
);
INSERT INTO inventory VALUES
('a', 'pic', 'panda.jpg'),
('b', 'book', 'How to do stuff'),
('c', 'misc', 'ball of wool'),
('d', 'book', 'The life of π'),
('e', 'pic', 'Self portrait (1889)');
INSERT INTO pictures VALUES
('a', 'b/w photo?'),
('e', 'nice, but missing right ear');
INSERT INTO books VALUES
('b', 'Hiro P'),
('d', 'Yann M');
Add triggers to maintain cross-table consistency:
-- TRIGGER: if inventory.type is 'book', there must be a corresponding record in
-- "books" (provides A, 1/2)
CREATE FUNCTION trg_inventory_insert_check_details () RETURNS TRIGGER AS $fun$
DECLARE
type_table_map HSTORE := hstore(ARRAY[
['book', 'books'],
['pic', 'pictures'] -- etc...
]);
details_table TEXT;
num_details INT;
BEGIN
IF type_table_map ? NEW.type THEN
details_table := type_table_map->(NEW.type);
EXECUTE 'SELECT count(*) FROM ' || details_table::REGCLASS || ' WHERE inv_id = $1'
INTO num_details
USING NEW.id;
IF num_details != 1 THEN
RAISE EXCEPTION 'A new "%"-type inventory record also needs a record in "%".',
NEW.type, details_table;
END IF;
END IF;
RETURN NULL;
END;
$fun$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER insert_may_require_details
AFTER INSERT ON inventory
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE PROCEDURE trg_inventory_insert_check_details();
-- TRIGGER: when deleting details, parent must be gone, too (provides A, 2/2)
CREATE FUNCTION trg_inv_details_delete () RETURNS TRIGGER AS $fun$
BEGIN
IF EXISTS(SELECT 1 FROM inventory WHERE id = OLD.inv_id) THEN
RAISE EXCEPTION 'Cannot delete "%" record without deleting inventory record (id=%).',
TG_TABLE_NAME, OLD.inv_id;
END IF;
RETURN NULL;
END;
$fun$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER delete_parent_too
AFTER DELETE ON books
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE PROCEDURE trg_inv_details_delete();
CREATE CONSTRAINT TRIGGER delete_parent_too
AFTER DELETE ON pictures
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE PROCEDURE trg_inv_details_delete();
-- TRIGGER: details records must point to the correct inventory type (provides B)
CREATE FUNCTION trg_inv_details_check_parent_type () RETURNS TRIGGER AS $fun$
DECLARE
table_type_map HSTORE := hstore(ARRAY[
['books', 'book'],
['pictures', 'pic'] -- etc...
]);
required_type TEXT;
p_type TEXT;
BEGIN
required_type := table_type_map->(TG_TABLE_NAME);
SELECT type INTO p_type FROM inventory WHERE id = NEW.inv_id;
IF p_type != required_type THEN
RAISE EXCEPTION '%.inv_id (%) must point to an inventory item with type="%".',
TG_TABLE_NAME, NEW.inv_id, required_type;
END IF;
RETURN NULL;
END;
$fun$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER check_parent_inv_type
AFTER INSERT OR UPDATE ON books
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE PROCEDURE trg_inv_details_check_parent_type();
CREATE CONSTRAINT TRIGGER check_parent_inv_type
AFTER INSERT OR UPDATE ON pictures
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE PROCEDURE trg_inv_details_check_parent_type();
-- TRIGGER: value of inventory.type cannot be changed (provides C)
CREATE FUNCTION trg_fixed_cols () RETURNS TRIGGER AS $fun$
DECLARE
old_rec HSTORE := hstore(OLD);
new_rec HSTORE := hstore(NEW);
col TEXT;
BEGIN
FOREACH col IN ARRAY TG_ARGV LOOP
IF NOT (old_rec ? col) THEN
RAISE EXCEPTION 'Column "%.%" does not exist.', TG_TABLE_NAME, col;
ELSIF (old_rec->col) != (new_rec->col) THEN
RAISE EXCEPTION 'Column "%.%" cannot be modified.', TG_TABLE_NAME, col;
END IF;
END LOOP;
RETURN NULL;
END;
$fun$ LANGUAGE plpgsql;
CREATE CONSTRAINT TRIGGER fixed_cols
AFTER UPDATE ON inventory
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE PROCEDURE trg_fixed_cols('type');

Zilk, the problem here is that you start out by saying that the schema cannot be modified. The reason you are needing to write this trigger spaghetti code is because the business logic and the design of your schema do not agree. For example, the trg_inv_details_delete is screaming at me as just an attempt to reinvent foreign key referential integrity—something that a DBMS like Postgres does for you.
This is actually a pretty typical subclass/superclass hierarchy problem that needs to be addressed in the design phase of the database. The details of the actual miniworld you are trying to represent is going to dictate how you would model this. From that knowledge would come an enhanced ERD, and then you would translate this conceptual model into a logical schema.
In this specific example, the superclass is Inventory and the subclasses are Books and Pictures. The subclasses form a partial/incomplete disjoint union with the superclass. I would go through the following references as this is a somewhat complex topic to describe here. Essentially, careful design and use of composite primary keys that base their foreign key on the superclass's key will take care of (B), and the presence of the Type attribute in Inventory would be unnecessary, thereby taking care of (A).
So your schema definition only needs a small change, which is to remove the Type attribute as it's redundant and would require triggers to keep the data aligned:
CREATE TABLE inventory (
id CHAR(1) PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE pictures (
inv_id CHAR(1) PRIMARY KEY REFERENCES inventory(id) ON UPDATE CASCADE ON DELETE CASCADE,
quality TEXT
);
CREATE TABLE books (
inv_id CHAR(1) PRIMARY KEY REFERENCES inventory(id) ON UPDATE CASCADE ON DELETE CASCADE,
author TEXT
);
Now, by nature of the schema design, there is no way to have an inventory item be considered a "book," for example, and yet not have the book attributes, so there is no need to have a trigger for that.
If you want to select all pictures and their picture-specific attributes:
SELECT id, name, quality
FROM inventory, pictures
WHERE id = inv_id;
If you want to select all pictures or misc items:
SELECT id, name
FROM inventory
WHERE id NOT IN (SELECT inv_id FROM books);
As far as requirement (C), never allowing an update on an attribute, that's kind of a strange one. This may actually be best done using permissions or something at the application level, as I can't see a scenario where you would want to make it impossible to fix a mistake.
See more on this:
Good Blog Post
Elmasri Chapter 8 Slides
Elmasri Chapter 9 Slides- Start reading on slide 17

Related

PostgreSQL trigger function not working properly

I have a trigger function:
CREATE OR REPLACE FUNCTION Day_21_bankTriggerFunction()
RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
DECLARE
act VARCHAR(30);
BEGIN
SELECT account_number INTO act
DELETE FROM depositor
WHERE depositor.account_number = act;
RETURN act;
END;
$$;
\`
and then I have a trigger:
CREATE TRIGGER Day_21_bankTrigger AFTER DELETE on account FOR EACH ROW EXECUTE PROCEDURE Day_21_bankTriggerFunction()
The thought behind this is that is an account was deleted from the account table then this should trigger the function to run and then delete all records on the depositor table where that account is present.
I can create the trigger function and trigger without an issues but if I attempt to delete an account from the account table...I still see the account # in the depositor table when I shouldn't.
Any thoughts?
above is what I tried. Expected results would be to delete an account from the account table and then the trigger function should kick off and remove that account from the depositor table
Usually, relationships of any mode between tables are created by foreign keys. This is the best way and are standards for DBs. Using foreign keys you can control your data. SQL sample:
CREATE TABLE contacts(
contact_id INT GENERATED ALWAYS AS IDENTITY,
customer_id INT,
contact_name VARCHAR(255) NOT NULL,
email VARCHAR(100),
PRIMARY KEY(contact_id),
CONSTRAINT fk_customer
FOREIGN KEY(customer_id)
REFERENCES customers(customer_id)
ON DELETE SET NULL
ON UPDATE SET NULL
);
Third, specify the parent table and parent key columns referenced by the foreign key columns in the REFERENCES clause.
Finally, specify the delete and update actions in the ON DELETE and ON UPDATE clauses.
The delete and update actions determine the behaviors when the primary key in the parent table is deleted and updated. Since the primary key is rarely updated, the ON UPDATE action is not often used in practice. We’ll focus on the ON DELETE action.
PostgreSQL supports the following actions after updating or deleting:
SET NULL (Set values to NULL if data exists on referencing table)
SET DEFAULT (Set values to DEFAULT VALUES of this field if data exists on referencing table)
RESTRICT (Similar to NO ACTION)
NO ACTION (Can not update or delete data if exists on referencing table)
CASCADE (Delete all data if exists on referencing table)
I wrote for you a sample trigger function:
CREATE OR REPLACE FUNCTION Day_21_bankTriggerFunction()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
declare
act varchar(30);
begin
-- get account_number from deleted record
act = old.account_number;
-- SECTION-1 :: Protect deleting if existing data
if (exists(select 1 from depositor where account_number = act)) then
return null;
end if;
-- SECTION-1 :: END
-- SECTION-2 :: Delete all data in the anothers table if exists */
delete from depositor where account_number = act;
return old;
-- SECTION-2 :: END
end
$function$;
CREATE TRIGGER Day_21_bankTrigger
BEFORE DELETE on account
FOR EACH ROW EXECUTE PROCEDURE Day_21_bankTriggerFunction();
Inside my trigger function, I have written two types of SQL codes. (SECTION-1, SECTION-2). You must choose one of them.

How to create a conditional trigger

I have a table with an id as auto incremented primary key and another id.
CREATE TABLE tester (
"id" integer PRIMARY KEY AUTOINCREMENT,
"refId" integer DEFAULT 0
);
refId should be able to either be 0 (the default) or reference id if refId > 0 (i.e. act as foreign key).
Now I need two constraints:
A row should only be deletable if its id is not used (referenced?) by any other row's refId
A row should only be deletable if its refId is 0.
From what I have understood, I need to create a trigger that checks for these constraints before a DELETE event happens. And depending on refId's value either abort the delete action or allow it.
However, I have a hard time understanding the syntax for this and how to do a conditional check. But what I have so far (in mind!) is concerning 1.):
CREATE TRIGGER no_delete_if_inuse
BEFORE DELETE ON tester
FOR EACH ROW BEGIN
SELECT RAISE(ABORT, 'cannot delete because of foreign key violation')
WHERE (SELECT "refId" FROM tester WHERE "refId" = OLD."id") IS NOT NULL;
END;
And concerning 2.)
CREATE TRIGGER no_delete_if_ref
BEFORE DELETE ON tester
FOR EACH ROW BEGIN
IF OLD."refId" > 0 THEN RAISE(ABORT, "cannot delete tester because it refers to an existing tester");
END;
Does this make sense and is valid?
I am totally not sure, to me it does but well, I am all noob.
Also as a last question, can I alternatively combine this into a single trigger? For example would this be a valid query:
CREATE TRIGGER no_delete_if_inuse
BEFORE DELETE ON tester
FOR EACH ROW BEGIN
SELECT RAISE(ABORT, 'cannot delete because of foreign key violation')
WHERE (SELECT "refId" FROM tester WHERE ("refId" = OLD."id" OR "refId" > 0) ) IS NOT NULL;
END;
You can define a foreign key referring to the same table. Use null instead of 0 for rows without a reference:
create table tester(
id int primary key,
refid int references tester,
check (id <> refid)
);
insert into tester values
(1, null),
(2, null),
(3, 1),
(4, 3);
You need a trigger to ensure that a row which references another one cannot be deleted.
create or replace function before_delete_on_tester()
returns trigger language plpgsql as $$
begin
if old.refid is not null then
raise exception
'Cannot delete: (id)=(%) references (id)=(%)', old.id, old.refid;
end if;
return old;
end $$;
create trigger before_delete_on_tester
before delete on tester
for row execute procedure before_delete_on_tester();
Test:
delete from tester where id = 1;
ERROR: update or delete on table "tester" violates foreign key constraint "tester_refid_fkey" on table "tester"
DETAIL: Key (id)=(1) is still referenced from table "tester".
delete from tester where id = 4;
ERROR: Cannot delete from tester. (id)=(4) references (id)=(3)
CONTEXT: PL/pgSQL function before_delete_on_tester() line 4 at RAISE
In Postgres you have to define a trigger function. Read more:
Overview of Trigger Behavior
Trigger Procedures
Create Trigger

How to avoid "blank" insert when inserting data into a view with a trigger & insert procedure?

I'm trying to update tables from insert or update call on a PostgreSQL view. Here's a simplified example of what I do:
[Person] table:
id | lastname | firstname | city | age
[Person_View] table:
id | lastname | firstname | city
Here is the trigger and the related procedure :
CREATE TRIGGER tg_update_person_view
INSTEAD OF INSERT OR UPDATE OR DELETE ON
Person_View FOR EACH ROW EXECUTE PROCEDURE update_person_view_table();
CREATE OR REPLACE FUNCTION update_person_view_table()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $function$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO Person (id, lastname, firstname)
VALUES(NEW.id, NEW.lastname, NEW.firstname);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
UPDATE Person
SET id=NEW.id, lastname=NEW.lastname, firstname=NEW.firstname
WHERE id=OLD.id;
RETURN NEW;
END IF;
RETURN NEW;
END;
$function$;
If I do:
INSERT INTO Person_View (id, city) VALUES ('3', 'Berlin')
A row with only the ID is added to the view and the parent table.
How can I check in the procedure that columns in which values are being inserted have a "mapping" defined in the procedure and if there ain't any mapped columns, it does not proceed ?
You can define a check constraint on the table, e.g.:
create table person(
id int primary key,
lastname text,
firstname text,
city text,
age int,
check(coalesce(lastname, firstname, city, age::text) is not null)
);
insert into person (id)
values (1);
ERROR: new row for relation "person" violates check constraint "person_check"
DETAIL: Failing row contains (1, null, null, null, null).
The solution works regardless whether any views based on the table were created or not.
Have a separate trigger & trigger function for ON DELETE to simplify. (You are not doing anything ON DELETE anyway?)
A CHECK constraint like klin suggested seems like a good idea. You don't need COALESCE and casting, though. Check a row value for NULL.
CHECK (NOT ROW(lastname, firstname) IS NULL) -- ROW keyword is noise
This enforces at least one notnull value in the row. Works for any number of columns and any data type.
Note in particular that ROW(lastname, firstname) IS NOT NULL is not the same and would not work. Detailed explanation:
NOT NULL constraint over a set of columns
If the CHECK constraint is not an option, you can use the same expression in a trigger - which should be faster than adding it to the trigger function. The manual on CREATE TRIGGER:
Also, a trigger definition can specify a Boolean WHEN condition, which
will be tested to see whether the trigger should be fired. In
row-level triggers the WHEN condition can examine the old and/or new
values of columns of the row.
CREATE TRIGGER tg_update_person_view
INSTEAD OF INSERT OR UPDATE ON Person_View
FOR EACH ROW
WHEN (NOT (NEW.lastname, NEW.firstname) IS NULL) -- more columns?
EXECUTE PROCEDURE update_person_view_table();
If the WHEN expression does not evaluate to TRUE, the trigger function is not even called - so it does not proceed like requested.
However, I missed your trigger INSTEAD OF. The manual:
INSTEAD OF triggers do not support WHEN conditions.
In this case you have to move the check into the function body:
IF NOT (NEW.lastname, NEW.firstname) IS NULL THEN
-- do stuff
END IF;

How to delete/update records inside trigger based on the updated/deleted row?

I have a table with the following format
id | name | supervisor_id
I made a "BEFORE INSERT" trigger that checks if the supervisor_id exists in the id column and if not, then assign a null value to the supervisor_id.
I am trying to write two more triggers. One that checks if the supervisor_id exists in the id column before each update of the supervisor_id, and one that sets the supervisor_id to NULL for each employee if his supervisor is deleted.
This is my code, of course it's not working, help please.
CREATE OR REPLACE TRIGGER EAP_users_TRG3
AFTER DELETE
ON EAP_users
FOR EACH ROW
DECLARE
d NUMBER;
BEGIN
SELECT id INTO d FROM EAP_users WHERE id = :OLD.id;
UPDATE EAP_users SET supervisor = NULL WHERE supervisor = d;
END;
/
This is the "working" trigger:
CREATE OR REPLACE TRIGGER EAP_users_TRG1
BEFORE INSERT
ON EAP_users
FOR EACH ROW
DECLARE
supervisor EAP_users.supervisor%TYPE;
CURSOR supervisor_CUR IS SELECT idFROM EAP_users;
b BOOLEAN := FALSE;
BEGIN
IF ( :NEW.supervisor IS NOT NULL ) THEN
FOR s IN supervisor_CUR LOOP
IF ( :NEW.supervisor = s.id ) THEN
b := TRUE;
END IF;
END LOOP;
IF (b = FALSE) THEN
:NEW.supervisor := NULL;
END IF;
END IF;
END;
/
According to the definition of your problem, you are trying to enforce referential integrity of your data. In that case, a trigger is probably not the right tool. To quote Oracle's documentation:
You can use both triggers and integrity constraints to define and enforce any type of integrity rule. However, Oracle strongly recommends that you use triggers to constrain data input only in the following situations:
[...]
When a required referential integrity rule cannot be enforced using the following integrity constraints:
NOT NULL, UNIQUE
PRIMARY KEY
FOREIGN KEY
CHECK
DELETE CASCADE
DELETE SET NULL
In that particular case you should use FOREIGN KEY constraint using the DELETE SET NULL modifier. Assuming you have an index on id, all you need is:
ALTER TABLE EAP_users
ADD CONSTRAINT EAP_users_supervisor_cst
FOREIGN KEY (supervisor_id)
REFERENCES EAP_users(id)
ON DELETE SET NULL;
This simple referential integrity constraint will perform probably better the same things as your 3 triggers -- namely:
prevent insert/update with a non existing (non-NULL) supervisor_id
set all supervisor_id to NULL when you delete the supervisor
See http://sqlfiddle.com/#!4/1f8fb/1 for a live example.

sql triggers - want to compare existing row value with value being inserted

I am creating a trigger that should compare of the values being inserted with one that already exists in the table. The old referencer doesn't work here because I am inserting, but how can I reference something that already exists?
Here is my tables and trigger:
create table events(eid char(2) primary key, cid char(2));
create table activities(mid char(2), eid char(2),
primary key (mid, eid),
constraint activities_fk foreign key (eid) references events(eid));
create or replace trigger check_valid
before insert or update on activities
for each row when (old.mid=new.mid)
declare
v_eid char(2);
v_cid char(2);
n_cid char(2);
begin
select eid into v_eid from activities
where mid=:new.mid;
select cid into v_cid from events
where eid=v_eid;
select cid into n_cid from events
where eid=:new.eid;
if v_cid=n_cid then
raise_application_error(-20000, 'Error');
end if;
end check_valid;
/
show errors;
You can't generally select from the table you're inserting into in a trigger. This is the mutating table problem, or as I often call it, the "damn mutating table problem".
Basically, don't do this. It's a bad idea. What happens if you have two sessions operating on the table at once? The trigger fires and neither session sees what the other has done until the commit, which is after the trigger. Then you've got unexpected data in your database.
Tom Kyte says, "when I hit a mutating table error, I've got a serious fatal flaw
in my logic."