Skip to content

v0.21.0

Choose a tag to compare

@0x054 0x054 released this 17 Sep 01:53
· 26 commits to main since this release

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)

  • query: Value expressions in update recipes (#384, dc6087a)

  • save: Partial persist with only= and exclude= (#391, e5e4692)


Detailed Changes: v0.20.0...v0.21.0