Shapes let you declare the SQL for a row right next to the dataclass it fills,
then verify at startup that your templates only read columns that SQL actually
fetched. You write a:name-parameterized SELECT, decorate a frozen dataclass
with@shape, and app.check()proves the contract before you serve a request.
Reach for a Shape when a template block renders a stable, named row that you'd be
unhappy to see silently renderNone. The contract catches drift between your SQL
and your template that is otherwise invisible until a user hits the page.
Shapes sit on top of the Database layer —
same "SQL in, frozen dataclasses out" model, with a declared SQL sidecar and a
startup contract added on top. Import everything fromchirp.data. For one-off or
dynamic queries, usedb.fetch or the Querybuilder instead (see the decision
table below).
The honest scope line: Shapes give you a field-level startup contract and
bounded query counts for the relationships you declare withnested(). They
do not make N+1 queries impossible in general.
@shape vs db.fetch vs Query
Reach for@shapewhen a block needs a verified, co-located row contract. Reach
for a plaindb.fetchwhen the query is one-off, dynamic, or deliberately opaque
(aSELECT *, an aggregate, a hand-tuned join). Reach for the Querybuilder when
you compose aWHEREclause programmatically from optional filters at request time.
| Reach for | When | What you get | What you give up |
|---|---|---|---|
@shape |
A block renders a stable, named row you want verified, nested, or tenant-scoped | Field-level + registry-drift startup contract, boundednested() batching, scope=injection, co-located SQL |
Fixed SQL shape (dynamicWHERE belongs in Query); opaque SQL can't be field-verified |
db.fetch |
A one-off, dynamic, or opaque query — aggregates, ad-hoc joins,SELECT * |
Full SQL freedom, zero ceremony | No startup contract; drift is invisible until a user hits the page |
Query |
AWHERE / ORDER BY / LIMITassembled from optional, request-time filters |
Immutable chainable builder, transparent.sql / .params |
No contract; you bind a plain dataclass, not a Shape |
Rule of thumb: if the same(template, block, row model)triple ships in every
render and you'd be sad to find it silently renderingNone, it wants a Shape. If
the SQL is computed per request, it wantsQuery. Everything else is db.fetch.
Declare a Shape
Decorate a@dataclass(frozen=True, slots=True) row model with @shape("SELECT ..."). The decorated class is the row type — @shapeis an identity decorator
that attaches an immutable metadata sidecar and registers the Shape by name.
from dataclasses import dataclass
from chirp.data import Shape, shape
@shape("SELECT id, title FROM boards WHERE id = :id")
@dataclass(frozen=True, slots=True)
class BoardView:
id: int
title: str
The dataclass must be frozen and slotted. @shapefails loud with a
ShapeErrorotherwise — a mutable or unslotted target is a declaration bug, not
something to paper over at runtime.
Fetch rows
Shapeexposes three async methods. Each takes the Shape class positionally,
theDatabase positionally, then :nameplaceholder values as keyword arguments:
# All matching rows -> list[BoardView]
boards = await Shape.fetch(BoardView, db, id=42)
# First row or None -> BoardView | None
board = await Shape.fetch_one(BoardView, db, id=42)
# Incremental iteration -> AsyncIterator[BoardView]
async for board in Shape.stream(BoardView, db, id=42):
...
| Method | Returns | Notes |
|---|---|---|
Shape.fetch(cls, db, **params) |
list[T] |
All rows as frozen dataclasses |
Shape.fetch_one(cls, db, **params) |
T | None |
First row, orNone |
Shape.stream(cls, db, **params) |
AsyncIterator[T] |
Yields rows incrementally |
The accessor methods expose the declared metadata without running anything:
Shape.sql(cls), Shape.columns(cls) (the parsed SELECT output columns, or ()
when opaque), andShape.computed(cls).
:nameparameter binding
You always write:nameplaceholders. The driver dialect is resolved in one place
at fetch time — SQLite gets?, PostgreSQL gets $N— and parameter values are
never concatenated into the SQL text, so binding stays injection-safe:
@shape("SELECT id, title FROM boards WHERE community_id = :community AND id = :id")
@dataclass(frozen=True, slots=True)
class BoardDetail:
id: int
title: str
board = await Shape.fetch_one(BoardDetail, db, community=1, id=42)
A placeholder referenced but not passed raises ShapeError. A repeated :name
reuses the same value. PostgreSQL::castsyntax passes through verbatim, and a
colon inside a string literal (a time literal like'12:30:00') is not misread as
a placeholder — the binder is comment- and quoted-string aware.
Computed members
A Shape often exposes derived values that are notSELECTcolumns. There are two
idioms, and the contract understands both.
A@propertyor method on the dataclass resolves at runtime and is recognized as a
derived accessor automatically — no declaration needed:
@shape("SELECT id, first_name, last_name FROM members WHERE id = :id")
@dataclass(frozen=True, slots=True)
class Member:
id: int
first_name: str
last_name: str
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
A template reading {{ member.full_name }}is never flagged.
Thecomputed= argument is for derived members a block reads as shapevar.field
that the dataclass does not expose as an attribute (a value injected into the render
context elsewhere). Declaring it tells the contract the read is intentional:
@shape("SELECT id, title FROM boards WHERE id = :id", computed=("badge",))
@dataclass(frozen=True, slots=True)
class BoardCard:
id: int
title: str
Now {{ board.badge }}is treated as Shape-provided.
Why a Shape earns its keep: the under-fetch contract
Theshapecheck category runs inside app.check()and verifies the render
side of a@shapemodel: the fields a block reads must be fields the bound Shape
fetched (SELECT columns) or declared (computed=). A read of a field the Shape
never provided — one that would silently render asNone— is an ERROR.
shapecheckowns four claims, with these default severities:
| Claim | Default severity | Meaning |
|---|---|---|
| Registry drift | ERROR | A surface contract names a Shape no registered Shape backs. |
| Under-fetch | ERROR | A block reads a field the bound Shape never fetched or declared. |
| Over-fetch | WARNING | A Shape column no bound block reads. |
| Un-injectable scope | ERROR | A scoped Shape's SQL is opaque, so the scope predicate can't be injected. |
Registry drift and under-fetch are zero-false-positive and fail loud. Over-fetch is a WARNING because static block coverage is incomplete (loop and macro reads are invisible), so a "column never read" claim is humble by default. One INFO PASS line summarizes the count of verified bindings when nothing errored.
shapecheck cannot double-fire with the data contract: datamatches only
db.fetch(cls, sql) database-handle receivers, while Shape.fetch(...)has the
Shape class as its receiver. The two categories fire on disjoint call sites.
You can adjust how severe eachshapecheck claim is with override_contract_severity
— but that lever is coarser than it looks.
When shapecheck stays silent (escape hatches)
The field-level claim is made only for single-objectshapevar.fieldaccess. The
following are subtracted from a block's reads before any field claim, so a read that
falls into one of these is intentionally not checked:
- Template globals —
url_for,csrf_token,csp_nonce,range,len, and any name registered as an environment global. - Block-local bindings — names bound inside the block via
{% set %},let,export,capture,def, orregion, plus{% for %}loop targets and macro parameters. - The literal context keys
errorandform, plus Suspense's injected__chirp_defer_pending__key — reactive dependency-analysis noise, never fields. - Derived accessors — a read where the name is a real
@property/method on the bound dataclass. (Reading one also suppresses the over-fetch claim for that binding, since its column coverage is invisibly incomplete.) - Loop-collapsed reads — in
{% for c in cards %}{{ c.field }}{% endfor %}, only the collection rootcardsappears in the dependency set; the per-item reads are invisible. The contract verifies the root is bound, not the per-item fields. nested()field reads — a nested relationship is a real dataclass field but not aSELECTcolumn, and the contract recognizes it as Shape-provided.- Macro /
def-arg reads — the def name leaks into dependencies, but field reads behind an arg name do not. - Opaque Shapes —
SELECT */ expression projections / CTE / UNION resolve tocolumns == (), an explicit escape hatch with no field claims. - Framework templates — anything under
chirp/orchirpui/is skipped.
Only the first attribute of a dotted path is ever checked:board.meta.created
checksmeta, never created. The one-line takeaway: genuine typos still fire;
globals, loop/macro reads, derived accessors, and opaque SQL are skipped.
Registry drift detection
Every@shape is auto-registered by name (its class name, or an explicit name=),
so the framework keeps a process-wide registry of named Shapes. A surface
contract maps a surface name (a page, a view, an endpoint) to the Shape name that
backs it. Register it as contract-check data:
app.set_contract_check_data("surface_contracts", {
"board-page": "BoardView",
"board-detail": "BoardDetail",
})
At app.check()time, every surface-contract target is resolved against the live
registry. A target that resolves to no registered Shape — a typo, or a view renamed
away — is an ERROR with a closest-match suggestion:
ERROR shapecheck Surface contract 'board-page' names Shape
'BoardViwe', but no such Shape is registered.
Register a @shape-decorated row model named 'BoardViwe', or
fix the surface-contract name. Did you mean 'BoardView'?
This check is fully static, zero false-positive, and runs even with no other
contract data registered — it catches the failure that is otherwise invisible until
a user hits the page. Registering a different class under an already-used name
raisesShapeError; give one of them a distinct @shape(..., name=...).
Nested and batched Shapes
New in 0.8Declare a parent-child relationship on a Shape field withnested(child, *, on, key, optional=False). The child must itself be a @shape-decorated Shape (it
carries its own SQL):
@shape("SELECT id, card_id, body FROM comments WHERE card_id = :card_id")
@dataclass(frozen=True, slots=True)
class Comment:
id: int
card_id: int
body: str
@shape("SELECT id, board_id, title FROM cards WHERE board_id = :board_id")
@dataclass(frozen=True, slots=True)
class Card:
id: int
board_id: int
title: str
comments: tuple[Comment, ...] = nested(Comment, on="card_id", key="id")
@shape("SELECT id, title FROM boards WHERE id = :id")
@dataclass(frozen=True, slots=True)
class BoardDetail:
id: int
title: str
cards: tuple[Card, ...] = nested(Card, on="board_id", key="id")
Shape.fetch(BoardDetail, db, id=1)returns boards with their cards, and each card
with its comments — all frozen. The query count is independent of the child row
count: a level that joins three comments or thirty thousand issues the same
handful of batchedIN-list queries, never one query per parent row.
Two ordering rules to know up front: everynested()field must come after all
scalar fields (@shapefails loud otherwise, instead of letting Python raise the
opaque "non-default argument follows default argument"), andShape.streamraises
ShapeError on a Shape with nested()children — the compiler must buffer parents
to batch children, so useShape.fetch instead. optional=Trueskips the child
level for parents whosekey value is None.
How the bounded query count works (and its exact ceiling)
The compiler runs a batchedIN-list query per child level. The parent keys for a
level are chunked to the driver's variable limit (the historical
SQLITE_MAX_VARIABLE_NUMBERfloor), so a level with more distinct parent keys than
the chunk size is split across a few batched queries and merged.
The total is1 + Σ ceil(distinct_keys_per_level / chunk_size). For the depth-2 tree
above with a normal number of parents, that is exactly three queries — one for
boards, one for all their cards, one for all those cards' comments — and it stays
three whether each board has one comment or three hundred. Only when a single level
has thousands of distinct parent keys does it spill into additional chunk queries,
still O(chunks per level), never O(child rows). The compiler collects the distinct
parent keys, runs the batched query per chunk, merges and groups children by their
join column, and rebuilds each parent viadataclasses.replace.
The compiler reserves the__chirp_placeholder prefix (generated batch keys, the
__chirp_rn row number, the per-parent limit), so an author :nameplaceholder in a
child Shape must not begin with__chirp_. This is enforced: declaring such a
Shape fails loud withShapeErrorat decoration, so a reserved-name collision can
never silently mis-bind at fetch time.
Ordered and limited children
The batched rewrite preserves a child SQL's trailingORDER BYand per-parent
LIMIT — it does not silently flatten "top 5 recent comments per card" into "all
comments, arbitrary order." A per-parentLIMITis compiled into a window-function
top-N so each parent gets its own slice in the single batched query:
@shape("SELECT id, card_id, body, created_at FROM comments "
"WHERE card_id = :card_id ORDER BY created_at DESC LIMIT 5")
@dataclass(frozen=True, slots=True)
class Comment:
id: int
card_id: int
body: str
created_at: str
Now each card carries its 5 most recent comments, ordered, batched — one query,
not one per parent. The child's own non-joinWHERE filters (e.g. AND deleted = 0)
are preserved in the batched form too.
Cases the window rewrite can't express (these fail loud)
This is a bounded top-N compilation, not a general query engine —
ROW_NUMBER() OVER (PARTITION BY {on} ORDER BY ...)and only that shape. Window
functions are available on both backends (SQLite ≥ 3.25 and PostgreSQL). Cases the
rewrite cannot express deterministically fail loud at startup rather than degrade
into "all rows, arbitrary order":
- a
LIMITwith noORDER BY(the per-parent top-N would be nondeterministic); - an
OFFSET(it would apply globally, not per parent); - a per-parent
LIMITon a child that also has its own nested grandchildren (the partition plus further batching is ambiguous); - a join predicate the compiler cannot isolate.
Shape.validate(cls) also raises ShapeErrorwhen a child SQL is opaque, the child
does not carry itsonjoin column as a field, or the parent does not carry the
key field. These checks run at app.check() startup — shapecheckcalls
Shape.validate(cls)on every Shape your app uses — so a malformed declaration is an
ERROR before you serve a byte, withShape.fetchraising the same error as a runtime
backstop.
Tenant scoping
New in 0.8Declare a tenant scope withscope="community_id". The guarantee is delivered by
structurally injecting the scope predicate into every compiled statement — the
parent query and every batched childIN-list query — not by scanning for a
WHEREcolumn you hoped you wrote:
@shape("SELECT id, board_id, title FROM cards WHERE board_id = :board_id",
scope="community_id")
@dataclass(frozen=True, slots=True)
class Card:
id: int
board_id: int
title: str
@shape("SELECT id, title FROM boards", scope="community_id")
@dataclass(frozen=True, slots=True)
class Board:
id: int
title: str
cards: tuple[Card, ...] = nested(Card, on="board_id", key="id")
The :scopevalue threads from the fetch call:
boards = await Shape.fetch(Board, db, scope=1)
The compiler rewrites SELECT id, title FROM boards into ... WHERE community_id = :scope, and the child IN-list query is scoped the same way — so a cross-tenant
child row that happens to join to an in-tenant parent is excluded.
The scope predicate is the compiler's to own. If your SQL already contains
exactlycommunity_id = :scope, it is not duplicated — but a different
author-written predicate on the scope column (community_id = :tenant,
community_id IN (...)) is a fail-loud ShapeError. Remove the hand-written
predicate and let the compiler inject it.
When tenant scope can't be injected (fails loud)
The scope guarantee is unconditional. If a scoped Shape's SQL is one the compiler
cannot structurally analyze a single outerWHEREtarget on, it refuses rather than
ship a query that would silently query across tenants. That covers the opaque cases
(a CTE, aUNION, a SELECT *, no analyzable FROM) and any outer query whose
FROMis a derived table or subquery, or that carries a correlated/scalar subquery —
there, naively appendingAND community_id = :scopewould attach to the inner
subquery'sWHEREand produce invalid or unscoped SQL. This surfaces as a
shapecheck ERROR at startup and a ShapeError from Shape.fetch / Shape.validate:
ERROR shapecheck Shape 'SecretBoard' declares scope='community_id', but its
SQL is opaque/un-injectable (CTE / UNION / SELECT * / derived-table or
subquery FROM / no single analyzable WHERE target): the tenant-scope
predicate cannot be structurally injected.
The fix is to rewrite the SQL as a simple single SELECTwith an explicit column
list and one analyzableFROM.
Page-composite Shapes
New in 0.8A@compositeaggregates several Shapes for one page so the page declares its data
once. Each field is a single Shape (loaded withfetch_one) or a
tuple[Shape, ...] (loaded with fetch):
from chirp.data import Composite, composite
@composite(scope="community_id")
@dataclass(frozen=True, slots=True)
class BoardPage:
board: Board # single-object member -> fetch_one
members: tuple[Member, ...] # sequence member -> fetch
activity: tuple[Event, ...]
Composite.loadruns the batched query set across the members — one query per
member Shape (nested members reuse the bounded compiler) — coalesces the shared
scopeand params, and returns one frozen instance:
page = await Composite.load(BoardPage, db, board_id=7, scope=1)
The page scopes once: when the composite declares scope=, the :scopevalue is
threaded to every member Shape that declares a matchingscope=, so members inherit
the page's single declaration. A field that is not a Shape member fails loud with
ShapeErrorat decoration.
The repository seam
Shapes co-locate the SQL declaration with the block's row model, and the compiled
SQL materializes behindchirp.data — the Databasefacade reached through
Shape.fetch and Composite.load. There is deliberately no render-time API that
accepts a raw SQL string: no return type (Template, Fragment, Page, OOB,
Suspense, Stream, EventStream) takes a sqlparameter. SQL lives only on the
@shape / @compositedeclarations; the frozen result — never a SQL string — is
what reaches the template.
The principle is one-directional: declare a Shape next to the block that renders it, hand the loaded frozen result to the template, and never thread a SQL string through a handler kwarg into a render. This is the same "the return type is the intent" boundary applied to data: the composite is where a page's data lives, and loading it is the repository boundary.
The Shapes data layer end-to-end — startup-verified contract, tenantscope=
isolation at the page and data layer, and a boundednested() / @composite
dashboard whose query count stays constant as rows grow — ships as a runnable
example.
Source: examples/standalone/shapes_workspaces.
Migrate an existing app:chirp shapes-codegen
New in 0.8chirp shapes-codegenhelps you adopt Shapes incrementally, view by view. It has
two non-destructive jobs.
Suggest@shapedecorators (default). Scan Python modules for frozen dataclasses
sitting near an explicit named-columnSELECTliteral, pair each dataclass to the
SELECT whose output columns are a subset of its fields, and print a @shape(...)
suggestion above each match:
chirp shapes-codegen pages/
--- pages/boards.py:14 (BoardView)
+ @shape('SELECT id, title FROM boards WHERE id = :id')
@dataclass(frozen=True, slots=True)
class BoardView: # columns: id, title
3 @shape suggestion(s) (dry-run — no files written).
This is a preview only — --dry-runis the explicit, safe default, and the only
write behavior in v1. Already-decorated classes are skipped, and onlySELECTs the
conservative parser can read are paired, so a suggestion is always oneshapecheck
can later verify.
Audit drift (--audit). Load an app and report every surface-contract name with
no backing Shape, reusing the exact registry-drift logicapp.check()runs. The
pathargument becomes an app import string, and the command exits non-zero when
drift is found, so it drops into CI:
chirp shapes-codegen myapp:app --audit
| Flag | Purpose |
|---|---|
path |
Directory/file to scan (default.); with --audit, an app import string like myapp:app. |
--dry-run |
Print suggested@shapedecorators without writing files (default behavior). |
--audit |
Auditsurface_contractsfor names with no backing Shape; exit non-zero on drift. |
--migrations DIR |
Migrations directory (reserved for future incremental codegen output). |
Error handling
All Shape declaration and execution errors raiseShapeError(importable from
chirp.data). A ShapeErrormeans a declaration is wrong — a non-frozen target, a
missing placeholder value, an un-injectable scoped Shape, an unexpressible nested
relationship, or a name collision — and is meant to fail loud, not be caught and
ignored.