Databases
Time limit: 90 minutes Total marks: 60 Instructions: Answer all three questions. Show all reasoning: schema, SQL, proofs, and quantitative estimates. Assume PostgreSQL semantics unless stated otherwise.
Question 1 — Normalization, Functional Dependencies & Anomaly Proof (20 marks)
A logistics company stores shipment data in a single table Shipment:
| ShipmentID | TruckID | DriverName | DepotID | DepotCity | Weight | RatePerKg | Cost |
|---|
The following functional dependencies (FDs) are known:
(a) Compute the attribute closure under . Hence prove that ShipmentID is the (only) candidate key. (5)
(b) Identify every FD that violates 2NF and 3NF respectively, naming the exact anomaly (insert / update / delete) each causes. (4)
(c) Decompose the schema into BCNF. State each resulting relation with its primary and foreign keys, and prove your decomposition is lossless-join using the chase / common-attribute-key argument. (7)
(d) Is your BCNF decomposition dependency-preserving? If any FD is not preserved, identify it and state precisely why BCNF sometimes cannot preserve all dependencies. (4)
Question 2 — SQL Window Functions, Recursive CTE & Cost Estimation (22 marks)
Given tables:
Employee(empID PK, name, managerID FK->Employee.empID, deptID, salary)
Dept(deptID PK, deptName)
(a) Write a single query returning, for each department, the employee(s) with the second-highest salary (handle ties correctly so that if two people share the top salary the "second" is the next distinct salary). Use a window function. Justify your choice of DENSE_RANK vs RANK. (6)
(b) Write a recursive CTE that returns, for a given :rootID, every employee in that manager's subtree together with their depth (root = 0). Explain the anchor member, recursive member, and the termination condition. (6)
(c) For the query in (a), Employee has rows uniformly spread over departments. A B-tree composite index (deptID, salary DESC) exists.
(i) Estimate the number of index leaf entries scanned to produce the result versus a full sort of the whole table, and state the asymptotic cost of each in Big-O. (4)
(ii) The optimizer estimates cost as where = pages read, = tuples processed. Given , , a full scan reads pages and processes tuples, while the index-only plan reads pages and processes tuples. Compute both costs and state which plan the optimizer picks. (6)
Question 3 — Concurrency, Isolation & MVCC (18 marks)
Two transactions run concurrently on a PostgreSQL-style MVCC database. Table Account(id, balance) has row id=1, balance=100.
T1: BEGIN; UPDATE Account SET balance = balance - 50 WHERE id=1; ... COMMIT;
T2: BEGIN; SELECT balance FROM Account WHERE id=1; ... SELECT balance FROM Account WHERE id=1; COMMIT;
(a) Define the three read anomalies (dirty read, non-repeatable read, phantom read) and, for each of the four standard isolation levels, state in a table which anomalies are permitted. (6)
(b) Under READ COMMITTED, if T1's UPDATE and COMMIT occur between T2's two SELECTs, what values does T2 read? Which anomaly (if any) occurs? Explain using MVCC row versioning (xmin/xmax visibility). (5)
(c) Repeat (b) under REPEATABLE READ in PostgreSQL's snapshot implementation. Explain why no anomaly appears, and prove that this can create a write skew problem that even REPEATABLE READ does not prevent — give a concrete two-transaction example. (7)
Answer keyMark scheme & solutions
Question 1
(a) Closure of ShipmentID: (5)
Start {ShipmentID}.
- add TruckID, DepotID, Weight →
{ShipmentID, TruckID, DepotID, Weight}(1) - (TruckID→DriverName) → add DriverName (0.5)
- (DepotID→DepotCity) → add DepotCity (0.5)
- (DepotCity→RatePerKg) → add RatePerKg (0.5)
- (Weight,RatePerKg→Cost) → add Cost (0.5)
all 8 attributes → ShipmentID is a superkey. (1) No other attribute appears on the LHS of the "top" FD generating ShipmentID; ShipmentID never appears on any RHS, so it must be in every key ⇒ it is minimal and the unique candidate key. (1)
(b) With key = ShipmentID (all non-keys are non-prime): (4)
- 2NF: since the key is a single attribute, there are no partial dependencies → no 2NF violation. (1) (Award for recognizing single-attribute key ⇒ automatically 2NF.)
- 3NF violations are transitive dependencies:
- TruckID→DriverName (2)
- DepotID→DepotCity, DepotCity→RatePerKg, Weight,RatePerKg→Cost — none of these LHS are superkeys and RHS is non-prime → all violate BCNF/3NF. (1)
Anomalies (name any correct mapping): DriverName re-stored on every shipment of a truck ⇒ update anomaly; can't record a depot's city/rate with no shipment ⇒ insert anomaly; deleting last shipment loses truck's driver/depot city ⇒ delete anomaly.
(c) BCNF decomposition (each relation: LHS of an FD becomes a key): (7)
- — from (1)
- — from (1)
- — from (1)
- — from (1)
- — residual with the key. (1) FKs: R5.TruckID→R1, R5.DepotID→R2, R2.DepotCity→R3, (R5.Weight, R3.RatePerKg via join)→R4.
Lossless proof (1): At each binary split the common attribute is the key of one side (e.g. splitting off R1, common attr TruckID is key of R1 ⇒ holds). By the lossless-join test, if the shared attribute set is a superkey of one fragment the join is lossless; applying this at every step ⇒ overall decomposition is lossless. (1)
(d) Dependency preservation: (4) Each of is fully contained inside one relation → preserved. (ShipmentID→TruckID,DepotID,Weight) lives entirely in R5 → preserved. (2) So this decomposition is dependency-preserving. However, note the general principle worth stating: BCNF is not guaranteed dependency-preserving (3NF is), because forcing every determinant to be a key can split an FD across relations whose join must be recomputed to check it. (2)
Question 2
(a) (6)
SELECT deptID, empID, name, salary
FROM (
SELECT e.*,
DENSE_RANK() OVER (PARTITION BY deptID ORDER BY salary DESC) AS rnk
FROM Employee e
) t
WHERE rnk = 2;Justification: DENSE_RANK assigns rank on distinct salary values, so if two employees tie for the top salary they both get rank 1 and the next distinct salary gets rank 2 — exactly "second-highest distinct salary." (3) RANK would skip to rank 3 after a two-way tie (leaving no rank 2), and ROW_NUMBER would arbitrarily pick one top earner as 1 and the other as 2 — both wrong for the requirement. (3)
(b) (6)
WITH RECURSIVE subtree AS (
SELECT empID, name, managerID, 0 AS depth
FROM Employee
WHERE empID = :rootID -- anchor member
UNION ALL
SELECT e.empID, e.name, e.managerID, s.depth + 1
FROM Employee e
JOIN subtree s ON e.managerID = s.empID -- recursive member
)
SELECT * FROM subtree;- Anchor (2): selects the root at depth 0.
- Recursive member (2): joins Employee to the working table on
managerID = parent.empID, incrementing depth. - Termination (2): recursion stops when the recursive member returns no new rows (a leaf has no children satisfying the join), i.e. the UNION ALL fixpoint is reached. Assumes an acyclic hierarchy.
(c)(i) (4) The result touches only the top-2 salary rows per department. With the ordered composite index, the engine can walk each department's leaf range and stop after the second distinct salary: roughly where = rows returned (a few per dept), i.e. leaf touches vs a full sort of all rows at comparisons. (Index plan asymptotically far cheaper.) (4)
(c)(ii) (6) .
- Full scan: . (2)
- Index-only: . (2) Since , the optimizer chooses the index-only plan. (2)
Question 3
(a) (6) Definitions (3, 1 each):
- Dirty read: reading a row modified by another uncommitted transaction.
- Non-repeatable read: re-reading the same row yields a different value because another committed txn updated it.
- Phantom read: re-running the same range query returns a different set of rows because another committed txn inserted/deleted matching rows.
Permitted-anomalies table (3):
| Isolation Level | Dirty | Non-repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | ✔ | ✔ | ✔ |
| READ COMMITTED | ✘ | ✔ | ✔ |
| REPEATABLE READ | ✘ | ✘ | ✔ (✘ in PG) |
| SERIALIZABLE | ✘ | ✘ | ✘ |
(b) (5) Under READ COMMITTED each statement takes a fresh snapshot at statement start.
- First SELECT (before T1 commits): sees old row version (xmax from T1 not yet committed / not visible) → 100. (1.5)
- T1 commits (its new version balance=50 becomes visible). (1)
- Second SELECT: new snapshot sees the committed version → 50. (1.5) The two reads differ → non-repeatable read occurs (permitted at READ COMMITTED). (1)
(c) (7) Under REPEATABLE READ, PostgreSQL takes one snapshot at the first statement of the transaction and reuses it for the whole txn. (1)
- Both SELECTs see the version whose commit was visible at snapshot time → 100 and 100 — consistent, no non-repeatable read; xmin/xmax visibility rules hide T1's later-committed version. (2)
Write skew example (4): SERIALIZABLE-only anomaly; RR does not prevent it. Constraint: "at least one doctor must remain on call." Two on-call doctors A, B.
T1: SELECT COUNT(*) WHERE on_call=true; -- sees 2
UPDATE Doctor SET on_call=false WHERE name='A';
T2: SELECT COUNT(*) WHERE on_call=true; -- sees 2 (own snapshot)
UPDATE Doctor SET on_call=false WHERE name='B';
Both COMMIT.
Each read a snapshot of 2, each updated a different row (no write-write conflict, so RR/snapshot isolation lets both commit), but the final state has 0 on call — the invariant is violated. Only SERIALIZABLE (SSI) detects the read/write dependency cycle and aborts one. (4)
[
{"claim":"ShipmentID closure yields all 8 attributes and cost is derivable last","code":"attrs={'ShipmentID'}\nfds=[({'ShipmentID'},{'TruckID','DepotID','Weight'}),({'TruckID'},{'DriverName'}),({'DepotID'},{'DepotCity'}),({'DepotCity'},{'RatePerKg'}),({'Weight','RatePerKg'},{'Cost'})]\nchanged=True\nwhile changed:\n changed=False\n for l,r in fds:\n if l<=attrs and not r<=attrs:\n attrs|=r; changed=True\nresult = attrs=={'ShipmentID','TruckID','DepotID','Weight','DriverName','DepotCity','RatePerKg','Cost'}"},
{"claim":"Full scan cost = 22500","code":"C=1.0*12500+0.01*10**6\nresult = C==22500"},
{"claim":"Index-only cost = 14000 and is cheaper","code":"Cf=1.0*12500+0.01*10**6\nCi=1.0*4000+0.01*10**6\nresult = (Ci==14000) and (Ci<Cf)"},
{"claim":"Index leaf touches ~1e4 << full sort comparisons ~2e7","code":"import math\nidx=500*math.log2(10**6)\nfull=10**6*math.log2(10**6)\nresult = idx<11000 and full>1.9*10**7 and idx<full"}
]