Chapter 11 — Reps
Conditioning, not grading. SQL and Python sqlite3 reps this week.
Ground rules:
- Type every line yourself. No copy-paste of SQL, especially the queries. The placeholder discipline only becomes muscle memory if your fingers build it.
- Run everything against a real file. Use the
sqlite3command-line shell for the SQL reps and Python for the rest. Watch each statement do what you predicted. When it doesn’t, find out why before moving on. - AI/agents are ON for Phase 2 — but these reps are hand-built unless a rep explicitly says otherwise. You are building the muscle that lets you review the database code an agent writes for the project. You cannot review a CRUD layer you have never typed.
- Commit your writes. Every
INSERT/UPDATE/DELETEin Python needs acon.commit()or it didn’t happen. Burn this in now.
Everything here uses Python’s standard-library sqlite3 (no install) and the sqlite3 command-line shell. If you don’t have the shell, every SQL rep can be run from Python via con.executescript(...) instead. Toolchain setup is in Appendix A.
Reps 1–3: Raw SQL in the Shell
Rep 1 — Your First Table
Open a fresh database in the shell:
sqlite3 practice.db
At the sqlite> prompt, type these one at a time and watch each one:
CREATE TABLE verses (id INTEGER PRIMARY KEY, reference TEXT NOT NULL, text TEXT NOT NULL);
INSERT INTO verses (reference, text) VALUES ('John 3:16', 'For God so loved the world...');
INSERT INTO verses (reference, text) VALUES ('Psalm 23:1', 'The LORD is my shepherd; I shall not want.');
INSERT INTO verses (reference, text) VALUES ('Romans 8:28', 'And we know that for those who love God all things work together for good...');
SELECT * FROM verses;
Confirm three rows come back, each with an auto-assigned id you never typed. Then run .schema verses and read the table definition the database stored. Type .quit to exit, then re-open practice.db and SELECT * FROM verses again. The rows are still there. That is persistence. Sit with it for a second — this is the thing your APIs were missing.
Rep 2 — WHERE and ORDER BY
Still in practice.db, add two more verses, then practice filtering and sorting:
SELECT reference FROM verses ORDER BY reference;
SELECT reference FROM verses ORDER BY id DESC;
SELECT * FROM verses WHERE reference = 'John 3:16';
SELECT * FROM verses WHERE id > 2;
SELECT reference FROM verses WHERE reference LIKE 'Psalm%';
For each query, predict the result before you press Enter, then confirm. The LIKE 'Psalm%' one is new — % matches any run of characters, so it finds every reference that starts with “Psalm.” Write down in one sentence what ORDER BY does when two rows would tie.
Rep 3 — UPDATE and DELETE (carefully)
UPDATE verses SET text = 'For God so loved the world, that he gave his only Son...' WHERE reference = 'John 3:16';
SELECT text FROM verses WHERE reference = 'John 3:16';
DELETE FROM verses WHERE reference = 'Romans 8:28';
SELECT COUNT(*) FROM verses;
Now the lesson that has to land in your hands, not just your head. Type this — and read it before you run it:
-- DO NOT RUN THIS against data you care about:
-- DELETE FROM verses;
Leave it commented. Write one sentence explaining what it would have done and why a missing WHERE is the most dangerous habit in SQL. Then delete practice.db from disk and confirm — one file, gone, whole database gone. That is what “the database is a single file” means, in both directions.
Reps 4–5: Python sqlite3
Rep 4 — CRUD From Python
Write crud.py. No framework, just sqlite3. Do all five operations in order against a file called reps.db:
- Connect, set
con.row_factory = sqlite3.Row, and create theversestable (id,reference,text,translationwith a default of'ESV'). - INSERT three verses using
con.execute(..., (?, ?))with placeholders. Commit. - SELECT them all and print
idandreferencefor each, sorted byreference. - UPDATE one verse’s text by
id. Commit. SELECT it back and confirm the change. - DELETE one verse by
id. Commit. Print the finalCOUNT(*).
Run it twice. The second run should fail on CREATE TABLE (the table already exists) — fix it by using CREATE TABLE IF NOT EXISTS, then notice that the second run now has six verses, because the first run’s rows persisted. Add a cleanup line or a fresh-file step so reruns start clean, and write one sentence about why “it persisted between runs” is the entire point.
Rep 5 — lastrowid and fetchone
Extend crud.py (or write crud2.py):
- After an
INSERT, capturecur = con.execute(...)and printcur.lastrowid. Confirm it matches theidthe database assigned. - Write a function
get_verse(con, verse_id)that runs a parameterizedSELECT ... WHERE id = ?and returnscon.execute(...).fetchone()— a single row orNone. - Call it with a real id (get a row) and a nonexistent id like
9999(getNone). Print both results.
This is the exact pattern your API’s POST (needs lastrowid for the 201) and GET /verses/{id} (needs fetchone and a None check for the 404) will use. You’re building the project’s parts.
Reps 6–7: Wiring SQLite Into the API
Rep 6 — Persist One Route
Open your Week 10 FastAPI app (or start from code/api_sqlite_starter.py). Replace just the POST /verses route so it inserts into SQLite with a parameterized query, commits, and returns VerseOut(id=cur.lastrowid, ...).
Then prove persistence the only way that counts:
- Start the server.
curl -X POST localhost:8000/verses -H 'content-type: application/json' -d '{"reference":"Psalm 46:1","text":"God is our refuge and strength..."}' - Confirm you get back a
201with anid. - Stop the server (Ctrl-C). Start it again.
curl localhost:8000/verses(once you’ve wiredGETtoo, Rep 7) — the verse you POSTed before the restart is still there.
Write one sentence in your notes: what exactly survived the restart, and where did it survive? (The process’s RAM was wiped. The answer is the file.)
Rep 7 — The Full Read Layer
Wire the two read routes against SQLite:
GET /verses—SELECT id, reference, text FROM verses ORDER BY id, return a list ofVerseOut.GET /verses/{verse_id}— parameterizedSELECT ... WHERE id = ?,fetchone(), andraise HTTPException(404, ...)if it’sNone.
Confirm GET /verses/9999 returns a clean 404, not a 500 and not an empty 200. Then add PUT and DELETE, both parameterized. By the end you have the entire Normal-tier CRUD layer running against a file. Restart the server between every test and confirm nothing is lost.
Reps 8–9: Relationships and JOINs
Rep 8 — A Second Table
Back in the shell or in Python, against a fresh file:
CREATE TABLE collections (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE verses (
id INTEGER PRIMARY KEY,
reference TEXT NOT NULL,
text TEXT NOT NULL,
collection_id INTEGER REFERENCES collections(id)
);
INSERT INTO collections (name) VALUES ('Romans Road'), ('Comfort');
INSERT INTO verses (reference, text, collection_id) VALUES ('Romans 3:23', '...sinned...', 1);
INSERT INTO verses (reference, text, collection_id) VALUES ('John 3:16', '...so loved...', 2);
Now insert a verse pointing at a collection that doesn’t exist (collection_id = 99). It will succeed — because foreign keys are off by default. Then start a fresh Python connection, run con.execute("PRAGMA foreign_keys = ON;") first, and try the bad insert again. It now raises IntegrityError. Write one sentence on why the PRAGMA is not optional.
Rep 9 — Your First JOIN
Against the two tables from Rep 8 (with the bad row removed), write and run:
SELECT v.reference, c.name AS collection
FROM verses AS v
JOIN collections AS c ON v.collection_id = c.id
ORDER BY c.name, v.reference;
Predict the output before you run it. Then answer in writing: if “Romans Road” were stored as a text column on every verse instead of in its own table, what would you have to do to rename the collection to “The Romans Road”? What does the JOIN-based design save you? (This is the no-duplicated-data argument in your own words.)
Reps 10–11: Injection and the Limit
Rep 10 — Run the Injection Both Ways
Run code/injection_demo.py. Watch the unsafe version leak the whole table and the safe version return nothing. Then, by hand, write your own tiny version: a users table with two rows, and a lookup(name) function written the unsafe way (f-string). Pass it ' OR '1'='1 (with a leading quote) and confirm you can read both rows. Then rewrite lookup with a ? placeholder and confirm the same input now returns nothing.
Write the attacker’s input string into your notes and, beside it, the full SQL the unsafe version actually sent. Seeing the assembled malicious query in your own handwriting is the rep.
Rep 11 — Make It Say “database is locked”
Cause SQLite’s defining limit on purpose. Write contention.py:
- Create a file database with one table and one row.
- Define a worker that connects (with a short timeout, e.g.
sqlite3.connect(path, timeout=0.1)), runsBEGIN IMMEDIATE, does anUPDATE, sleeps 0.3 seconds while holding the write lock, then commits. - Launch five of these workers as threads at once. Catch
sqlite3.OperationalErrorin each and count how many fail.
You should see several workers fail with database is locked — they couldn’t get the write lock before their timeout because another worker was holding it. Write two sentences: what caused the error, and why this exact behavior is the constraint that pushes a high-write-concurrency app off SQLite and toward Postgres. (You’ll expand this into the Project 11 Hard-tier memo.)
Done? One Last Thing.
From scratch, no looking — build a complete, persistent, parameterized CRUD store in a single Python file, store.py:
- A
versestable (id,referenceNOT NULL,textNOT NULL,translationdefault'ESV', and aUNIQUE(reference, translation)constraint). - Four functions, all parameterized:
add(con, reference, text)returning the new id,get(con, id)returning a row orNone,all_verses(con)returning a sorted list, anddelete(con, id). addmust catchsqlite3.IntegrityError(a duplicate reference) and raise a clearValueError("verse already exists")instead — the seed of the 409 you’ll build in the project.- A
main()that adds three verses, prints them, attempts a duplicate (and reports the clean error), deletes one, and prints the final count.
Run it. Then run it again without deleting the file and confirm the duplicate-detection fires on the rows from the first run — proof the constraint is enforced by the database, across process lifetimes, not by your Python. If you can write this cold — schema, parameterized CRUD, constraint handled, persistence proven — you have the week’s muscle.
Up next: Project 11 — Project 11: Persist It in SQLite.