Repository navigation
Rendered statement: .sql() on every Query terminal, and the parity pin #766
Description
Parent
What to build
Every Query terminal renders what it would send. After this ticket:
query = User.where(lambda user: user.age >= 18).order_by(lambda user: user.name)
rendered = await query.all.sql()
rendered.sql # 'SELECT "user"."id", "user"."name", "user"."age" FROM "user" WHERE "user"."age" >= $1 ORDER BY "user"."name" ASC'
rendered.params # (18,)
rendered.dialect # 'postgres'
print(rendered) # the SQL alone
await query.count.sql()
await query.exists.sql()
await query.first.sql()
await query.update.sql(age=19) # same signature as .update(age=19)
await query.update.sql(lambda user: {"age": user.age + 1})
await query.delete.sql()
await Account.where(lambda account: account.id == 1).update.sql(token=SecretStr("sk_live_2"))
# params: (***, 1)all, first, count, exists, update and delete stay async def methods; a decorator gives each a .sql attribute with the method's own signature that returns a RenderedSql instead of running. The rendered statement is produced by the same Rust function that sends the statement: each terminal's build step is separated from its send step so the executing and rendering FFI entries share it. Rendering routes like the call (enclosing session, using=, session=), reads the cached catalog on Postgres so the casts are the ones the call would use, opens no transaction, writes nothing to the identity map, sends nothing but that cached probe, and raises the existing no-route error outside a session. params are masked with the operation ticket's secret-value set, so REDACTED appears where the log would show ***.
The typing holds through the overrides: Relation.all.sql(), ProjectedQuery.all.sql() and ProjectedQuery.count.sql() type-check under ty with the same return shapes as their calls, and query.all.sql is a callable with a .sql attribute in the API reference (the decorator keeps all a method for griffe).
The parity pin ties the two doors: for every terminal, on both dialects, with a model that has a secret column, await query.<terminal>.sql() equals the ferro.sql record the executed call writes, in sql, params (sentinel included) and dialect. AGENTS.md lists it under I-1's enforcement as the query-side counterpart of the cross-emitter pins.
Acceptance criteria
-
all,first,count,exists,update(both forms) anddeleteeach have.sql()returningRenderedSql;str(rendered) == rendered.sql;RenderedSqlis frozen and exported fromferrowithREDACTED. - For every terminal on SQLite and Postgres,
rendered.sql,rendered.paramsandrendered.dialectequal theferro.sqlrecord of the executed call (the parity pin), including awhereon a secret column (REDACTEDin both) and anincludefetch. -
.sql()onupdate/deletechanges no row and leaves noferro.sqlrecord for the statement itself; on Postgres the only record it may leave is onecatalogprobe on first touch. -
.sql()outside a session raises the sameRuntimeError(no database route) as the call. -
update.sql(**fields)andupdate.sql(recipe)accept exactly whatupdate()accepts and raise the sameTypeErrorfor mixed forms;first.sql()renders theLIMIT 1statementfirst()sends. -
typasses onQuery,RelationandProjectedQueryuses of.all.sql(),.first.sql(),.count.sql();tests/test_query_typing.pycarries the cases. - The docstrings of
.sql(),RenderedSqlandREDACTEDare I-14 reference entries; the.sql()example is fenced{test=run prelude=reference}. - AGENTS.md's I-1 enforcement list names the pin and its file.
Files owned
- Create:
src/ferro/query/rendered.py,tests/test_rendered_statement.py - Modify:
src/ferro/query/builder.py,src/ferro/query/__init__.py,src/ferro/__init__.py,src/ferro/_core.pyi,src/operations.rs,src/lib.rs,AGENTS.md - Test:
tests/test_query_typing.py(the.sqltyping cases),tests/test_static_contracts.py(the mutating-terminals contract now coversupdate.sql/delete.sql)
Interfaces
- Consumes:
CompiledQuery.secret_values,secret_values_of, thesecret_valuesFFI keyword andsql_log::secret_columns_forfrom Statement log: every operation statement, with secrets redacted #765 (shipped in PR feat: every operation's statements on the statement log, secrets redacted (#765) #769);ferro._sql_log.REDACTED; thestatement_recordsfixture. Shipped deviations to build on:count_filteredtakesoperation="count"|"exists"(sorender_countandrender_existsmay be onerender_counttwin taking the same keyword, or two entries; either wayexists.sql()renders the statementexists()sends), andclose_sessionrequiresconnection_name. - Produces:
RenderedSql(frozen dataclass:sql: str,params: tuple[object, ...],dialect: Literal["postgres", "sqlite"];__str__returnssql), exported asferro.RenderedSql;ferro.REDACTEDre-exported. - Produces:
terminaldecorator insrc/ferro/query/rendered.py: wraps anasync defmethod into an object that is callable (runs) and has.sql(renders), typed asTerminal[P, R]with__call__(*P) -> Awaitable[R]andsql(*P) -> Awaitable[RenderedSql]; used by the follow-up that extends.sql()to instance and bulk operations. - Produces (FFI,
_core.pyi):render_fetch,render_count,render_exists,render_update,render_delete, each with the parameter list of its executing twin, returning(sql: str, params: tuple, mask: tuple[bool, ...], dialect: str);existsrenders the statementexists()sends. - Produces (Rust,
src/operations.rs): per terminal, onebuild_<terminal>(plan, backend, catalog, …)used by both<terminal>_filteredandrender_<terminal>; shipped asBuiltFetch { statement, schema, join_plan, include_hops }for fetch and a statement-carrying result for count/update/delete (PR feat(query): .sql() on every Query terminal, and the parity pin (#766) #770; panel F12).
Test scenarios
- Happy path: the parity pin, every terminal × both dialects, including a secret-column predicate and an
include. - Happy path:
str(rendered)is the SQL;rendered.dialectis"sqlite"or"postgres"by route;using=/session=routing selects the right database in a two-connection test. - Happy path:
update.sql(age=19)andupdate.sql(lambda user: {"age": user.age + 1})render the statementsupdate()sends, with the same params. - Edge case:
.sql()onupdate/delete→ row count unchanged afterwards, no identity-map entry created, noferro.sqlrecord except a Postgrescatalogprobe on first touch. - Edge case: a
Relation(author.posts.all.sql()) and aProjectedQuery(select(...)) render;tyaccepts the return shapes. - Error path:
.sql()outside a session → the no-routeRuntimeError;update.sql(lambda …, age=1)→ the mixed-formsTypeError. - Integration: the
.sql()docstring example runs under the docstring harness once Docs rewrite: every snippet runs, every fact matches the code, and the API reference renders cleanly #698 lands; until then it renders in the API reference without error (zensical build --strictonmain).
Verify
uv run maturin develop
uv run --no-sync pytest tests/test_rendered_statement.py tests/test_statement_log.py tests/test_query_typing.py tests/test_static_contracts.py -q --no-cov -p no:cacheprovider
uv run --no-sync ty check src/ferro/query tests/test_query_typing.py tests/test_static_contracts.py tests/test_alembic_typing.py
cargo test --no-default-features --features testing
uv run --no-sync ruff check src/ferro tests
uv run --no-sync python -m tests._docs_checks src/ferro/query/builder.py src/ferro/query/rendered.pySet FERRO_POSTGRES_URL and pass --db-backends=sqlite,postgres so the Postgres cases run rather than skip. If tests/_docs_checks.py is absent on the branch (it lands with #698), skip that line and say so in the PR.
Blocked by
- Statement log: every operation statement, with secrets redacted #765 (every operation statement in the log, with redaction).
Activity
0x054 commentedon Oct 21, 2026
Status: verify-passed
Worker: worker-766
Branch:
sdd/766-rendered-statementPR: #770
Verify: passed 23e9e22
Scoped re-review by reviewer-766 after fix round 1: Spec PASS, Quality PASS (#770 (review)).
The Important finding is ADDRESSED.
update()andupdate.sql()each reject the mixed form underty, pinned bytests/static_fixtures/bad_updates.py/good_updates.py.Open Minors from the first review: add
ProjectedQuery.first/.existsto the parity pin;build_fetchreturnsBuiltFetch.Verify re-run on 23e9e22 with SQLite and Postgres: passed. Trial merge not needed (base unchanged).
feat(query): .sql() on every Query terminal renders its statement (#766…
0x054 commentedon Oct 21, 2026
Merged in #770 into
feat/700-sql-inspection(bcb9931).fix(log): panel round 1 for #765/#766 — keyset redaction, hashed secr…
feat: SQL inspection and logging (#700) (#771)