4.4.12 · D4Databases

Exercises — Stored procedures, triggers, functions

2,510 words11 min readBack to topic

Before we start, one shared table we will reuse. Picture a plain grid with three columns:

Figure — Stored procedures, triggers, functions

Every row is one bank account. id names the account, balance is how much money sits in it. When we "transfer", we subtract from one row's balance and add the same amount to another row's balance. Keep this picture in your head — most exercises touch it.


Level 1 — Recognition

L1.1 — Name the three tools

For each description, name stored procedure, function, or trigger:

  1. Runs automatically the moment a row is deleted.
  2. You type CALL cleanup(); to start it.
  3. Returns one number and can sit inside SELECT price_with_tax(price).
Recall Solution
  1. Trigger — the defining trait is automatic firing on a data-change event; nobody calls it.
  2. Stored procedure — the word CALL is the giveaway; it is invoked explicitly and need not return a value.
  3. Function — it returns exactly one value and is embedded inside an SQL expression.

L1.2 — Which pseudo-table exists?

State whether OLD, NEW, or both are available inside each trigger body:

  • AFTER INSERT
  • BEFORE DELETE
  • AFTER UPDATE
Recall Solution
  • AFTER INSERT → only NEW (a fresh row appeared; there is no previous version).
  • BEFORE DELETE → only OLD (a row is about to vanish; there is no incoming version).
  • AFTER UPDATEboth OLD and NEW (the row changed from one value to another).

Mnemonic from the parent: OLD–DELETE, NEW–INSERT, UPDATE = BOTH.


Level 2 — Application

L2.1 — Write a tax function

Write a MySQL function net_price that takes a price and a tax rate and returns the price including tax. Then compute by hand: net_price(200.00, 0.18).

Recall Solution
DELIMITER //
CREATE FUNCTION net_price(price DECIMAL(10,2), rate DECIMAL(4,3))
RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
  RETURN price * (1 + rate);
END //
DELIMITER ;

Why RETURNS? The engine must know the output type to slot the result into an expression. Why DETERMINISTIC? Same inputs always give the same output (no NOW(), no table reads), so it is honestly a pure calculator.

By hand: .

L2.2 — Write the transfer procedure and trace it

Write a procedure transfer(src, dst, amt) that moves money atomically. Starting from account 1 = 500.00 and account 2 = 300.00, trace the balances after CALL transfer(1, 2, 120.00).

Recall Solution
DELIMITER //
CREATE PROCEDURE transfer(IN src INT, IN dst INT, IN amt DECIMAL(10,2))
BEGIN
  START TRANSACTION;
  UPDATE accounts SET balance = balance - amt WHERE id = src;
  UPDATE accounts SET balance = balance + amt WHERE id = dst;
  COMMIT;
END //
DELIMITER ;

Why the transaction? Either both updates land or neither does — see Transactions and ACID. If the server crashed after the first UPDATE, the missing COMMIT means the whole thing rolls back; money is never lost.

Trace:

  • Start: acc1 = 500.00, acc2 = 300.00.
  • balance - 120 on acc1 → 380.00.
  • balance + 120 on acc2 → 420.00.
  • Total before = 800.00, total after = 380 + 420 = 800.00 — conserved, as it must be.

Level 3 — Analysis

L3.1 — Why is this SELECT illegal?

A student writes:

SELECT transfer(1, 2, 100.00);

and gets an error. Explain precisely why, using the invocation-context rule.

Recall Solution

transfer is a procedure, not a function. A SELECT evaluates an expression per row and needs each call to return one value with no side effects. But transfer:

  • returns nothing (no RETURNS),
  • runs START TRANSACTION ... COMMIT (a side effect the query planner must never trigger mid-scan).

So the engine refuses. Procedures are invoked with CALL transfer(1,2,100.00); standalone. The invocation context — inside an expression vs. standalone CALL — is the dividing line, exactly as the parent note's first Common Mistake warns.

L3.2 — The lying-DETERMINISTIC bug

This function is declared DETERMINISTIC but reads the wall clock:

CREATE FUNCTION age_now(dob DATE)
RETURNS INT
DETERMINISTIC
BEGIN
  RETURN TIMESTAMPDIFF(YEAR, dob, NOW());
END //

Describe the concrete wrong behaviour this can cause.

Recall Solution

DETERMINISTIC is a promise: "same inputs → same output, forever." The optimizer trusts it and may cache a computed value or reuse it across rows/queries. But NOW() changes with time, so the true output depends on more than dob.

Concrete failure: the DB caches age_now('2000-01-01') = 25 and keeps serving 25 even after a year passes, or uses it to build a functional index (see Indexes) that silently goes stale. Result: silently wrong data with no error message. The fix is to declare it NOT DETERMINISTIC (and READS SQL DATA where relevant), or better, pass the reference date in as a parameter so it becomes honestly pure.


Level 4 — Synthesis

L4.1 — Design an audit trigger with a guard

Design an AFTER UPDATE trigger on employees that appends a row to salary_log(emp_id, old_sal, new_sal, changed_at) only when the salary actually changed. Then, given an UPDATE employees SET dept = 'Sales' WHERE id IN (1,2,3) that touches 3 rows but changes no salaries, state how many log rows appear.

Recall Solution
DELIMITER //
CREATE TRIGGER audit_salary
AFTER UPDATE ON employees
FOR EACH ROW
BEGIN
  IF NEW.salary <> OLD.salary THEN
    INSERT INTO salary_log(emp_id, old_sal, new_sal, changed_at)
    VALUES (OLD.id, OLD.salary, NEW.salary, NOW());
  END IF;
END //
DELIMITER ;

Why AFTER? We only log a change once it is safely written — we are reacting, not editing. Why FOR EACH ROW? It fires once per affected row, so both OLD and NEW refer to that one row. Why the IF? To skip noise when the salary was untouched.

The UPDATE hits 3 rows, so the trigger body runs 3 times. But in each, NEW.salary = OLD.salary (only dept changed), so the guard is false every time. Zero log rows are inserted.

L4.2 — Combine a procedure with a validating trigger

You want a rule: no account balance may ever go negative, enforced even against raw admin SQL. Should this live in the transfer procedure, or in a trigger? Write the enforcement.

Recall Solution

It must live in a BEFORE UPDATE trigger, because a procedure only guards its own code path — a careless admin running a raw UPDATE bypasses it. A trigger fires no matter who touches the table.

DELIMITER //
CREATE TRIGGER no_negative
BEFORE UPDATE ON accounts
FOR EACH ROW
BEGIN
  IF NEW.balance < 0 THEN
    SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Balance cannot go negative';
  END IF;
END //
DELIMITER ;

Why BEFORE? We must inspect (and reject) the incoming value before it is written — NEW.balance is still editable/inspectable here. In an AFTER trigger the bad value would already be committed. Why SIGNAL? It raises an error, which forces the surrounding transaction (from transfer) to roll back — tying the invariant into Transactions and ACID.

Test: transfer(1, 2, 999999.00) when acc1 has only 500 → the first UPDATE sets NEW.balance = 500 - 999999 = -999499, the trigger fires, SIGNAL aborts, the transaction rolls back, nothing changes.


Level 5 — Mastery

L5.1 — Trigger-cascade counting

Table A has an AFTER INSERT trigger that inserts one row into table B. Table B has an AFTER INSERT trigger that inserts one row into table C. You run a single INSERT INTO A that adds 4 rows. How many total rows are inserted across A, B, and C?

Recall Solution

FOR EACH ROW triggers fire once per affected row, and inserts cascade:

  • Into A: 4 rows (your statement).
  • A's trigger fires 4 times → 4 rows into B.
  • B's trigger fires 4 times → 4 rows into C.

Total = rows. This is exactly the "spooky action at a distance" the parent note warns about: one visible statement quietly produced twelve writes through hidden chained triggers.

L5.2 — Concurrency at the boundary

Two clients call transfer(1, 2, 100) at the same instant, both reading acc1 = 500 at the start. Without any locking, both compute 500 - 100 = 400 and write it. What is the bug, and what actually prevents it?

Recall Solution

This is a lost update. Both sessions read 500, both write 400 — but two transfers of 100 should have left 300. One update silently overwrote the other; 100 vanished.

What saves us: the UPDATE accounts SET balance = balance - amt form does not read-then-write in the app; it computes inside the engine under a row lock. Whichever transaction grabs the lock on account 1 first applies -100, releases at COMMIT, then the second reads the already-updated 400 and applies -100300. This locking is the subject of Concurrency Control. The dangerous version is when logic reads the balance into a variable in the procedure and writes it back — that reintroduces the race.

L5.3 — Ordering: BEFORE guard vs. the transaction

In L4.2 we added a BEFORE UPDATE guard that SIGNALs on a negative balance, and transfer wraps both updates in a transaction. If the second update (crediting dst) somehow failed, is the first update (debiting src) still applied? Explain the exact timeline.

Recall Solution

Timeline inside transfer:

  1. START TRANSACTION.
  2. UPDATE src → the BEFORE guard runs; if the debit is legal it is applied within the transaction (not yet committed).
  3. UPDATE dst fails → an error is raised.
  4. Because we never reach COMMIT, the transaction is rolled back, and step 2's debit is undone.

So no — the debit is not left behind. Atomicity (Transactions and ACID) means all statements between START TRANSACTION and COMMIT are one indivisible unit: reaching an error before COMMIT reverts everything, including the earlier successful UPDATE. This is the whole reason the money-transfer logic lives server-side in one transactional block.



Connections

  • Transactions and ACID — L2.2, L4.2, L5.3 all rest on atomic commit/rollback.
  • Concurrency Control — L5.2's lost-update and row locks.
  • Indexes — L3.2's stale functional index from a false DETERMINISTIC.
  • SQL Queries (SELECT, JOIN) — L3.1's illegal SELECT transfer(...).
  • Database Normalization — audit/derived columns maintained by triggers (L4.1).
  • Parent: Stored procedures, triggers, functions (index 4.4.12)