v0.21.0
Postgres Row-Level Security, declared on the model 🔐
Tenant scope used to live entirely in application discipline — a where nobody forgets, forever. That gap is closed. A session carries the tenant; the database enforces it.
from typing import ClassVar
from ferro import Field, Model, RowPolicy, RowSecurity
class Invoice(Model):
id: int | None = Field(default=None, primary_key=True)
ledger_id: uuid.UUID
total: int
__ferro_rls__: ClassVar = RowSecurity(
RowPolicy(column="ledger_id", setting="pinch.ledger_id")
)
async with ferro.engines.session(settings={"pinch.ledger_id": str(ledger_id)}):
open_invoices = await Invoice.where(lambda invoice: invoice.total > 0).all()
# only this ledger's rows. Unset setting → zero rows, not a leak.auto_migrate (or migrate_updates on an existing table) emits the policy:
ALTER TABLE "invoice" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "invoice" FORCE ROW LEVEL SECURITY;
CREATE POLICY "rls_invoice_ledger_id" ON "invoice" FOR ALL
USING ("ledger_id" = NULLIF(current_setting('pinch.ledger_id', true), '')::uuid)
WITH CHECK ("ledger_id" = NULLIF(current_setting('pinch.ledger_id', true), '')::uuid);Default delivery is SET LOCAL at every BEGIN — explicit transaction() blocks, and an implicit per-operation transaction for everything else — so it is safe behind PgBouncer transaction mode, RDS Proxy, and the Supabase pooler. Opt-in PoolConfig(settings_delivery="connection") pins one connection for direct pools; it is never automatic. Tenant not known at session open? Resolve it later with ferro.current_session().set_config(...). Alembic autogenerate emits the same SQL the runtime would run. SQLite registers the declaration, warns, and emits no DDL.
Ship settings delivery first, then the __ferro_rls__ declaration. Reversed, FORCE ROW LEVEL SECURITY with no setting delivered is a blank dashboard, not an error.
Guide: Using with Postgres RLS · PR #426 · closes #406
Position paging: after() / before() from order_by
Keyset paging is derived from the query's own order_by. A position is the ordered tuple of order-key values; after(row) / before(row) are sugar. Cursor encoding stays yours.
page = (
await Card.select()
.order_by(lambda card: card.pinned_at, "desc")
.order_by(lambda card: card.id)
.limit(20)
.all()
)
next_page = (
await Card.select()
.order_by(lambda card: card.pinned_at, "desc")
.order_by(lambda card: card.id)
.after(page[-1])
.limit(20)
.all()
)None is legal in every non-PK slot, so a pinned-first list can cross into unpinned rows in one query (after((None, id))). before(position).limit(n) is the adjacent previous page in declared order.
Breaking vs v0.20: omitting nulls= now means NULLS LAST on every dialect, so a cursor starts in the same bucket on Postgres and SQLite. Pass nulls="native" to keep each backend's own default. Postgres DESC used to put NULLs first.
Guide: Ordering, Limit & Offset · PR #404 · closes #372
Writes that don't clobber: save(only=…) and update recipes
A loaded instance can persist an explicit write-set, so save() no longer overwrites columns another request changed. Bare save() still writes every column.
row = await Conversation.get(cid)
row.messages = msgs
row.updated_at = utcnow()
await row.save(only={"messages", "updated_at"})
# or, on a wide model:
await row.save(exclude={"turns"})Set-oriented writes gained a recipe door — expressions that run in the database. Keyword update(name="x") stays literals-only.
from ferro import now
await Counter.where(lambda counter: counter.id == cid).update(
lambda counter: {"n": counter.n + 1, "updated_at": now}
)
await Conversation.where(lambda conversation: conversation.id == cid).update(
lambda conversation: {"turns": conversation.turns.merge({run_id: entry})}
)+ / - are honest SQL (NULL + 1 is NULL). now is a singleton import, not now(). .merge() is Postgres-only shallow object merge (NULL treated as {}); SQLite names Postgres or .concat() at compile time. .concat() is not shipped yet.
Guide: Partial UPDATE · Recipe updates · PRs #391, #384
Also in this release
JSON object/array backfill on NOT NULL column adds. migrate_updates can add a required json-family column to a table that already has rows — Field(default_factory=dict) backfills existing rows with {}, the same role "draft" already plays for a string. Postgres drops the backfill default afterwards; SQLite keeps it. Scalar factories (uuid4, datetime.now) stay refused. PR #374 · closes #373
Alembic emits checks after table ops. Autogenerate no longer proposes ADD CONSTRAINT before ADD COLUMN for a same-revision column+check pair. PR #431 · closes #423
Query literals match save() for datetime / UUID / Decimal. SQLite stores those as TEXT; after((the_datetime_I_saved, pk)) no longer skips the cursor row because query used +00:00 and save() wrote Z. PR #432 · closes #430
v0.21.0 (2026-09-17)
Bug Fixes
-
alembic: Emit check constraints after table ops (#431,
fca2434) -
query: Canonicalize datetime/UUID/Decimal literals against save() (#432,
23c4031) -
query: Session-aware merge dialect and recipe-column kwargs (#385,
961e1d8)
Features
-
Postgres Row-Level Security — session settings + declarative row policies (PRD #406) (#426,
c5d72f8) -
migrate: Accept JSON object/array literals on NOT NULL column adds (#374,
aa55c16) -
query: Position paging after()/before() from order_by (#404,
de92b42) -
save: Partial persist with only= and exclude= (#391,
e5e4692)
Detailed Changes: v0.20.0...v0.21.0