TCX2003 | Database Systems Finals Helpsheet

One-A4 double-sided helpsheet for TCX2003 Final Exam (Jul 13): SQL patterns, triggers, functions, procedures, ER design, normalization.

1. SQL

1.1 ER & cardinality

RelationshipTable shape
M:N (seeker claims many skills, skill claimed by many)junction table job_seeker_skill_map: both FKs + PRIMARY KEY (js_id, skill_id) composite. Relationship attributes (proficiency, verified) live HERE — they describe the claim, not either parent. (sale in 1.2 is this shape.)
1:N (seeker has many resumes)FK on the many side: resume.js_id. No new table. FK lives on the side that has “exactly one” of the other.
1:1FK on either side + UNIQUE on the FK column.
Weak entity (unit #02 repeats across buildings)PK = owner FK + partial key: PRIMARY KEY (building_id, unit_no), FK ON DELETE CASCADE. Can’t be identified by own attributes alone.
  • Key vocab ladder: superkey (any uniquely-identifying column set) ⊇ candidate key (minimal superkey) → primary key (the chosen one). Weak-entity PK parts are role names: owner’s key + partial key — not “superkeys”.
  • Total participation (“every resume MUST have an owner”) → FK NOT NULL · partial (“may have”) → FK nullable.
  • “Latest per parent” (active resume) = query concern, not schema: correlated WHERE r.uploaded = (SELECT MAX(...) WHERE r2.js_id = r.js_id).

1.2 CREATE TABLE

Spec word → constraint (marks leak here): “required” → NOT NULL · “unique / never share” → UNIQUE · “one of {…}” → ENUM · “must be positive / 350–500” → CHECK · “format like D0001” → CHECK … REGEXP · phone/id/code = text → VARCHAR, not INT · money → DECIMAL(m,2), never FLOAT (binary rounding corrupts cents) · “enforce / must” → constraint keyword, not a SELECT fix (COALESCE is display).

 1CREATE TABLE IF NOT EXISTS customer (          -- IF NOT EXISTS: idempotent re-run
 2  customer_id INT PRIMARY KEY,
 3  name        VARCHAR(60) NOT NULL,            -- "required"
 4  email       VARCHAR(120) UNIQUE,             -- no dup, NULL allowed
 5  city        VARCHAR(40),
 6  joined      DATE,                            -- DATETIME/TIMESTAMP if time needed
 7  referred_by INT,                             -- self-FK column (NULL = direct signup)
 8  CHECK (email REGEXP '^[^@]+@[^@]+\\.[^@]+$'),
 9  -- FIXED-FORMAT ID: CHECK (id REGEXP '^D[0-9]{4}$') = D0001 · '^A[0-9]{7}[A-Z]$' = matric
10  --   {n} = exactly n · ^...$ anchor the WHOLE value (omit → any substring matches!) · REGEXP is case-INSENSITIVE
11  FOREIGN KEY (referred_by) REFERENCES customer(customer_id)  -- self-FK: MUST be nullable
12    ON DELETE SET NULL
13);
14
15CREATE TABLE IF NOT EXISTS product (
16  product_id INT AUTO_INCREMENT PRIMARY KEY,   -- AUTO_INCREMENT must be leftmost key column
17  sku        CHAR(8) UNIQUE,                   -- fixed width = CHAR; variable = VARCHAR
18  name       VARCHAR(80) NOT NULL,
19  category   ENUM('book','tech','food'),       -- invalid value rejected
20  price      DECIMAL(8,2) NOT NULL DEFAULT 0.00,
21  stock      INT NOT NULL DEFAULT 0,
22  added      DATE,
23  CHECK (price >= 0 AND stock >= 0)            -- multi-column CHECK
24);
25
26CREATE TABLE IF NOT EXISTS sale (
27  customer_id INT,
28  product_id  INT,
29  sale_date   DATE,
30  qty         INT NOT NULL,
31  PRIMARY KEY (customer_id, product_id, sale_date),            -- composite PK (table-level)
32  FOREIGN KEY (customer_id) REFERENCES customer(customer_id)   -- FK must reference a PK/UNIQUE column
33    ON UPDATE CASCADE ON DELETE CASCADE,
34  FOREIGN KEY (product_id) REFERENCES product(product_id)
35    ON UPDATE CASCADE ON DELETE RESTRICT,
36  CHECK (qty > 0)
37);

FK referential actions — chosen per FK:

ActionEffect on child when parent row deleted / key updated
CASCADEchild rows deleted / child FK follows new value
SET NULLchild FK set NULL — column MUST be nullable
RESTRICTreject the parent delete/update — the DEFAULT if omitted
NO ACTION= RESTRICT in InnoDB
SET DEFAULTparses but InnoDB rejects at create-time — never use

Composite FK — child FK spans the same columns, same order as the parent’s composite PK (recurring hard-spot):

1CREATE TABLE IF NOT EXISTS bin (
2  warehouse VARCHAR(10), aisle INT, shelf INT, label VARCHAR(40),
3  PRIMARY KEY (warehouse, aisle, shelf)
4);
5CREATE TABLE IF NOT EXISTS slot (
6  warehouse VARCHAR(10), aisle INT, shelf INT, slot_no INT,
7  PRIMARY KEY (warehouse, aisle, shelf, slot_no),
8  FOREIGN KEY (warehouse, aisle, shelf) REFERENCES bin(warehouse, aisle, shelf)
9);
  • Natural PK (donor_id VARCHAR(5) PRIMARY KEY, a code you supply like D0001) vs surrogate PK (id INT AUTO_INCREMENT). Natural when the domain has a stable unique code; FK column type must match the PK it references.
  • Extra constructs: named cross-column CHECK CONSTRAINT chk_op CHECK (role != 'operator' OR process_id IS NOT NULL) · named composite unique UNIQUE KEY uk_step_serial (job_step_id, serial_nb) · secondary INDEX idx_status (status).

1.3 INSERT / UPDATE / DELETE

 1INSERT INTO customer (customer_id, name, email, city, joined)
 2VALUES (1, 'Ada', 'ada@mail.com', 'Singapore', '2026-01-10');
 3
 4INSERT INTO product (sku, name, category, price, stock, added) VALUES  -- multi-row: atomic, id omitted -> AUTO_INCREMENT
 5  ('BK000001', 'SQL Primer', 'book', 19.90, 50, '2026-02-01'),
 6  ('TC000002', 'USB-C Hub',  'tech', 45.00, 12, '2026-02-03');
 7
 8UPDATE product
 9SET price = price * 1.10, stock = stock - 5
10WHERE category = 'tech';                       -- no WHERE = EVERY row changed!
11
12DELETE FROM sale
13WHERE sale_date < '2026-01-01';                -- no WHERE = whole table gone!

1.4 SELECT … WHERE

 1SELECT product_id, name, price, stock
 2FROM   product
 3WHERE  category = 'tech'
 4  AND  price < 50.00
 5  AND  stock > 0
 6ORDER  BY added DESC
 7LIMIT  10;
 8
 9SELECT name, city, joined
10FROM   customer
11WHERE  city IN ('Singapore', 'Penang')                    -- set membership
12  AND  joined BETWEEN '2024-01-01' AND '2024-12-31'       -- inclusive both ends
13  AND  name LIKE 'A%'                                     -- % = any run, _ = one char
14  AND  email IS NOT NULL                                  -- never = NULL
15ORDER  BY joined;
16
17SELECT DISTINCT city FROM customer ORDER BY city;         -- DISTINCT = whole selected row
OperatorNote
= <> < <= > >=<> not != in exam style
AND OR NOTNOT binds tightest, then AND, then OR — parenthesise mixed
LIKEdefault case-insensitive collation
IS NULL / IS NOT NULL= NULL is always UNKNOWN
  • ORDER BY col [ASC|DESC], ASC default; multi-key ORDER BY a DESC, b.
  • LIMIT n or LIMIT offset, n (offset 0-based).
  • 3-valued logic: comparison with NULL → UNKNOWN; WHERE keeps only TRUE rows.

1.5 JOINs

 1-- INNER: matches only
 2SELECT c.name, p.name AS product, s.qty, s.sale_date
 3FROM   customer c
 4JOIN   sale s    ON s.customer_id = c.customer_id
 5JOIN   product p ON p.product_id  = s.product_id;
 6
 7-- LEFT: every customer, NULLs where no match
 8SELECT c.name, s.product_id, s.qty
 9FROM   customer c
10LEFT JOIN sale s ON s.customer_id = c.customer_id;
11
12-- OLD-STYLE comma join (JIANG KAN idiom) = INNER JOIN
13SELECT c.name, p.name
14FROM   customer c, sale s, product p
15WHERE  c.customer_id = s.customer_id
16  AND  p.product_id  = s.product_id;
17
18-- SELF-join, shared attribute: same city as 'Alice'
19SELECT a.name, b.name AS same_city
20FROM   customer a
21JOIN   customer b ON a.city = b.city
22WHERE  a.name = 'Alice' AND b.name <> a.name;
23
24-- SELF-join via self-FK (hierarchy): who referred them (LEFT keeps roots)
25SELECT c.name AS customer, r.name AS referred_by
26FROM   customer c
27LEFT JOIN customer r ON r.customer_id = c.referred_by;
28
29-- ANTI-join: products NEVER sold
30SELECT p.product_id, p.name
31FROM   product p
32LEFT JOIN sale s ON s.product_id = p.product_id
33WHERE  s.product_id IS NULL;
JoinReturnsUnmatched rows
INNER JOIN / JOINmatched pairs onlydropped both sides
LEFT JOINall left + matched rightright cols NULL
RIGHT JOINall right + matched leftleft cols NULL
CROSS JOINevery combination, m×nno ON
comma FROM a,b WHERE= INNER with the predicateCROSS product if you forget it
  • Anti-join: LEFT JOIN … WHERE right.key IS NULL = “in A with no match in B”.
  • Self-join = two aliases of one table; add b.x <> a.x to drop self-matches.
  • Qualify columns (c.name) whenever a name exists in 2+ tables.

1.6 Set operations

 1SELECT city FROM customer
 2UNION                                          -- dedup; UNION ALL keeps duplicates
 3SELECT 'Online' AS city;                       -- column count + order must match
 4
 5SELECT customer_id FROM sale
 6INTERSECT                                      -- MySQL 8.0.31+
 7SELECT customer_id FROM customer WHERE YEAR(joined) = 2024;
 8
 9SELECT customer_id FROM customer
10EXCEPT                                         -- MySQL 8.0.31+
11SELECT customer_id FROM sale;
  • Pre-8.0.31: INTERSECTWHERE id IN (subquery) · EXCEPT ≡ anti-join — NOT EXISTS (§1.8) or LEFT JOIN … IS NULL (§1.5); both NULL-safe, unlike NOT IN.
  • Result column names come from the first branch; ORDER BY/LIMIT once at the very end.
  • UNION ALL over UNION when dupes impossible — skips the dedup pass.

1.7 Aggregation

 1SELECT p.category,
 2       COUNT(*)                AS n_products,
 3       COALESCE(SUM(s.qty), 0) AS units_sold,  -- NULL -> 0 when no sales
 4       ROUND(AVG(p.price), 2)  AS avg_price,
 5       MIN(p.price)            AS cheapest,
 6       MAX(p.price)            AS dearest
 7FROM   product p
 8LEFT JOIN sale s ON s.product_id = p.product_id
 9GROUP  BY p.category
10HAVING units_sold > 0                          -- filter AFTER aggregation
11ORDER  BY units_sold DESC;
12
13SELECT s.customer_id, SUM(s.qty) AS total_qty
14FROM   sale s
15WHERE  s.sale_date >= '2025-01-01'             -- row filter, BEFORE grouping
16GROUP  BY s.customer_id
17HAVING SUM(s.qty) > 10;                        -- group filter, AFTER grouping
FuncReturns
COUNT(*)all rows in group (incl. NULLs)
COUNT(col)non-NULL values of col
COUNT(DISTINCT col)distinct non-NULL values
SUM / AVG / MIN / MAXignore NULLs
  • Aggregates skip NULL → all-NULL group returns NULL — wrap COALESCE(SUM(x),0).
  • HAVING may reference the alias or repeat the aggregate expression.
  • ⚠ Aggregate in WHERE = illegal (WHERE = rows before grouping) · ONLY_FULL_GROUP_BY: every non-aggregated SELECT column must appear in GROUP BY.

1.8 Subqueries & window

 1-- SCALAR: above overall average (swap AVG->MAX, > to = : "find the dearest")
 2SELECT name, price FROM product
 3WHERE  price > (SELECT AVG(price) FROM product);
 4
 5-- IN: bought any 'tech' product
 6SELECT name FROM customer
 7WHERE  customer_id IN (
 8    SELECT s.customer_id
 9    FROM   sale s JOIN product p ON p.product_id = s.product_id
10    WHERE  p.category = 'tech');
11
12-- EXISTS / NOT EXISTS (correlated; NULL-safe anti-pattern)
13SELECT c.name FROM customer c
14WHERE  NOT EXISTS (SELECT 1 FROM sale s WHERE s.customer_id = c.customer_id);
15
16-- CORRELATED scalar: above own category average
17SELECT p.name, p.category, p.price
18FROM   product p
19WHERE  p.price > (SELECT AVG(p2.price) FROM product p2
20                  WHERE p2.category = p.category);
21
22-- DERIVED TABLE: pre-aggregate then filter (alias mandatory)
23SELECT t.customer_id, t.total
24FROM ( SELECT customer_id, SUM(qty) AS total
25       FROM   sale GROUP BY customer_id ) AS t
26WHERE  t.total > 5;
27
28-- WINDOW: rank within category, rows not collapsed
29SELECT name, category, price,
30       ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn,
31       RANK()       OVER (PARTITION BY category ORDER BY price DESC) AS rnk
32FROM   product;
  • NOT IN NULL trap: any NULL from the subquery → empty result. NOT EXISTS instead.
  • EXISTS stops at first match; SELECT 1 idiomatic, select list ignored.
  • ROW_NUMBER unique (1,2,3) · RANK ties share then skip (1,1,3) · DENSE_RANK ties share, no skip (1,1,2).
  • Window funcs run after WHERE/GROUP BY/HAVING — to filter on rn, wrap in a derived table.

1.9 CREATE PROCEDURE — two shapes

Question wants…ShapeRead it via
ONE value (“total spent by X”)IN/OUT params + SELECT … INTOCALL p(1,@t); SELECT @t;
A TABLE ("for every X show …")no OUT, plain SELECT (LEFT JOIN + GROUP BY)CALL p(); → result set

Reaching for the scalar shape when the question wants rows is the classic trap.

 1-- RESULT-SET shape (most common exam ask)
 2DROP PROCEDURE IF EXISTS sales_summary;
 3DELIMITER //
 4CREATE PROCEDURE sales_summary()                        -- NO params: returns ROWS
 5BEGIN
 6    SELECT c.customer_id, c.name, COALESCE(SUM(s.qty),0) AS total_qty
 7    FROM customer c LEFT JOIN sale s ON s.customer_id = c.customer_id
 8    GROUP BY c.customer_id, c.name ORDER BY total_qty DESC;  -- body = §1.7 pattern
 9END //
10DELIMITER ;
11CALL sales_summary();
 1-- SCALAR-OUT shape
 2DELIMITER //
 3CREATE PROCEDURE total_spent(IN cid INT, OUT total DECIMAL(10,2))
 4BEGIN
 5    SELECT COALESCE(SUM(s.qty * p.price), 0.00)         -- COALESCE: no-sale customer = 0
 6    INTO total
 7    FROM sale s, product p                              -- comma join (Jiang idiom)
 8    WHERE s.product_id = p.product_id
 9      AND s.customer_id = cid;
10END //
11DELIMITER ;
12CALL total_spent(1, @t);  SELECT @t;
  • UPSERT body: IF EXISTS (SELECT 1 FROM product WHERE sku = sku_) THEN UPDATE … ELSE INSERT …; END IF; — trailing-underscore params (sku_) dodge column-name clash (Jiang idiom); don’t forget the closing END IF;.
  • IN read-only (default) · OUT write-back · INOUT both.
  • SELECT … INTO var must return exactly one row/value or it errors.
  • No RETURN value in a procedure — that’s a function.
  • DELIMITER // before any CREATE PROCEDURE/FUNCTION/TRIGGER (§1.9–1.12), reset DELIMITER ; after.

1.10 Full-dress procedure — cursor, handlers, transaction

MySQL’s required DECLARE order: vars → cursor → handlers.

 1DROP PROCEDURE IF EXISTS publish_plan;
 2DELIMITER $$
 3CREATE PROCEDURE publish_plan(IN p_job_id INT, IN p_quantity INT,
 4                              IN p_start_sn INT, IN p_process_csv VARCHAR(64))
 5BEGIN
 6    DECLARE v_step_id INT; DECLARE v_proc_id INT;
 7    DECLARE v_i INT; DECLARE v_done INT DEFAULT 0;
 8
 9    DECLARE cur_proc CURSOR FOR                         -- AFTER vars, BEFORE handlers
10        SELECT id FROM process
11        WHERE FIND_IN_SET(code, p_process_csv) > 0;
12
13    DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
14
15    DECLARE EXIT HANDLER FOR SQLEXCEPTION               -- any error: undo all, re-raise
16    BEGIN
17        ROLLBACK;
18        RESIGNAL;
19    END;
20
21    START TRANSACTION;
22    OPEN cur_proc;
23    proc_loop: LOOP
24        FETCH cur_proc INTO v_proc_id;
25        IF v_done = 1 THEN LEAVE proc_loop; END IF;
26
27        INSERT INTO job_step (job_id, process_id) VALUES (p_job_id, v_proc_id);
28        SET v_step_id = LAST_INSERT_ID();
29
30        SET v_i = 0;
31        WHILE v_i < p_quantity DO
32            INSERT INTO pcb_task (job_step_id, serial_nb)
33            VALUES (v_step_id, p_start_sn + v_i);
34            SET v_i = v_i + 1;
35        END WHILE;
36    END LOOP;
37    CLOSE cur_proc;
38    COMMIT;
39END$$
40DELIMITER ;

1.11 CREATE FUNCTION

 1DELIMITER //
 2CREATE FUNCTION discounted_price(p DECIMAL(8,2), pct INT)
 3RETURNS DECIMAL(8,2)                                    -- RETURNS = clause (type)
 4DETERMINISTIC                                           -- required under default binlog settings
 5BEGIN
 6    RETURN ROUND(p * (1 - pct / 100), 2);               -- RETURN = statement (value)
 7END //
 8DELIMITER ;
 9
10SELECT name, discounted_price(price, 10) AS sale_price FROM product;  -- inline anywhere an expression fits
11SELECT name FROM product WHERE discounted_price(price, 15) > 20.00;
  • Returns exactly ONE scalar; no result sets, no side-effects (that’s a procedure’s job).

1.12 CREATE TRIGGER

 1-- ONE comprehensive trigger: guard (SIGNAL) + maintain (UPDATE side-effect)
 2DROP TRIGGER IF EXISTS trg_sale_stock;                  -- no CREATE OR REPLACE; drop first
 3DELIMITER //
 4CREATE TRIGGER trg_sale_stock
 5BEFORE INSERT ON sale
 6FOR EACH ROW
 7BEGIN
 8    DECLARE avail INT;
 9    SELECT stock INTO avail FROM product WHERE product_id = NEW.product_id;
10    IF NEW.qty > avail THEN
11        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Insufficient stock';
12    END IF;
13    UPDATE product SET stock = stock - NEW.qty          -- maintenance leg
14    WHERE product_id = NEW.product_id;
15END //
16DELIMITER ;
  • If asked for maintenance alone: same body, AFTER INSERT ON sale, no DECLARE/IF needed.

  • Timing × event: {BEFORE | AFTER} {INSERT | UPDATE | DELETE} + FOR EACH ROW.

  • NEW.col = incoming value (INSERT, UPDATE) · OLD.col = existing (UPDATE, DELETE).

  • SET NEW.col = … only legal in BEFORE — e.g. SET NEW.joined = OLD.joined freezes a column; compare NEW.status <> OLD.status to stamp transitions.

  • SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '…' = the reject; use when the rule needs a subquery CHECK can’t do.

1.13 Procedure vs Function vs Trigger

AspectPROCEDUREFUNCTIONTRIGGER
InvokeCALL name(...)inline in an expressionimplicit, fires on DML
ReturnsOUT params / result setexactly 1 scalar via RETURNnothing (side-effect)
ParamsIN / OUT / INOUTIN onlynone — uses NEW. / OLD.
Side-effectsyesdiscouraged, should be pureyes, that’s the point
DETERMINISTICnoeffectively yesno
Re-createDROP PROCEDURE IF EXISTSDROP FUNCTION IF EXISTSDROP TRIGGER IF EXISTS

2. Flask / Python

2.1 Driver rules

GotchaRule
Write “ran” but data is goneconn.commit() after every INSERT/UPDATE/DELETE (not SELECT)
Paramsexecute(sql, (val,))%s for every type (no %d, no quotes), never sql % val
One-value tuple(val,) with the trailing comma; (val) is not a tuple
SQL injectionnever f-string / + / % user values into the query — %s + params tuple is the defence

2.2 Calling DB objects from Python

DB objectCall viaRead the result
PROCEDUREcursor.callproc("name", [args])rows → cursor.stored_results(); OUTresult[i] by index
FUNCTIONinside a query: execute("SELECT f(a,b) …")fetchall() — it’s just a value
TRIGGERyou don’t — fires on INSERT/UPDATE/DELETEeffect shows in the affected table
1# result-set proc: rows come via stored_results(), NOT fetchall() on the cursor
2cur = conn.cursor(dictionary=True)
3cur.callproc("sales_summary")
4for r in cur.stored_results():
5    rows = r.fetchall()
6
7# scalar-OUT proc: OUT slots filled in the returned tuple, read BY INDEX
8result = cur.callproc("total_spent", (1, 0))   # placeholder 0 for the OUT position
9total = result[1]                              # 2nd param = 'total'
comments powered by Disqus

Recent Updates

See all →