Programming with Python › SQL and Relational Databases › Day 90
Hands-on lab — Day 90: SQLite from Python
- ← Back to the Day 90 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-090-sqlite-from-python/
Commands
Setup
cd labs/sections/programming-with-python/day-090-sqlite-from-python
python3 -c "import sqlite3, sys; print(sys.version.split()[0], sqlite3.sqlite_version)" Run
python3 examples/injection_demo.py
python3 examples/transactions_demo.py
python3 examples/cursors_demo.py
python3 examples/errors_demo.py
python3 examples/bulk_insert.py 2000 # the default of 20000 takes about 15 seconds
python3 examples/report.py
python3 examples/test_repository.py -v
python3 examples/no_sql_strings.py examples
cd starter && python3 smoke.py # exits 1 until all nine exercises are written Test
bash tests/run_tests.sh File tree
examples/bulk_insert.py examples/cursors_demo.py examples/db.py examples/domain.py examples/errors_demo.py examples/injection_demo.py examples/no_sql_strings.py examples/report.py examples/seed.py examples/test_repository.py examples/transactions_demo.py expected-output/bulk-insert.txt expected-output/cursors.txt expected-output/errors.txt expected-output/FIELDS.md expected-output/injection.txt expected-output/no-sql-strings.txt expected-output/report.txt expected-output/starter-smoke.txt expected-output/test-run.txt expected-output/transactions.txt expected-output/unit-tests.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/db.py starter/domain.py starter/seed.py starter/smoke.py tests/run_tests.sh troubleshooting.md
Lab README
Day 090 lab — A Real Data Layer
Lesson
- Lesson title: SQLite from Python
- Day number: 90 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-090-sqlite-from-python
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-090-sqlite-from-pythonwhen the site is running.
Purpose
For five days the database has been a place you visited with a shell. Today it moves inside a program, and two things start to go wrong that the shell never let you get wrong: SQL built out of strings, and transactions you thought you understood.
So this lab does not describe either. It demonstrates both, and then makes you build the layer that prevents them.
You will do four things, in this order:
- Break a database on purpose.
injection_demo.pybuilds a throwaway database in a temporary directory, hands the same crafted value to a concatenated query and to a bound one, and prints both results. The first returns every member's address and PIN and then destroys a table. The second returns nothing and changes nothing. Same value, same schema, one character of difference in the code. - Build the data layer from first principles. A connection factory, a
transaction context manager, row-to-object mapping, and a repository
whose every statement is a literal with every value bound. Nine numbered
exercises in
starter/db.py, each naming the check that confirms it. - Prove the properties rather than assume them. A failure halfway
through a transaction leaves the database unchanged — for a SQL error and
for a Python one, checked from a second connection.
PRAGMA foreign_keysis per-connection, and is a silent no-op inside a transaction.with connection:does not close the connection.executemanybeats a loop, and one transaction beats both by more. - Make the guard mechanical.
no_sql_strings.pyparses every file withastand fails if any statement reachingexecutewas built from parts — and the test suite feeds it a deliberately unsafe file to prove it still catches one.
All 64 checks run offline. No server, no port, no credential, no third-party package: the standard library and bash, nothing else. The suite asserts that mechanically.
Learning objectives
- Open a connection deliberately: turn foreign keys on, choose a row factory, decide who controls transactions, and know why each of those is per-connection.
- Bind every value, in both qmark and named styles, and know why escaping is not an equivalent fix and why parameters cannot stand in for identifiers.
- See a crafted input arrive as code and then as data, in the same program, against the same schema.
- Write a transaction context manager and prove it undoes a partial change, whether the failure came from SQL or from Python.
- Choose between
fetchone,fetchmany,fetchalland iterating a cursor, with the memory difference measured rather than asserted. - Map rows to domain objects in one place, and translate storage errors into domain errors at the boundary.
- Tell
IntegrityError,OperationalErrorandProgrammingErrorapart by making each of them happen. - Keep SQL out of the rest of the program — and check that mechanically rather than by reading.
Prerequisites
- The Day 90 lesson (read it first).
- Days 85–89: the relational model,
SELECT, joins, writing and schema design, and indexes. Everything you learned at the shell prompt is what the repository sends over the wire today. - Day 70: modelling a domain with objects.
domain.pyhere is that model, unchanged, anddb.pyis the repository it always implied. - Day 74: mocking and testing boundaries. The argument that fakes beat mocks
and that the best move is to relocate the boundary is what makes
test_repository.pylook the way it does. - A terminal and a text editor. Nothing to install.
Supported operating systems
- macOS — fully supported. Captures taken on macOS 26.5.1 (Apple Silicon, arm64), Python 3.14.0, bash 3.2.57, SQLite 3.53.3 as linked into Python.
- Linux — fully supported on any distribution with Python 3.12+ and bash.
- Windows — use WSL and follow the Linux path. On native Windows,
tests/run_tests.shis a bash script and will not run; every Python file works unchanged, because paths go throughpathlibandtempfilerather than being hard-coded.expected-output/FIELDS.mdrecords what may legitimately differ rather than inventing captures never taken.
Hardware requirements
Any computer that runs Python 3.12 or newer. No GPU, no special memory. The
largest thing created is a temporary database of 40,000 short rows used to
measure fetchall against cursor iteration, and it is deleted before the
script returns. The whole harness finishes in a few seconds.
examples/bulk_insert.py, run on its own with its default of 20,000 rows,
takes about fifteen seconds — because its first method commits once per row,
which is the finding rather than a fault. The test suite runs it with 2,000.
Required software
python3, 3.12 or newer (captures on 3.14.0), with the standard-librarysqlite3module — already there.bashfor the test harness — preinstalled on macOS and Linux.
No packages to install. See
requirements/README.md, which explains why 3.12
rather than 3.11.
Free and open-source options
Everything here is free. Python and its sqlite3 module are free under the
Python Software Foundation License; SQLite's source code is in the public
domain; bash is free under the GPL. There is no paid tier, no account and
nothing to sign up for.
The lesson's Alternatives section covers sqlite3 itself, SQLAlchemy Core,
the SQLAlchemy ORM, a hand-rolled repository, pandas.read_sql and
aiosqlite — with when to choose each and free versus paid stated plainly.
None of them is installed for this lab, and no output from any of them is
claimed anywhere.
Installation
cd labs/sections/programming-with-python/day-090-sqlite-from-python
python3 -c "import sqlite3, sys; print(sys.version.split()[0], sqlite3.sqlite_version)"
That is the installation. Both numbers printed on one line: the Python version, then the SQLite library linked into it.
If your python3 is older than 3.12, point the harness at a newer one:
PYTHON=/path/to/python3 bash tests/run_tests.sh
File structure
day-090-sqlite-from-python/
├── README.md ← you are here
├── metadata.yml
├── examples/ ← the finished work, all runnable
│ ├── domain.py ← Day 70's objects. Note: no sqlite3 import
│ ├── db.py ← THE DATA LAYER: connect, transaction,
│ │ mapping, BookRepository, LoanRepository
│ ├── seed.py ← fixed sample data; every date a literal
│ ├── injection_demo.py ← the same value as code, then as data
│ ├── cursors_demo.py ← fetch methods, row factories, memory
│ ├── transactions_demo.py ← implicit, with-block, explicit, autocommit
│ ├── errors_demo.py ← 13 deliberate mistakes and their classes
│ ├── bulk_insert.py ← loop vs transaction vs executemany
│ ├── report.py ← the application layer. No SQL anywhere
│ ├── test_repository.py ← 29 tests against a real temporary database
│ └── no_sql_strings.py ← the ast guard: no assembled SQL
├── starter/ ← YOUR work
│ ├── db.py ← 9 numbered exercises
│ ├── domain.py ← given, unchanged
│ ├── seed.py ← given, unchanged
│ └── smoke.py ← names the next exercise; exits 1 until done
├── tests/
│ └── run_tests.sh ← 64 behavioural checks, one exit code
├── expected-output/
│ ├── test-run.txt ← the full harness run
│ ├── injection.txt ← the four acts, with the leaked rows
│ ├── transactions.txt ← every transaction fact, on this interpreter
│ ├── cursors.txt ← fetch methods, factories, the memory ratio
│ ├── errors.txt ← the exception table, produced by erring
│ ├── bulk-insert.txt ← the three timings
│ ├── report.txt ← the application layer's output
│ ├── unit-tests.txt ← unittest, verbose
│ ├── no-sql-strings.txt ← the guard passing
│ ├── starter-smoke.txt ← the starter refusing to look finished
│ └── FIELDS.md ← what must match, what may differ
├── requirements/
│ ├── requirements.txt ← deliberately empty; the note says why
│ └── README.md
├── troubleshooting.md
└── security.md
How to run
Everything below runs from this directory. Nothing needs a scratch copy: every script creates its own database inside a temporary directory and removes it before it returns.
## 1. Watch the attack, then watch it fail. Read this one's output slowly.
python3 examples/injection_demo.py
## 2. The transaction model, on the interpreter you are actually running.
python3 examples/transactions_demo.py
## 3. Cursors, the four ways to get rows, and what fetchall costs.
python3 examples/cursors_demo.py
## 4. Thirteen deliberate mistakes, and the class each one raises.
python3 examples/errors_demo.py
## 5. A loop, a batched loop, and executemany. Takes about 15 seconds.
python3 examples/bulk_insert.py
python3 examples/bulk_insert.py 2000 # or a smaller number
## 6. The application layer. Open it and note what it does not import.
python3 examples/report.py
## 7. The data layer's own suite, against a real database in a temp file.
python3 examples/test_repository.py -v
## 8. The guard. Then break something and watch it complain.
python3 examples/no_sql_strings.py examples
## 9. YOUR TASK. Nine exercises; the smoke test names the next one.
cd starter
python3 smoke.py
## ... write exercise 1 in db.py, run it again, repeat ...
cd ..
And the whole thing behind one command:
bash tests/run_tests.sh
echo "exit code: $?"
What the commands do
python3 examples/injection_demo.py— builds a members table with addresses and PINs inside a fresh temporary directory. Act 1 concatenates the valueAda' OR '1'='1into a query and prints the three rows it should never have returned. Act 2 aims aDROP TABLEthrough the same hole and meetsexecute's one-statement limit — a limit of the module, not a defence. Act 3 hands the identical string toexecutescript, which accepts it, and the table is gone. Act 4 binds both values as parameters: zero rows, no error, nothing changed. The directory is removed in afinally:block.python3 examples/transactions_demo.py— prints what this interpreter does: the defaultisolation_level,autocommit, whether DDL opens a transaction, whatwith connection:commits and what it leaves open, a two-write transaction undone by a foreign-key failure,PRAGMA foreign_keysbeing silently ignored inside a transaction, andconnection.autocommitset both ways with a second connection watching.python3 examples/cursors_demo.py—executereturning a cursor,description,rowcountbeing-1for aSELECT, the four fetch methods on one cursor, thentracemallocmeasuringfetchallagainst iteration, then three row factories side by side, then two cursors open on one connection.python3 examples/errors_demo.py— prints the exception hierarchy from the module itself, then makes thirteen deliberate mistakes and tabulates the class and message each produced. It exits non-zero if any of them fails to raise.python3 examples/bulk_insert.py [n]— inserts n rows three ways into a fresh file each time and reports seconds, rows per second and the ratio. The order is the durable fact; the figures are not.python3 examples/report.py— the application layer: a shelf report, the three-table overdue query, a duplicate title surfacing asDuplicateTitlerather thansqlite3.IntegrityError, a missing id asBookNotFound, a runtime sort key checked against an allow-list, and a streamed read.python3 examples/test_repository.py— 29 tests in five classes against a real database file in a temporary directory, torn down per test.python3 examples/no_sql_strings.py [dir]— parses every.pyfile withastand fails on any statement reachingexecute,executemanyorexecutescriptthat was built with an f-string,+,%or.format.python3 starter/smoke.py— runs yourdb.pyand names the first exercise that is missing or wrong. Exits 1 until all nine pass.bash tests/run_tests.sh— all 64 checks, in eleven sections, on copies made in a temporary directory. Exits 0 on success, non-zero on any failure.
Expected output
The harness ends like this — a real captured run; see
expected-output/test-run.txt for all of it:
11. Offline, self-contained, and leaves nothing behind
ok: no executable lab file contains a network address of any kind
ok: no lab file imports a third-party package — standard library only
ok: nothing in the lab's code asks for sudo
ok: this run left no database file anywhere inside the lab directory
ok: no sandbox from any lab script was left in the temporary directory
64 checks, 0 failure(s).
The moment the whole lab exists for
(expected-output/injection.txt):
the crafted case, input = "Ada' OR '1'='1"
statement: SELECT name, email, pin FROM members WHERE name = 'Ada' OR '1'='1'
^ the apostrophe inside the value closed the string early
rows: 3 -> every member, with address and PIN:
('Ada Lovelace', 'ada@example.invalid', '4417')
('Grace Hopper', 'grace@example.invalid', '9021')
('Alan Turing', 'alan@example.invalid', '1912')
And the same value, bound:
leak attempt value = "Ada' OR '1'='1"
statement: SELECT name, email, pin FROM members WHERE name = ?
rows returned: 0
The misreading this lab exists partly to correct
(expected-output/transactions.txt):
is the connection still usable after the with-block? True
The pragma trap, in two lines:
inside a transaction, set ON -> 0 (silently ignored — no error, no warning)
outside again, set ON -> 1
What fetchall costs
(expected-output/cursors.txt) — figures
vary, the gap does not:
fetchall() peak traced memory: 15,612,096 bytes
iterating cursor peak traced memory: 826 bytes
And the bulk insert
(expected-output/bulk-insert.txt):
a loop, no transaction 13.3531 1,498 1.0x
a loop inside one transaction 0.0095 2,113,318 1411.0x
executemany inside one transaction 0.0054 3,677,372 2455.2x
expected-output/FIELDS.md states exactly which
of these values must be identical on your machine and which are expected to
differ, and what a difference would actually mean.
Validation steps
bash tests/run_tests.shends with64 checks, 0 failure(s).and exits 0.python3 examples/injection_demo.pyprints three leaked rows in act 1,members table exists afterwards: Falsein act 3, androws returned: 0twice in act 4 — then exits 0.- The same hostile strings passed to
BookRepository.find_by_authorreturn an empty list and leave all seven books in place. python3 examples/no_sql_strings.py examplesexits 0. Add an f-string query to any file and it exits 1, naming the file and line. Try it, then put it back.transactions_demo.pyprintsis the connection still usable after the with-block? True.- It also prints
inside a transaction, set ON -> 0and thenoutside again, set ON -> 1. - A failed transaction leaves
copies, the open-loan count and the book count exactly as they were — checked again from a freshly opened connection, so it is the file that is unchanged and not just the cache. python3 examples/errors_demo.pyreports13 deliberate mistakes, 0 of which raised nothing.python3 examples/test_repository.pyreportsRan 29 testsandOK.python3 examples/report.pyshows55,21and6days late, and refuses a duplicate title with a domain error.grep -n "import sqlite3" examples/report.py examples/domain.pyfinds nothing.starter/smoke.pyexits 1 as shipped and 0 once all nine exercises are written.- After any run,
find . -name "*.db"inside the lab finds nothing.
Tests
bash tests/run_tests.sh
Expected final line: 64 checks, 0 failure(s). The command exits 0 on
success and non-zero on any failure.
Three sections are worth reading before you run it.
Section 3 is the injection demonstration, and it asserts both halves. A suite that only checked the safe path would pass against code that was never unsafe in the first place, and would tell you nothing.
Section 4 tests the guard rather than trusting it: it writes a file containing a deliberately unsafe f-string query, requires the guard to reject it with the right file and line, then writes a file using adjacent string literals and requires the guard to accept it. A check nobody has watched fail is a check you are guessing about.
Section 10 runs the shipped starter and requires it to fail, then
drops the finished db.py in and requires the same script to pass. That is
how you know the exercises are really being checked rather than merely being
present.
Cleanup
There is nothing to clean up, which is the point: every script builds its
database inside a directory made with tempfile.mkdtemp() and removes it in
a finally: block. Two checks in section 11 assert that no .db file
remains in the lab and no sandbox remains in the temporary directory.
If a script was interrupted before its cleanup ran:
find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'day090-*' # look first
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
git checkout -- starter/ # optional: reset your work
Delete only what that first command lists, and only after checking it.
Troubleshooting
See troubleshooting.md. The five you are most likely
to meet: Incorrect number of bindings supplied, which is a missing comma
in (value,); a PRAGMA that runs without error and changes nothing, which
means you are inside a transaction; Cannot operate on a closed database,
which usually follows from believing with connection: closes it; writes
that vanish at exit, which means nothing committed them; and
OperationalError: no such table, which usually means a relative path
created an empty database next door.
Security notes
See security.md. Short version: examples/injection_demo.py
performs a real attack and drops a real table, entirely inside a temporary
directory it created and removes — it never opens a file you own. The habit
the lab is teaching is one character wide: bind every value, never build a
statement out of a string, and check that mechanically rather than by
reading. Turn PRAGMA foreign_keys on when you connect, because it is off
by default, per connection, and silently ignored inside a transaction. And
translate storage errors at the repository boundary, because a raw database
error leaks your schema to whoever provoked it.
This lab needs no credential, opens no port, reaches no network and needs no
sudo.
Extension exercises
- Add a second backend behind the same repository. Write
InMemoryBookRepositorywith the same methods, backed by a dict, and runreport.pyagainst it unchanged. The moment it works, you have proved the boundary is real — and you have the fake that Day 74 argued beats a mock. - Make the guard stricter, then live with it. Extend
no_sql_strings.pyto also flag a statement passed toexecuteas a bareNamewhose assignment it cannot see. Run it over the lab. Decide whether the false positives are worth the coverage, and write down why — that trade is what every real linter rule is. - Measure the busy timeout. Open two connections,
BEGIN IMMEDIATEon one, and try to write from the other. Time how long it waits beforedatabase is locked. Then settimeout=0.5and repeat. Then turn onPRAGMA journal_mode = WALand find out precisely which of the two operations stops blocking. - Register an adapter and a converter, explicitly. Store
datetime.dateobjects usingsqlite3.register_adapterand read them back withregister_converterplusdetect_types. Then compare against this lab's approach of storing ISO-8601 text. Note which one still sorts correctly in SQL, which one survives being read by another language, and which one the deprecation notes in Python 3.12 are steering you away from. - Break each property on purpose, one at a time. Remove the
PRAGMAfromconnect(). Changeexcept BaseExceptiontoexcept Exceptionintransaction(). Makefind_by_authoruse an f-string. Makestream_allcallfetchall. Run the harness after each. Four defects, and you now know rather than hope that each property is genuinely asserted. - Take the repository to a real dataset. Point it at something you own — a reading list, a music library, an export from an app — write a migration that reads the old format and inserts through the repository, and keep the list of rows it refuses. That list is the argument for constraints, written by your own data.
- Add a connection-per-thread factory. SQLite connections are not
shared between threads by default. Write a
threading.localfactory that gives each thread its own connection, run four threads writing concurrently, and record what actually happens: how oftendatabase is lockedappears, and whether WAL changes it. Then say in one sentence when you would use threads with SQLite at all.
Navigation
- Previous day: Day 89 — indexes and query performance
(
labs/sections/programming-with-python/). - Next day: Day 91 — designing and querying a real schema
(
labs/sections/programming-with-python/). - This week: Week 13, SQL and Relational Databases. Day 85 built the first database and Day 89 made it fast; today it moves inside a program, and Day 93 introduces the ORM that would have written this layer for you.
Expected output
FIELDS.md
# What must match, and what may legitimately differ
Every file in this directory was captured from a real run on the authoring
machine on 2026-08-16: macOS 26.5.1 (Apple Silicon, arm64), Python 3.14.0
with the standard-library `sqlite3` module linked against SQLite 3.53.3,
bash 3.2.57. Nothing here was typed by hand or adjusted afterwards.
Use this page before you conclude that something is wrong.
## Must be identical on your machine
These are properties of the code and of SQLite, not of the hardware.
| Where | Value | Why it cannot differ |
| --- | --- | --- |
| `injection.txt` | the concatenated query returns **3** rows and prints all three PINs | The crafted value makes the WHERE clause true for every row |
| `injection.txt` | the built statement reads `... WHERE name = 'Ada' OR '1'='1'` | It is printed before it is run |
| `injection.txt` | `execute` raises `ProgrammingError: You can only execute one statement at a time.` | A documented limit of the module |
| `injection.txt` | `members table exists afterwards: False` after `executescript` | The DROP really ran |
| `injection.txt` | every bound lookup returns **0** rows and the table keeps **3** | Binding cannot change a compiled statement |
| `transactions.txt` | `after CREATE TABLE (DDL): False`, `after INSERT (DML): True` | The module opens a transaction before DML, not DDL |
| `transactions.txt` | `is the connection still usable after the with-block? True` | `with connection:` manages a transaction, never the connection |
| `transactions.txt` | pragma inside a transaction `-> 0`, outside `-> 1` | `PRAGMA foreign_keys` is a no-op inside a transaction |
| `errors.txt` | the class in every row of the table | Fixed by the module and by SQLite |
| `report.txt` | `55`, `21` and `6` days late | The seed dates and `AS_OF` are literals |
| `report.txt` | book ids, titles and copy counts | Fixed by `seed.py` |
| `cursors.txt` | `cursor.rowcount for a SELECT -> -1` | SQLite cannot know the row count in advance |
| `cursors.txt` | `isinstance(row, dict) = False` for `sqlite3.Row` | `Row` is not a dict subclass |
| `unit-tests.txt` | `Ran 29 tests`, `OK` | The suite is fixed |
| `test-run.txt` | `64 checks, 0 failure(s).` and exit 0 | The harness is fixed |
## Expected to differ
| Where | What varies | What is still true |
| --- | --- | --- |
| `test-run.txt` | the two version lines: `python: 3.14.0` and `sqlite3.sqlite_version: 3.53.3` | Any Python 3.12+ with SQLite 3.37+ passes; the harness checks the capability, not the number |
| `bulk-insert.txt` | all six timing figures | The **order** is the fact: a loop with no transaction is far slower than a batched loop, which is a little slower than `executemany` |
| `bulk-insert.txt` | the `relative` column, here 1411x and 2455x | On a machine with different `fsync` behaviour this gap is smaller — a container on a virtual disk may show tens rather than thousands. The sign never changes |
| `cursors.txt` | the two peak-memory figures and their ratio | `fetchall` peaks in the megabytes; iterating peaks in the hundreds of bytes. The harness asserts only that they are more than 100x apart |
| `unit-tests.txt` | `Ran 29 tests in 0.048s` — the seconds | The count and `OK` do not vary |
| every file | temporary directory names such as `day090-injection-azz0ifv4` | They are created by `mktemp` and removed before the script exits |
## What a difference actually means
- **`foreign_keys(raw): 1`** instead of `0` — something on your system sets
the pragma by default. Nothing in the lab does; check for a `~/.sqliterc`
or an environment that preloads settings.
- **The bound lookup returns rows** — the value is being interpolated
somewhere rather than bound. That is the bug the whole lab is about.
- **`with connection:` closed the connection** — this has never been the
module's behaviour on any released version. Check that you did not call
`close()` yourself.
- **A timing "relative" column near 1.0x for the first row** — your
filesystem is not really flushing to disk, which is normal in some
containers and virtual machines. The lesson still holds; the demonstration
is just less dramatic.
## Windows
No captures were taken on native Windows, and none are invented here.
`tests/run_tests.sh` is a bash script and needs WSL or Git Bash. Every
Python file runs unchanged on Windows, including the temporary-directory
handling, because it goes through `tempfile` and `pathlib` rather than
hard-coded paths.
bulk-insert.txt
Inserting 20,000 rows, three ways, into a fresh file each time.
method seconds rows/second relative
---------------------------------- --------- ------------- --------
a loop, no transaction 13.3531 1,498 1.0x
a loop inside one transaction 0.0095 2,113,318 1411.0x
executemany inside one transaction 0.0054 3,677,372 2455.2x
Read it in this order:
* The first row pays a COMMIT — and on a durable filesystem an
fsync — once per row. That is the cost that dominates, and it
is the reason a bulk load in a loop feels broken.
* Wrapping the same loop in one transaction removes almost all of
it. Batching your writes matters more than which method you use.
* executemany then saves the remaining per-row cost of going
through the module: it prepares the statement once and steps it
once per row, binding new values each time.
These numbers are from one run on one machine and will differ on
yours. The ORDER is the durable fact, not the figures.
cursors.txt
1. execute returns a cursor, and the cursor is the result
---------------------------------------------------------
type(connection.execute(...)) -> Cursor
cursor.description names the columns: ['book_id', 'title', 'year']
cursor.rowcount for a SELECT -> -1 (SQLite cannot know how many rows a query will yield until it has run it)
the same cursor, reused for a second statement, forgets the first.
2. fetchone, fetchmany, fetchall, and iteration
-----------------------------------------------
fetchone() -> ('The Art of Computer Programming', 1968)
fetchmany(3) -> ['A Relational Model of Data', 'The Mythical Man-Month', 'A Discipline of Programming']
fetchall() -> 3 remaining rows: ['Structure and Interpretation', 'Programming Pearls', 'The Practice of Programming']
fetchone() now -> None (the cursor is exhausted)
iterating the cursor instead, which is the memory-safe default:
1: The Art of Computer Programming (Donald Knuth, 1968)
2: A Relational Model of Data (Edgar Codd, 1970)
3: The Mythical Man-Month (Fred Brooks, 1975)
4: A Discipline of Programming (Edsger Dijkstra, 1976)
5: Structure and Interpretation (Harold Abelson, 1985)
6: The Practice of Programming (Brian Kernighan, 1999)
7: Programming Pearls (Jon Bentley, 1986)
3. what fetchall actually costs, measured
-----------------------------------------
40,000 rows of about 200 bytes each
fetchall() peak traced memory: 15,612,096 bytes
iterating cursor peak traced memory: 826 bytes
ratio: 18901x
(both computed the same sum: 799,980,000)
fetchall builds the whole list first. Iteration holds one row.
4. row factories: tuple, sqlite3.Row, and a dict
------------------------------------------------
default (no factory) -> ('The Art of Computer Programming', 1968) — addressed by position only
sqlite3.Row -> row['title'] = 'The Art of Computer Programming', row[0] = 'The Art of Computer Programming'
row.keys() = ['title', 'year']
isinstance(row, dict) = False — it is NOT a dict; it has no .get and json.dumps refuses it
dict(row) = {'title': 'The Art of Computer Programming', 'year': 1968}
dict_factory -> {'title': 'The Art of Computer Programming', 'year': 1968} type: dict
5. a cursor of your own, when you want two open at once
-------------------------------------------------------
The Art of Computer Programming loans: 1
A Relational Model of Data loans: 0
Two cursors on one connection: independent positions, one transaction.
connection closed by contextlib.closing; sandbox removed.
errors.txt
The sqlite3 exception hierarchy, as this interpreter defines it:
sqlite3.Warning Exception <- BaseException
sqlite3.Error Exception <- BaseException
sqlite3.InterfaceError Error <- Exception <- BaseException
sqlite3.DatabaseError Error <- Exception <- BaseException
sqlite3.DataError DatabaseError <- Error <- Exception <- BaseException
sqlite3.OperationalError DatabaseError <- Error <- Exception <- BaseException
sqlite3.IntegrityError DatabaseError <- Error <- Exception <- BaseException
sqlite3.InternalError DatabaseError <- Error <- Exception <- BaseException
sqlite3.ProgrammingError DatabaseError <- Error <- Exception <- BaseException
sqlite3.NotSupportedError DatabaseError <- Error <- Exception <- BaseException
Everything except Warning descends from sqlite3.Error, which is
the one class a data layer should catch at its outer edge.
the mistake raises message
----------------------------------------------------- ---------------- ----------------------------------------------
duplicate title (UNIQUE) IntegrityError UNIQUE constraint failed: books.title
missing required column (NOT NULL) IntegrityError NOT NULL constraint failed: books.title
value fails a CHECK IntegrityError CHECK constraint failed: copies >= 0
loan naming a member who does not exist (FOREIGN KEY) IntegrityError FOREIGN KEY constraint failed
wrong type into a STRICT column IntegrityError cannot store TEXT value in INTEGER column books.year
table that is not there OperationalError no such table: shelves
column that is not there OperationalError no such column: isbn
SQL that is not SQL OperationalError near "SELEKT": syntax error
too many bindings for the placeholders ProgrammingError Incorrect number of bindings supplied. The current statement uses 1, and there are 2 supplied.
named placeholders given a sequence ProgrammingError Binding 1 (':book_id') is a named parameter, but you supplied a sequence which requires nameless (qmark) placeholders.
two statements in one execute() ProgrammingError You can only execute one statement at a time.
binding a Python type SQLite has no column for ProgrammingError Error binding parameter 1: type 'dict' is not supported
using a connection after close() ProgrammingError Cannot operate on a closed database.
Read the pattern rather than the rows:
IntegrityError — the DATA broke a rule you wrote in the schema.
OperationalError — the DATABASE could not do it: no such table,
bad syntax, file locked, disk full.
ProgrammingError — YOUR CODE misused the module: wrong number of
bindings, two statements, a closed connection.
The first is a fact about the user's input. The third is a bug.
13 deliberate mistakes, 0 of which raised nothing.
injection.txt
sandbox: a throwaway database inside day090-injection-jz72qjas/ — deleted on exit
ACT 1 — the string-built query, and what the crafted value does to it
---------------------------------------------------------------------
the ordinary case, input = "Ada Lovelace"
statement: SELECT name, email, pin FROM members WHERE name = 'Ada Lovelace'
rows: 1 -> [('Ada Lovelace', 'ada@example.invalid', '4417')]
ok: the concatenated query looks perfectly fine on ordinary input
the crafted case, input = "Ada' OR '1'='1"
statement: SELECT name, email, pin FROM members WHERE name = 'Ada' OR '1'='1'
^ the apostrophe inside the value closed the string early
rows: 3 -> every member, with address and PIN:
('Ada Lovelace', 'ada@example.invalid', '4417')
('Grace Hopper', 'grace@example.invalid', '9021')
('Alan Turing', 'alan@example.invalid', '1912')
ok: one apostrophe changed the statement's meaning: the WHERE clause became name = 'Ada' OR '1'='1', which is true for every row
Nothing was escaped, nothing was 'hacked'. The value became CODE
because it was inside the string before the parser ever saw it.
ACT 2 — the destructive version meets execute(), which takes one statement
--------------------------------------------------------------------------
statement: SELECT name FROM members WHERE name = 'Ada'; DROP TABLE members; --'
raised: ProgrammingError: You can only execute one statement at a time.
ok: sqlite3.Connection.execute refuses more than one statement, so this particular attack fails here
ok: the members table is still standing after act 2
Read that carefully. The attack failed because of a limit in the
Python module, NOT because the code was safe. Act 1 already leaked
every row through the same hole, and act 3 removes the limit.
ACT 3 — the same string handed to executescript(), which accepts many
---------------------------------------------------------------------
statement: SELECT name FROM members WHERE name = 'Ada'; DROP TABLE members; --'
members before: 3 rows
members table exists afterwards: False
querying it now: OperationalError: no such table: members
ok: the table was destroyed by a value that arrived as text
ok: and the program that did it contained no DROP anywhere
ACT 4 — the identical inputs, bound as parameters
-------------------------------------------------
leak attempt value = "Ada' OR '1'='1"
statement: SELECT name, email, pin FROM members WHERE name = ?
rows returned: 0
ok: leak attempt: bound as a value, it matched no member name
destroy attempt value = "Ada'; DROP TABLE members; --"
statement: SELECT name, email, pin FROM members WHERE name = ?
rows returned: 0
ok: destroy attempt: bound as a value, it matched no member name
ok: the members table is untouched
ok: and still holds all three rows
The statement was compiled with a '?' in it BEFORE any value
existed. Binding cannot change a compiled statement's shape, so
the apostrophe is just a character in a string that no member is
called. The engine compared it and moved on.
SUMMARY
-------
concatenated + crafted input -> 3 private rows leaked, then a table dropped
parameterised + same input -> 0 rows, no error, nothing changed
the difference is one character: ? instead of an f-string.
0 failure(s) in this demonstration.
sandbox removed. Nothing outside it was ever opened.
no-sql-strings.txt
Scanning 9 Python file(s) under examples/ for assembled SQL.
bulk_insert.py
cursors_demo.py
db.py
domain.py
errors_demo.py
report.py
seed.py
test_repository.py
transactions_demo.py
Exempt by name (they build broken SQL on purpose): injection_demo.py, no_sql_strings.py
ok: every SQL statement is a literal; every value is bound.
report.txt
Shelf, by year:
1968 The Art of Computer Programming 1 × (on the shelf)
1970 A Relational Model of Data 3 × (on the shelf)
1975 The Mythical Man-Month 0 × (all copies out)
1976 A Discipline of Programming 1 × (on the shelf)
1985 Structure and Interpretation 3 × (on the shelf)
1986 Programming Pearls 1 × (on the shelf)
1999 The Practice of Programming 2 × (on the shelf)
Overdue as of 2026-08-16:
Ada Lovelace The Mythical Man-Month due 2026-06-22 (55 days late)
Grace Hopper A Discipline of Programming due 2026-07-26 (21 days late)
Ada Lovelace Structure and Interpretation due 2026-08-10 (6 days late)
Adding books through the repository:
stored as book_id 8: Compilers (Alfred Aho, 1986)
refused: a book titled 'Compilers' is already stored
Looking one up that is not there:
BookNotFound: no book with id 4242
Sorting by a key chosen at runtime:
sort_key='author' first row: Compilers
sort_key='nonsense; DROP TABLE books' cannot sort by 'nonsense; DROP TABLE books'; choose from ['author', 'title', 'year']
Streaming every book without building a list:
8 titles, one row held at a time
starter-smoke.txt
EXERCISE 1 — connect()
what it must do: a configured connection: row factory, foreign keys on
open db.py, find 'EXERCISE 1', and write it. Then run this again.
0 of 9 exercises finished.
test-run.txt
Day 090 — A Real Data Layer
1. The interpreter reports itself, and the module is the standard one
python: 3.14.0
sqlite3.sqlite_version: 3.53.3
ok: the sqlite3 module is present and its paramstyle is qmark
ok: Connection.autocommit exists on this interpreter (Python 3.12 or newer)
ok: the linked SQLite is 3.37.0 or newer, so STRICT tables are available
2. The connection factory configures what has to be configured
ok: the factory turns foreign keys ON
ok: a plain sqlite3.connect leaves them OFF — the setting is per connection
ok: the factory turns off the module's implicit transaction handling
ok: rows arrive as sqlite3.Row, addressable by column name
3. Injection: the same value as CODE, then as DATA
ok: injection_demo.py runs and every one of its own assertions holds
ok: the concatenated query leaked all three private rows
ok: the crafted value changed the statement's meaning
ok: execute() refuses a second statement — a module limit, not a defence
ok: executescript() accepts it, and the table is destroyed for real
ok: the identical value, bound, returns zero rows
ok: and leaves all three members in place
ok: the demonstration built its database inside a throwaway directory
ok: the repository returns nothing for either hostile author name
ok: and the books table is unchanged afterwards
4. No SQL anywhere in this lab is built out of pieces
ok: the guard finds no assembled SQL in the lab's own code
ok: and the guard does catch a deliberately unsafe f-string
ok: naming the file, the line and the reason
ok: and does NOT flag two adjacent string literals, which Python joins at compile time
5. Transactions: all of it, or none of it
ok: transactions_demo.py runs to the end
ok: a fresh connection defaults to implicit transaction handling
ok: DDL opens no transaction; DML does
ok: with connection: rolls back when the block raises
ok: with connection: does NOT close the connection
ok: a foreign-key failure undid the earlier write in the same transaction
ok: PRAGMA foreign_keys set inside a transaction is silently ignored
ok: and takes effect outside one
ok: autocommit = True makes a write visible to another connection at once
ok: a SQL error and a Python error both leave the database byte-identical
ok: and a newly opened connection agrees that nothing was committed
6. Cursors, fetch methods and row factories
ok: cursors_demo.py runs to the end
ok: execute() returns a Cursor
ok: rowcount is -1 for a SELECT, because the row count is not known in advance
ok: an exhausted cursor returns None from fetchone
ok: sqlite3.Row is not a dict
ok: and a dict factory produces one when a dict is what you need
ok: fetchall holds the whole result in memory; iterating a cursor does not (>100x apart)
7. Errors: each mistake raises the class it should
ok: errors_demo.py made thirteen deliberate mistakes and all thirteen raised
ok: a broken constraint raises IntegrityError
ok: a missing table raises OperationalError
ok: misusing the module raises ProgrammingError
ok: a STRICT column refuses the wrong type
8. executemany, and the transaction that matters more
ok: bulk_insert.py stored every row by all three methods
ok: one transaction around the loop is dramatically faster than one per row
ok: all three methods were timed and reported
9. The data layer's own suite, and the boundary it protects
ok: python3 test_repository.py exits 0
ok: and reports OK
unit tests run: 29
ok: the suite contains at least 25 tests
ok: report.py runs the whole application layer
ok: the three-table overdue report is right
ok: a duplicate title surfaces as a domain error, never as sqlite3.IntegrityError
ok: a sort key that is not on the allow-list is refused before any SQL exists
ok: neither the application layer nor the domain imports sqlite3
10. The starter cannot look finished before it is
ok: the shipped starter exits non-zero and names exercise 1
ok: and says so in words rather than a traceback
ok: a completed db.py takes the same starter to exit 0
ok: reporting all nine
11. Offline, self-contained, and leaves nothing behind
ok: no executable lab file contains a network address of any kind
ok: no lab file imports a third-party package — standard library only
ok: nothing in the lab's code asks for sudo
ok: this run left no database file anywhere inside the lab directory
ok: no sandbox from any lab script was left in the temporary directory
64 checks, 0 failure(s).
transactions.txt
python 3.14.0 sqlite3.sqlite_version 3.53.3
1. the module's implicit transaction handling, watched
------------------------------------------------------
default isolation_level: '' (empty string = the module manages transactions for you)
connection.autocommit: -1 (== sqlite3.LEGACY_TRANSACTION_CONTROL, which is -1)
in_transaction on a fresh connection: False
after CREATE TABLE (DDL): False — no transaction was opened
after INSERT (DML): True — the module opened one for you
after rollback(), rows in t: 0
That INSERT was never committed. A program that forgets to
commit and then exits loses the write, and nothing warns it.
2. what `with connection:` does — and the two things it does not
----------------------------------------------------------------
after a successful with-block, in_transaction: False (committed)
the block raised: something went wrong halfway through
rows in t now: [1] — the second insert was rolled back
is the connection still usable after the with-block? True
THIS IS THE MISREADING WORTH KILLING: `with connection:` commits or
rolls back a TRANSACTION. It does not close the connection. For
closing, use contextlib.closing(sqlite3.connect(...)) — or both,
nested, which is the honest full form.
3. explicit control: isolation_level=None and a context manager you wrote
-------------------------------------------------------------------------
connect() sets isolation_level = None — nothing implicit remains
seeded inside one transaction: 2 books
4. all-or-nothing across two writes, proved by breaking the second
------------------------------------------------------------------
before: book 1 has 1 copy/copies, 0 open loan(s)
the transaction raised: IntegrityError: FOREIGN KEY constraint failed
after: book 1 has 1 copy/copies, 0 open loan(s)
Both halves of the FIRST borrow were undone as well. That is
atomicity: the group either lands or it does not.
5. the pragma trap: PRAGMA foreign_keys is a no-op inside a transaction
-----------------------------------------------------------------------
outside a transaction, set OFF -> 0
inside a transaction, set ON -> 0 (silently ignored — no error, no warning)
outside again, set ON -> 1
This is why connect() runs the pragma the moment the
connection is opened, before anything can begin a transaction.
6. connection.autocommit — the newer, explicit control
------------------------------------------------------
autocommit = True: in_transaction False, another connection already sees 1 row(s) — committed immediately
autocommit = False: in_transaction True, the other connection still sees 1 row(s) — uncommitted
after rollback(): the other connection sees 1 row(s)
autocommit=False opens a transaction and keeps one open, so a
long-lived connection holds a read lock until you commit.
set back to LEGACY_TRANSACTION_CONTROL (-1): isolation_level is honoured again
sandbox removed.
unit-tests.txt
test_executemany_is_atomic_inside_a_transaction (__main__.TestBulkAndStreaming.test_executemany_is_atomic_inside_a_transaction) ... ok
test_executemany_stores_every_row (__main__.TestBulkAndStreaming.test_executemany_stores_every_row) ... ok
test_streaming_yields_the_same_books_as_fetching_them_all (__main__.TestBulkAndStreaming.test_streaming_yields_the_same_books_as_fetching_them_all) ... ok
test_database_is_an_ordinary_file_where_we_asked (__main__.TestConnectionFactory.test_database_is_an_ordinary_file_where_we_asked) ... ok
test_foreign_keys_are_off_on_a_connection_that_did_not_ask (__main__.TestConnectionFactory.test_foreign_keys_are_off_on_a_connection_that_did_not_ask)
The pragma is per connection, not a property of the file. ... ok
test_foreign_keys_are_on_for_this_connection (__main__.TestConnectionFactory.test_foreign_keys_are_on_for_this_connection) ... ok
test_rows_are_addressable_by_name (__main__.TestConnectionFactory.test_rows_are_addressable_by_name) ... ok
test_the_configured_connection_refuses_that_same_write (__main__.TestConnectionFactory.test_the_configured_connection_refuses_that_same_write) ... ok
test_add_returns_the_book_with_the_id_the_database_assigned (__main__.TestMapping.test_add_returns_the_book_with_the_id_the_database_assigned) ... ok
test_duplicate_title_is_translated_at_the_boundary (__main__.TestMapping.test_duplicate_title_is_translated_at_the_boundary) ... ok
test_missing_row_raises_a_domain_error_not_a_storage_one (__main__.TestMapping.test_missing_row_raises_a_domain_error_not_a_storage_one) ... ok
test_rows_become_domain_objects (__main__.TestMapping.test_rows_become_domain_objects) ... ok
test_the_domain_refuses_a_bad_object_before_any_sql_runs (__main__.TestMapping.test_the_domain_refuses_a_bad_object_before_any_sql_runs) ... ok
test_a_crafted_value_is_treated_as_ordinary_text (__main__.TestParameterBinding.test_a_crafted_value_is_treated_as_ordinary_text) ... ok
test_a_placeholder_cannot_stand_in_for_a_column_name (__main__.TestParameterBinding.test_a_placeholder_cannot_stand_in_for_a_column_name)
ORDER BY ? binds a VALUE, so every row sorts by the same constant. ... ok
test_an_ordinary_value_still_works (__main__.TestParameterBinding.test_an_ordinary_value_still_works) ... ok
test_mixing_named_placeholders_with_a_sequence_is_an_error (__main__.TestParameterBinding.test_mixing_named_placeholders_with_a_sequence_is_an_error) ... ok
test_named_placeholders (__main__.TestParameterBinding.test_named_placeholders) ... ok
test_sorting_uses_an_allow_list (__main__.TestParameterBinding.test_sorting_uses_an_allow_list) ... ok
test_the_tables_survive_it (__main__.TestParameterBinding.test_the_tables_survive_it) ... ok
test_a_different_as_of_date_gives_a_different_answer (__main__.TestReports.test_a_different_as_of_date_gives_a_different_answer) ... ok
test_overdue_is_computed_from_the_date_we_pass_in (__main__.TestReports.test_overdue_is_computed_from_the_date_we_pass_in) ... ok
test_the_report_never_calls_todays_date (__main__.TestReports.test_the_report_never_calls_todays_date)
A fixture that moves is a test that fails on a Tuesday. ... ok
test_a_failure_mid_transaction_leaves_the_database_unchanged (__main__.TestTransactions.test_a_failure_mid_transaction_leaves_the_database_unchanged) ... ok
test_a_python_error_rolls_back_just_as_a_sql_one_does (__main__.TestTransactions.test_a_python_error_rolls_back_just_as_a_sql_one_does) ... ok
test_a_successful_transaction_is_visible_to_another_connection (__main__.TestTransactions.test_a_successful_transaction_is_visible_to_another_connection) ... ok
test_nesting_is_refused_rather_than_silently_wrong (__main__.TestTransactions.test_nesting_is_refused_rather_than_silently_wrong) ... ok
test_the_transaction_closes_itself_either_way (__main__.TestTransactions.test_the_transaction_closes_itself_either_way) ... ok
test_with_connection_does_not_close_the_connection (__main__.TestTransactions.test_with_connection_does_not_close_the_connection)
The misreading this lab exists partly to correct. ... ok
----------------------------------------------------------------------
Ran 29 tests in 0.048s
OK
Source files
examples/bulk_insert.py (3973 bytes)
"""executemany against a loop, and the transaction that dwarfs both.
The point of this script is not a benchmark figure — those move with the
machine, the filesystem and what else is running. The point is the SHAPE:
which of three choices actually matters, and by roughly how much.
Run it: python3 bulk_insert.py [row_count]
"""
from __future__ import annotations
import shutil
import sqlite3
import sys
import tempfile
import time
from contextlib import closing
from pathlib import Path
from db import transaction
CREATE = "CREATE TABLE rows_in (n INTEGER NOT NULL, payload TEXT NOT NULL) STRICT"
INSERT = "INSERT INTO rows_in (n, payload) VALUES (?, ?)"
def make_rows(count: int) -> list[tuple[int, str]]:
return [(n, f"payload-for-row-{n}") for n in range(count)]
def timed(label: str, path: Path, rows: list[tuple[int, str]], body) -> float:
with closing(sqlite3.connect(path, isolation_level=None)) as connection:
connection.execute(CREATE)
start = time.perf_counter()
body(connection, rows)
elapsed = time.perf_counter() - start
stored = connection.execute("SELECT count(*) FROM rows_in").fetchone()[0]
assert stored == len(rows), f"{label}: stored {stored}, expected {len(rows)}"
return elapsed
def loop_no_transaction(connection: sqlite3.Connection, rows) -> None:
"""One statement, one transaction, one fsync — per row. The slow one."""
for row in rows:
connection.execute(INSERT, row)
def loop_in_transaction(connection: sqlite3.Connection, rows) -> None:
"""The same loop, wrapped once. This is where nearly all the win is."""
with transaction(connection):
for row in rows:
connection.execute(INSERT, row)
def execute_many(connection: sqlite3.Connection, rows) -> None:
"""One prepared statement, stepped once per row, inside one transaction."""
with transaction(connection):
connection.executemany(INSERT, rows)
def main() -> int:
count = int(sys.argv[1]) if len(sys.argv) > 1 else 20_000
rows = make_rows(count)
sandbox = Path(tempfile.mkdtemp(prefix="day090-bulk-"))
try:
print(f"Inserting {count:,} rows, three ways, into a fresh file each time.")
print()
results = {
"a loop, no transaction": timed(
"loop", sandbox / "a.db", rows, loop_no_transaction),
"a loop inside one transaction": timed(
"loop-txn", sandbox / "b.db", rows, loop_in_transaction),
"executemany inside one transaction": timed(
"many", sandbox / "c.db", rows, execute_many),
}
finally:
shutil.rmtree(sandbox, ignore_errors=True)
slowest = max(results.values())
width = max(len(label) for label in results)
print(f"{'method':<{width}} {'seconds':>9} {'rows/second':>13} relative")
print(f"{'-' * width} {'-' * 9} {'-' * 13} {'-' * 8}")
for label, elapsed in results.items():
print(f"{label:<{width}} {elapsed:>9.4f} {count / elapsed:>13,.0f} "
f"{slowest / elapsed:>6.1f}x")
print()
print("Read it in this order:")
print(" * The first row pays a COMMIT — and on a durable filesystem an")
print(" fsync — once per row. That is the cost that dominates, and it")
print(" is the reason a bulk load in a loop feels broken.")
print(" * Wrapping the same loop in one transaction removes almost all of")
print(" it. Batching your writes matters more than which method you use.")
print(" * executemany then saves the remaining per-row cost of going")
print(" through the module: it prepares the statement once and steps it")
print(" once per row, binding new values each time.")
print()
print("These numbers are from one run on one machine and will differ on")
print("yours. The ORDER is the durable fact, not the figures.")
return 0
if __name__ == "__main__":
sys.exit(main())
examples/cursors_demo.py (5590 bytes)
"""Cursors, the four ways to get rows out, and row factories.
Run it: python3 cursors_demo.py
Everything happens in a database built inside a temporary directory, which
is removed on the way out.
"""
from __future__ import annotations
import shutil
import sqlite3
import sys
import tempfile
import tracemalloc
from contextlib import closing
from pathlib import Path
from db import BookRepository, apply_schema, connect, dict_factory, transaction
from domain import Book
def rule(text: str) -> None:
print()
print(text)
print("-" * len(text))
def main() -> int:
sandbox = Path(tempfile.mkdtemp(prefix="day090-cursors-"))
try:
return run(sandbox)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
def run(sandbox: Path) -> int:
import seed
path = sandbox / "library.db"
# `closing` is the idiom for "close this when the block ends". Note that
# `with connection:` would NOT close it — that form manages a
# transaction, not the connection's lifetime.
with closing(seed.build(path)) as connection:
books = BookRepository(connection)
rule("1. execute returns a cursor, and the cursor is the result")
cursor = connection.execute("SELECT book_id, title, year FROM books ORDER BY year")
print(f" type(connection.execute(...)) -> {type(cursor).__name__}")
print(f" cursor.description names the columns: "
f"{[column[0] for column in cursor.description]}")
print(" cursor.rowcount for a SELECT ->", cursor.rowcount,
"(SQLite cannot know how many rows a query will yield until it has run it)")
print(" the same cursor, reused for a second statement, forgets the first.")
rule("2. fetchone, fetchmany, fetchall, and iteration")
cursor = connection.execute("SELECT title, year FROM books ORDER BY year")
first = cursor.fetchone()
print(f" fetchone() -> {tuple(first)}")
batch = cursor.fetchmany(3)
print(f" fetchmany(3) -> {[row['title'] for row in batch]}")
rest = cursor.fetchall()
print(f" fetchall() -> {len(rest)} remaining rows: {[row['title'] for row in rest]}")
print(f" fetchone() now -> {cursor.fetchone()} (the cursor is exhausted)")
print()
print(" iterating the cursor instead, which is the memory-safe default:")
for book in books.stream_all():
print(f" {book.book_id}: {book.label}")
rule("3. what fetchall actually costs, measured")
# 40,000 rows in a second table, so the difference is visible.
connection.execute("CREATE TABLE wide (n INTEGER, payload TEXT) STRICT")
with transaction(connection):
connection.executemany(
"INSERT INTO wide (n, payload) VALUES (?, ?)",
[(n, "x" * 200) for n in range(40_000)],
)
tracemalloc.start()
rows = connection.execute("SELECT n, payload FROM wide").fetchall()
fetchall_peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
del rows
tracemalloc.start()
total = 0
for row in connection.execute("SELECT n, payload FROM wide"):
total += row["n"]
iterate_peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f" 40,000 rows of about 200 bytes each")
print(f" fetchall() peak traced memory: {fetchall_peak:>10,} bytes")
print(f" iterating cursor peak traced memory: {iterate_peak:>10,} bytes")
print(f" ratio: {fetchall_peak / max(iterate_peak, 1):.0f}x")
print(f" (both computed the same sum: {total:,})")
print(" fetchall builds the whole list first. Iteration holds one row.")
rule("4. row factories: tuple, sqlite3.Row, and a dict")
plain = sqlite3.connect(path)
row = plain.execute("SELECT title, year FROM books ORDER BY year").fetchone()
print(f" default (no factory) -> {row!r} — addressed by position only")
plain.row_factory = sqlite3.Row
row = plain.execute("SELECT title, year FROM books ORDER BY year").fetchone()
print(f" sqlite3.Row -> row['title'] = {row['title']!r}, row[0] = {row[0]!r}")
print(f" row.keys() = {row.keys()}")
print(f" isinstance(row, dict) = {isinstance(row, dict)}"
" — it is NOT a dict; it has no .get and json.dumps refuses it")
print(f" dict(row) = {dict(row)}")
plain.row_factory = dict_factory
row = plain.execute("SELECT title, year FROM books ORDER BY year").fetchone()
print(f" dict_factory -> {row} type: {type(row).__name__}")
plain.close()
rule("5. a cursor of your own, when you want two open at once")
outer = connection.cursor()
inner = connection.cursor()
outer.execute("SELECT book_id, title FROM books ORDER BY book_id")
for book_row in outer.fetchmany(2):
inner.execute(
"SELECT count(*) AS n FROM loans WHERE book_id = ?", (book_row["book_id"],)
)
print(f" {book_row['title']:<36} loans: {inner.fetchone()['n']}")
print(" Two cursors on one connection: independent positions, one transaction.")
print()
print("connection closed by contextlib.closing; sandbox removed.")
return 0
if __name__ == "__main__":
sys.exit(main())
examples/db.py (16687 bytes)
"""The data layer, built from first principles.
Five pieces, and nothing else:
1. `SCHEMA` — the tables, as one script.
2. `connect()` — a connection factory that sets the four things
every connection in this program must have.
3. `transaction()` — a context manager that begins, commits, and rolls
back explicitly.
4. `row_to_book()` — one function that turns a database row into a
domain object, so mapping lives in one place.
5. `BookRepository` — every SQL statement in the program, and nothing
but SQL statements.
The rule this file exists to enforce: **SQL lives here and only here.** No
other module in the lab imports `sqlite3`. Grep for it and see —
`tests/run_tests.sh` does exactly that and fails if it finds one.
Every statement below is parameterised. There is not one f-string, one `%`
and one `+` anywhere near a SQL string in this file, and the test suite
proves that mechanically rather than trusting the author.
"""
from __future__ import annotations
import sqlite3
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from pathlib import Path
from domain import Book, BookNotFound, DuplicateTitle, Loan, Member
SCHEMA = """
CREATE TABLE IF NOT EXISTS books (
book_id INTEGER PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
author TEXT NOT NULL,
year INTEGER NOT NULL CHECK (year BETWEEN 1400 AND 2100),
copies INTEGER NOT NULL CHECK (copies >= 0)
) STRICT;
CREATE TABLE IF NOT EXISTS members (
member_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
) STRICT;
CREATE TABLE IF NOT EXISTS loans (
loan_id INTEGER PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(book_id),
member_id INTEGER NOT NULL REFERENCES members(member_id),
borrowed_on TEXT NOT NULL,
due_on TEXT NOT NULL,
returned_on TEXT,
CHECK (due_on >= borrowed_on)
) STRICT;
CREATE INDEX IF NOT EXISTS loans_open ON loans(due_on) WHERE returned_on IS NULL;
"""
# ---------------------------------------------------------------------------
# 1. The connection factory
# ---------------------------------------------------------------------------
def connect(path: str | Path) -> sqlite3.Connection:
"""Open a connection configured the way this program needs it.
Four decisions, each of which is per-connection and would otherwise have
to be remembered at every call site:
* `isolation_level=None` — turn OFF the module's implicit transaction
handling. Nothing begins a transaction behind our back; `transaction()`
below is the only thing that opens one, which is what makes the
transaction boundaries visible in the code rather than implied.
* `PRAGMA foreign_keys = ON` — foreign keys are OFF by default in SQLite,
per connection. Without this line every REFERENCES clause in SCHEMA is
a comment. It is executed here, outside any transaction, because the
pragma is a silent no-op inside one.
* `row_factory = sqlite3.Row` — rows arrive addressable by column name
instead of by position, so `row["title"]` survives a change to the
SELECT list that `row[1]` would not.
* a busy timeout — if another connection holds the write lock, wait
rather than raising immediately.
The caller owns the connection and must close it. `contextlib.closing`
is the tidy way; see `report.py`.
"""
connection = sqlite3.connect(str(path), isolation_level=None, timeout=5.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
"""An alternative row factory: plain dicts instead of sqlite3.Row.
`sqlite3.Row` is not a dict — it has no `.get`, it is immutable, and
`json.dumps` refuses it. When a row has to leave the data layer as JSON,
this is the three-line answer. `cursor.description` is a 7-tuple per
column of which only the first item, the name, is populated by sqlite3.
"""
return {column[0]: value for column, value in zip(cursor.description, row)}
def apply_schema(connection: sqlite3.Connection) -> None:
"""Create the tables.
`executescript` is the right tool for a multi-statement script and the
wrong tool for anything containing a value: it takes no parameters at
all, so anything variable would have to be pasted into the string. It
also issues an implicit COMMIT before it runs, which means it cannot be
nested inside `transaction()`.
"""
connection.executescript(SCHEMA)
# ---------------------------------------------------------------------------
# 2. The transaction context manager
# ---------------------------------------------------------------------------
@contextmanager
def transaction(connection: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Run a block of writes as one indivisible act.
Commits if the block finishes, rolls back if anything at all is raised,
and re-raises. `BaseException` rather than `Exception` on purpose: a
KeyboardInterrupt in the middle of a two-statement change must roll back
too, and `except Exception` would let it through with the transaction
still open.
This is not the same thing as `with connection:`. That form commits or
rolls back a transaction the module opened implicitly, and it does NOT
close the connection — a genuinely common misreading. This function is
explicit about both ends, which is why the connection factory turns the
implicit machinery off.
"""
if connection.in_transaction:
raise RuntimeError(
"a transaction is already open on this connection; "
"SQLite has no nested transactions, only SAVEPOINTs"
)
connection.execute("BEGIN")
try:
yield connection
except BaseException:
connection.rollback()
raise
else:
connection.commit()
# ---------------------------------------------------------------------------
# 3. Row-to-object mapping — in one place, on purpose
# ---------------------------------------------------------------------------
def row_to_book(row: sqlite3.Row) -> Book:
"""Turn one database row into one domain object.
Every SELECT in the repository returns these five columns in this order
and hands the row here. When a column is renamed, this function is the
only thing that changes.
"""
return Book(
book_id=row["book_id"],
title=row["title"],
author=row["author"],
year=row["year"],
copies=row["copies"],
)
def row_to_member(row: sqlite3.Row) -> Member:
return Member(member_id=row["member_id"], name=row["name"], email=row["email"])
def row_to_loan(row: sqlite3.Row) -> Loan:
return Loan(
loan_id=row["loan_id"],
book_id=row["book_id"],
member_id=row["member_id"],
borrowed_on=row["borrowed_on"],
due_on=row["due_on"],
returned_on=row["returned_on"],
)
# ---------------------------------------------------------------------------
# 4. The repository
# ---------------------------------------------------------------------------
# Sorting has to vary at runtime, and a placeholder cannot stand in for an
# identifier: `ORDER BY ?` binds a VALUE, and ordering every row by the same
# constant is not ordering at all. The safe form is an allow-list — and the
# safest allow-list holds whole statements you wrote, so that no SQL string
# in this file is ever built out of pieces.
SORTED_QUERIES = {
"title": "SELECT book_id, title, author, year, copies FROM books ORDER BY title",
"author": "SELECT book_id, title, author, year, copies FROM books ORDER BY author",
"year": "SELECT book_id, title, author, year, copies FROM books ORDER BY year",
}
class BookRepository:
"""Every SQL statement about books, and no statement about anything else.
The repository takes a connection rather than a path. That one decision
is what makes it testable: the suite hands it a connection to a database
in a temporary directory, and nothing in the class knows the difference.
"""
def __init__(self, connection: sqlite3.Connection) -> None:
self._connection = connection
# -- reads ------------------------------------------------------------
def get(self, book_id: int) -> Book:
row = self._connection.execute(
"SELECT book_id, title, author, year, copies FROM books WHERE book_id = ?",
(book_id,),
).fetchone()
if row is None:
raise BookNotFound(f"no book with id {book_id}")
return row_to_book(row)
def find_by_author(self, author: str) -> list[Book]:
"""The query the injection demonstration attacks.
`author` arrives from outside this program. It is bound, so the
engine compares it to a column and never parses it.
"""
cursor = self._connection.execute(
"SELECT book_id, title, author, year, copies FROM books"
" WHERE author = ? ORDER BY year",
(author,),
)
return [row_to_book(row) for row in cursor]
def published_between(self, first: int, last: int) -> list[Book]:
"""Named placeholders, which read better as soon as there are three.
Named style takes a mapping; qmark style takes a sequence. Mixing
them raises sqlite3.ProgrammingError rather than doing something
surprising.
"""
cursor = self._connection.execute(
"SELECT book_id, title, author, year, copies FROM books"
" WHERE year BETWEEN :first AND :last ORDER BY year",
{"first": first, "last": last},
)
return [row_to_book(row) for row in cursor]
def all_sorted(self, sort_key: str = "title") -> list[Book]:
"""Sorting by a column chosen at runtime, done safely.
The key selects a whole statement from SORTED_QUERIES. An unknown
key raises before any SQL exists at all. This is the only correct
way to vary an identifier: an allow-list you wrote, never a value
you received — and note that no string is assembled even here.
"""
try:
statement = SORTED_QUERIES[sort_key]
except KeyError:
raise ValueError(
f"cannot sort by {sort_key!r}; choose from {sorted(SORTED_QUERIES)}"
) from None
return [row_to_book(row) for row in self._connection.execute(statement)]
def stream_all(self) -> Iterator[Book]:
"""Iterate the cursor instead of fetching everything.
`fetchall()` on a ten-million-row table builds a ten-million-item
list in memory. Iterating a cursor asks the virtual machine for one
row per step, so memory stays flat however large the table is. This
is the default worth reaching for; `fetchall` is the special case.
"""
cursor = self._connection.execute(
"SELECT book_id, title, author, year, copies FROM books ORDER BY book_id"
)
for row in cursor:
yield row_to_book(row)
def count(self) -> int:
return self._connection.execute("SELECT count(*) AS n FROM books").fetchone()["n"]
# -- writes -----------------------------------------------------------
def add(self, book: Book) -> Book:
"""Insert one book and return it with the id the database assigned.
`cursor.lastrowid` is the rowid of the last successful INSERT on
THAT cursor — which is why the cursor returned by `execute` is worth
keeping rather than discarding.
"""
try:
cursor = self._connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
(book.title, book.author, book.year, book.copies),
)
except sqlite3.IntegrityError as error:
# Translate the storage error into a domain error at the boundary.
# Nothing above this line should ever have to import sqlite3 to
# find out that a title was taken.
if "books.title" in str(error):
raise DuplicateTitle(f"a book titled {book.title!r} is already stored") from error
raise
return Book(
book_id=cursor.lastrowid,
title=book.title,
author=book.author,
year=book.year,
copies=book.copies,
)
def add_many(self, books: Sequence[Book]) -> int:
"""Bulk insert with one prepared statement and many bindings.
`executemany` compiles the statement once and steps it once per row.
The loop version compiles it once per row as well, and pays the
round trip through the module each time. `bulk_insert.py` measures
the difference rather than asserting it.
"""
cursor = self._connection.executemany(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
[(b.title, b.author, b.year, b.copies) for b in books],
)
return cursor.rowcount
def set_copies(self, book_id: int, copies: int) -> None:
cursor = self._connection.execute(
"UPDATE books SET copies = ? WHERE book_id = ?",
(copies, book_id),
)
if cursor.rowcount == 0:
raise BookNotFound(f"no book with id {book_id}")
def delete(self, book_id: int) -> None:
cursor = self._connection.execute("DELETE FROM books WHERE book_id = ?", (book_id,))
if cursor.rowcount == 0:
raise BookNotFound(f"no book with id {book_id}")
class LoanRepository:
"""The loans half, kept separate so each class stays readable."""
def __init__(self, connection: sqlite3.Connection) -> None:
self._connection = connection
def add_member(self, member: Member) -> Member:
cursor = self._connection.execute(
"INSERT INTO members (name, email) VALUES (?, ?)",
(member.name, member.email),
)
return Member(member_id=cursor.lastrowid, name=member.name, email=member.email)
def borrow(self, book_id: int, member_id: int, borrowed_on: str, due_on: str) -> Loan:
"""Two writes that are only true together — the reason transactions exist.
A loan row appears and the shelf count drops. Call this inside
`transaction()`; if the second statement fails, the first must not
survive, and `test_repository.py` proves that it does not.
"""
cursor = self._connection.execute(
"INSERT INTO loans (book_id, member_id, borrowed_on, due_on)"
" VALUES (?, ?, ?, ?)",
(book_id, member_id, borrowed_on, due_on),
)
self._connection.execute(
"UPDATE books SET copies = copies - 1 WHERE book_id = ?",
(book_id,),
)
return Loan(
loan_id=cursor.lastrowid,
book_id=book_id,
member_id=member_id,
borrowed_on=borrowed_on,
due_on=due_on,
)
def overdue(self, as_of: str) -> list[dict]:
"""The three-table question, answered in one statement.
The returned rows are dicts rather than domain objects because this
is a report, not an entity: nothing here has an identity to preserve.
"""
cursor = self._connection.execute(
"""
SELECT members.name AS borrower,
books.title AS book,
loans.due_on AS due,
julianday(:as_of) - julianday(loans.due_on) AS days_late
FROM loans
JOIN books ON books.book_id = loans.book_id
JOIN members ON members.member_id = loans.member_id
WHERE loans.returned_on IS NULL
AND loans.due_on < :as_of
ORDER BY days_late DESC
""",
{"as_of": as_of},
)
return [
{
"borrower": row["borrower"],
"book": row["book"],
"due": row["due"],
"days_late": int(row["days_late"]),
}
for row in cursor
]
def open_count(self) -> int:
return self._connection.execute(
"SELECT count(*) AS n FROM loans WHERE returned_on IS NULL"
).fetchone()["n"]
examples/domain.py (3136 bytes)
"""The domain objects — Day 70's model, unchanged and unaware of storage.
Read the imports at the top of this file. There is no `sqlite3` here, and
there never will be. That absence is the whole architectural claim of this
lab: the objects that carry your program's meaning do not know that a
database exists, which is exactly what makes them testable without one
(Day 74) and replaceable without rewriting them.
Everything that knows about SQL lives in `db.py`. Everything that knows
about the problem lives here.
"""
from __future__ import annotations
from dataclasses import dataclass
class LibraryError(Exception):
"""Base class for every error this domain raises.
The outer layer catches this one class and can say something a human can
act on, instead of leaking an `sqlite3.IntegrityError` from three layers
down into a user interface.
"""
class InvalidBook(LibraryError):
"""A Book was asked to exist in a state the domain forbids."""
class BookNotFound(LibraryError):
"""A lookup by identity found nothing."""
class DuplicateTitle(LibraryError):
"""A write would have created a second book with an existing title."""
@dataclass(frozen=True)
class Book:
"""One book. Frozen, because a book's identity does not change.
`book_id` is `None` for a book that has been built in memory but never
stored. The repository fills it in when the database assigns one, by
returning a new Book rather than mutating this one.
"""
title: str
author: str
year: int
copies: int
book_id: int | None = None
def __post_init__(self) -> None:
if not self.title.strip():
raise InvalidBook("a book must have a title")
if not self.author.strip():
raise InvalidBook("a book must have an author")
if not isinstance(self.year, int) or not (1400 <= self.year <= 2100):
raise InvalidBook(f"year out of range: {self.year!r}")
if not isinstance(self.copies, int) or self.copies < 0:
raise InvalidBook(f"copies must be a non-negative integer: {self.copies!r}")
@property
def label(self) -> str:
"""How a book prints in a report. Presentation, not persistence."""
return f"{self.title} ({self.author}, {self.year})"
@dataclass(frozen=True)
class Member:
"""One library member."""
name: str
email: str
member_id: int | None = None
def __post_init__(self) -> None:
if not self.name.strip():
raise LibraryError("a member must have a name")
if "@" not in self.email:
raise LibraryError(f"not an address: {self.email!r}")
@dataclass(frozen=True)
class Loan:
"""One book, out with one member, due on one date.
`returned_on` is None while the book is still out. That is the one place
in this model where None means something specific rather than "missing".
"""
book_id: int
member_id: int
borrowed_on: str
due_on: str
returned_on: str | None = None
loan_id: int | None = None
@property
def is_open(self) -> bool:
return self.returned_on is None
examples/errors_demo.py (6500 bytes)
"""Which mistake raises which exception — produced by making each mistake.
The table this prints is not copied from documentation. Each row is one
deliberate error, caught, and reported with the class the interpreter
actually raised.
Run it: python3 errors_demo.py
"""
from __future__ import annotations
import shutil
import sqlite3
import sys
import tempfile
from contextlib import closing
from pathlib import Path
from db import apply_schema, connect
from domain import Book
SETUP = [
("INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
("The Mythical Man-Month", "Fred Brooks", 1975, 1)),
("INSERT INTO members (name, email) VALUES (?, ?)",
("Ada Lovelace", "ada@example.invalid")),
]
def attempt(connection: sqlite3.Connection, label: str, call) -> tuple[str, str, str]:
"""Run one deliberate mistake and report what came back."""
try:
call()
except sqlite3.Error as error:
return (label, type(error).__name__, str(error))
except Exception as error: # noqa: BLE001 - we want to see anything else too
return (label, type(error).__name__ + " (not a sqlite3.Error)", str(error))
return (label, "nothing raised", "")
def main() -> int:
sandbox = Path(tempfile.mkdtemp(prefix="day090-errors-"))
try:
return run(sandbox)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
def run(sandbox: Path) -> int:
print("The sqlite3 exception hierarchy, as this interpreter defines it:")
for cls in (
sqlite3.Warning,
sqlite3.Error,
sqlite3.InterfaceError,
sqlite3.DatabaseError,
sqlite3.DataError,
sqlite3.OperationalError,
sqlite3.IntegrityError,
sqlite3.InternalError,
sqlite3.ProgrammingError,
sqlite3.NotSupportedError,
):
parents = " <- ".join(base.__name__ for base in cls.__mro__[1:-1])
print(f" sqlite3.{cls.__name__:<18} {parents}")
print()
print(" Everything except Warning descends from sqlite3.Error, which is")
print(" the one class a data layer should catch at its outer edge.")
print()
with closing(connect(sandbox / "errors.db")) as connection:
apply_schema(connection)
for statement, values in SETUP:
connection.execute(statement, values)
connection.commit()
results = [
attempt(connection, "duplicate title (UNIQUE)",
lambda: connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
("The Mythical Man-Month", "Fred Brooks", 1975, 1))),
attempt(connection, "missing required column (NOT NULL)",
lambda: connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
(None, "Fred Brooks", 1975, 1))),
attempt(connection, "value fails a CHECK",
lambda: connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
("Negative Copies", "Nobody", 1975, -1))),
attempt(connection, "loan naming a member who does not exist (FOREIGN KEY)",
lambda: connection.execute(
"INSERT INTO loans (book_id, member_id, borrowed_on, due_on)"
" VALUES (?, ?, ?, ?)",
(1, 999, "2026-08-01", "2026-08-15"))),
attempt(connection, "wrong type into a STRICT column",
lambda: connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
("Bad Year", "Nobody", "nineteen seventy", 1))),
attempt(connection, "table that is not there",
lambda: connection.execute("SELECT * FROM shelves")),
attempt(connection, "column that is not there",
lambda: connection.execute("SELECT isbn FROM books")),
attempt(connection, "SQL that is not SQL",
lambda: connection.execute("SELEKT * FROM books")),
attempt(connection, "too many bindings for the placeholders",
lambda: connection.execute(
"SELECT * FROM books WHERE book_id = ?", (1, 2))),
attempt(connection, "named placeholders given a sequence",
lambda: connection.execute(
"SELECT * FROM books WHERE book_id = :book_id", (1,))),
attempt(connection, "two statements in one execute()",
lambda: connection.execute("SELECT 1; SELECT 2;")),
attempt(connection, "binding a Python type SQLite has no column for",
lambda: connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
({"title": "a dict"}, "Nobody", 1975, 1))),
attempt(connection, "using a connection after close()",
lambda: use_closed(sandbox)),
]
width_label = max(len(row[0]) for row in results)
width_class = max(len(row[1]) for row in results)
print(f"{'the mistake':<{width_label}} {'raises':<{width_class}} message")
print(f"{'-' * width_label} {'-' * width_class} {'-' * 46}")
for label, cls, message in results:
print(f"{label:<{width_label}} {cls:<{width_class}} {message}")
print()
print("Read the pattern rather than the rows:")
print(" IntegrityError — the DATA broke a rule you wrote in the schema.")
print(" OperationalError — the DATABASE could not do it: no such table,")
print(" bad syntax, file locked, disk full.")
print(" ProgrammingError — YOUR CODE misused the module: wrong number of")
print(" bindings, two statements, a closed connection.")
print(" The first is a fact about the user's input. The third is a bug.")
failures = [row for row in results if row[1] == "nothing raised"]
print()
print(f"{len(results)} deliberate mistakes, {len(failures)} of which raised nothing.")
return 1 if failures else 0
def use_closed(sandbox: Path) -> None:
connection = sqlite3.connect(sandbox / "closed.db")
connection.close()
connection.execute("SELECT 1")
if __name__ == "__main__":
sys.exit(main())
examples/injection_demo.py (7688 bytes)
"""SQL injection, demonstrated rather than warned about.
This script builds a throwaway database in a fresh temporary directory,
attacks it for real, and deletes it on the way out. Nothing outside that
directory is touched, and the directory is removed even if a check fails.
Four acts:
1. A query built by string concatenation is broken by a crafted input,
and every private address in the table comes back.
2. The same trick aimed at destroying a table meets `execute`, which
refuses more than one statement — an honest limit worth knowing, and
not a defence you may rely on.
3. The same string handed to `executescript`, which does accept several
statements, destroys the table for real. The damage is printed.
4. The identical hostile strings, bound as parameters, are treated as
ordinary text: zero rows, nothing dropped, no error.
Run it: python3 injection_demo.py
"""
from __future__ import annotations
import shutil
import sqlite3
import sys
import tempfile
from pathlib import Path
# The two inputs. Imagine each arriving from a search box, a query string,
# a CSV column, or the output of a language model asked to name an author.
LEAK = "Ada' OR '1'='1"
DESTROY = "Ada'; DROP TABLE members; --"
SCHEMA = """
CREATE TABLE members (
member_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
pin TEXT NOT NULL
);
INSERT INTO members (name, email, pin) VALUES
('Ada Lovelace', 'ada@example.invalid', '4417'),
('Grace Hopper', 'grace@example.invalid', '9021'),
('Alan Turing', 'alan@example.invalid', '1912');
"""
failures = 0
def check(label: str, ok: bool) -> None:
global failures
if ok:
print(f" ok: {label}")
else:
print(f" FAIL: {label}")
failures += 1
def rule(text: str) -> None:
print()
print(text)
print("-" * len(text))
def members_table_exists(connection: sqlite3.Connection) -> bool:
row = connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'members'"
).fetchone()
return row is not None
def main() -> int:
sandbox = Path(tempfile.mkdtemp(prefix="day090-injection-"))
print(f"sandbox: a throwaway database inside {sandbox.name}/ — deleted on exit")
try:
return run(sandbox)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
print()
print("sandbox removed. Nothing outside it was ever opened.")
def run(sandbox: Path) -> int:
path = sandbox / "victim.db"
# ---------------------------------------------------------------- act 1
rule("ACT 1 — the string-built query, and what the crafted value does to it")
connection = sqlite3.connect(path)
connection.executescript(SCHEMA)
honest = "Ada Lovelace"
print(f' the ordinary case, input = "{honest}"')
built = "SELECT name, email, pin FROM members WHERE name = '" + honest + "'"
print(f" statement: {built}")
rows = connection.execute(built).fetchall()
print(f" rows: {len(rows)} -> {rows}")
check("the concatenated query looks perfectly fine on ordinary input", len(rows) == 1)
print()
print(f' the crafted case, input = "{LEAK}"')
built = "SELECT name, email, pin FROM members WHERE name = '" + LEAK + "'"
print(f" statement: {built}")
print(" " + " " * (len("statement: ") + built.index("' OR")) + "^ the apostrophe inside the value closed the string early")
rows = connection.execute(built).fetchall()
print(f" rows: {len(rows)} -> every member, with address and PIN:")
for row in rows:
print(f" {row}")
check(
"one apostrophe changed the statement's meaning: the WHERE clause "
"became name = 'Ada' OR '1'='1', which is true for every row",
len(rows) == 3,
)
print(
" Nothing was escaped, nothing was 'hacked'. The value became CODE\n"
" because it was inside the string before the parser ever saw it."
)
# ---------------------------------------------------------------- act 2
rule("ACT 2 — the destructive version meets execute(), which takes one statement")
built = "SELECT name FROM members WHERE name = '" + DESTROY + "'"
print(f" statement: {built}")
try:
connection.execute(built).fetchall()
raised = "nothing"
except sqlite3.Error as error:
raised = f"{type(error).__name__}: {error}"
print(f" raised: {raised}")
check(
"sqlite3.Connection.execute refuses more than one statement, so this "
"particular attack fails here",
raised.startswith("ProgrammingError"),
)
check("the members table is still standing after act 2", members_table_exists(connection))
print(
" Read that carefully. The attack failed because of a limit in the\n"
" Python module, NOT because the code was safe. Act 1 already leaked\n"
" every row through the same hole, and act 3 removes the limit."
)
# ---------------------------------------------------------------- act 3
rule("ACT 3 — the same string handed to executescript(), which accepts many")
print(f" statement: {built}")
before = connection.execute("SELECT count(*) FROM members").fetchone()[0]
print(f" members before: {before} rows")
connection.executescript(built)
exists = members_table_exists(connection)
print(f" members table exists afterwards: {exists}")
try:
connection.execute("SELECT count(*) FROM members").fetchone()
aftermath = "still queryable"
except sqlite3.Error as error:
aftermath = f"{type(error).__name__}: {error}"
print(f" querying it now: {aftermath}")
check("the table was destroyed by a value that arrived as text", not exists)
check("and the program that did it contained no DROP anywhere", "DROP" not in SCHEMA)
connection.close()
# ---------------------------------------------------------------- act 4
rule("ACT 4 — the identical inputs, bound as parameters")
path2 = sandbox / "protected.db"
connection = sqlite3.connect(path2)
connection.executescript(SCHEMA)
for label, value in (("leak attempt", LEAK), ("destroy attempt", DESTROY)):
rows = connection.execute(
"SELECT name, email, pin FROM members WHERE name = ?", (value,)
).fetchall()
print(f' {label:16} value = "{value}"')
print(f" {'':16} statement: SELECT name, email, pin FROM members WHERE name = ?")
print(f" {'':16} rows returned: {len(rows)}")
check(f"{label}: bound as a value, it matched no member name", rows == [])
check("the members table is untouched", members_table_exists(connection))
check(
"and still holds all three rows",
connection.execute("SELECT count(*) FROM members").fetchone()[0] == 3,
)
print(
" The statement was compiled with a '?' in it BEFORE any value\n"
" existed. Binding cannot change a compiled statement's shape, so\n"
" the apostrophe is just a character in a string that no member is\n"
" called. The engine compared it and moved on."
)
connection.close()
rule("SUMMARY")
print(" concatenated + crafted input -> 3 private rows leaked, then a table dropped")
print(" parameterised + same input -> 0 rows, no error, nothing changed")
print(" the difference is one character: ? instead of an f-string.")
print()
print(f"{failures} failure(s) in this demonstration.")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
examples/no_sql_strings.py (6230 bytes)
"""A guard: no SQL statement in this lab is built out of pieces.
Reviewing for injection by reading is unreliable — the dangerous line looks
exactly like the safe one until you notice the `f`. So this check is
mechanical, it runs in the test suite, and it fails the build.
It parses each file with `ast` rather than grepping, because a regular
expression over source text cannot tell a statement from the word "select"
in a comment or a print. Two places are inspected, and only those two:
* the first argument of any `.execute`, `.executemany` or `.executescript`
call — the string that actually reaches the engine;
* any assignment to a name that says it holds SQL (`sql`, `query`,
`statement`, `stmt`, or those names with a prefix or suffix).
In either place, four shapes are refused:
f"SELECT ... {value} ..." an f-string with a substitution
"SELECT ... " + value a concatenation with a non-literal
"SELECT ... %s" % value percent formatting
"SELECT ... {}".format(value) str.format
Adjacent string literals — "SELECT a, b" " FROM t" — are NOT flagged.
Python joins those at compile time; no runtime value can enter, so they are
the recommended way to wrap a long statement across lines.
`injection_demo.py` is exempt by name, because building a broken query on
purpose is the entire point of that file.
Run it: python3 no_sql_strings.py [directory]
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
EXEMPT = {"injection_demo.py", "no_sql_strings.py"}
SQL_WORDS = (
"select ", "insert into", "update ", "delete from", "create table",
"drop table", " from ", " where ", "order by", "values (",
)
def looks_like_sql(text: str) -> bool:
lowered = text.lower()
return any(word in lowered for word in SQL_WORDS)
def literal_parts(node: ast.AST) -> str:
"""The constant text of an expression, ignoring the variable parts."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
return "".join(
part.value for part in node.values
if isinstance(part, ast.Constant) and isinstance(part.value, str)
)
if isinstance(node, ast.BinOp):
return literal_parts(node.left) + literal_parts(node.right)
return ""
EXECUTE_METHODS = {"execute", "executemany", "executescript"}
SQL_NAME_PARTS = ("sql", "query", "statement", "stmt")
def names_sql(name: str) -> bool:
return any(part in name.lower() for part in SQL_NAME_PARTS)
class Scanner(ast.NodeVisitor):
"""Look only where a built string could actually become a statement."""
def __init__(self, path: Path) -> None:
self.path = path
self.findings: list[str] = []
def report(self, node: ast.AST, how: str, where: str) -> None:
self.findings.append(
f"{self.path.name}:{node.lineno}: SQL built by {how} and {where}"
)
def inspect(self, node: ast.AST, where: str) -> None:
"""Refuse any assembled string that reads like SQL."""
if isinstance(node, ast.JoinedStr):
substituted = any(isinstance(part, ast.FormattedValue) for part in node.values)
if substituted and looks_like_sql(literal_parts(node)):
self.report(node, "an f-string", where)
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
both_literal = isinstance(node.left, ast.Constant) and isinstance(
node.right, ast.Constant
)
if not both_literal and looks_like_sql(literal_parts(node)):
self.report(node, "concatenation with +", where)
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
if looks_like_sql(literal_parts(node.left)):
self.report(node, "percent formatting", where)
elif (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "format"
and looks_like_sql(literal_parts(node.func.value))
):
self.report(node, "str.format", where)
def visit_Call(self, node: ast.Call) -> None:
if (
isinstance(node.func, ast.Attribute)
and node.func.attr in EXECUTE_METHODS
and node.args
):
self.inspect(node.args[0], f"passed straight to {node.func.attr}()")
self.generic_visit(node)
def visit_Assign(self, node: ast.Assign) -> None:
for target in node.targets:
if isinstance(target, ast.Name) and names_sql(target.id):
self.inspect(node.value, f"stored in {target.id}")
self.generic_visit(node)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
if isinstance(node.target, ast.Name) and names_sql(node.target.id) and node.value:
self.inspect(node.value, f"stored in {node.target.id}")
self.generic_visit(node)
def scan(directory: Path) -> list[str]:
findings: list[str] = []
for path in sorted(directory.rglob("*.py")):
if path.name in EXEMPT:
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
scanner = Scanner(path)
scanner.visit(tree)
findings.extend(scanner.findings)
return findings
def main() -> int:
directory = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
checked = [p.name for p in sorted(directory.rglob("*.py")) if p.name not in EXEMPT]
findings = scan(directory)
print(f"Scanning {len(checked)} Python file(s) under {directory.name}/ for assembled SQL.")
for name in checked:
print(f" {name}")
print(f"Exempt by name (they build broken SQL on purpose): {', '.join(sorted(EXEMPT))}")
print()
if findings:
for finding in findings:
print(f" FAIL: {finding}")
print()
print(f"{len(findings)} SQL string(s) built from parts. Bind the values instead.")
return 1
print(" ok: every SQL statement is a literal; every value is bound.")
return 0
if __name__ == "__main__":
sys.exit(main())
examples/report.py (3728 bytes)
"""The application layer. Look at what it does not import.
There is no `sqlite3` here, no SQL string, and no connection object built by
hand. This module asks a repository for domain objects and prints them. It
would work unchanged against a repository backed by a different database, a
file, or a fake built for a test — which is the argument Day 74 made about
boundaries, now cashed in.
Run it: python3 report.py
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from contextlib import closing
from pathlib import Path
import seed
from db import BookRepository, LoanRepository
from domain import Book, BookNotFound, DuplicateTitle, LibraryError
def shelf_report(books: BookRepository) -> list[str]:
"""Pure formatting over domain objects. Nothing here can fail on I/O."""
lines = ["Shelf, by year:"]
for book in sorted(books.all_sorted("year"), key=lambda b: b.year):
status = "on the shelf" if book.copies else "all copies out"
lines.append(f" {book.year} {book.title:<34} {book.copies} × ({status})")
return lines
def overdue_report(loans: LoanRepository, as_of: str) -> list[str]:
lines = [f"Overdue as of {as_of}:"]
rows = loans.overdue(as_of)
if not rows:
lines.append(" nothing is overdue")
for row in rows:
lines.append(
f" {row['borrower']:<14} {row['book']:<32} due {row['due']}"
f" ({row['days_late']} days late)"
)
return lines
def add_with_domain_errors(books: BookRepository, book: Book) -> str:
"""Show the boundary translating storage errors into domain errors.
The caller never sees an sqlite3 exception. It sees DuplicateTitle,
which is a sentence about the library rather than about the engine.
"""
try:
stored = books.add(book)
except DuplicateTitle as error:
return f" refused: {error}"
return f" stored as book_id {stored.book_id}: {stored.label}"
def main() -> int:
sandbox = Path(tempfile.mkdtemp(prefix="day090-report-"))
try:
with closing(seed.build(sandbox / "library.db")) as connection:
books = BookRepository(connection)
loans = LoanRepository(connection)
for line in shelf_report(books):
print(line)
print()
for line in overdue_report(loans, seed.AS_OF):
print(line)
print()
print("Adding books through the repository:")
print(add_with_domain_errors(
books, Book(title="Compilers", author="Alfred Aho", year=1986, copies=1)))
print(add_with_domain_errors(
books, Book(title="Compilers", author="Alfred Aho", year=1986, copies=1)))
print()
print("Looking one up that is not there:")
try:
books.get(4242)
except LibraryError as error:
print(f" {type(error).__name__}: {error}")
print()
print("Sorting by a key chosen at runtime:")
for key in ("author", "nonsense; DROP TABLE books"):
try:
first = books.all_sorted(key)[0]
print(f" sort_key={key!r:<32} first row: {first.title}")
except ValueError as error:
print(f" sort_key={key!r:<32} {error}")
print()
print("Streaming every book without building a list:")
titles = [book.title for book in books.stream_all()]
print(f" {len(titles)} titles, one row held at a time")
finally:
shutil.rmtree(sandbox, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main())
examples/seed.py (2193 bytes)
"""Fixed sample data, and one function that builds a database from it.
Every date here is a literal. Nothing calls `date.today()`, because a
fixture that moves makes today's captured output stop matching tomorrow's
run for no reason anybody can debug.
"""
from __future__ import annotations
from pathlib import Path
from db import BookRepository, LoanRepository, apply_schema, connect, transaction
from domain import Book, Member
BOOKS = [
Book(title="The Art of Computer Programming", author="Donald Knuth", year=1968, copies=2),
Book(title="A Relational Model of Data", author="Edgar Codd", year=1970, copies=3),
Book(title="The Mythical Man-Month", author="Fred Brooks", year=1975, copies=1),
Book(title="A Discipline of Programming", author="Edsger Dijkstra", year=1976, copies=2),
Book(title="Structure and Interpretation", author="Harold Abelson", year=1985, copies=4),
Book(title="The Practice of Programming", author="Brian Kernighan", year=1999, copies=2),
Book(title="Programming Pearls", author="Jon Bentley", year=1986, copies=1),
]
MEMBERS = [
Member(name="Ada Lovelace", email="ada@example.invalid"),
Member(name="Grace Hopper", email="grace@example.invalid"),
Member(name="Alan Turing", email="alan@example.invalid"),
]
# (book_id, member_id, borrowed_on, due_on)
LOANS = [
(3, 1, "2026-06-08", "2026-06-22"),
(4, 2, "2026-07-12", "2026-07-26"),
(5, 1, "2026-07-27", "2026-08-10"),
(1, 3, "2026-08-14", "2026-08-28"),
]
AS_OF = "2026-08-16"
def build(path: str | Path):
"""Create and populate a database, and hand back an open connection.
The whole seed happens inside one transaction: either every row lands or
none does. A half-seeded fixture is worse than no fixture.
"""
connection = connect(path)
apply_schema(connection)
books = BookRepository(connection)
loans = LoanRepository(connection)
with transaction(connection):
books.add_many(BOOKS)
for member in MEMBERS:
loans.add_member(member)
for book_id, member_id, borrowed_on, due_on in LOANS:
loans.borrow(book_id, member_id, borrowed_on, due_on)
return connection
examples/test_repository.py (10826 bytes)
"""The data layer's own test suite, against a real database in a temp file.
Two decisions are worth arguing with, because both are deliberate.
**A real database file, not a mock.** Day 74 said that fakes beat mocks and
that the best move is usually to relocate the boundary rather than patch
across it. That is exactly what a repository does. So these tests do not
mock `sqlite3` — mocking it would test that the code calls the functions the
author expected, which is a tautology. They open a real SQLite database in a
temporary directory, which costs a few milliseconds and tests the thing that
can actually be wrong: the SQL.
**A file, not `:memory:`.** An in-memory database is faster and cannot test
anything about files, paths or durability. Using a file in `tempfile` keeps
the tests honest and still leaves nothing behind, because `tearDown` removes
the directory.
Run it: python3 test_repository.py (verbose: -v)
"""
from __future__ import annotations
import shutil
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
import seed
from db import BookRepository, LoanRepository, apply_schema, connect, transaction
from domain import Book, BookNotFound, DuplicateTitle, InvalidBook, Member
class RepositoryTestCase(unittest.TestCase):
"""A fresh, seeded database per test. Tests never share state."""
def setUp(self) -> None:
self.sandbox = Path(tempfile.mkdtemp(prefix="day090-tests-"))
self.path = self.sandbox / "library.db"
self.connection = seed.build(self.path)
self.books = BookRepository(self.connection)
self.loans = LoanRepository(self.connection)
def tearDown(self) -> None:
self.connection.close()
shutil.rmtree(self.sandbox, ignore_errors=True)
class TestConnectionFactory(RepositoryTestCase):
def test_database_is_an_ordinary_file_where_we_asked(self) -> None:
self.assertTrue(self.path.is_file())
self.assertGreater(self.path.stat().st_size, 0)
def test_foreign_keys_are_on_for_this_connection(self) -> None:
self.assertEqual(self.connection.execute("PRAGMA foreign_keys").fetchone()[0], 1)
def test_foreign_keys_are_off_on_a_connection_that_did_not_ask(self) -> None:
"""The pragma is per connection, not a property of the file."""
raw = sqlite3.connect(self.path)
try:
self.assertEqual(raw.execute("PRAGMA foreign_keys").fetchone()[0], 0)
# And the consequence: the same bad write is accepted.
raw.execute(
"INSERT INTO loans (book_id, member_id, borrowed_on, due_on)"
" VALUES (?, ?, ?, ?)",
(1, 999, "2026-08-01", "2026-08-15"),
)
raw.rollback()
finally:
raw.close()
def test_the_configured_connection_refuses_that_same_write(self) -> None:
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(
"INSERT INTO loans (book_id, member_id, borrowed_on, due_on)"
" VALUES (?, ?, ?, ?)",
(1, 999, "2026-08-01", "2026-08-15"),
)
def test_rows_are_addressable_by_name(self) -> None:
row = self.connection.execute("SELECT title, year FROM books LIMIT 1").fetchone()
self.assertIsInstance(row, sqlite3.Row)
self.assertEqual(row["title"], row[0])
class TestMapping(RepositoryTestCase):
def test_rows_become_domain_objects(self) -> None:
book = self.books.get(3)
self.assertIsInstance(book, Book)
self.assertEqual(book.title, "The Mythical Man-Month")
self.assertEqual(book.year, 1975)
self.assertEqual(book.label, "The Mythical Man-Month (Fred Brooks, 1975)")
def test_missing_row_raises_a_domain_error_not_a_storage_one(self) -> None:
with self.assertRaises(BookNotFound):
self.books.get(4242)
def test_add_returns_the_book_with_the_id_the_database_assigned(self) -> None:
stored = self.books.add(Book(title="Compilers", author="Alfred Aho", year=1986, copies=1))
self.assertIsNotNone(stored.book_id)
self.assertEqual(self.books.get(stored.book_id).title, "Compilers")
def test_duplicate_title_is_translated_at_the_boundary(self) -> None:
with self.assertRaises(DuplicateTitle):
self.books.add(
Book(title="Programming Pearls", author="Jon Bentley", year=1986, copies=1)
)
def test_the_domain_refuses_a_bad_object_before_any_sql_runs(self) -> None:
with self.assertRaises(InvalidBook):
Book(title="Impossible", author="Nobody", year=1975, copies=-4)
class TestParameterBinding(RepositoryTestCase):
HOSTILE = "Fred Brooks' OR '1'='1"
DESTRUCTIVE = "Fred Brooks'; DROP TABLE books; --"
def test_a_crafted_value_is_treated_as_ordinary_text(self) -> None:
self.assertEqual(self.books.find_by_author(self.HOSTILE), [])
self.assertEqual(self.books.find_by_author(self.DESTRUCTIVE), [])
def test_the_tables_survive_it(self) -> None:
self.books.find_by_author(self.DESTRUCTIVE)
self.assertEqual(self.books.count(), len(seed.BOOKS))
def test_an_ordinary_value_still_works(self) -> None:
found = self.books.find_by_author("Fred Brooks")
self.assertEqual([book.title for book in found], ["The Mythical Man-Month"])
def test_named_placeholders(self) -> None:
found = self.books.published_between(1968, 1976)
self.assertEqual([book.year for book in found], [1968, 1970, 1975, 1976])
def test_mixing_named_placeholders_with_a_sequence_is_an_error(self) -> None:
with self.assertRaises(sqlite3.ProgrammingError):
self.connection.execute("SELECT * FROM books WHERE year = :year", (1975,))
def test_a_placeholder_cannot_stand_in_for_a_column_name(self) -> None:
"""ORDER BY ? binds a VALUE, so every row sorts by the same constant."""
rows = self.connection.execute(
"SELECT title FROM books ORDER BY ?", ("year",)
).fetchall()
by_year = self.connection.execute("SELECT title FROM books ORDER BY year").fetchall()
self.assertNotEqual([r["title"] for r in rows], [r["title"] for r in by_year])
def test_sorting_uses_an_allow_list(self) -> None:
self.assertEqual(self.books.all_sorted("year")[0].year, 1968)
with self.assertRaises(ValueError):
self.books.all_sorted("year; DROP TABLE books")
class TestTransactions(RepositoryTestCase):
def test_a_failure_mid_transaction_leaves_the_database_unchanged(self) -> None:
copies_before = self.books.get(1).copies
loans_before = self.loans.open_count()
with self.assertRaises(sqlite3.IntegrityError):
with transaction(self.connection):
self.loans.borrow(1, 1, "2026-08-01", "2026-08-15")
self.loans.borrow(2, 999, "2026-08-01", "2026-08-15") # no member 999
self.assertEqual(self.books.get(1).copies, copies_before)
self.assertEqual(self.loans.open_count(), loans_before)
def test_a_python_error_rolls_back_just_as_a_sql_one_does(self) -> None:
loans_before = self.loans.open_count()
with self.assertRaises(ZeroDivisionError):
with transaction(self.connection):
self.loans.borrow(1, 1, "2026-08-01", "2026-08-15")
_ = 1 / 0
self.assertEqual(self.loans.open_count(), loans_before)
def test_a_successful_transaction_is_visible_to_another_connection(self) -> None:
with transaction(self.connection):
self.loans.borrow(1, 1, "2026-08-01", "2026-08-15")
other = connect(self.path)
try:
self.assertEqual(LoanRepository(other).open_count(), self.loans.open_count())
finally:
other.close()
def test_the_transaction_closes_itself_either_way(self) -> None:
self.assertFalse(self.connection.in_transaction)
with transaction(self.connection):
self.assertTrue(self.connection.in_transaction)
self.assertFalse(self.connection.in_transaction)
def test_nesting_is_refused_rather_than_silently_wrong(self) -> None:
with transaction(self.connection):
with self.assertRaises(RuntimeError):
with transaction(self.connection):
pass
def test_with_connection_does_not_close_the_connection(self) -> None:
"""The misreading this lab exists partly to correct."""
with self.connection:
self.connection.execute("SELECT 1")
self.connection.execute("SELECT 1").fetchone() # would raise if it were closed
class TestBulkAndStreaming(RepositoryTestCase):
def test_executemany_stores_every_row(self) -> None:
extra = [
Book(title=f"Volume {n}", author="Anon", year=1990, copies=1)
for n in range(500)
]
with transaction(self.connection):
written = self.books.add_many(extra)
self.assertEqual(written, 500)
self.assertEqual(self.books.count(), len(seed.BOOKS) + 500)
def test_executemany_is_atomic_inside_a_transaction(self) -> None:
before = self.books.count()
clashing = [
Book(title="Fresh Title", author="Anon", year=1990, copies=1),
Book(title="Programming Pearls", author="Anon", year=1990, copies=1), # taken
]
with self.assertRaises(sqlite3.IntegrityError):
with transaction(self.connection):
self.books.add_many(clashing)
self.assertEqual(self.books.count(), before)
def test_streaming_yields_the_same_books_as_fetching_them_all(self) -> None:
streamed = [book.title for book in self.books.stream_all()]
fetched = [book.title for book in self.books.all_sorted("title")]
self.assertEqual(sorted(streamed), sorted(fetched))
self.assertEqual(len(streamed), len(seed.BOOKS))
class TestReports(RepositoryTestCase):
def test_overdue_is_computed_from_the_date_we_pass_in(self) -> None:
rows = self.loans.overdue(seed.AS_OF)
self.assertEqual([row["days_late"] for row in rows], [55, 21, 6])
def test_a_different_as_of_date_gives_a_different_answer(self) -> None:
self.assertEqual(self.loans.overdue("2026-06-01"), [])
def test_the_report_never_calls_todays_date(self) -> None:
"""A fixture that moves is a test that fails on a Tuesday."""
source = Path(__file__).with_name("db.py").read_text(encoding="utf-8")
for forbidden in ("date.today", "datetime.now", "date('now')", "CURRENT_DATE"):
self.assertNotIn(forbidden, source)
if __name__ == "__main__":
unittest.main(verbosity=2 if "-v" in sys.argv else 1)
examples/transactions_demo.py (8355 bytes)
"""The transaction model, demonstrated on the interpreter you are running.
This is the part of `sqlite3` that has changed most across Python versions,
so nothing here is asserted from memory: every line prints what THIS
interpreter actually does, and the test suite checks the behaviour rather
than the version number.
Run it: python3 transactions_demo.py
"""
from __future__ import annotations
import shutil
import sqlite3
import sys
import tempfile
from contextlib import closing
from pathlib import Path
from db import BookRepository, LoanRepository, apply_schema, connect, transaction
from domain import Book, Member
def rule(text: str) -> None:
print()
print(text)
print("-" * len(text))
def main() -> int:
sandbox = Path(tempfile.mkdtemp(prefix="day090-transactions-"))
try:
return run(sandbox)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
def run(sandbox: Path) -> int:
print(f"python {sys.version.split()[0]} sqlite3.sqlite_version {sqlite3.sqlite_version}")
rule("1. the module's implicit transaction handling, watched")
path = sandbox / "implicit.db"
with closing(sqlite3.connect(path)) as connection:
print(f" default isolation_level: {connection.isolation_level!r}"
" (empty string = the module manages transactions for you)")
print(f" connection.autocommit: {connection.autocommit}"
f" (== sqlite3.LEGACY_TRANSACTION_CONTROL, which is"
f" {sqlite3.LEGACY_TRANSACTION_CONTROL})")
print(f" in_transaction on a fresh connection: {connection.in_transaction}")
connection.execute("CREATE TABLE t (n INTEGER) STRICT")
print(f" after CREATE TABLE (DDL): {connection.in_transaction}"
" — no transaction was opened")
connection.execute("INSERT INTO t (n) VALUES (1)")
print(f" after INSERT (DML): {connection.in_transaction}"
" — the module opened one for you")
connection.rollback()
remaining = connection.execute("SELECT count(*) AS n FROM t").fetchone()[0]
print(f" after rollback(), rows in t: {remaining}")
print(" That INSERT was never committed. A program that forgets to")
print(" commit and then exits loses the write, and nothing warns it.")
rule("2. what `with connection:` does — and the two things it does not")
path = sandbox / "withblock.db"
connection = sqlite3.connect(path)
connection.execute("CREATE TABLE t (n INTEGER) STRICT")
with connection:
connection.execute("INSERT INTO t (n) VALUES (1)")
print(f" after a successful with-block, in_transaction: {connection.in_transaction}"
" (committed)")
try:
with connection:
connection.execute("INSERT INTO t (n) VALUES (2)")
raise ValueError("something went wrong halfway through")
except ValueError as error:
print(f" the block raised: {error}")
kept = [row[0] for row in connection.execute("SELECT n FROM t ORDER BY n")]
print(f" rows in t now: {kept} — the second insert was rolled back")
still_open = True
try:
connection.execute("SELECT 1").fetchone()
except sqlite3.ProgrammingError:
still_open = False
print(f" is the connection still usable after the with-block? {still_open}")
print(" THIS IS THE MISREADING WORTH KILLING: `with connection:` commits or")
print(" rolls back a TRANSACTION. It does not close the connection. For")
print(" closing, use contextlib.closing(sqlite3.connect(...)) — or both,")
print(" nested, which is the honest full form.")
connection.close()
rule("3. explicit control: isolation_level=None and a context manager you wrote")
path = sandbox / "explicit.db"
with closing(connect(path)) as connection:
apply_schema(connection)
print(f" connect() sets isolation_level = {connection.isolation_level!r}"
" — nothing implicit remains")
books = BookRepository(connection)
loans = LoanRepository(connection)
with transaction(connection):
books.add_many(
[
Book(title="The Mythical Man-Month", author="Fred Brooks", year=1975, copies=1),
Book(title="Programming Pearls", author="Jon Bentley", year=1986, copies=2),
]
)
loans.add_member(Member(name="Ada Lovelace", email="ada@example.invalid"))
print(f" seeded inside one transaction: {books.count()} books")
rule("4. all-or-nothing across two writes, proved by breaking the second")
before_copies = books.get(1).copies
before_loans = loans.open_count()
print(f" before: book 1 has {before_copies} copy/copies, {before_loans} open loan(s)")
try:
with transaction(connection):
loans.borrow(1, 1, "2026-08-01", "2026-08-15")
# Member 999 does not exist. PRAGMA foreign_keys is ON, so
# this write is refused — after the loan row and the
# decremented copy count are already in the transaction.
loans.borrow(2, 999, "2026-08-01", "2026-08-15")
except sqlite3.IntegrityError as error:
print(f" the transaction raised: {type(error).__name__}: {error}")
after_copies = books.get(1).copies
after_loans = loans.open_count()
print(f" after: book 1 has {after_copies} copy/copies, {after_loans} open loan(s)")
print(" Both halves of the FIRST borrow were undone as well. That is")
print(" atomicity: the group either lands or it does not.")
rule("5. the pragma trap: PRAGMA foreign_keys is a no-op inside a transaction")
connection.execute("PRAGMA foreign_keys = OFF")
print(f" outside a transaction, set OFF -> "
f"{connection.execute('PRAGMA foreign_keys').fetchone()[0]}")
connection.execute("BEGIN")
connection.execute("PRAGMA foreign_keys = ON")
inside = connection.execute("PRAGMA foreign_keys").fetchone()[0]
print(f" inside a transaction, set ON -> {inside}"
" (silently ignored — no error, no warning)")
connection.commit()
connection.execute("PRAGMA foreign_keys = ON")
outside = connection.execute("PRAGMA foreign_keys").fetchone()[0]
print(f" outside again, set ON -> {outside}")
print(" This is why connect() runs the pragma the moment the")
print(" connection is opened, before anything can begin a transaction.")
rule("6. connection.autocommit — the newer, explicit control")
path = sandbox / "autocommit.db"
with closing(sqlite3.connect(path)) as writer, closing(sqlite3.connect(path)) as reader:
writer.execute("CREATE TABLE t (n INTEGER) STRICT")
writer.commit()
writer.autocommit = True
writer.execute("INSERT INTO t (n) VALUES (1)")
seen = reader.execute("SELECT count(*) AS n FROM t").fetchone()[0]
print(f" autocommit = True: in_transaction {writer.in_transaction},"
f" another connection already sees {seen} row(s) — committed immediately")
writer.autocommit = False
writer.execute("INSERT INTO t (n) VALUES (2)")
seen = reader.execute("SELECT count(*) AS n FROM t").fetchone()[0]
print(f" autocommit = False: in_transaction {writer.in_transaction},"
f" the other connection still sees {seen} row(s) — uncommitted")
writer.rollback()
seen = reader.execute("SELECT count(*) AS n FROM t").fetchone()[0]
print(f" after rollback(): the other connection sees {seen} row(s)")
print(" autocommit=False opens a transaction and keeps one open, so a")
print(" long-lived connection holds a read lock until you commit.")
writer.autocommit = sqlite3.LEGACY_TRANSACTION_CONTROL
print(f" set back to LEGACY_TRANSACTION_CONTROL"
f" ({sqlite3.LEGACY_TRANSACTION_CONTROL}): isolation_level is honoured again")
print()
print("sandbox removed.")
return 0
if __name__ == "__main__":
sys.exit(main())
metadata.yml (1293 bytes)
lesson_id: D090
day: 90
kind: guided-build
languages: [python, sql, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-090-sqlite-from-python
- 'python3 -c "import sqlite3, sys; print(sys.version.split()[0], sqlite3.sqlite_version)"'
run_commands:
- python3 examples/injection_demo.py
- python3 examples/transactions_demo.py
- python3 examples/cursors_demo.py
- python3 examples/errors_demo.py
- 'python3 examples/bulk_insert.py 2000 # the default of 20000 takes about 15 seconds'
- python3 examples/report.py
- python3 examples/test_repository.py -v
- python3 examples/no_sql_strings.py examples
- 'cd starter && python3 smoke.py # exits 1 until all nine exercises are written'
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- 'find "${TMPDIR:-/tmp}" -maxdepth 1 -name ''day090-*'' # look first; scripts clean up after themselves'
- find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
- 'git checkout -- starter/ # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.1 (Apple Silicon, arm64), Python 3.14.0, SQLite 3.53.3 as linked into Python, bash 3.2.57 — bash tests/run_tests.sh -> 64 checks, 0 failure(s), exit 0'
requirements/README.md (2677 bytes)
# What this lab needs, and where it comes from
Nothing to install. Every import in every file is standard library.
## The one thing you need
| Thing | Where it comes from | Cost | How to check |
| --- | --- | --- | --- |
| Python 3.12 or newer, with `sqlite3` | Part of Python since 2.5. You already have it | Free; part of Python | `python3 -c "import sqlite3; print(sqlite3.sqlite_version)"` |
| `bash`, for the test harness | Preinstalled on macOS and Linux; WSL or Git Bash on Windows | Free | `bash --version` |
Captures were taken on **Python 3.14.0** with the module linked against
**SQLite 3.53.3**.
## Why 3.12 rather than 3.11
Two things in this lab are newer than 3.11 and both are checked by the test
suite rather than assumed:
- **`Connection.autocommit`** arrived in Python 3.12. `transactions_demo.py`
demonstrates it directly, and section 1 of the harness asserts the
attribute exists. On 3.11 that check fails honestly rather than the demo
crashing halfway through.
- **The default date and timestamp adapters are deprecated as of Python
3.12.** The lesson reports the exact deprecation message this interpreter
produces. Nothing in the lab relies on those adapters — every date is
stored as an ISO-8601 `TEXT` string, which is what the deprecation notes
recommend you do instead.
`STRICT` tables need **SQLite 3.37.0 or newer**, which the harness also
checks. That is the library linked into Python, not a separate install.
## Two SQLite version numbers on one machine
If you also have the `sqlite3` command-line shell, `sqlite3 --version` may
print a different number from `python3 -c "import sqlite3;
print(sqlite3.sqlite_version)"`. That is normal: two programs, each linking
its own copy of the library, both reading and writing the same file format.
This lab needs only the Python one. Day 85's lab covers the shell.
## What the lab deliberately does not use
- **No ORM.** SQLAlchemy Core and the SQLAlchemy ORM are excellent, and Day
93 covers them properly. Today you write the layer they would replace, so
that when you do reach for one you know what it is doing for you.
- **No `pytest`.** `unittest` is in the standard library and does everything
this suite needs. `tests/run_tests.sh` is a bash harness that runs it.
- **No `aiosqlite`, no `pandas`.** Both appear in the lesson's Alternatives
section, described rather than demonstrated. Neither is installed for this
lab, and no output from either is claimed anywhere.
- **No network, asserted mechanically.** The harness fails if any file under
`examples/` or `starter/` contains a URL or an IP address.
- **No `sudo`, no server, no port, no credential.**
requirements/requirements.txt (826 bytes)
# Day 090 — A Real Data Layer
#
# This file is deliberately empty of packages.
#
# Everything this lab needs is in the Python standard library: sqlite3,
# contextlib, dataclasses, tempfile, unittest, ast and tracemalloc. There is
# no pip install, no virtual environment, no network and no account.
#
# It exists so that `pip install -r requirements/requirements.txt` succeeds
# and does nothing, and so that the absence of dependencies is a written-down
# decision rather than an omission somebody has to guess about.
#
# The lesson's Alternatives section covers SQLAlchemy Core, the SQLAlchemy
# ORM, aiosqlite and pandas honestly — including the fact that none of them
# is installed here, so no output from any of them is claimed anywhere.
#
# See requirements/README.md for what the lab uses and where it comes from.
starter/db.py (15034 bytes)
"""YOUR data layer. Nine numbered exercises.
`domain.py` and `seed.py` beside this file are finished and need no changes.
Everything you write goes here.
How to work:
python3 smoke.py # names the next unfinished exercise, exits 1
python3 smoke.py # ...until it exits 0
python3 test_repository.py # then the real suite, from examples/
Every exercise says exactly which call to use. None of them needs more than
about five lines. The one rule that applies to all nine: **no value ever
goes into a SQL string.** Statements are literals; values are bound.
"""
from __future__ import annotations
import sqlite3
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from pathlib import Path
from domain import Book, BookNotFound, DuplicateTitle, Loan, Member
# Given: the schema. Read it before you start — every exercise below is
# constrained by something written here.
SCHEMA = """
CREATE TABLE IF NOT EXISTS books (
book_id INTEGER PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
author TEXT NOT NULL,
year INTEGER NOT NULL CHECK (year BETWEEN 1400 AND 2100),
copies INTEGER NOT NULL CHECK (copies >= 0)
) STRICT;
CREATE TABLE IF NOT EXISTS members (
member_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
) STRICT;
CREATE TABLE IF NOT EXISTS loans (
loan_id INTEGER PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(book_id),
member_id INTEGER NOT NULL REFERENCES members(member_id),
borrowed_on TEXT NOT NULL,
due_on TEXT NOT NULL,
returned_on TEXT,
CHECK (due_on >= borrowed_on)
) STRICT;
CREATE INDEX IF NOT EXISTS loans_open ON loans(due_on) WHERE returned_on IS NULL;
"""
# ===========================================================================
# EXERCISE 1 — the connection factory
# ===========================================================================
def connect(path: str | Path) -> sqlite3.Connection:
"""Open a connection and configure it.
Do four things, in this order:
1. `sqlite3.connect(str(path), isolation_level=None, timeout=5.0)`
— isolation_level=None turns off the module's implicit transaction
handling, so `transaction()` below is the only thing that opens one.
2. set `connection.row_factory = sqlite3.Row` so rows are addressable
by column name.
3. `connection.execute("PRAGMA foreign_keys = ON")` — foreign keys are
OFF by default, per connection. Do it here, before anything can
start a transaction: the pragma is a silent no-op inside one.
4. return the connection.
Checked by: smoke.py step 1, and
TestConnectionFactory in examples/test_repository.py.
"""
raise NotImplementedError("exercise 1: connect()")
def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
"""Given, as a worked model of what a row factory actually is.
A row factory is any callable taking (cursor, row) and returning
whatever you want a row to be. `cursor.description` is a 7-tuple per
column of which sqlite3 fills in only the first item, the name.
"""
return {column[0]: value for column, value in zip(cursor.description, row)}
def apply_schema(connection: sqlite3.Connection) -> None:
"""Given. Note that `executescript` takes no parameters at all, and
issues an implicit COMMIT before it runs — so it can never be nested
inside a transaction."""
connection.executescript(SCHEMA)
# ===========================================================================
# EXERCISE 2 — the transaction context manager
# ===========================================================================
@contextmanager
def transaction(connection: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Begin, then commit on success and roll back on anything at all.
1. if `connection.in_transaction` is already True, raise RuntimeError
with a message saying SQLite has no nested transactions.
2. `connection.execute("BEGIN")`.
3. `yield connection` inside a try.
4. `except BaseException:` roll back and re-raise. BaseException, not
Exception: a KeyboardInterrupt halfway through a two-statement
change must roll back too.
5. `else:` commit.
Checked by: smoke.py step 2, TestTransactions.
"""
raise NotImplementedError("exercise 2: transaction()")
# ===========================================================================
# EXERCISE 3 — row-to-object mapping
# ===========================================================================
def row_to_book(row: sqlite3.Row) -> Book:
"""Build one Book from one row, addressing columns BY NAME.
Use `row["book_id"]`, not `row[0]`. Positional access is a bug waiting
for somebody to add a column to the SELECT list.
Checked by: smoke.py step 3, TestMapping.
"""
raise NotImplementedError("exercise 3: row_to_book()")
def row_to_member(row: sqlite3.Row) -> Member:
"""Given, as the model for exercise 3."""
return Member(member_id=row["member_id"], name=row["name"], email=row["email"])
def row_to_loan(row: sqlite3.Row) -> Loan:
"""Given."""
return Loan(
loan_id=row["loan_id"],
book_id=row["book_id"],
member_id=row["member_id"],
borrowed_on=row["borrowed_on"],
due_on=row["due_on"],
returned_on=row["returned_on"],
)
# Given: sorting has to vary at runtime, and a placeholder cannot stand in
# for an identifier — `ORDER BY ?` binds a VALUE. The safe form is an
# allow-list of whole statements you wrote.
SORTED_QUERIES = {
"title": "SELECT book_id, title, author, year, copies FROM books ORDER BY title",
"author": "SELECT book_id, title, author, year, copies FROM books ORDER BY author",
"year": "SELECT book_id, title, author, year, copies FROM books ORDER BY year",
}
class BookRepository:
"""Every SQL statement about books lives in this class, and nothing else does."""
def __init__(self, connection: sqlite3.Connection) -> None:
self._connection = connection
# =======================================================================
# EXERCISE 4 — a read with one bound parameter
# =======================================================================
def get(self, book_id: int) -> Book:
"""SELECT one book by id.
`connection.execute("SELECT ... WHERE book_id = ?", (book_id,))`,
then `.fetchone()`. If the result is None, raise BookNotFound —
never return None, because a caller will forget to check it.
Otherwise return `row_to_book(row)`.
Note the trailing comma in `(book_id,)`. Without it that is not a
tuple, and you get a ProgrammingError about bindings.
Checked by: smoke.py step 4, TestMapping.
"""
raise NotImplementedError("exercise 4: BookRepository.get()")
# =======================================================================
# EXERCISE 5 — the query the attacker aims at
# =======================================================================
def find_by_author(self, author: str) -> list[Book]:
"""SELECT every book by one author, ORDER BY year.
`author` comes from outside the program. Bind it. Return a list of
Book objects built by iterating the cursor:
cursor = self._connection.execute("SELECT ... WHERE author = ?"
" ORDER BY year", (author,))
return [row_to_book(row) for row in cursor]
Two adjacent string literals like that are joined by Python at
compile time — no runtime value can enter, so it is the right way to
wrap a long statement. An f-string is not.
Checked by: smoke.py step 5, TestParameterBinding.
"""
raise NotImplementedError("exercise 5: BookRepository.find_by_author()")
# =======================================================================
# EXERCISE 6 — named placeholders
# =======================================================================
def published_between(self, first: int, last: int) -> list[Book]:
"""SELECT books with year BETWEEN :first AND :last, ORDER BY year.
Named style takes a MAPPING: `{"first": first, "last": last}`.
Passing a tuple to named placeholders raises ProgrammingError —
try it once on purpose so you recognise the message.
Checked by: smoke.py step 6, TestParameterBinding.
"""
raise NotImplementedError("exercise 6: BookRepository.published_between()")
def all_sorted(self, sort_key: str = "title") -> list[Book]:
"""Given, as the model for handling an identifier chosen at runtime."""
try:
statement = SORTED_QUERIES[sort_key]
except KeyError:
raise ValueError(
f"cannot sort by {sort_key!r}; choose from {sorted(SORTED_QUERIES)}"
) from None
return [row_to_book(row) for row in self._connection.execute(statement)]
# =======================================================================
# EXERCISE 7 — streaming instead of fetching everything
# =======================================================================
def stream_all(self) -> Iterator[Book]:
"""Yield every book, one row at a time, without building a list.
Execute the SELECT, then `for row in cursor: yield row_to_book(row)`.
Do NOT call fetchall(). On a large table fetchall builds the whole
result in memory; iterating holds one row.
Checked by: smoke.py step 7, TestBulkAndStreaming.
"""
raise NotImplementedError("exercise 7: BookRepository.stream_all()")
def count(self) -> int:
"""Given."""
return self._connection.execute("SELECT count(*) AS n FROM books").fetchone()["n"]
# =======================================================================
# EXERCISE 8 — a write, its assigned id, and the error translated
# =======================================================================
def add(self, book: Book) -> Book:
"""INSERT one book; return it with the id the database assigned.
* `cursor = self._connection.execute("INSERT INTO books (title,
author, year, copies) VALUES (?, ?, ?, ?)", (book.title,
book.author, book.year, book.copies))`
* the new id is `cursor.lastrowid` — which is why keeping the
cursor that execute() returned is worth doing.
* wrap it in `try / except sqlite3.IntegrityError as error:` and,
when `"books.title" in str(error)`, raise
`DuplicateTitle(...) from error`. Anything else, re-raise.
That translation is the boundary doing its job: nothing above
this line should have to import sqlite3 to learn that a title
was taken.
* return a NEW Book with book_id filled in. Book is frozen, so you
build one rather than assigning to a field.
Checked by: smoke.py step 8, TestMapping.
"""
raise NotImplementedError("exercise 8: BookRepository.add()")
# =======================================================================
# EXERCISE 9 — the bulk write
# =======================================================================
def add_many(self, books: Sequence[Book]) -> int:
"""INSERT many books with ONE prepared statement.
`cursor = self._connection.executemany(statement, sequence_of_tuples)`
where the sequence is `[(b.title, b.author, b.year, b.copies) for b
in books]`. Return `cursor.rowcount`.
Call it inside `transaction()`; `examples/bulk_insert.py` measures
why that matters more than executemany itself does.
Checked by: smoke.py step 9, TestBulkAndStreaming.
"""
raise NotImplementedError("exercise 9: BookRepository.add_many()")
def set_copies(self, book_id: int, copies: int) -> None:
"""Given. Note `cursor.rowcount` here: for UPDATE and DELETE it is
the number of rows changed, which is how you tell "updated nothing"
from "updated something"."""
cursor = self._connection.execute(
"UPDATE books SET copies = ? WHERE book_id = ?", (copies, book_id)
)
if cursor.rowcount == 0:
raise BookNotFound(f"no book with id {book_id}")
def delete(self, book_id: int) -> None:
"""Given."""
cursor = self._connection.execute("DELETE FROM books WHERE book_id = ?", (book_id,))
if cursor.rowcount == 0:
raise BookNotFound(f"no book with id {book_id}")
class LoanRepository:
"""Given in full. Read `borrow` — it is the reason transactions exist."""
def __init__(self, connection: sqlite3.Connection) -> None:
self._connection = connection
def add_member(self, member: Member) -> Member:
cursor = self._connection.execute(
"INSERT INTO members (name, email) VALUES (?, ?)", (member.name, member.email)
)
return Member(member_id=cursor.lastrowid, name=member.name, email=member.email)
def borrow(self, book_id: int, member_id: int, borrowed_on: str, due_on: str) -> Loan:
cursor = self._connection.execute(
"INSERT INTO loans (book_id, member_id, borrowed_on, due_on) VALUES (?, ?, ?, ?)",
(book_id, member_id, borrowed_on, due_on),
)
self._connection.execute(
"UPDATE books SET copies = copies - 1 WHERE book_id = ?", (book_id,)
)
return Loan(
loan_id=cursor.lastrowid,
book_id=book_id,
member_id=member_id,
borrowed_on=borrowed_on,
due_on=due_on,
)
def overdue(self, as_of: str) -> list[dict]:
cursor = self._connection.execute(
"""
SELECT members.name AS borrower,
books.title AS book,
loans.due_on AS due,
julianday(:as_of) - julianday(loans.due_on) AS days_late
FROM loans
JOIN books ON books.book_id = loans.book_id
JOIN members ON members.member_id = loans.member_id
WHERE loans.returned_on IS NULL
AND loans.due_on < :as_of
ORDER BY days_late DESC
""",
{"as_of": as_of},
)
return [
{
"borrower": row["borrower"],
"book": row["book"],
"due": row["due"],
"days_late": int(row["days_late"]),
}
for row in cursor
]
def open_count(self) -> int:
return self._connection.execute(
"SELECT count(*) AS n FROM loans WHERE returned_on IS NULL"
).fetchone()["n"]
starter/domain.py (3136 bytes)
"""The domain objects — Day 70's model, unchanged and unaware of storage.
Read the imports at the top of this file. There is no `sqlite3` here, and
there never will be. That absence is the whole architectural claim of this
lab: the objects that carry your program's meaning do not know that a
database exists, which is exactly what makes them testable without one
(Day 74) and replaceable without rewriting them.
Everything that knows about SQL lives in `db.py`. Everything that knows
about the problem lives here.
"""
from __future__ import annotations
from dataclasses import dataclass
class LibraryError(Exception):
"""Base class for every error this domain raises.
The outer layer catches this one class and can say something a human can
act on, instead of leaking an `sqlite3.IntegrityError` from three layers
down into a user interface.
"""
class InvalidBook(LibraryError):
"""A Book was asked to exist in a state the domain forbids."""
class BookNotFound(LibraryError):
"""A lookup by identity found nothing."""
class DuplicateTitle(LibraryError):
"""A write would have created a second book with an existing title."""
@dataclass(frozen=True)
class Book:
"""One book. Frozen, because a book's identity does not change.
`book_id` is `None` for a book that has been built in memory but never
stored. The repository fills it in when the database assigns one, by
returning a new Book rather than mutating this one.
"""
title: str
author: str
year: int
copies: int
book_id: int | None = None
def __post_init__(self) -> None:
if not self.title.strip():
raise InvalidBook("a book must have a title")
if not self.author.strip():
raise InvalidBook("a book must have an author")
if not isinstance(self.year, int) or not (1400 <= self.year <= 2100):
raise InvalidBook(f"year out of range: {self.year!r}")
if not isinstance(self.copies, int) or self.copies < 0:
raise InvalidBook(f"copies must be a non-negative integer: {self.copies!r}")
@property
def label(self) -> str:
"""How a book prints in a report. Presentation, not persistence."""
return f"{self.title} ({self.author}, {self.year})"
@dataclass(frozen=True)
class Member:
"""One library member."""
name: str
email: str
member_id: int | None = None
def __post_init__(self) -> None:
if not self.name.strip():
raise LibraryError("a member must have a name")
if "@" not in self.email:
raise LibraryError(f"not an address: {self.email!r}")
@dataclass(frozen=True)
class Loan:
"""One book, out with one member, due on one date.
`returned_on` is None while the book is still out. That is the one place
in this model where None means something specific rather than "missing".
"""
book_id: int
member_id: int
borrowed_on: str
due_on: str
returned_on: str | None = None
loan_id: int | None = None
@property
def is_open(self) -> bool:
return self.returned_on is None
starter/seed.py (2193 bytes)
"""Fixed sample data, and one function that builds a database from it.
Every date here is a literal. Nothing calls `date.today()`, because a
fixture that moves makes today's captured output stop matching tomorrow's
run for no reason anybody can debug.
"""
from __future__ import annotations
from pathlib import Path
from db import BookRepository, LoanRepository, apply_schema, connect, transaction
from domain import Book, Member
BOOKS = [
Book(title="The Art of Computer Programming", author="Donald Knuth", year=1968, copies=2),
Book(title="A Relational Model of Data", author="Edgar Codd", year=1970, copies=3),
Book(title="The Mythical Man-Month", author="Fred Brooks", year=1975, copies=1),
Book(title="A Discipline of Programming", author="Edsger Dijkstra", year=1976, copies=2),
Book(title="Structure and Interpretation", author="Harold Abelson", year=1985, copies=4),
Book(title="The Practice of Programming", author="Brian Kernighan", year=1999, copies=2),
Book(title="Programming Pearls", author="Jon Bentley", year=1986, copies=1),
]
MEMBERS = [
Member(name="Ada Lovelace", email="ada@example.invalid"),
Member(name="Grace Hopper", email="grace@example.invalid"),
Member(name="Alan Turing", email="alan@example.invalid"),
]
# (book_id, member_id, borrowed_on, due_on)
LOANS = [
(3, 1, "2026-06-08", "2026-06-22"),
(4, 2, "2026-07-12", "2026-07-26"),
(5, 1, "2026-07-27", "2026-08-10"),
(1, 3, "2026-08-14", "2026-08-28"),
]
AS_OF = "2026-08-16"
def build(path: str | Path):
"""Create and populate a database, and hand back an open connection.
The whole seed happens inside one transaction: either every row lands or
none does. A half-seeded fixture is worse than no fixture.
"""
connection = connect(path)
apply_schema(connection)
books = BookRepository(connection)
loans = LoanRepository(connection)
with transaction(connection):
books.add_many(BOOKS)
for member in MEMBERS:
loans.add_member(member)
for book_id, member_id, borrowed_on, due_on in LOANS:
loans.borrow(book_id, member_id, borrowed_on, due_on)
return connection
starter/smoke.py (10658 bytes)
"""Runs your `db.py` and names the next unfinished exercise.
python3 smoke.py
Exits 1 until all nine are written, on purpose: an unfinished lab should not
be able to look finished. Every database it builds lives in a temporary
directory that is removed on the way out.
"""
from __future__ import annotations
import ast
import shutil
import sqlite3
import sys
import tempfile
from pathlib import Path
import db
from domain import Book, BookNotFound, DuplicateTitle, Member
STEPS: list[tuple[int, str, str]] = [
(1, "connect()", "a configured connection: row factory, foreign keys on"),
(2, "transaction()", "begin, commit on success, roll back on anything"),
(3, "row_to_book()", "one row becomes one Book, addressed by column name"),
(4, "BookRepository.get()", "one bound parameter, and BookNotFound when there is no row"),
(5, "BookRepository.find_by_author()", "a hostile value bound, not interpolated"),
(6, "BookRepository.published_between()", "named placeholders take a mapping"),
(7, "BookRepository.stream_all()", "iterate the cursor, never fetchall"),
(8, "BookRepository.add()", "lastrowid, and IntegrityError translated to DuplicateTitle"),
(9, "BookRepository.add_many()", "executemany, one prepared statement"),
]
SAMPLE = [
Book(title="The Mythical Man-Month", author="Fred Brooks", year=1975, copies=1),
Book(title="A Discipline of Programming", author="Edsger Dijkstra", year=1976, copies=2),
Book(title="Programming Pearls", author="Jon Bentley", year=1986, copies=1),
]
def calls_fetchall(function_name: str) -> bool:
"""Look at the code of one function in db.py, ignoring its docstring.
Parsed with `ast` rather than searched as text, so a mention of
"fetchall" in a comment or docstring does not count as a call to it.
"""
tree = ast.parse(Path(db.__file__).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == function_name:
return any(
isinstance(inner, ast.Call)
and isinstance(inner.func, ast.Attribute)
and inner.func.attr == "fetchall"
for inner in ast.walk(node)
)
return False
def announce(number: int, detail: str = "") -> None:
_, name, hint = STEPS[number - 1]
print(f"\nEXERCISE {number} — {name}")
print(f" what it must do: {hint}")
if detail:
print(f" what happened: {detail}")
print(f" open db.py, find 'EXERCISE {number}', and write it. Then run this again.")
def main() -> int:
sandbox = Path(tempfile.mkdtemp(prefix="day090-smoke-"))
try:
return run(sandbox)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
def run(sandbox: Path) -> int:
path = sandbox / "mine.db"
done = 0
# ---- 1 --------------------------------------------------------------
try:
connection = db.connect(path)
assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1, (
"connected, but PRAGMA foreign_keys is off on this connection"
)
db.apply_schema(connection)
row = connection.execute("SELECT 1 AS one").fetchone()
assert row["one"] == 1, "connected, but rows are not addressable by name"
except NotImplementedError:
announce(1)
return summary(done)
except (AssertionError, sqlite3.Error) as error:
announce(1, str(error))
return summary(done)
done = 1
print(" ok: 1. connect() — foreign keys on, rows by name")
books = db.BookRepository(connection)
loans = db.LoanRepository(connection)
# ---- 2 --------------------------------------------------------------
try:
with db.transaction(connection):
connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
("Temporary", "Nobody", 1999, 1),
)
raise RuntimeError("deliberate")
except NotImplementedError:
announce(2)
return summary(done)
except RuntimeError:
pass
left = connection.execute("SELECT count(*) FROM books").fetchone()[0]
if left != 0:
announce(2, f"the failed block left {left} row(s) behind — it did not roll back")
return summary(done)
done = 2
print(" ok: 2. transaction() — a failure left nothing behind")
# seed, using only the given pieces
try:
with db.transaction(connection):
for book in SAMPLE:
connection.execute(
"INSERT INTO books (title, author, year, copies) VALUES (?, ?, ?, ?)",
(book.title, book.author, book.year, book.copies),
)
loans.add_member(Member(name="Ada Lovelace", email="ada@example.invalid"))
except sqlite3.Error as error:
announce(2, f"seeding failed: {error}")
return summary(done)
# ---- 3 --------------------------------------------------------------
row = connection.execute(
"SELECT book_id, title, author, year, copies FROM books WHERE book_id = 1"
).fetchone()
try:
book = db.row_to_book(row)
assert isinstance(book, Book), "row_to_book did not return a Book"
assert book.title == "The Mythical Man-Month", f"got title {book.title!r}"
assert book.book_id == 1, f"book_id should be 1, got {book.book_id!r}"
except NotImplementedError:
announce(3)
return summary(done)
except AssertionError as error:
announce(3, str(error))
return summary(done)
done = 3
print(" ok: 3. row_to_book() — a row became a domain object")
# ---- 4 --------------------------------------------------------------
try:
assert books.get(2).title == "A Discipline of Programming"
try:
books.get(4242)
except BookNotFound:
pass
else:
raise AssertionError("get(4242) returned something instead of raising BookNotFound")
except NotImplementedError:
announce(4)
return summary(done)
except (AssertionError, sqlite3.Error) as error:
announce(4, str(error))
return summary(done)
done = 4
print(" ok: 4. get() — found one, and raised BookNotFound for a missing id")
# ---- 5 --------------------------------------------------------------
hostile = "Fred Brooks' OR '1'='1"
try:
assert [b.title for b in books.find_by_author("Fred Brooks")] == [
"The Mythical Man-Month"
], "the ordinary lookup did not find the one book by Fred Brooks"
assert books.find_by_author(hostile) == [], (
"the crafted value returned rows — the value is being interpolated, not bound"
)
assert books.count() == 3, "the crafted value changed the table"
except NotImplementedError:
announce(5)
return summary(done)
except (AssertionError, sqlite3.Error) as error:
announce(5, str(error))
return summary(done)
done = 5
print(" ok: 5. find_by_author() — the crafted value was treated as data")
# ---- 6 --------------------------------------------------------------
try:
years = [b.year for b in books.published_between(1975, 1980)]
assert years == [1975, 1976], f"expected [1975, 1976], got {years}"
except NotImplementedError:
announce(6)
return summary(done)
except (AssertionError, sqlite3.Error) as error:
announce(6, str(error))
return summary(done)
done = 6
print(" ok: 6. published_between() — named placeholders, in year order")
# ---- 7 --------------------------------------------------------------
try:
streamed = list(books.stream_all())
assert len(streamed) == 3, f"expected 3 books, got {len(streamed)}"
assert all(isinstance(b, Book) for b in streamed), "stream_all yielded something else"
assert not calls_fetchall("stream_all"), (
"stream_all calls fetchall; iterate the cursor instead"
)
except NotImplementedError:
announce(7)
return summary(done)
except (AssertionError, sqlite3.Error, IndexError) as error:
announce(7, str(error))
return summary(done)
done = 7
print(" ok: 7. stream_all() — three books, one row at a time")
# ---- 8 --------------------------------------------------------------
try:
stored = books.add(Book(title="Compilers", author="Alfred Aho", year=1986, copies=1))
assert stored.book_id is not None, "add() returned a Book with no book_id"
assert books.get(stored.book_id).title == "Compilers"
try:
books.add(Book(title="Compilers", author="Alfred Aho", year=1986, copies=1))
except DuplicateTitle:
pass
except sqlite3.IntegrityError:
raise AssertionError(
"the duplicate raised sqlite3.IntegrityError — translate it to DuplicateTitle"
) from None
else:
raise AssertionError("the duplicate title was accepted")
except NotImplementedError:
announce(8)
return summary(done)
except (AssertionError, sqlite3.Error) as error:
announce(8, str(error))
return summary(done)
done = 8
print(" ok: 8. add() — id assigned, duplicate translated to DuplicateTitle")
# ---- 9 --------------------------------------------------------------
try:
before = books.count()
extra = [
Book(title=f"Volume {n}", author="Anon", year=1990, copies=1) for n in range(50)
]
with db.transaction(connection):
written = books.add_many(extra)
assert written == 50, f"add_many reported {written} rows, expected 50"
assert books.count() == before + 50, "the rows are not in the table"
except NotImplementedError:
announce(9)
return summary(done)
except (AssertionError, sqlite3.Error) as error:
announce(9, str(error))
return summary(done)
done = 9
print(" ok: 9. add_many() — fifty rows, one prepared statement")
connection.close()
return summary(done)
def summary(done: int) -> int:
print()
print(f"{done} of {len(STEPS)} exercises finished.")
if done == len(STEPS):
print("All nine. Now run the real suite: python3 ../examples/test_repository.py")
print("(or, from the lab directory, bash tests/run_tests.sh)")
return 0
return 1
if __name__ == "__main__":
sys.exit(main())
tests/run_tests.sh (21199 bytes)
#!/usr/bin/env bash
# Tests for the Day 090 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# These checks ask whether the data layer actually has the properties it
# claims, rather than whether it runs:
#
# * is a crafted value CODE when concatenated and DATA when bound — both
# demonstrated on a throwaway database, both asserted?
# * does a failure halfway through a transaction leave the database
# exactly as it was, for a SQL error and for a Python one?
# * is PRAGMA foreign_keys really per-connection, and really a no-op
# inside a transaction?
# * does `with connection:` leave the connection OPEN?
# * is every SQL statement in the lab a literal, with every value bound?
# * does executemany beat a loop, and does a transaction beat both?
# * does the starter refuse to look finished before it is?
#
# Everything runs offline. No server, no port, no credential, no third-party
# package. Every database is created inside a directory made with mktemp -d
# and removed in a trap, so a completed run leaves nothing behind — and one
# of the checks asserts exactly that.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
work_root=""
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
yesno() { if [ "$1" -eq 0 ]; then echo yes; else echo no; fi; }
contains() {
# contains <file> <literal string>
if grep -qF -- "$2" "$1"; then echo yes; else echo no; fi
}
cleanup() {
[ -n "${work_root}" ] && [ -d "${work_root}" ] && rm -rf "${work_root}"
}
trap cleanup EXIT INT TERM
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
python_bin="$(resolve_tool python3 "${PYTHON:-}")" || {
echo "FAIL: python3 not found on PATH." >&2
echo " Install Python 3.11 or newer and try again." >&2
exit 1
}
work_root="$(mktemp -d "${TMPDIR:-/tmp}/day090-XXXXXX")"
work="${work_root}/lab"
out="${work_root}/out"
mkdir -p "${work}" "${out}"
cp "${lab_dir}/examples/"*.py "${work}/"
echo "Day 090 — A Real Data Layer"
echo
# ===========================================================================
echo "1. The interpreter reports itself, and the module is the standard one"
# ===========================================================================
py_version="$("${python_bin}" -c 'import sys; print(".".join(map(str, sys.version_info[:3])))')"
sqlite_version="$("${python_bin}" -c 'import sqlite3; print(sqlite3.sqlite_version)')"
echo " python: ${py_version}"
echo " sqlite3.sqlite_version: ${sqlite_version}"
"${python_bin}" -c 'import sqlite3, sys; sys.exit(0 if sqlite3.paramstyle == "qmark" else 1)'
check "the sqlite3 module is present and its paramstyle is qmark" "$(yesno $?)"
"${python_bin}" - <<'PY' >/dev/null 2>&1
import sqlite3, sys
sys.exit(0 if hasattr(sqlite3.connect(":memory:"), "autocommit") else 1)
PY
check "Connection.autocommit exists on this interpreter (Python 3.12 or newer)" "$(yesno $?)"
"${python_bin}" -c 'import sqlite3, sys; sys.exit(0 if sqlite3.sqlite_version_info >= (3, 37, 0) else 1)'
check "the linked SQLite is 3.37.0 or newer, so STRICT tables are available" "$(yesno $?)"
# ===========================================================================
echo
echo "2. The connection factory configures what has to be configured"
# ===========================================================================
cd "${work}" || exit 1
"${python_bin}" - <<'PY' >"${out}/factory.txt" 2>&1
import sqlite3, tempfile, shutil
from pathlib import Path
from db import connect, apply_schema
sandbox = Path(tempfile.mkdtemp(prefix="day090-factory-"))
try:
path = sandbox / "x.db"
configured = connect(path)
apply_schema(configured)
print("foreign_keys(configured):", configured.execute("PRAGMA foreign_keys").fetchone()[0])
print("isolation_level:", repr(configured.isolation_level))
row = configured.execute("SELECT 1 AS one").fetchone()
print("row type:", type(row).__name__, "by name:", row["one"])
configured.close()
raw = sqlite3.connect(path)
print("foreign_keys(raw):", raw.execute("PRAGMA foreign_keys").fetchone()[0])
raw.close()
finally:
shutil.rmtree(sandbox, ignore_errors=True)
PY
check "the factory turns foreign keys ON" \
"$(contains "${out}/factory.txt" 'foreign_keys(configured): 1')"
check "a plain sqlite3.connect leaves them OFF — the setting is per connection" \
"$(contains "${out}/factory.txt" 'foreign_keys(raw): 0')"
check "the factory turns off the module's implicit transaction handling" \
"$(contains "${out}/factory.txt" 'isolation_level: None')"
check "rows arrive as sqlite3.Row, addressable by column name" \
"$(contains "${out}/factory.txt" 'row type: Row by name: 1')"
# ===========================================================================
echo
echo "3. Injection: the same value as CODE, then as DATA"
# ===========================================================================
"${python_bin}" injection_demo.py >"${out}/injection.txt" 2>&1
injection_status=$?
check "injection_demo.py runs and every one of its own assertions holds" "$(yesno ${injection_status})"
check "the concatenated query leaked all three private rows" \
"$(contains "${out}/injection.txt" 'rows: 3 -> every member, with address and PIN')"
check "the crafted value changed the statement's meaning" \
"$(contains "${out}/injection.txt" "WHERE name = 'Ada' OR '1'='1'")"
check "execute() refuses a second statement — a module limit, not a defence" \
"$(contains "${out}/injection.txt" 'ProgrammingError: You can only execute one statement at a time.')"
check "executescript() accepts it, and the table is destroyed for real" \
"$(contains "${out}/injection.txt" 'members table exists afterwards: False')"
check "the identical value, bound, returns zero rows" \
"$(contains "${out}/injection.txt" 'rows returned: 0')"
check "and leaves all three members in place" \
"$(contains "${out}/injection.txt" 'ok: and still holds all three rows')"
check "the demonstration built its database inside a throwaway directory" \
"$(contains "${out}/injection.txt" 'sandbox removed. Nothing outside it was ever opened.')"
# The repository must never be fooled by the same input.
"${python_bin}" - <<'PY' >"${out}/repo_injection.txt" 2>&1
import shutil, tempfile
from pathlib import Path
import seed
from db import BookRepository
sandbox = Path(tempfile.mkdtemp(prefix="day090-repoinj-"))
try:
connection = seed.build(sandbox / "library.db")
books = BookRepository(connection)
before = books.count()
for hostile in ("Fred Brooks' OR '1'='1", "Fred Brooks'; DROP TABLE books; --"):
found = books.find_by_author(hostile)
print(f"rows for hostile input: {len(found)}")
print("books before:", before, "after:", books.count())
connection.close()
finally:
shutil.rmtree(sandbox, ignore_errors=True)
PY
check "the repository returns nothing for either hostile author name" \
"$(contains "${out}/repo_injection.txt" 'rows for hostile input: 0')"
check "and the books table is unchanged afterwards" \
"$(contains "${out}/repo_injection.txt" 'books before: 7 after: 7')"
# ===========================================================================
echo
echo "4. No SQL anywhere in this lab is built out of pieces"
# ===========================================================================
"${python_bin}" no_sql_strings.py "${work}" >"${out}/guard.txt" 2>&1
check "the guard finds no assembled SQL in the lab's own code" "$(yesno $?)"
guard_dir="${work_root}/guardcheck"
mkdir -p "${guard_dir}"
cat >"${guard_dir}/bad.py" <<'PY'
def find(connection, author):
return connection.execute(f"SELECT * FROM books WHERE author = '{author}'").fetchall()
PY
"${python_bin}" no_sql_strings.py "${guard_dir}" >"${out}/guard_bad.txt" 2>&1
guard_bad_status=$?
if [ ${guard_bad_status} -ne 0 ]; then bad_ok=0; else bad_ok=1; fi
check "and the guard does catch a deliberately unsafe f-string" "$(yesno ${bad_ok})"
check "naming the file, the line and the reason" \
"$(contains "${out}/guard_bad.txt" 'bad.py:2: SQL built by an f-string and passed straight to execute()')"
cat >"${guard_dir}/fine.py" <<'PY'
def find(connection, author):
return connection.execute(
"SELECT book_id, title FROM books"
" WHERE author = ? ORDER BY year",
(author,),
).fetchall()
PY
rm -f "${guard_dir}/bad.py"
"${python_bin}" no_sql_strings.py "${guard_dir}" >/dev/null 2>&1
check "and does NOT flag two adjacent string literals, which Python joins at compile time" \
"$(yesno $?)"
# ===========================================================================
echo
echo "5. Transactions: all of it, or none of it"
# ===========================================================================
"${python_bin}" transactions_demo.py >"${out}/transactions.txt" 2>&1
check "transactions_demo.py runs to the end" "$(yesno $?)"
check "a fresh connection defaults to implicit transaction handling" \
"$(contains "${out}/transactions.txt" "default isolation_level: ''")"
check "DDL opens no transaction; DML does" \
"$(contains "${out}/transactions.txt" 'after INSERT (DML): True')"
check "with connection: rolls back when the block raises" \
"$(contains "${out}/transactions.txt" 'rows in t now: [1]')"
check "with connection: does NOT close the connection" \
"$(contains "${out}/transactions.txt" 'is the connection still usable after the with-block? True')"
check "a foreign-key failure undid the earlier write in the same transaction" \
"$(contains "${out}/transactions.txt" 'the transaction raised: IntegrityError: FOREIGN KEY constraint failed')"
check "PRAGMA foreign_keys set inside a transaction is silently ignored" \
"$(contains "${out}/transactions.txt" 'inside a transaction, set ON -> 0')"
check "and takes effect outside one" \
"$(contains "${out}/transactions.txt" 'outside again, set ON -> 1')"
check "autocommit = True makes a write visible to another connection at once" \
"$(contains "${out}/transactions.txt" 'autocommit = True: in_transaction False')"
# The rollback property, asserted directly rather than read from a log.
"${python_bin}" - <<'PY' >"${out}/rollback.txt" 2>&1
import shutil, sqlite3, sys, tempfile
from pathlib import Path
import seed
from db import BookRepository, LoanRepository, connect, transaction
sandbox = Path(tempfile.mkdtemp(prefix="day090-rollback-"))
ok = True
try:
path = sandbox / "library.db"
connection = seed.build(path)
books, loans = BookRepository(connection), LoanRepository(connection)
before = (books.get(1).copies, loans.open_count(), books.count())
try:
with transaction(connection):
loans.borrow(1, 1, "2026-08-01", "2026-08-15")
loans.borrow(2, 999, "2026-08-01", "2026-08-15")
except sqlite3.IntegrityError:
pass
after_sql = (books.get(1).copies, loans.open_count(), books.count())
try:
with transaction(connection):
loans.borrow(1, 1, "2026-08-01", "2026-08-15")
raise ZeroDivisionError("a bug in the middle of a transaction")
except ZeroDivisionError:
pass
after_python = (books.get(1).copies, loans.open_count(), books.count())
connection.close()
# A second connection sees the same thing: nothing was committed.
fresh = connect(path)
after_reopen = (
BookRepository(fresh).get(1).copies,
LoanRepository(fresh).open_count(),
BookRepository(fresh).count(),
)
fresh.close()
print("before: ", before)
print("after sql: ", after_sql)
print("after python:", after_python)
print("after reopen:", after_reopen)
ok = before == after_sql == after_python == after_reopen
print("UNCHANGED:", ok)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
sys.exit(0 if ok else 1)
PY
check "a SQL error and a Python error both leave the database byte-identical" "$(yesno $?)"
check "and a newly opened connection agrees that nothing was committed" \
"$(contains "${out}/rollback.txt" 'UNCHANGED: True')"
# ===========================================================================
echo
echo "6. Cursors, fetch methods and row factories"
# ===========================================================================
"${python_bin}" cursors_demo.py >"${out}/cursors.txt" 2>&1
check "cursors_demo.py runs to the end" "$(yesno $?)"
check "execute() returns a Cursor" \
"$(contains "${out}/cursors.txt" 'type(connection.execute(...)) -> Cursor')"
check "rowcount is -1 for a SELECT, because the row count is not known in advance" \
"$(contains "${out}/cursors.txt" 'cursor.rowcount for a SELECT -> -1')"
check "an exhausted cursor returns None from fetchone" \
"$(contains "${out}/cursors.txt" 'fetchone() now -> None')"
check "sqlite3.Row is not a dict" \
"$(contains "${out}/cursors.txt" 'isinstance(row, dict) = False')"
check "and a dict factory produces one when a dict is what you need" \
"$(contains "${out}/cursors.txt" 'type: dict')"
"${python_bin}" - <<'PY' >"${out}/memory.txt" 2>&1
import shutil, sys, tempfile, tracemalloc
from pathlib import Path
import seed
from db import BookRepository, transaction
sandbox = Path(tempfile.mkdtemp(prefix="day090-memory-"))
try:
connection = seed.build(sandbox / "library.db")
connection.execute("CREATE TABLE wide (n INTEGER, payload TEXT) STRICT")
with transaction(connection):
connection.executemany(
"INSERT INTO wide (n, payload) VALUES (?, ?)",
[(n, "x" * 200) for n in range(40_000)],
)
tracemalloc.start()
rows = connection.execute("SELECT n, payload FROM wide").fetchall()
peak_fetchall = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
del rows
tracemalloc.start()
total = 0
for row in connection.execute("SELECT n, payload FROM wide"):
total += row["n"]
peak_iterate = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
connection.close()
print(f"fetchall peak: {peak_fetchall:,} iterate peak: {peak_iterate:,}")
sys.exit(0 if peak_fetchall > peak_iterate * 100 else 1)
finally:
shutil.rmtree(sandbox, ignore_errors=True)
PY
check "fetchall holds the whole result in memory; iterating a cursor does not (>100x apart)" \
"$(yesno $?)"
# ===========================================================================
echo
echo "7. Errors: each mistake raises the class it should"
# ===========================================================================
"${python_bin}" errors_demo.py >"${out}/errors.txt" 2>&1
check "errors_demo.py made thirteen deliberate mistakes and all thirteen raised" "$(yesno $?)"
check "a broken constraint raises IntegrityError" \
"$(contains "${out}/errors.txt" 'duplicate title (UNIQUE) IntegrityError')"
check "a missing table raises OperationalError" \
"$(contains "${out}/errors.txt" 'table that is not there OperationalError')"
check "misusing the module raises ProgrammingError" \
"$(contains "${out}/errors.txt" 'two statements in one execute() ProgrammingError')"
check "a STRICT column refuses the wrong type" \
"$(contains "${out}/errors.txt" 'cannot store TEXT value in INTEGER column books.year')"
# ===========================================================================
echo
echo "8. executemany, and the transaction that matters more"
# ===========================================================================
"${python_bin}" bulk_insert.py 2000 >"${out}/bulk.txt" 2>&1
check "bulk_insert.py stored every row by all three methods" "$(yesno $?)"
"${python_bin}" - "${out}/bulk.txt" <<'PY' >"${out}/bulk_order.txt" 2>&1
import re, sys
text = open(sys.argv[1], encoding="utf-8").read()
seconds = {}
for line in text.splitlines():
match = re.match(r"^(.+?)\s{2,}(\d+\.\d+)\s+([\d,]+)\s+([\d.]+)x$", line)
if match:
seconds[match.group(1).strip()] = float(match.group(2))
print(seconds)
loop = seconds["a loop, no transaction"]
batched = seconds["a loop inside one transaction"]
many = seconds["executemany inside one transaction"]
print(f"loop/batched = {loop / batched:.1f}x batched/many = {batched / many:.2f}x")
sys.exit(0 if loop > batched > 0 and many > 0 else 1)
PY
check "one transaction around the loop is dramatically faster than one per row" "$(yesno $?)"
check "all three methods were timed and reported" \
"$(contains "${out}/bulk.txt" 'executemany inside one transaction')"
# ===========================================================================
echo
echo "9. The data layer's own suite, and the boundary it protects"
# ===========================================================================
"${python_bin}" test_repository.py >"${out}/unittest.txt" 2>&1
check "python3 test_repository.py exits 0" "$(yesno $?)"
check "and reports OK" "$(contains "${out}/unittest.txt" 'OK')"
unit_count="$(grep -oE '^Ran [0-9]+ test' "${out}/unittest.txt" | grep -oE '[0-9]+' | head -1)"
echo " unit tests run: ${unit_count:-0}"
if [ "${unit_count:-0}" -ge 25 ]; then units_ok=0; else units_ok=1; fi
check "the suite contains at least 25 tests" "$(yesno ${units_ok})"
"${python_bin}" report.py >"${out}/report.txt" 2>&1
check "report.py runs the whole application layer" "$(yesno $?)"
check "the three-table overdue report is right" \
"$(contains "${out}/report.txt" 'The Mythical Man-Month due 2026-06-22 (55 days late)')"
check "a duplicate title surfaces as a domain error, never as sqlite3.IntegrityError" \
"$(contains "${out}/report.txt" "refused: a book titled 'Compilers' is already stored")"
check "a sort key that is not on the allow-list is refused before any SQL exists" \
"$(contains "${out}/report.txt" 'cannot sort by')"
if grep -qE '^\s*import sqlite3|^\s*from sqlite3' "${work}/report.py" "${work}/domain.py"; then
boundary=1
else
boundary=0
fi
check "neither the application layer nor the domain imports sqlite3" "$(yesno ${boundary})"
# ===========================================================================
echo
echo "10. The starter cannot look finished before it is"
# ===========================================================================
starter_work="${work_root}/starter"
mkdir -p "${starter_work}"
cp "${lab_dir}/starter/"*.py "${starter_work}/"
( cd "${starter_work}" && "${python_bin}" smoke.py >"${out}/starter.txt" 2>&1 )
starter_status=$?
if [ ${starter_status} -ne 0 ]; then starter_ok=0; else starter_ok=1; fi
check "the shipped starter exits non-zero and names exercise 1" "$(yesno ${starter_ok})"
check "and says so in words rather than a traceback" \
"$(contains "${out}/starter.txt" '0 of 9 exercises finished.')"
cp "${work}/db.py" "${starter_work}/db.py"
( cd "${starter_work}" && "${python_bin}" smoke.py >"${out}/starter_done.txt" 2>&1 )
check "a completed db.py takes the same starter to exit 0" "$(yesno $?)"
check "reporting all nine" \
"$(contains "${out}/starter_done.txt" '9 of 9 exercises finished.')"
# ===========================================================================
echo
echo "11. Offline, self-contained, and leaves nothing behind"
# ===========================================================================
if grep -rlE 'https?://|[0-9]{1,3}(\.[0-9]{1,3}){3}' "${lab_dir}/examples" "${lab_dir}/starter" >/dev/null 2>&1; then
net=1
else
net=0
fi
check "no executable lab file contains a network address of any kind" "$(yesno ${net})"
if grep -rhE '^\s*(import|from)\s+' "${lab_dir}/examples" "${lab_dir}/starter" \
| grep -qE '\b(requests|httpx|urllib3|pandas|sqlalchemy|aiosqlite|numpy|pytest)\b'; then
thirdparty=1
else
thirdparty=0
fi
check "no lab file imports a third-party package — standard library only" "$(yesno ${thirdparty})"
if grep -rq 'sudo' "${lab_dir}/examples" "${lab_dir}/starter"; then sudo_used=1; else sudo_used=0; fi
check "nothing in the lab's code asks for sudo" "$(yesno ${sudo_used})"
leftover="$(find "${lab_dir}" -name '*.db' -o -name '*.db-journal' -o -name '*.db-wal' | wc -l | tr -d ' ')"
if [ "${leftover}" = "0" ]; then left_ok=0; else left_ok=1; fi
check "this run left no database file anywhere inside the lab directory" "$(yesno ${left_ok})"
# Every script makes its own sandbox with a day090- prefix and removes it in
# a finally block. The only one that may still exist is this harness's own,
# which its trap removes when the script exits.
stray="$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'day090-*' \
! -name "$(basename "${work_root}")" 2>/dev/null | wc -l | tr -d ' ')"
if [ "${stray}" = "0" ]; then stray_ok=0; else stray_ok=1; fi
check "no sandbox from any lab script was left in the temporary directory" "$(yesno ${stray_ok})"
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ] || exit 1
exit 0
Troubleshooting
Troubleshooting — Day 090
Every entry says what you will see, what is actually happening, and what to do about it.
sqlite3.ProgrammingError: Incorrect number of bindings supplied. The current statement uses 1, and there are 5 supplied.
You forgot the comma:
connection.execute("SELECT * FROM books WHERE title = ?", (title)) # WRONG
connection.execute("SELECT * FROM books WHERE title = ?", (title,)) # right
(title) is just title in brackets. If title is a five-character
string, the module sees a five-item sequence and reports exactly that. A
list works too and is harder to get wrong: [title].
sqlite3.ProgrammingError: Binding 1 (':title') is a named parameter, but you supplied a sequence which requires nameless (qmark) placeholders.
You mixed the two styles. Named placeholders take a mapping:
connection.execute("SELECT * FROM books WHERE title = :title", {"title": title})
Qmark placeholders take a sequence. Pick one per statement.
sqlite3.ProgrammingError: You can only execute one statement at a time.
execute runs exactly one statement. For a multi-statement script, use
executescript — and understand what you are agreeing to: it takes no
parameters at all, and it issues an implicit COMMIT before it runs, so
it can never be nested inside a transaction.
If this error appeared because a value contained a semicolon, stop and
read security.md. You are building SQL out of a string.
The same value returns rows when concatenated and none when bound
That is the lab working. See examples/injection_demo.py, act 1 and act 4.
sqlite3.IntegrityError: FOREIGN KEY constraint failed
The write named a row that does not exist. Check the id.
The opposite is more interesting: if a write you expected to be refused is
accepted, PRAGMA foreign_keys is off on that connection. It is OFF by
default, it is per connection, and it is not remembered in the file. Use
db.connect() rather than sqlite3.connect() and it is handled.
PRAGMA foreign_keys = ON runs without error and changes nothing
You are inside a transaction. The pragma is a silent no-op there: no
error, no warning, no change. Run it immediately after connecting, which is
what db.connect() does, before anything can open a transaction.
connection.execute("PRAGMA foreign_keys = ON")
print(connection.execute("PRAGMA foreign_keys").fetchone()[0]) # 1, or 0 if it was ignored
sqlite3.IntegrityError: cannot store TEXT value in INTEGER column books.year
A STRICT table refusing the wrong type — which is what you asked it to do.
Convert the value before binding, or declare the column ANY if it
genuinely holds mixed types. Note the exception class: a type violation in a
STRICT table is an IntegrityError, not a DataError.
sqlite3.ProgrammingError: Cannot operate on a closed database.
You used the connection after close(). The usual cause is this misreading:
with sqlite3.connect(path) as connection: # does NOT close on exit
...
connection.execute("SELECT 1") # so this actually works
with connection: manages a transaction, not the connection's lifetime.
For closing, use contextlib.closing:
from contextlib import closing
with closing(db.connect(path)) as connection:
with db.transaction(connection):
...
My writes vanished when the program exited
Nothing committed them. In the module's default mode a transaction opens
before the first INSERT, UPDATE or DELETE and stays open until you
commit. Exit without committing and the work is rolled back, silently.
Either use db.transaction() — which commits at the end of the block — or
with connection:, or set connection.autocommit = True and accept that
every statement then stands alone.
sqlite3.OperationalError: database is locked
Another connection holds the write lock. Usually it is a connection your own
program forgot to close, or a db.connect() left open in an interactive
session. connect() sets a five-second busy timeout, so this means the lock
was held for longer than that.
SQLite allows many readers and one writer at a time. Keep write transactions short: do the computation first, then open the transaction.
sqlite3.OperationalError: no such table: books
You connected to the wrong file. sqlite3.connect("library.db") is a
relative path, and if the file does not exist SQLite creates a new empty one
rather than complaining — so this error usually means "you just made an
empty database next door".
print(Path("library.db").resolve(), Path("library.db").stat().st_size)
DeprecationWarning: The default date adapter is deprecated as of Python 3.12
You passed a datetime.date or datetime.datetime straight to a
placeholder. The default adapters are deprecated and will be removed. Store
dates as ISO-8601 text, which is what this lab does throughout:
connection.execute("INSERT INTO loans (due_on) VALUES (?)", (due.isoformat(),))
Or register your own adapter and converter, explicitly, so the conversion is code you own rather than a default that is going away.
smoke.py prints "0 of 9 exercises finished"
That is the starter telling you where to begin. Open starter/db.py, find
EXERCISE 1, write it, and run smoke.py again. It exits non-zero until
all nine are done, on purpose.
smoke.py says "stream_all calls fetchall; iterate the cursor instead"
Exercise 7 asks for a generator. Replace return [row_to_book(r) for r in cursor.fetchall()] with a loop that yields:
for row in cursor:
yield row_to_book(row)
The check parses your code with ast, so a mention of fetchall in a
comment does not count — only a call.
bulk_insert.py takes a long time
The first of its three methods commits once per row, and on a filesystem
that really flushes, each commit is an fsync. Twenty thousand rows that
way took about thirteen seconds on the authoring machine. That slowness is
the finding, not a fault. Pass a smaller number if you are impatient:
python3 bulk_insert.py 2000.
bulk_insert.py shows almost no difference between the three methods
Your filesystem is not really flushing to disk — normal in some containers
and virtual machines. The ordering still holds; the demonstration is just
less dramatic. expected-output/FIELDS.md says so explicitly.
tests/run_tests.sh fails one check
Read the FAIL: line — each one names a property, not a file. Then run the
matching script from examples/ by hand to see the whole output. The
harness copies everything into a temporary directory, so a failure never
leaves your own work in a strange state.
If it is the last check — a sandbox left in the temporary directory — a
script crashed before its finally: ran. Remove the leftovers with
find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'day090-*' and delete what it
lists.
bash: tests/run_tests.sh: No such file or directory
Run it from the lab directory, not from tests/:
cd labs/sections/programming-with-python/day-090-sqlite-from-python
bash tests/run_tests.sh
Something left a .db file behind
Delete it. A SQLite database is one ordinary file with no service and no
registry entry. If you see -journal or -wal beside it, those belong to
the same database; remove them together, and only when no process has it
open.
Security notes
Security notes — Day 090
This lab is mostly a security lab wearing a data-access hat. Read this page before you run anything, because one of the scripts destroys a table on purpose.
The one thing to know before you run it
examples/injection_demo.py performs a real SQL injection attack and really
drops a table. It is safe, and here is precisely why:
- It calls
tempfile.mkdtemp()and builds its own database inside that fresh directory. It never opens a file you created, never takes a path from the command line, and never touches the lab's own data. - The directory is removed in a
finally:block, so it goes away even if a check inside the script fails. - The last line it prints is
sandbox removed. Nothing outside it was ever opened., andtests/run_tests.shasserts that line is there.
Every other script does the same thing. There is no database file anywhere in this lab directory at rest, and a harness check fails if a run leaves one.
SQL injection, demonstrated rather than described
The dangerous line and the safe line look almost identical, which is exactly why this is worth seeing rather than being told:
## WRONG — the value is inside the statement before the parser sees it.
connection.execute("SELECT * FROM members WHERE name = '" + name + "'")
## RIGHT — the statement is compiled first; the value is bound afterwards.
connection.execute("SELECT * FROM members WHERE name = ?", (name,))
With name = "Ada' OR '1'='1", the first form returns every member in the
table, addresses and PINs included. The second returns nothing, because no
member is called that.
Four precisions, because half-understood advice is what gets people hurt.
- Escaping is not the fix. Writing your own quote-doubling means being right about every encoding and dialect quirk, forever, in code that gets copied. Binding moves the problem to the engine, where it belongs.
executerefusing two statements is not a defence. It is a limit of Python'ssqlite3module, and the lab shows the same string succeeding throughexecutescript. More importantly, act 1 of the demonstration leaks every row without needing a second statement at all. Reading a table you should not see is usually worth more to an attacker than destroying it.- Parameters are for values, not identifiers.
ORDER BY ?binds a value, so every row sorts by the same constant — a test inexamples/test_repository.pyproves it. When a column name must vary, select from an allow-list you wrote.db.pykeeps whole statements inSORTED_QUERIESso that nothing is assembled even there. LIKEpatterns still need care. Binding a value into aLIKEcomparison is safe from injection, but%and_inside that value are still wildcards. Use anESCAPEclause when the user's text should be matched literally.
The mechanical guard
examples/no_sql_strings.py parses every Python file with ast and fails
if a statement reaching execute, executemany or executescript was
built by an f-string, +, % or .format. It runs inside the test suite.
Two properties make it worth having rather than performative: it does not flag adjacent string literals, which are the correct way to wrap a long statement across lines, and the test suite feeds it a deliberately unsafe file to prove it still catches one. A guard nobody has watched fail is a guard you are guessing about.
What a SQLite file is, from a program's point of view
- No users, no roles, no passwords. Filesystem permissions are the
entire access control.
chmod 600a database holding anything private. - Not encrypted.
strings library.dbreads your data. Encrypted builds exist as separate products; the standard library does not encrypt. - A deleted row is not scrubbed. The space is marked free and reused.
VACUUMrebuilds the file;PRAGMA secure_delete = ONoverwrites. Neither is on by default. - Your program's privileges are the database's privileges. There is no
boundary between them, which is why the injection above is not just a
data-leak risk:
PRAGMAstatements and, in some builds, extension loading are reachable from SQL text. - Never open a database file you were sent. The format is complex and a deliberately corrupted file is an attack surface. Nothing in this lab opens a file it did not create.
Errors are a security surface too
db.py catches sqlite3.IntegrityError at the repository boundary and
raises DuplicateTitle. That is a design decision with a security edge: raw
database errors leak schema details — table names, column names, constraint
names — and an error message is the cheapest reconnaissance an attacker
gets. Translate at the boundary; log the original, show the translation.
Foreign keys are a security control
PRAGMA foreign_keys is OFF by default in SQLite, per connection. Off,
every REFERENCES clause in your schema enforces nothing. connect() turns
it on the moment the connection is opened, and a test asserts that a plain
sqlite3.connect to the same file has it off — because the setting belongs
to the connection, not the file.
There is a second trap, and it is silent: setting the pragma inside a
transaction is a no-op. No error, no warning, and the value simply does
not change. transactions_demo.py prints 0 and then 1 to show it.
What this lab does not do
- No server, no listening socket, no port.
- No credential of any kind, so nothing to leak.
- No network — asserted mechanically by the harness.
- No third-party package, so no supply chain beyond Python itself.
- No
sudo, and nothing written outside this directory or a temporary one.