Databases · Week 2 practice: PostgreSQL & SQL

psql, roles and permissions, CRUD, and the cheat-sheet queries, on the lecture's To Do List app

Practice for the PostgreSQL and SQL half of Week 2, using the lecture's own To Do List app. The todo_item table below has exactly the columns and types from the slides; I've added the lookup tables its *_id columns were meant to point at (the slides say “we will look at ways to link everything together”), so you can query real data. Sections A–C follow the slides; sections D–E cover the rest of the Neon PostgreSQL cheat sheet and are tagged cheat sheet, since joins and set operations are mostly next week's Advanced SQL.

Every SQL answer here was run on a real PostgreSQL 16 server against exactly this data, with the output pasted underneath. Questions that change data either wrap the change in BEGIN … ROLLBACK (so nothing sticks) or tell you to re-run the setup script.

Setup in pgAdmin 4: right-click Databases → Create → Database…, name it todo_practice; select it, open Tools → Query Tool; paste the script and press ▶ Execute (F5). It starts by dropping everything, so re-run it whenever you want to reset. For the psql questions, pgAdmin also has Tools → PSQL Tool, which opens a real psql prompt connected to the selected database.

Setup script
-- Practice database for Week 2: the lecture's To Do List app, filled in.
-- todo_item is exactly the table from the slides; the other tables are the
-- ones its *_id columns were always meant to point at.
-- Safe to re-run: it drops everything first.

DROP VIEW  IF EXISTS overdue_items;
DROP TABLE IF EXISTS todo_item, project, app_user, priority, status, context, place, room CASCADE;

CREATE TABLE public.app_user (
    user_id    integer NOT NULL,
    username   text    NOT NULL UNIQUE,
    full_name  text,
    PRIMARY KEY (user_id)
);

CREATE TABLE public.priority (
    priority_id integer NOT NULL,
    name        text    NOT NULL,
    rank        integer NOT NULL CHECK (rank BETWEEN 1 AND 3),   -- 1 = most urgent
    PRIMARY KEY (priority_id)
);

CREATE TABLE public.status (
    status_id integer NOT NULL,
    name      text    NOT NULL,
    PRIMARY KEY (status_id)
);

CREATE TABLE public.context (
    context_id integer NOT NULL,
    name       text    NOT NULL,
    PRIMARY KEY (context_id)
);

CREATE TABLE public.project (
    project_id integer NOT NULL,
    name       text    NOT NULL,
    owner_id   integer REFERENCES app_user(user_id),
    PRIMARY KEY (project_id)
);

-- Same columns and types as the lecture slide, plus FOREIGN KEYs.
CREATE TABLE public.todo_item (
    todo_item_id    integer NOT NULL,
    description     text,
    owner_id        integer REFERENCES app_user(user_id),
    priority_id     integer REFERENCES priority(priority_id),
    context_id      integer REFERENCES context(context_id),
    status_id       integer REFERENCES status(status_id),
    project_id      integer REFERENCES project(project_id),
    due_date        date,
    completion_date date,
    PRIMARY KEY (todo_item_id)
);

INSERT INTO app_user VALUES
 (1, 'testuser', 'Test User'),
 (2, 'amira',    'Amira Khan'),
 (3, 'ben',      'Ben Osei'),
 (4, 'chloe',    NULL);            -- no items, no projects

INSERT INTO priority VALUES (1,'High',1), (2,'Medium',2), (3,'Low',3);
INSERT INTO status   VALUES (1,'To do'), (2,'In progress'), (3,'Done');
INSERT INTO context  VALUES (1,'@uni'), (2,'@home'), (3,'@phone'), (4,'@gym');

INSERT INTO project VALUES
 (1, 'Databases coursework', 2),
 (2, 'Move flat',            1),
 (3, 'Rowing season',        3),
 (4, 'Learn Haskell',        2);   -- no items yet

INSERT INTO todo_item VALUES
 (1,  'Read normalisation slides',   2, 1, 1, 3, 1,    '2026-10-05', '2026-10-04'),
 (2,  'Draw ER diagram in JDL',      2, 1, 1, 2, 1,    '2026-10-09', NULL),
 (3,  'Practice SQL queries',        2, 1, 1, 1, 1,    '2026-10-12', NULL),
 (4,  'Book removal van',            1, 2, 3, 1, 2,    '2026-10-20', NULL),
 (5,  'Pack kitchen',                2, 3, 2, 1, 2,    '2026-10-24', NULL),  -- Amira helping with Test User's project
 (6,  'Cancel old broadband',        1, 1, 3, 3, 2,    '2026-10-01', '2026-09-30'),
 (7,  'Erg 2k test',                 3, 2, 4, 1, 3,    '2026-10-18', NULL),
 (8,  'Buy heart rate strap',        3, 3, NULL, 3, 3, NULL,         '2026-10-02'),
 (9,  'Email tutor about essay',     2, 2, 3, 1, NULL, '2026-10-08', NULL),
 (10, 'Write essay plan',            3, 1, 1, 2, NULL, '2026-10-15', NULL),
 (11, 'Renew railcard',              1, 3, NULL, 1, NULL, NULL,       NULL),
 (12, 'Submit DB practice test',     2, 1, 1, 3, 1,    '2026-10-10', '2026-10-10');
app_user (user_id PK, username UNIQUE, full_name)          4 users; chloe (4) has no items or projects
priority (priority_id PK, name, rank 1–3)                   High / Medium / Low
status   (status_id PK, name)                               To do / In progress / Done
context  (context_id PK, name)                              @uni / @home / @phone / @gym
project  (project_id PK, name, owner_id FK)                 'Learn Haskell' has no items
todo_item(todo_item_id PK, description, owner_id FK, priority_id FK, context_id FK,
          status_id FK, project_id FK, due_date, completion_date)   12 items, some NULLs
Convention check. (1) The cheat sheet has two typos: CREATE OR REPLACE view_name AS query is missing the word VIEW, and its LIMIT … OFFSET … ORDER BY line is in the wrong order; ORDER BY must come before LIMIT/OFFSET. (2) The slides recommend PostgreSQL 14.7 or older; everything here also works on 14. The one version difference that matters is noted in Q5. (3) “Today” is fixed as 11 Oct 2026 in date questions so the answers stay reproducible; the lecture uses now().

A. PostgreSQL, psql, users and security

Q1multiple choicepsql vs SQL

Which of these is a psql command rather than an SQL command?

Explanation

Answer: Lecture rule: psql commands start with a backslash; SQL commands end with a semicolon. psql is PostgreSQL's command-line client. Its backslash “meta-commands” are handled by psql itself and only work there; SQL commands are sent to the server and (mostly) work in any DBMS. Writing SQL keywords in upper case is a readability convention; most DBMSs aren't case-sensitive about keywords.

Q2psql8 marks

Say what each psql command does: \l, \c todo_list testuser, \dt, \d todo_item, \d+ todo_item, \du, \dn, \x, \q. How would you start psql connected to the postgres database?

Show answer
CommandDoes
psql postgres / psql -U postgresstart psql (from the terminal) on the postgres database / as user postgres
\llist all databases
\c todo_list testuserconnect to database todo_list as user testuser
\dt (\dt+)list tables (+ adds size and description)
\d todo_item (\d+)describe a table: columns, types, nullability, indexes, FKs
\dulist users/roles and their attributes
\dnlist schemas (e.g. public)
\xtoggle expanded display (one column per line, handy for wide rows)
\qquit psql

Real \d todo_item output on the practice database (shortened):

            Table "public.todo_item"
     Column      |  Type   | Collation | Nullable | Default
-----------------+---------+-----------+----------+---------
 todo_item_id    | integer |           | not null |
 description     | text    |           |          |
 owner_id        | integer |           |          |
 ...
 completion_date | date    |           |          |
Indexes:
    "todo_item_pkey" PRIMARY KEY, btree (todo_item_id)
Foreign-key constraints:
    "todo_item_owner_id_fkey" FOREIGN KEY (owner_id) REFERENCES app_user(user_id)
    ...

These all read the DBMS catalog from Week 1, the database the DBMS keeps about its own databases. The slides write \ du and \ c with a space; that's a font artefact. There's no space after the backslash.

Q3roles6 marks

Write the lecture's command to create a user testuser with password 'password' that can log in and doesn't inherit privileges. Explain NOINHERIT, LOGIN and PASSWORD. What does \du then show for it, and what do SET ROLE and GRANT role_2 TO role_1 do?

Show answer
CREATE ROLE testuser
NOINHERIT LOGIN PASSWORD 'password';
  • Role: PostgreSQL's single concept for both users and groups. A role with LOGIN is effectively a user.
  • LOGIN: this role may connect. Without it, it's just a group of permissions.
  • PASSWORD 'password': sets its password. Single quotes, since it's a string literal.
  • NOINHERIT: if testuser is made a member of another role, it does not automatically get that role's privileges; it must explicitly run SET ROLE that_role; to act with them.
  • GRANT role_2 TO role_1; makes role_1 a member of role_2.
                             List of roles
 Role name |                         Attributes
-----------+------------------------------------------------------------
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS
 testuser  | No inheritance

As the lecture notes, the new testuser shows no attributes except “No inheritance”: it isn't a superuser and can't create databases or roles. Compare postgres, the superuser.

Q4CREATE DATABASE5 marks

Write the lecture's statement creating database todo_list owned by testuser, UTF-8 encoded, with no connection limit, then connect to it as testuser. Explain ENCODING and CONNECTION LIMIT = -1, and why the owner matters.

Show answer
CREATE DATABASE todo_list
    WITH
    OWNER = testuser
    ENCODING = 'UTF8'
    CONNECTION LIMIT = -1;

\c todo_list testuser
  • OWNER: the owner can do almost anything with the database, which is why the lecture creates it for testuser rather than working as the superuser.
  • ENCODING = 'UTF8': UTF-8 is the character encoding that turns every character into bytes. It covers all languages and symbols, so “Zoë” or “€” store correctly.
  • CONNECTION LIMIT = -1: maximum simultaneous connections; −1 means no limit beyond the server's own.

I ran this on the practice server: \l todo_list then showed owner testuser and encoding UTF8. The first line is SQL (semicolon); the \c line is psql (backslash).

Q5permissionssecurity6 marks

(a) Why does the lecture warn against working as the superuser? (b) Write the commands to take away, then give back, testuser's permission to read public.todo_item. (c) What principle is this an example of? (d) On a newer PostgreSQL, creating a table as testuser gives “permission denied for schema public”. What fixes it?

Show answer

(a) A superuser has the highest level of access and can bypass all permission checks. Using it day to day is a security risk: one mistake, or one stolen password, can damage everything. So the lecture creates a separate user for a specific database.

REVOKE SELECT ON public.todo_item FROM testuser;
GRANT  SELECT ON public.todo_item TO   testuser;

(c) The principle of least privilege: give each user only the permissions they need (the slide's access-control picture of masked columns per user is the same idea).

(d) GRANT CREATE ON SCHEMA public TO testuser;. From PostgreSQL 15 onward, ordinary users no longer get CREATE on the public schema by default, so this grant is needed on 15+. (The slide's note says this “only works in 14 and below”. I believe it's the other way round, i.e. it's needed on 15+ and harmless on 14; worth checking on your install.)

Q6multiple choiceschemas

In public.todo_item, what is public?

Explanation

Answer: A PostgreSQL schema groups tables within one database: e.g. a management schema holding payroll tables and an employee schema with only daily-work tables, giving finer access control. public is the default schema, created automatically with every database. public.todo_item means “the todo_item table in schema public”; plain todo_item finds the same table. Note the two meanings of “schema”: Week 1's (the description/metadata of a database) and this one (a namespace).

Q7SQL injectionsecurity6 marks

A Java login page builds its query like this:

// username comes straight from a web form
String query = "SELECT * FROM app_user WHERE username = '" + username + "'";
ResultSet rs = statement.executeQuery(query);

(a) What query runs if someone types x' OR '1'='1 as the username, and what does it return? (b) Name three preventions from the lecture and rewrite the Java safely.

Show answer

(a) The input's quote closes the string early, and the rest becomes SQL:

SELECT * FROM app_user WHERE username = 'x' OR '1'='1'

'1'='1' is always true, so the WHERE is true for every row and the query returns every user. This is SQL injection: user input concatenated into a statement is interpreted as code, not data. Consequences on the slide: data leakage, tampering (insert/update/delete), even administrative control.

(b) Parameterised queries/prepared statements; input validation and sanitisation (allow-lists); principle of least privilege (so a compromised account can't do much).

// The ? is a placeholder. The driver sends the value separately,
// so it is always treated as data and can never change the query's structure.
PreparedStatement ps = connection.prepareStatement(
        "SELECT * FROM app_user WHERE username = ?");
ps.setString(1, username);          // fill placeholder 1 with the input
ResultSet rs = ps.executeQuery();
Graph link: this is the same root bug as many attacks in Security and Networks: mixing the control channel (code) with the data channel (input). Prepared statements separate them again.

B. Creating tables and CRUD

Q8CREATE TABLEdata types6 marks

Write CREATE TABLE for the lecture's room table (room_id like 'G0210', room_name, room_capacity, room_zoom) with room_id as primary key, insert the three rooms from the slide, and list rooms with Zoom that seat at least 50. The slide uses text for room_zoom with values 'yes', 'no', 'Yes'. What's the problem, and what type is better?

Show answer
Answer
CREATE TABLE public.room
(
    room_id       text NOT NULL,
    room_name     text,
    room_capacity integer,
    room_zoom     boolean,
    PRIMARY KEY (room_id)
);

INSERT INTO public.room VALUES
    ('G0210', 'Seminar Room', 50,  true),
    ('E1236', 'Lab',          40,  false),
    ('G0205', 'Auditorium',   100, true);

SELECT room_name, room_capacity
FROM public.room
WHERE room_zoom = true AND room_capacity >= 50;
Output (run on PostgreSQL 16)
INSERT 0 3
  room_name   | room_capacity 
--------------+---------------
 Seminar Room |            50
 Auditorium   |           100
(2 rows)

Column definition = name, data type, optional parameters (NOT NULL …). Column names follow Java-variable-like rules: no spaces, no special characters, can't start with a digit. room_id is text because 'G0210' contains letters; capacity is integer.

Storing Zoom as text with values 'yes'/'Yes'/'no' is a Week 1 Guideline 1 problem (unclear semantics): WHERE room_zoom = 'yes' misses 'Yes'. A boolean allows only true/false (and NULL), so the query is unambiguous.

Q9constraints4 marks

The slides say we need both NOT NULL and PRIMARY KEY on todo_item_id. Predict the result of each, then run them:

INSERT INTO public.todo_item (todo_item_id, description) VALUES (1, 'Duplicate id');
INSERT INTO public.todo_item (description) VALUES ('No id at all');
Show answer
ERROR:  duplicate key value violates unique constraint "todo_item_pkey"
DETAIL:  Key (todo_item_id)=(1) already exists.
ERROR:  null value in column "todo_item_id" of relation "todo_item" violates not-null constraint
DETAIL:  Failing row contains (null, No id at all, null, null, null, null, null, null, null).

The first breaks uniqueness (id 1 exists), the second NOT NULL (no id given, so it's NULL). Together they're what a primary key means: never missing, never repeated (Week 1: PK = NOT NULL + UNIQUE). In PostgreSQL, PRIMARY KEY on its own already implies NOT NULL, so writing both is harmless but redundant. I'd still follow the slides' style in the test.

Q10INSERT5 marks

Insert item 13 “Revise ACID properties” for user 2, High priority, @uni, To do, Databases project, due 13 Oct 2026, not completed, using the positional form (no column list). Then insert item 14 “Find pgAdmin password” for user 1, due 11 Oct, using an explicit column list and only the columns you need. What happens to the columns you leave out? What does '2026-10-13'::date do?

Show answer
Answer
INSERT INTO public.todo_item
VALUES (13, 'Revise ACID properties', 2, 1, 1, 1, 1, '2026-10-13'::date, NULL);

SELECT todo_item_id, description, due_date
FROM public.todo_item
WHERE todo_item_id = 13;
Output (run on PostgreSQL 16)
INSERT 0 1
 todo_item_id |      description       |  due_date  
--------------+------------------------+------------
           13 | Revise ACID properties | 2026-10-13
(1 row)
Answer
INSERT INTO public.todo_item (todo_item_id, description, owner_id, due_date)
VALUES (14, 'Find pgAdmin password', 1, '2026-10-11');

SELECT * FROM public.todo_item WHERE todo_item_id = 14;
Output (run on PostgreSQL 16)
INSERT 0 1
 todo_item_id |      description      | owner_id | priority_id | context_id | status_id | project_id |  due_date  | completion_date 
--------------+-----------------------+----------+-------------+------------+-----------+------------+------------+-----------------
           14 | Find pgAdmin password |        1 |             |            |           |            | 2026-10-11 | 
(1 row)

Positional form: values must be in the same order as the table's columns, and you must supply all of them. Explicit column list: you choose the order and can skip columns. Skipped columns get their DEFAULT, or NULL if there's none, as shown. The explicit form is safer: it still works if someone later adds or reorders columns.

::date is PostgreSQL's cast operator: convert this value to type date (standard SQL: CAST('2026-10-13' AS date)). It's optional here, since Postgres converts a well-formed string into a date column automatically, but the slides use it to make the intent explicit. INSERT 0 1 means 1 row inserted.

Q11INSERTcheat sheet2 marks

Add two statuses, 4 “Blocked” and 5 “Cancelled”, in a single INSERT.

Show answer
Answer
INSERT INTO public.status (status_id, name)
VALUES (4, 'Blocked'),
       (5, 'Cancelled');

SELECT * FROM public.status ORDER BY status_id;
Output (run on PostgreSQL 16)
INSERT 0 2
 status_id |    name     
-----------+-------------
         1 | To do
         2 | In progress
         3 | Done
         4 | Blocked
         5 | Cancelled
(5 rows)

Multi-row form from the cheat sheet: one VALUES list per row, separated by commas. It's one statement, so either both rows go in or neither does.

Q12INSERTforeign keys3 marks

The lecture's first INSERT example is below. It worked in the lecture, but fails on the practice database. Why?

INSERT INTO public.todo_item
VALUES (0, 'New Task', 0, 0, 0, 0, 0, '2022-02-02'::date, NULL);
Show answer
ERROR:  insert or update on table "todo_item" violates foreign key constraint "todo_item_owner_id_fkey"
DETAIL:  Key (owner_id)=(0) is not present in table "app_user".

In the lecture, todo_item had no foreign keys yet, so any integer was accepted. Here, owner_id REFERENCES app_user(user_id), and there's no user 0. Referential integrity stops a row pointing at something that doesn't exist. (Priority, context, status and project 0 would fail too; Postgres reports the first violation it finds.) This is the “links between tables” the FOREIGN KEY constraint protects.

Q13UPDATE4 marks

Mark item 3 as done today: set its completion date to 11 Oct 2026 and its status to 3. Show the row, then undo the change. How would the lecture's version set “today” automatically?

Show answer
Answer
BEGIN;

UPDATE public.todo_item
SET completion_date = '2026-10-11', status_id = 3
WHERE todo_item_id = 3;

SELECT todo_item_id, description, status_id, completion_date
FROM public.todo_item
WHERE todo_item_id = 3;

ROLLBACK;
Output (run on PostgreSQL 16)
UPDATE 1
 todo_item_id |     description      | status_id | completion_date 
--------------+----------------------+-----------+-----------------
            3 | Practice SQL queries |         3 | 2026-10-11
(1 row)

Structure: UPDATE table SET column = value, … WHERE condition;. The table, the columns, the new values, and which rows. The lecture writes SET completion_date = now(). now() is a built-in function returning the current date and time (stored in a date column, the time part is dropped; CURRENT_DATE gives just the date).

BEGIN starts a transaction; ROLLBACK undoes everything since; COMMIT would keep it. These are Week 1's ACID transactions, live. pgAdmin auto-commits each statement unless you use BEGIN.

Q14UPDATEtrap2 marks

Predict how many rows this changes, then run it (it's safely wrapped):

UPDATE public.todo_item
SET completion_date = now();
Show answer
Answer
BEGIN;

UPDATE public.todo_item
SET completion_date = now();

SELECT COUNT(*) AS rows_marked_complete
FROM public.todo_item
WHERE completion_date IS NOT NULL;

ROLLBACK;
Output (run on PostgreSQL 16)
UPDATE 12
 rows_marked_complete 
----------------------
                   12
(1 row)

All 12. No WHERE means every row. It's the most common destructive mistake in SQL, and the reason to try risky updates inside BEGIN … ROLLBACK first.

Q15DELETE4 marks

(a) Delete every item completed before 5 Oct 2026 (in a rolled-back transaction) and show which completed items remain. (b) Then try DELETE FROM public.priority WHERE priority_id = 1; What happens and why?

Show answer
Answer
BEGIN;

DELETE FROM public.todo_item
WHERE completion_date < '2026-10-05';

SELECT todo_item_id, description, completion_date
FROM public.todo_item
WHERE completion_date IS NOT NULL
ORDER BY todo_item_id;

ROLLBACK;
Output (run on PostgreSQL 16)
DELETE 3
 todo_item_id |       description       | completion_date 
--------------+-------------------------+-----------------
           12 | Submit DB practice test | 2026-10-10
(1 row)

Items 1 (4 Oct), 6 (30 Sep) and 8 (2 Oct) go. DELETE removes whole rows, so you never name columns. < on dates means “earlier than”. Rows with a NULL completion date aren't deleted: NULL < '2026-10-05' is unknown, not true.

(b)
ERROR:  update or delete on table "priority" violates foreign key constraint "todo_item_priority_id_fkey" on table "todo_item"
DETAIL:  Key (priority_id)=(1) is still referenced from table "todo_item".

Items still reference priority 1 (High), so deleting it would leave them pointing at nothing. The foreign key blocks it (default behaviour, NO ACTION), and nothing is deleted.

Q16multiple choiceDELETE

What does DELETE FROM public.todo_item; do?

Explanation

Answer: The slide's warning: without a WHERE clause, DELETE removes all the rows. The table and its structure remain. DROP TABLE todo_item; is what removes the table itself (section E).

C. Querying one table

Q17SELECTAS2 marks

Show user 1's items as two columns headed task and deadline, soonest first. Where does the item with no due date end up?

Show answer
Answer
SELECT description AS task, due_date AS deadline
FROM public.todo_item
WHERE owner_id = 1
ORDER BY due_date;
Output (run on PostgreSQL 16)
         task         |  deadline  
----------------------+------------
 Cancel old broadband | 2026-10-01
 Book removal van     | 2026-10-20
 Renew railcard       | 
(3 rows)

AS renames a column in the result only; the table isn't changed. In ascending order PostgreSQL puts NULLs last (NULLS FIRST would override).

Q18DISTINCT2 marks

Which users own at least one item? Each user once.

Show answer
Answer
SELECT DISTINCT owner_id
FROM public.todo_item
ORDER BY owner_id;
Output (run on PostgreSQL 16)
 owner_id 
----------
        1
        2
        3
(3 rows)

DISTINCT removes duplicate rows from the result. Without it you'd get 12 rows, one per item. Chloe (4) doesn't appear: she has no items.

Q19IN2 marks

List items whose context is @home (2) or @phone (3), using IN.

Show answer
Answer
SELECT todo_item_id, description, context_id
FROM public.todo_item
WHERE context_id IN (2, 3)
ORDER BY todo_item_id;
Output (run on PostgreSQL 16)
 todo_item_id |       description       | context_id 
--------------+-------------------------+------------
            4 | Book removal van        |          3
            5 | Pack kitchen            |          2
            6 | Cancel old broadband    |          3
            9 | Email tutor about essay |          3
(4 rows)

col IN (2, 3) is shorthand for col = 2 OR col = 3. Items with a NULL context are never matched.

Q20LIKE3 marks

(a) Find items whose description contains “essay”. (b) Find items whose description starts with “E”. (c) Would LIKE '%Essay%' find either essay item?

Show answer
Answer
SELECT todo_item_id, description
FROM public.todo_item
WHERE description LIKE '%essay%';
Output (run on PostgreSQL 16)
 todo_item_id |       description       
--------------+-------------------------
            9 | Email tutor about essay
           10 | Write essay plan
(2 rows)
Answer
SELECT todo_item_id, description
FROM public.todo_item
WHERE description LIKE 'E%';
Output (run on PostgreSQL 16)
 todo_item_id |       description       
--------------+-------------------------
            7 | Erg 2k test
            9 | Email tutor about essay
(2 rows)

% matches any run of characters (including none); _ matches exactly one. '%essay%' = contains; 'E%' = starts with E. (c) No: LIKE is case-sensitive in PostgreSQL and both items have lower-case “essay”. Use ILIKE for case-insensitive matching.

Q21BETWEEN2 marks

Items due from 12 to 20 October 2026 inclusive, in date order.

Show answer
Answer
SELECT description, due_date
FROM public.todo_item
WHERE due_date BETWEEN '2026-10-12' AND '2026-10-20'
ORDER BY due_date;
Output (run on PostgreSQL 16)
     description      |  due_date  
----------------------+------------
 Practice SQL queries | 2026-10-12
 Write essay plan     | 2026-10-15
 Erg 2k test          | 2026-10-18
 Book removal van     | 2026-10-20
(4 rows)

BETWEEN low AND high is inclusive at both ends (= >= low AND <= high): the 12th and 20th are both included.

Q22NULLtrap3 marks

Find items that have no due date and aren't completed. Then explain why this returns nothing:

SELECT todo_item_id, description
FROM public.todo_item
WHERE due_date = NULL;
Show answer
Answer
SELECT todo_item_id, description
FROM public.todo_item
WHERE completion_date IS NULL
  AND due_date IS NULL;
Output (run on PostgreSQL 16)
 todo_item_id |  description   
--------------+----------------
           11 | Renew railcard
(1 row)
 todo_item_id | description 
--------------+-------------
(0 rows)

NULL means “unknown”, and any comparison with unknown is itself unknown, so due_date = NULL is never true, even for NULL dates. WHERE keeps only rows where the condition is true. Always use IS NULL / IS NOT NULL. (Week 1's Guideline 3 warned that NULLs “complicate” operations; this is what it meant.)

Q23ORDER BY3 marks

List unfinished items (status not 3) sorted by priority (High first), and within each priority by due date, latest first.

Show answer
Answer
SELECT priority_id, due_date, description
FROM public.todo_item
WHERE status_id <> 3
ORDER BY priority_id ASC, due_date DESC;
Output (run on PostgreSQL 16)
 priority_id |  due_date  |       description       
-------------+------------+-------------------------
           1 | 2026-10-15 | Write essay plan
           1 | 2026-10-12 | Practice SQL queries
           1 | 2026-10-09 | Draw ER diagram in JDL
           2 | 2026-10-20 | Book removal van
           2 | 2026-10-18 | Erg 2k test
           2 | 2026-10-08 | Email tutor about essay
           3 |            | Renew railcard
           3 | 2026-10-24 | Pack kitchen
(8 rows)

Each column in ORDER BY gets its own direction; later columns only break ties in earlier ones. <> means “not equal”. In DESC order, NULLs come first (the reverse of ASC), which is why “Renew railcard” leads the Low group.

Q24LIMIT/OFFSETcheat sheet3 marks

Show the 4th–6th items by due date (page 2 of a list showing 3 per page), ignoring items without a due date. The cheat sheet writes this as LIMIT … OFFSET … ORDER BY …. Does that run?

Show answer
Answer
SELECT todo_item_id, description, due_date
FROM public.todo_item
WHERE due_date IS NOT NULL
ORDER BY due_date
LIMIT 3 OFFSET 3;
Output (run on PostgreSQL 16)
 todo_item_id |       description       |  due_date  
--------------+-------------------------+------------
            2 | Draw ER diagram in JDL  | 2026-10-09
           12 | Submit DB practice test | 2026-10-10
            3 | Practice SQL queries    | 2026-10-12
(3 rows)

OFFSET 3 skips the first 3 rows; LIMIT 3 then keeps the next 3. Order: ORDER BY first, then LIMIT/OFFSET. The cheat sheet's order is a syntax error. Without ORDER BY, “the first 3” is whatever order the DBMS happens to return: not guaranteed.

Q25COUNTNULL3 marks

In one query: the total number of items, how many have a due date, and how many belong to a project.

Show answer
Answer
SELECT COUNT(*) AS total_items,
       COUNT(due_date) AS items_with_due_date,
       COUNT(project_id) AS items_in_a_project
FROM public.todo_item;
Output (run on PostgreSQL 16)
 total_items | items_with_due_date | items_in_a_project 
-------------+---------------------+--------------------
          12 |                  10 |                  9
(1 row)

COUNT(*) counts rows; COUNT(column) counts non-NULL values in that column. Two items have no due date; three have no project.

Q26GROUP BYHAVING4 marks

(a) How many items does each user own? (b) Which users have at least 3 unfinished items (no completion date)?

Show answer
Answer
SELECT owner_id, COUNT(*) AS num_items
FROM public.todo_item
GROUP BY owner_id
ORDER BY owner_id;
Output (run on PostgreSQL 16)
 owner_id | num_items 
----------+-----------
        1 |         3
        2 |         6
        3 |         3
(3 rows)
Answer
SELECT owner_id, COUNT(*) AS open_items
FROM public.todo_item
WHERE completion_date IS NULL
GROUP BY owner_id
HAVING COUNT(*) >= 3;
Output (run on PostgreSQL 16)
 owner_id | open_items 
----------+------------
        2 |          4
(1 row)

GROUP BY owner_id makes one group per owner; COUNT(*) counts rows in each. Every column in the SELECT must be grouped or inside an aggregate.

WHERE vs HAVING: WHERE filters rows before grouping (only unfinished items); HAVING filters groups after (only groups with 3+). You can't write WHERE COUNT(*) >= 3, since groups don't exist yet when WHERE runs. Order of evaluation: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.

D. Joins and set operations

Q27INNER JOINcheat sheet4 marks

List Amira's (user 2) items with the names of their status and priority instead of the ids.

Show answer
Answer
SELECT t.description, s.name AS status, p.name AS priority
FROM public.todo_item t
INNER JOIN public.status   s ON t.status_id   = s.status_id
INNER JOIN public.priority p ON t.priority_id = p.priority_id
WHERE t.owner_id = 2
ORDER BY t.todo_item_id;
Output (run on PostgreSQL 16)
        description        |   status    | priority 
---------------------------+-------------+----------
 Read normalisation slides | Done        | High
 Draw ER diagram in JDL    | In progress | High
 Practice SQL queries      | To do       | High
 Pack kitchen              | To do       | Low
 Email tutor about essay   | To do       | Medium
 Submit DB practice test   | Done        | High
(6 rows)

INNER JOIN t2 ON condition pairs each row with every row of t2 where the condition is true. Here it's FK = PK, so each item finds its one status and one priority. t, s, p are table aliases, needed to say which table's column you mean. This is how the Week 2 normalised tables get “connected” back together.

Q28LEFT JOINcheat sheet4 marks

(a) An inner join of todo_item to project gives how many rows, and why not 12? (b) Count the items in every project, including projects with none.

Show answer
Answer
SELECT COUNT(*) AS rows_after_inner_join
FROM public.todo_item t
INNER JOIN public.project pr ON t.project_id = pr.project_id;
Output (run on PostgreSQL 16)
 rows_after_inner_join 
-----------------------
                     9
(1 row)

(a) 9: the three items with NULL project_id match no project, and an inner join drops unmatched rows.

Answer
SELECT pr.name AS project, COUNT(t.todo_item_id) AS num_items
FROM public.project pr
LEFT JOIN public.todo_item t ON t.project_id = pr.project_id
GROUP BY pr.project_id, pr.name
ORDER BY num_items DESC, pr.name;
Output (run on PostgreSQL 16)
       project        | num_items 
----------------------+-----------
 Databases coursework |         4
 Move flat            |         3
 Rowing season        |         2
 Learn Haskell        |         0
(4 rows)

(b) LEFT JOIN keeps every row of the left table (project) even with no match, filling the right side with NULLs. Count t.todo_item_id, not *: for Learn Haskell the joined row has a NULL item id, so COUNT(t.todo_item_id) gives 0 while COUNT(*) would wrongly give 1.

Q29FULL OUTER JOINcheat sheet3 marks

Using one FULL OUTER JOIN, find both items that belong to no project and projects that have no items.

Show answer
Answer
SELECT t.description, pr.name AS project
FROM public.todo_item t
FULL OUTER JOIN public.project pr ON t.project_id = pr.project_id
WHERE t.todo_item_id IS NULL OR pr.project_id IS NULL
ORDER BY project, t.description;
Output (run on PostgreSQL 16)
       description       |    project    
-------------------------+---------------
                         | Learn Haskell
 Email tutor about essay | 
 Renew railcard          | 
 Write essay plan        | 
(4 rows)

A FULL OUTER JOIN keeps unmatched rows from both sides (LEFT keeps the left's, RIGHT the right's). Filtering for a NULL key on either side keeps only the orphans.

Q30CROSS JOINcheat sheet3 marks

How many rows does status CROSS JOIN priority produce, and why? Which Week 1 idea is this?

Show answer
Answer
SELECT COUNT(*) AS cross_join_rows
FROM public.status
CROSS JOIN public.priority;
Output (run on PostgreSQL 16)
 cross_join_rows 
-----------------
               9
(1 row)
   status    | priority 
-------------+----------
 To do       | High
 To do       | Medium
 To do       | Low
 In progress | High
 In progress | Medium
 In progress | Low
 Done        | High
 Done        | Medium
 Done        | Low
(9 rows)

A CROSS JOIN pairs every row of one table with every row of the other: 3 × 3 = 9. It's the Cartesian product A × B from Week 1's spurious tuples guideline. Joining with no (or the wrong) condition produces combinations that were never real. Useful occasionally (e.g. generating a grid of every status/priority pair), dangerous by accident.

Q31NATURAL JOINtrapcheat sheet4 marks

Compare todo_item NATURAL JOIN project with an explicit inner join on project_id. They return different numbers of rows. Which item disappears from the NATURAL JOIN, and why?

Show answer
Answer
SELECT todo_item_id, description, name AS project
FROM public.todo_item
NATURAL JOIN public.project
ORDER BY todo_item_id;
Output (run on PostgreSQL 16)
 todo_item_id |        description        |       project        
--------------+---------------------------+----------------------
            1 | Read normalisation slides | Databases coursework
            2 | Draw ER diagram in JDL    | Databases coursework
            3 | Practice SQL queries      | Databases coursework
            4 | Book removal van          | Move flat
            6 | Cancel old broadband      | Move flat
            7 | Erg 2k test               | Rowing season
            8 | Buy heart rate strap      | Rowing season
           12 | Submit DB practice test   | Databases coursework
(8 rows)
Answer
SELECT t.todo_item_id, t.description, pr.name AS project
FROM public.todo_item t
INNER JOIN public.project pr ON t.project_id = pr.project_id
ORDER BY t.todo_item_id;
Output (run on PostgreSQL 16)
 todo_item_id |        description        |       project        
--------------+---------------------------+----------------------
            1 | Read normalisation slides | Databases coursework
            2 | Draw ER diagram in JDL    | Databases coursework
            3 | Practice SQL queries      | Databases coursework
            4 | Book removal van          | Move flat
            5 | Pack kitchen              | Move flat
            6 | Cancel old broadband      | Move flat
            7 | Erg 2k test               | Rowing season
            8 | Buy heart rate strap      | Rowing season
           12 | Submit DB practice test   | Databases coursework
(9 rows)

The NATURAL JOIN gives 8 rows, the explicit join 9. “Pack kitchen” (item 5) is missing. A NATURAL JOIN joins on every column with the same name in both tables. Both have project_id and owner_id, so it silently requires the item's owner to equal the project's owner. Item 5 is Amira's item in Test User's project, so it's dropped. Explicit JOIN … ON says exactly what you mean.

Q32UNIONcheat sheet3 marks

(a) List every user who owns a project or an item, each once. (b) How many rows would UNION ALL give instead?

Show answer
Answer
SELECT owner_id AS user_id FROM public.project
UNION
SELECT owner_id FROM public.todo_item
ORDER BY user_id;
Output (run on PostgreSQL 16)
 user_id 
---------
       1
       2
       3
(3 rows)
Answer
SELECT COUNT(*) AS union_all_rows
FROM (
    SELECT owner_id FROM public.project
    UNION ALL
    SELECT owner_id FROM public.todo_item
) AS combined;
Output (run on PostgreSQL 16)
 union_all_rows 
----------------
             16
(1 row)

Set operations combine the results of two SELECTs with the same number and types of columns. UNION removes duplicates; UNION ALL keeps them (4 projects + 12 items = 16 rows). The column name comes from the first SELECT, hence the alias there.

Q33INTERSECTEXCEPTcheat sheet4 marks

(a) Which contexts are used by both Amira (2) and Ben (3)? (b) Which does Amira use that Ben doesn't? (c) Which users own no items at all?

Show answer
Answer
SELECT c.name AS context
FROM public.todo_item t JOIN public.context c ON c.context_id = t.context_id
WHERE t.owner_id = 2
INTERSECT
SELECT c.name
FROM public.todo_item t JOIN public.context c ON c.context_id = t.context_id
WHERE t.owner_id = 3;
Output (run on PostgreSQL 16)
 context 
---------
 @uni
(1 row)
Answer
SELECT c.name AS context
FROM public.todo_item t JOIN public.context c ON c.context_id = t.context_id
WHERE t.owner_id = 2
EXCEPT
SELECT c.name
FROM public.todo_item t JOIN public.context c ON c.context_id = t.context_id
WHERE t.owner_id = 3
ORDER BY context;
Output (run on PostgreSQL 16)
 context 
---------
 @home
 @phone
(2 rows)
Answer
SELECT user_id FROM public.app_user
EXCEPT
SELECT owner_id FROM public.todo_item
ORDER BY user_id;
Output (run on PostgreSQL 16)
 user_id 
---------
       4
(1 row)

INTERSECT = rows in both results; EXCEPT = rows in the first but not the second (order matters). (c) is the “find what's missing” pattern: all users EXCEPT users who own items leaves Chloe. Ben's NULL context doesn't match anything, and neither does his @gym.

E. Changing structure, views and indexes

Q34ALTER TABLEcheat sheet4 marks

Add an optional notes column to todo_item, and make new items default to status 1 (“To do”). Insert item 15 without giving a status and show it. Then rename context's name column to label and the table to place. (Re-run the setup script afterwards to reset.)

Show answer
Answer
ALTER TABLE public.todo_item ADD COLUMN notes text;
ALTER TABLE public.todo_item ALTER COLUMN status_id SET DEFAULT 1;

INSERT INTO public.todo_item (todo_item_id, description, owner_id)
VALUES (15, 'Try ALTER TABLE', 3);

SELECT todo_item_id, description, status_id, notes
FROM public.todo_item
WHERE todo_item_id = 15;
Output (run on PostgreSQL 16)
INSERT 0 1
 todo_item_id |   description   | status_id | notes 
--------------+-----------------+-----------+-------
           15 | Try ALTER TABLE |         1 | 
(1 row)

ADD COLUMN adds a column; existing rows get NULL. ALTER COLUMN … SET DEFAULT only affects future inserts, so item 15 gets status 1, which is the DEFAULT constraint from Week 1. Remove it with DROP DEFAULT; remove a column with DROP COLUMN.

Answer
ALTER TABLE public.context RENAME COLUMN name TO label;
ALTER TABLE public.context RENAME TO place;

SELECT * FROM public.place ORDER BY context_id;
Output (run on PostgreSQL 16)
 context_id | label  
------------+--------
          1 | @uni
          2 | @home
          3 | @phone
          4 | @gym
(4 rows)

Renaming the table doesn't break the foreign key from todo_item: PostgreSQL's FKs refer to the table itself, not its name. (The setup script drops place too, so re-running it resets everything.)

Q35viewscheat sheet5 marks

Create a view overdue_items: unfinished items due before 11 Oct 2026, with the owner's username. Query it. Then mark item 9 complete and query the view again. What changed and why?

Show answer
Answer
CREATE OR REPLACE VIEW overdue_items AS
SELECT t.todo_item_id, t.description, u.username, t.due_date
FROM public.todo_item t
JOIN public.app_user u ON u.user_id = t.owner_id
WHERE t.completion_date IS NULL
  AND t.due_date < '2026-10-11';

SELECT * FROM overdue_items ORDER BY due_date;
Output (run on PostgreSQL 16)
 todo_item_id |       description       | username |  due_date  
--------------+-------------------------+----------+------------
            9 | Email tutor about essay | amira    | 2026-10-08
            2 | Draw ER diagram in JDL  | amira    | 2026-10-09
(2 rows)
Answer
CREATE OR REPLACE VIEW overdue_items AS
SELECT t.todo_item_id, t.description, u.username, t.due_date
FROM public.todo_item t
JOIN public.app_user u ON u.user_id = t.owner_id
WHERE t.completion_date IS NULL
  AND t.due_date < '2026-10-11';

UPDATE public.todo_item SET completion_date = '2026-10-11' WHERE todo_item_id = 9;

SELECT * FROM overdue_items;
Output (run on PostgreSQL 16)
UPDATE 1
 todo_item_id |      description       | username |  due_date  
--------------+------------------------+----------+------------
            2 | Draw ER diagram in JDL | amira    | 2026-10-09
(1 row)

A view is Week 1's “virtual table”: a stored query, not stored data. Every SELECT from it re-runs the query against the base tables, so the view is always up to date: completing item 9 removed it. CREATE OR REPLACE VIEW creates it or updates its definition. (The cheat sheet drops the word VIEW; it's required.) CREATE MATERIALIZED VIEW instead stores the result, and needs REFRESH MATERIALIZED VIEW to update.

Q36DROP TABLEcheat sheet3 marks

With the view from the last question in place, what happens on DROP TABLE public.app_user;? What would DROP TABLE IF EXISTS public.app_user CASCADE; do?

Show answer
Answer
CREATE OR REPLACE VIEW overdue_items AS
SELECT t.todo_item_id, t.description, u.username, t.due_date
FROM public.todo_item t
JOIN public.app_user u ON u.user_id = t.owner_id
WHERE t.completion_date IS NULL
  AND t.due_date < '2026-10-11';

DROP TABLE public.app_user;
Output (run on PostgreSQL 16)
ERROR:  cannot drop table app_user because other objects depend on it
DETAIL:  constraint project_owner_id_fkey on table project depends on table app_user
constraint todo_item_owner_id_fkey on table todo_item depends on table app_user
view overdue_items depends on table app_user
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

The drop is refused: two foreign keys and the view depend on app_user. CASCADE would drop the table and remove what depends on it: the view, and the FK constraints on project and todo_item (the constraints, not those tables' rows). IF EXISTS just avoids an error if the table isn't there, which is why the setup script uses both.

Q37indexesEXPLAINcheat sheet3 marks

Create an index on todo_item's owner_id and use EXPLAIN to see how WHERE owner_id = 2 is executed. Is the index used?

Show answer
Answer
CREATE INDEX idx_todo_owner ON public.todo_item (owner_id);

EXPLAIN SELECT * FROM public.todo_item WHERE owner_id = 2;
Output (run on PostgreSQL 16)
                        QUERY PLAN                        
----------------------------------------------------------
 Seq Scan on todo_item  (cost=0.00..1.15 rows=1 width=64)
   Filter: (owner_id = 2)
(2 rows)

An index is a separate lookup structure (a B-tree, like a sorted map) that lets the DBMS find matching rows without reading the whole table, Week 1's CREATE INDEX constraint. EXPLAIN shows the plan without running the query; EXPLAIN ANALYZE runs it and reports real timings.

Surprise: it says Seq Scan (read every row). With 12 rows, reading the whole table is cheaper than consulting an index, so the planner ignores it. On thousands of rows it would switch to an Index Scan. Indexes are physical design (Week 1 step 5): they change speed, never results.