A Minimal ORM: Mapping Real Python Objects to Real SQL
Building a Web Framework
Chapter 7 ยท A Minimal ORM: Mapping Real Python Objects to Real SQL
Chapter 6 closed on an honest gap: SessionStore's own in-memory dict forgets everything the instant the process ends. This chapter builds a real, minimal ORM — a descriptor-based mapping layer between Python objects and actual SQL — against a genuine SQLite database, and uses it to give SessionStore the real alternative Chapter 6 promised: one that survives a genuine restart.
The Basic Job: Mapping a Row to a Real Object
A database row is a tuple of raw values with no names attached beyond column position; a Python object is a set of named attributes. A small descriptor class declares, once per model class, which attribute maps to which column:
Declaring a real model is two lines of class body:
Author.get(conn, 1) genuinely queries a real database and returns a real Author instance with .id and .name populated from the actual row. __init_subclass__ runs once, the moment Author itself is defined, well before any query is issued — every later call to .get() reuses that already-collected field mapping.
The Real SQL Injection Problem
Model.get() already uses a parameter placeholder — but a lookup by name, written the more tempting way with an f-string, doesn't:
SELECT id, name FROM authors WHERE name = 'x' OR '1'='1'. The input's own embedded quote closes the string literal early; OR '1'='1' is always true, so every row matches. This is exactly Chapter 4's own real XSS bug, one layer down — naive string interpolation trusted a value to stay data, and it stopped being data.
Parameterized queries fix it the same way Chapter 4's own SafeString did — separate the query's fixed structure from the untrusted value:
malicious string, run through find_author_safe(), returns an empty result. The ? placeholder tells the driver where the query's own structure ends and a value begins, and sends the value separately from the SQL text — no character it contains can ever be interpreted as part of the query itself.
The N+1 Query Problem, Measured
A parameterized single-row lookup is safe. Calling it once per related object, though, adds up fast. A one-line addition to Model fetches every row at once:
A second real model, plus a thin wrapper counting every real query issued through it, sets up the measurement — 20 posts split across only 2 distinct authors:
Listing every post along with its own author, the straightforward way:
Batching the author lookup into one IN-clause query, instead of one per post:
prefetch_related() and SQLAlchemy's own real selectinload() both work by this identical mechanism, per each project's own documentation.
The Real Payoff: A SqliteSessionStore
A model with three columns — an ID, a JSON-serialized data blob, and a creation timestamp — is enough to reimplement Chapter 6's own SessionStore, keeping its exact create()/load()/save() interface unchanged:
Wiring it in means changing exactly one line in Chapter 6's own setup — SessionStore() becomes SqliteSessionStore('sessions.db') — and nothing about make_session_middleware() or App has to know or care, since both classes expose the identical three-method interface.
store1 = SqliteSessionStore(db_path), a real create() + save() with {'visits': 1, 'username': 'sam'}, then del store1 — simulating the whole process ending. A genuinely fresh store2 = SqliteSessionStore(db_path), a brand-new connection pointed at the same real file, loading the same session ID, returns the identical {'visits': 1, 'username': 'sam'} back. The in-memory dict from Chapter 6 could never have survived that; a real file on disk does.
A Real, Severe Bug Found Wiring This Into the Live App
Plugging SqliteSessionStore straight into a real App, served through wsgiref on a background thread exactly like every prior chapter's own live demos, crashed immediately — not in this chapter's own isolated tests above, only once a real server was actually handling requests:
sqlite3.connect() ties its own connection object to whichever thread created it, by design, specifically to stop a connection being shared unsafely across threads by accident. make_server(...).serve_forever() runs every real request on its own server thread — genuinely different from the thread that called SqliteSessionStore(db_path) in the first place — so the very first live request touching self.conn failed outright. The fix is a real, explicit opt-in: sqlite3.connect(db_path, check_same_thread=False), telling Python this specific connection genuinely will be used from more than one thread.
With the fix applied, run end to end through the entire real stack — App, Router, Request/Response, the session middleware, and SqliteSessionStore together:
App, a completely new SqliteSessionStore, and a new port standing in for a fresh process — correctly continues the count at 3, not 1. Nothing about the client changed; its own cookie jar simply kept sending the same real session ID it had all along. What changed is that the server, this time, remembered it.
Where This Course Is Headed
Chapter 8 builds real error handling and a development server with live reload — directly resolving Chapter 4's own stale-cache finding, and giving crashes like this chapter's own ProgrammingError a genuinely useful debug page instead of wsgiref's own generic 500.
Hands-On Exercises
Add an update(conn) method to this chapter's own Model base class โ distinct from save(), which uses INSERT OR REPLACE โ that updates an existing row's own non-id columns via a real SQL UPDATE statement, and verify it against a real change to an existing author's name, confirming the change is re-fetchable afterward and that a different row is left completely untouched.
๐ View solutionExtend this chapter's own find_author_naive() into a login_naive(conn, name, password) function checking both a name AND a password column with the identical naive string-interpolation style, and construct a real, working login-bypass injection that returns a matching row without ever knowing the real password.
๐ View solutionAdd a real delete(sid) method to SqliteSessionStore (a real SQL DELETE, not just a Set-Cookie clear), wire it into a logout_handler matching Chapter 6's own real logout exercise, and verify โ by querying the real row count in the sessions table directly, not just by checking the response โ that logging out genuinely removes the session's own row from the database, not just its cookie from the client.
๐ View solutionChapter 7 Quick Reference
- The basic job โ Field/Model maps class attributes to real database columns, verified against live SQLite
- The real SQL injection problem โ naive f-string query building, verified returning every row on a crafted input; the same underlying bug as Chapter 4's own XSS, one layer down
- Parameterized queries โ a ? placeholder, verified turning the identical malicious input completely inert
- The N+1 problem, measured โ 20 posts, 21 real queries via naive lazy loading, down to 2 via one batched IN-clause query
- SqliteSessionStore โ reimplements Chapter 6's own exact create()/load()/save() interface on real SQLite, verified surviving a genuine restart with data intact
- check_same_thread=False โ a real, necessary fix once this store is wired into a live, threaded WSGI server; verified crashing without it, working correctly end to end with it
- Next chapter: Error handling & a real development server with live reload