What you'll practice: writing PL/pgSQL functions and procedures, using
CREATE FUNCTION/CREATE PROCEDURE/CALL, and wiring up triggers (including an audit trigger).
PL/pgSQL is the scripting language baked into PostgreSQL. Once you know the basics, you can push logic into the database where it's fast, transactional, and reusable across every app that talks to the DB.
- Write a function with
CREATE FUNCTION ... LANGUAGE plpgsql - Declare parameters and a
RETURNStype - Use
DECLARE/BEGIN/ENDand local variables - Use
IF / ELSIF / ELSE,LOOP,FOR ... IN SELECT,RAISE NOTICE,RAISE EXCEPTION - Write a stored procedure and call it with
CALL - Understand the difference between a function and a procedure (procedures can
COMMIT) - Attach a
BEFORE/AFTERtrigger onINSERT/UPDATE/DELETE - Use the trigger's
NEWandOLDpseudo-rows - Build an audit trail that records every change
psql -U postgres -d sql_exercise -f 04-advanced-databases/17-functions-stored-procedures-and-triggers/schema.sql
psql -U postgres -d sql_exercise -f 04-advanced-databases/17-functions-stored-procedures-and-triggers/seed.sql
psql -U postgres -d sql_exerciseInside psql:
SET search_path TO adv_funcs;Write a function add_ints(a INTEGER, b INTEGER) RETURNS INTEGER that returns a + b. Start with a trivial SQL function:
CREATE OR REPLACE FUNCTION add_ints(a INTEGER, b INTEGER)
RETURNS INTEGER
LANGUAGE sql
AS $$ SELECT a + b $$;
SELECT add_ints(2, 3);Question: what's the $$ ... $$ syntax called, and why would you use it instead of regular quotes?
Rewrite add_ints as a PL/pgSQL function with a DECLARE block and a local variable.
CREATE OR REPLACE FUNCTION add_ints(a INTEGER, b INTEGER)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
result INTEGER;
BEGIN
result := a + b;
RETURN result;
END
$$;Verify: SELECT add_ints(10, 20);
Write a function describe_balance(balance INTEGER) RETURNS TEXT that returns:
'broke'if balance < 1000'ok'if balance < 100000'wealthy'otherwise
Use IF/ELSIF/ELSE.
Verify: SELECT holder, describe_balance(balance) FROM account;
Write account_count() RETURNS INTEGER that returns SELECT COUNT(*) FROM account.
Verify: SELECT account_count();
Write a function total_balance() RETURNS INTEGER that iterates through every account with a FOR rec IN SELECT balance FROM account LOOP and accumulates the total.
CREATE OR REPLACE FUNCTION total_balance()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
rec RECORD;
total INTEGER := 0;
BEGIN
FOR rec IN SELECT balance FROM account LOOP
total := total + rec.balance;
END LOOP;
RETURN total;
END
$$;(In real life you'd just write SELECT SUM(balance) ... — this is pedagogical.)
Write a function transfer(sender_id INT, receiver_id INT, amount_cents INT) RETURNS VOID that:
- Raises an exception if
amount_cents <= 0(RAISE EXCEPTION 'amount must be positive, got %', amount_cents;). - Raises an exception if the sender doesn't have enough.
- Otherwise: decrements sender, increments receiver,
RAISE NOTICE 'Transferred % cents from % to %', amount_cents, sender_id, receiver_id;
Verify:
SELECT transfer(1, 2, 10000);
SELECT holder, balance FROM account WHERE id IN (1, 2);Then try a bad transfer to watch the exception fire.
Write get_account(p_id INT) RETURNS account that returns the entire row from account with that id.
CREATE OR REPLACE FUNCTION get_account(p_id INT)
RETURNS account
LANGUAGE plpgsql
AS $$
DECLARE
rec account;
BEGIN
SELECT * INTO rec FROM account WHERE id = p_id;
RETURN rec;
END
$$;
SELECT * FROM get_account(1);Write wealthy_accounts() RETURNS SETOF account that returns every row from account where balance >= 100000. Call it with SELECT * FROM wealthy_accounts();.
Write a procedure sp_transfer(sender_id INT, receiver_id INT, amount_cents INT) that does the same thing as Exercise 6 but as a procedure. Call it with CALL sp_transfer(1, 2, 500);.
Question: what's the difference between a function (CREATE FUNCTION) and a procedure (CREATE PROCEDURE)? When would you reach for one over the other?
Write a trigger function set_updated_at() that sets NEW.updated_at = NOW(), and attach it as a BEFORE UPDATE trigger on product.
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END
$$;
CREATE TRIGGER product_set_updated_at
BEFORE UPDATE ON product
FOR EACH ROW EXECUTE FUNCTION set_updated_at();Verify:
UPDATE product SET price_cents = price_cents + 100 WHERE id = 1;
SELECT id, price_cents, updated_at FROM product WHERE id = 1;The updated_at should be the current timestamp.
Write a trigger function product_audit_trigger() that writes a row into product_audit every time a row in product is INSERTed, UPDATEd, or DELETEd. Attach it as an AFTER trigger for each of those three operations.
CREATE OR REPLACE FUNCTION product_audit_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO product_audit (product_id, action, old_price_cents, new_price_cents)
VALUES (NEW.id, 'I', NULL, NEW.price_cents);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO product_audit (product_id, action, old_price_cents, new_price_cents)
VALUES (NEW.id, 'U', OLD.price_cents, NEW.price_cents);
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO product_audit (product_id, action, old_price_cents, new_price_cents)
VALUES (OLD.id, 'D', OLD.price_cents, NULL);
RETURN OLD;
END IF;
RETURN NULL;
END
$$;
CREATE TRIGGER product_audit_ins AFTER INSERT ON product FOR EACH ROW EXECUTE FUNCTION product_audit_trigger();
CREATE TRIGGER product_audit_upd AFTER UPDATE ON product FOR EACH ROW EXECUTE FUNCTION product_audit_trigger();
CREATE TRIGGER product_audit_del AFTER DELETE ON product FOR EACH ROW EXECUTE FUNCTION product_audit_trigger();Verify:
UPDATE product SET price_cents = 13999 WHERE id = 1;
DELETE FROM product WHERE id = 4;
INSERT INTO product (name, price_cents) VALUES ('New Cam', 9999);
SELECT * FROM product_audit ORDER BY id;You should see one row for each change.
You only care about audit rows when the price actually changes on an UPDATE. Drop the product_audit_upd trigger and recreate it with a WHEN clause:
DROP TRIGGER product_audit_upd ON product;
CREATE TRIGGER product_audit_upd
AFTER UPDATE ON product
FOR EACH ROW
WHEN (OLD.price_cents IS DISTINCT FROM NEW.price_cents)
EXECUTE FUNCTION product_audit_trigger();Verify: update name only (not price) — no audit row. Update price — one audit row.
Write a trigger that prevents deleting any product whose price_cents > 40000. Raise an exception if someone tries.
CREATE OR REPLACE FUNCTION block_expensive_delete()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.price_cents > 40000 THEN
RAISE EXCEPTION 'Cannot delete expensive product %', OLD.name;
END IF;
RETURN OLD;
END
$$;
CREATE TRIGGER product_block_expensive_delete
BEFORE DELETE ON product
FOR EACH ROW EXECUTE FUNCTION block_expensive_delete();Verify: try DELETE FROM product WHERE id = 2; (the 4K Monitor at £459) — should error.
Query information_schema.triggers and information_schema.routines to list everything you've created in this section.
SELECT trigger_name, event_manipulation, event_object_table
FROM information_schema.triggers
WHERE trigger_schema = 'adv_funcs';
SELECT routine_name, routine_type, data_type
FROM information_schema.routines
WHERE routine_schema = 'adv_funcs';Rewrite transfer to have a fourth parameter memo TEXT DEFAULT 'transfer' and include it in the RAISE NOTICE. Try calling it with and without that argument.
Procedures can COMMIT inside them. Write a procedure that loops over a batch of account ids and commits after every 100 updates. This is how you do "chunked" bulk updates without one giant transaction.
In Section 16 you moved money with explicit transactions that also inserted into a ledger table. Instead, write an AFTER UPDATE trigger on account that writes a ledger row whenever balance changes. (Not always a good idea in practice — why?)
DROP SCHEMA IF EXISTS adv_funcs CASCADE;