diff --git a/lessons/08-programmability/03-triggers/lesson.mdx b/lessons/08-programmability/03-triggers/lesson.mdx new file mode 100644 index 0000000..e3c589a --- /dev/null +++ b/lessons/08-programmability/03-triggers/lesson.mdx @@ -0,0 +1,127 @@ +A *trigger* runs a function automatically whenever a row is inserted, updated, or deleted — no application code has to remember to call it. The database enforces the behavior itself, which is exactly what you want for things like "always stamp `updated_at`" or "log every change". + +The seed is a small `documents` table plus an empty `document_audit` log. Have a look: + + +SELECT id, title, updated_at FROM documents ORDER BY id; + + +## A trigger needs a function first + +You can't attach arbitrary SQL to a trigger — you attach a *function* that returns the special type `trigger`. Inside it, PL/pgSQL hands you two implicit rows: `NEW` (the row as it will be, for INSERT/UPDATE) and `OLD` (the row as it was, for UPDATE/DELETE). Write the function, then wire it up. + +Here's one that forces `updated_at` to the current time on every change. It edits `NEW` and returns it — a `BEFORE` trigger uses the returned row as the row to actually write: + + +CREATE FUNCTION set_updated_at() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + + +The function on its own does nothing yet — it's just defined. `CREATE TRIGGER` is what binds it to a table and an event: + + +CREATE TRIGGER documents_set_updated_at + BEFORE UPDATE ON documents + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + + +Now change a row and read `updated_at` back — you never set it, but it moved: + + +UPDATE documents SET body = 'Getting started, revised.' WHERE title = 'Welcome'; +SELECT title, updated_at FROM documents WHERE title = 'Welcome'; + + +`BEFORE ... FOR EACH ROW` is the key pairing here: **BEFORE** means the trigger fires before the write, so anything it does to `NEW` lands in the stored row. **FOR EACH ROW** means it fires once per affected row (a `FOR EACH STATEMENT` trigger fires once per statement, regardless of row count — useful for coarse "something changed" signals, but it gets no `NEW`/`OLD`). + +## AFTER triggers: side effects like an audit log + +`BEFORE` is for shaping the row that's about to be written. For *side effects* — writing somewhere else after the change is committed to the statement — use `AFTER`. An audit trigger is the classic case: it can't change the row (too late), it just records what happened. + +This function inserts one row into `document_audit` describing the operation. `TG_OP` is another automatic variable holding the event name (`'INSERT'`, `'UPDATE'`, or `'DELETE'`). For a DELETE there's no `NEW`, so it reads the id from whichever of `NEW`/`OLD` exists: + + +CREATE FUNCTION log_document_change() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO document_audit (document_id, action) + VALUES (COALESCE(NEW.id, OLD.id), TG_OP); + RETURN NULL; +END; +$$; + + +An `AFTER` trigger's return value is ignored, so `RETURN NULL` is conventional. One trigger can cover several events at once — list them with `OR`: + + +CREATE TRIGGER documents_audit + AFTER INSERT OR UPDATE OR DELETE ON documents + FOR EACH ROW + EXECUTE FUNCTION log_document_change(); + + +Exercise it. Insert, update, delete — then look at the log the trigger built for you: + + +INSERT INTO documents (title, body) VALUES ('Changelog', 'v1 released.'); +UPDATE documents SET title = 'Product roadmap' WHERE title = 'Roadmap'; +DELETE FROM documents WHERE title = 'Style guide'; +SELECT document_id, action, changed_at FROM document_audit ORDER BY id; + + +Three statements, three audit rows — and the application code that ran them never mentioned `document_audit`. + +## Firing only when it matters: `WHEN` + +Every UPDATE fires the trigger above, even one that changes nothing meaningful. A `WHEN (...)` condition on the trigger skips the function unless the condition holds, which is cheaper than firing and returning early. For example, only audit an update when the title actually changed: + +```sql +CREATE TRIGGER documents_audit_title + AFTER UPDATE ON documents + FOR EACH ROW + WHEN (NEW.title IS DISTINCT FROM OLD.title) + EXECUTE FUNCTION log_document_change(); +``` + +`IS DISTINCT FROM` is the null-safe "not equal" — it treats two NULLs as equal, so a title going to or from NULL still counts as a change. + +## Your turn + +Start clean so the counts are unambiguous — clear the audit log, then make **exactly one** update and **exactly one** delete. The `documents_audit` trigger you built above is already attached, so both statements should each write one audit row: + + +TRUNCATE document_audit; +UPDATE documents SET body = 'Reviewed.' WHERE title = 'Welcome'; +DELETE FROM documents WHERE title = 'Product roadmap'; +SELECT action, count(*) FROM document_audit GROUP BY action ORDER BY action; + + +You should see one `DELETE` and one `UPDATE`. + + +After truncating and running one UPDATE and one DELETE, `document_audit` holds exactly one row per action. We'll confirm the AFTER trigger recorded both. + + +## Cautions + +Triggers are invisible magic: a plain `UPDATE` can now touch other tables, and someone reading the app code won't see it. That power cuts both ways. + +- **Keep trigger functions simple and fast.** They run *inside* the statement's transaction — every affected row pays their cost, and a slow trigger silently slows every write. +- **They're part of the transaction.** If the statement rolls back, the trigger's effects roll back too. That's good for consistency (the audit row can't survive a failed change), but it means an exception raised in a trigger aborts the whole statement. +- **Don't hide critical logic no one expects.** Maintained columns and audit logs are great fits. Business rules that a reader would never guess are lurking in a trigger are a maintenance trap. + +## What you learned + +- A trigger runs a function automatically on INSERT/UPDATE/DELETE. Write the function first — `RETURNS trigger`, `LANGUAGE plpgsql` — using the implicit `NEW`/`OLD` rows and `TG_OP`. +- `CREATE TRIGGER ... BEFORE|AFTER event ON table FOR EACH ROW EXECUTE FUNCTION fn()` binds it. **BEFORE** can modify `NEW` before it's written; **AFTER** is for side effects like audit rows. +- `FOR EACH ROW` fires once per row (with `NEW`/`OLD`); `FOR EACH STATEMENT` fires once per statement. +- A `WHEN (...)` condition skips the function unless it holds — use `IS DISTINCT FROM` for null-safe comparisons. +- Triggers run inside the statement's transaction, so keep them simple: every write pays their cost, and their effects roll back with the statement. + +Up next: stored procedures and transaction control. diff --git a/lessons/08-programmability/03-triggers/lesson.yaml b/lessons/08-programmability/03-triggers/lesson.yaml new file mode 100644 index 0000000..58ae1ff --- /dev/null +++ b/lessons/08-programmability/03-triggers/lesson.yaml @@ -0,0 +1,22 @@ +title: Triggers +summary: Run a function automatically on INSERT, UPDATE, or DELETE — for maintained columns and audit logs — with CREATE TRIGGER. +estimatedMinutes: 15 +tags: + - triggers + - create-trigger + - plpgsql + - audit + - updated-at +authors: + - exekias +seed: seed.sql +checks: + - id: audit-rows-written + type: query-returns + description: Attach the AFTER trigger, then make one UPDATE and one DELETE so the audit log records both. + sql: SELECT action, count(*) FROM document_audit GROUP BY action ORDER BY action + expect: + rowCount: 2 + rows: + - [DELETE, 1] + - [UPDATE, 1] diff --git a/lessons/08-programmability/03-triggers/seed.sql b/lessons/08-programmability/03-triggers/seed.sql new file mode 100644 index 0000000..68c3c0f --- /dev/null +++ b/lessons/08-programmability/03-triggers/seed.sql @@ -0,0 +1,23 @@ +-- Seed for "03-triggers": a tiny document store plus an empty audit log. +-- documents holds a handful of rows the lesson will UPDATE (so a BEFORE +-- trigger can maintain updated_at) and change (so an AFTER trigger can write +-- history rows). document_audit starts empty — the learner's trigger fills it. + +CREATE TABLE documents ( + id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title text NOT NULL, + body text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE document_audit ( + id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + document_id int NOT NULL, + action text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO documents (title, body) VALUES + ('Welcome', 'Getting started with the docs.'), + ('Roadmap', 'What we plan to build this quarter.'), + ('Style guide', 'How we write and format things.'); diff --git a/lessons/08-programmability/module.yaml b/lessons/08-programmability/module.yaml new file mode 100644 index 0000000..51cb120 --- /dev/null +++ b/lessons/08-programmability/module.yaml @@ -0,0 +1,3 @@ +title: Programmability +difficulty: advanced +summary: Put logic in the database — views, functions, triggers, and stored procedures.