Shapes

Declare the SQL for a row next to the dataclass it fills, then verify at startup that your templates only read columns that SQL fetched.

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

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.

New in 0.8

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.

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.8

Declare 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.

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.

Tenant scoping

New in 0.8

Declare 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.

Page-composite Shapes

New in 0.8

A@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 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.8

chirp 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.