TCX2003 (NUS Database Systems) series: TCX2005 Finals Cheatsheet · Finals helpsheet (current)
Mon 13 Jul 09:00-11:00 · LT27 · Closed book · 25 questions · ONE A4 double-sided handwritten/printed helpsheet — this page IS the sheet
v1 — simplified from the uncompressed class-test port (1495 lines). Two buckets: §1 SQL (ER → DDL → queries → procs/functions/triggers, gotchas inlined as ⚠) and §2 Flask/Python (driver rules + call shapes only).
1. SQL
1.1 ER & cardinality
| Relationship | Table 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:1 | FK 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:
| Action | Effect on child when parent row deleted / key updated |
|---|---|
CASCADE | child rows deleted / child FK follows new value |
SET NULL | child FK set NULL — column MUST be nullable |
RESTRICT | reject the parent delete/update — the DEFAULT if omitted |
NO ACTION | = RESTRICT in InnoDB |
SET DEFAULT | parses 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 likeD0001) 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 uniqueUNIQUE KEY uk_step_serial (job_step_id, serial_nb)· secondaryINDEX 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
| Operator | Note |
|---|---|
= <> < <= > >= | <> not != in exam style |
AND OR NOT | NOT binds tightest, then AND, then OR — parenthesise mixed |
LIKE | default case-insensitive collation |
IS NULL / IS NOT NULL | = NULL is always UNKNOWN |
ORDER BY col [ASC|DESC], ASC default; multi-keyORDER BY a DESC, b.LIMIT norLIMIT offset, n(offset 0-based).- 3-valued logic: comparison with NULL → UNKNOWN;
WHEREkeeps 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;
| Join | Returns | Unmatched rows |
|---|---|---|
INNER JOIN / JOIN | matched pairs only | dropped both sides |
LEFT JOIN | all left + matched right | right cols NULL |
RIGHT JOIN | all right + matched left | left cols NULL |
CROSS JOIN | every combination, m×n | no ON |
comma FROM a,b WHERE | = INNER with the predicate | CROSS 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.xto 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:
INTERSECT≡WHERE id IN (subquery)·EXCEPT≡ anti-join —NOT EXISTS(§1.8) orLEFT JOIN … IS NULL(§1.5); both NULL-safe, unlikeNOT IN. - Result column names come from the first branch;
ORDER BY/LIMITonce at the very end. UNION ALLoverUNIONwhen 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
| Func | Returns |
|---|---|
COUNT(*) | all rows in group (incl. NULLs) |
COUNT(col) | non-NULL values of col |
COUNT(DISTINCT col) | distinct non-NULL values |
SUM / AVG / MIN / MAX | ignore NULLs |
- Aggregates skip NULL → all-NULL group returns NULL — wrap
COALESCE(SUM(x),0). HAVINGmay reference the alias or repeat the aggregate expression.- ⚠ Aggregate in
WHERE= illegal (WHERE= rows before grouping) · ONLY_FULL_GROUP_BY: every non-aggregatedSELECTcolumn must appear inGROUP 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 EXISTSinstead. EXISTSstops at first match;SELECT 1idiomatic, select list ignored.ROW_NUMBERunique (1,2,3) ·RANKties share then skip (1,1,3) ·DENSE_RANKties share, no skip (1,1,2).- Window funcs run after
WHERE/GROUP BY/HAVING— to filter onrn, wrap in a derived table.
1.9 CREATE PROCEDURE — two shapes
| Question wants… | Shape | Read it via |
|---|---|---|
| ONE value (“total spent by X”) | IN/OUT params + SELECT … INTO | CALL 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 closingEND IF;. INread-only (default) ·OUTwrite-back ·INOUTboth.SELECT … INTO varmust return exactly one row/value or it errors.- No
RETURN valuein a procedure — that’s a function. - ⚠
DELIMITER //before anyCREATE PROCEDURE/FUNCTION/TRIGGER(§1.9–1.12), resetDELIMITER ;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.joinedfreezes a column; compareNEW.status <> OLD.statusto stamp transitions.SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '…'= the reject; use when the rule needs a subqueryCHECKcan’t do.
1.13 Procedure vs Function vs Trigger
| Aspect | PROCEDURE | FUNCTION | TRIGGER |
|---|---|---|---|
| Invoke | CALL name(...) | inline in an expression | implicit, fires on DML |
| Returns | OUT params / result set | exactly 1 scalar via RETURN | nothing (side-effect) |
| Params | IN / OUT / INOUT | IN only | none — uses NEW. / OLD. |
| Side-effects | yes | discouraged, should be pure | yes, that’s the point |
DETERMINISTIC | no | effectively yes | no |
| Re-create | DROP PROCEDURE IF EXISTS | DROP FUNCTION IF EXISTS | DROP TRIGGER IF EXISTS |
2. Flask / Python
2.1 Driver rules
| Gotcha | Rule |
|---|---|
| Write “ran” but data is gone | conn.commit() after every INSERT/UPDATE/DELETE (not SELECT) |
| Params | execute(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 injection | never f-string / + / % user values into the query — %s + params tuple is the defence |
2.2 Calling DB objects from Python
| DB object | Call via | Read the result |
|---|---|---|
| PROCEDURE | cursor.callproc("name", [args]) | rows → cursor.stored_results(); OUT → result[i] by index |
| FUNCTION | inside a query: execute("SELECT f(a,b) …") | fetchall() — it’s just a value |
| TRIGGER | you don’t — fires on INSERT/UPDATE/DELETE | effect 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'