Skip to content

Rendered statement: .sql() on every Query terminal, and the parity pin #766

Closed

Description

@0x054

Parent

#700

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) and delete each have .sql() returning RenderedSql; str(rendered) == rendered.sql; RenderedSql is frozen and exported from ferro with REDACTED.
  • For every terminal on SQLite and Postgres, rendered.sql, rendered.params and rendered.dialect equal the ferro.sql record of the executed call (the parity pin), including a where on a secret column (REDACTED in both) and an include fetch.
  • .sql() on update/delete changes no row and leaves no ferro.sql record for the statement itself; on Postgres the only record it may leave is one catalog probe on first touch.
  • .sql() outside a session raises the same RuntimeError (no database route) as the call.
  • update.sql(**fields) and update.sql(recipe) accept exactly what update() accepts and raise the same TypeError for mixed forms; first.sql() renders the LIMIT 1 statement first() sends.
  • ty passes on Query, Relation and ProjectedQuery uses of .all.sql(), .first.sql(), .count.sql(); tests/test_query_typing.py carries the cases.
  • The docstrings of .sql(), RenderedSql and REDACTED are 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 .sql typing cases), tests/test_static_contracts.py (the mutating-terminals contract now covers update.sql/delete.sql)

Interfaces

  • Consumes: CompiledQuery.secret_values, secret_values_of, the secret_values FFI keyword and sql_log::secret_columns_for from 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; the statement_records fixture. Shipped deviations to build on: count_filtered takes operation="count"|"exists" (so render_count and render_exists may be one render_count twin taking the same keyword, or two entries; either way exists.sql() renders the statement exists() sends), and close_session requires connection_name.
  • Produces: RenderedSql (frozen dataclass: sql: str, params: tuple[object, ...], dialect: Literal["postgres", "sqlite"]; __str__ returns sql), exported as ferro.RenderedSql; ferro.REDACTED re-exported.
  • Produces: terminal decorator in src/ferro/query/rendered.py: wraps an async def method into an object that is callable (runs) and has .sql (renders), typed as Terminal[P, R] with __call__(*P) -> Awaitable[R] and sql(*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); exists renders the statement exists() sends.
  • Produces (Rust, src/operations.rs): per terminal, one build_<terminal>(plan, backend, catalog, …) used by both <terminal>_filtered and render_<terminal>; shipped as BuiltFetch { 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.dialect is "sqlite" or "postgres" by route; using=/session= routing selects the right database in a two-connection test.
  • Happy path: update.sql(age=19) and update.sql(lambda user: {"age": user.age + 1}) render the statements update() sends, with the same params.
  • Edge case: .sql() on update/delete → row count unchanged afterwards, no identity-map entry created, no ferro.sql record except a Postgres catalog probe on first touch.
  • Edge case: a Relation (author.posts.all.sql()) and a ProjectedQuery (select(...)) render; ty accepts the return shapes.
  • Error path: .sql() outside a session → the no-route RuntimeError; update.sql(lambda …, age=1) → the mixed-forms TypeError.
  • 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 --strict on main).

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

Set 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

Activity

  1. added
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:LCross-cutting or multi-worker; local waves only
    on Oct 21, 2026
  2. added theissue type on Oct 21, 2026
  3. self-assigned this
    on Oct 21, 2026
  4. 0x054 commented on Oct 21, 2026

    @0x054
    ContributorAuthor

    Status: verify-passed
    Worker: worker-766
    Branch: sdd/766-rendered-statement
    PR: #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() and update.sql() each reject the mixed form under ty, pinned by tests/static_fixtures/bad_updates.py / good_updates.py.
    Open Minors from the first review: add ProjectedQuery.first / .exists to the parity pin; build_fetch returns BuiltFetch.
    Verify re-run on 23e9e22 with SQLite and Postgres: passed. Trial merge not needed (base unchanged).

  5. 0x054 commented on Oct 21, 2026

    @0x054
    ContributorAuthor

    Merged in #770 into feat/700-sql-inspection (bcb9931).

  6. added 2 commits that reference this issue on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

ready-for-agentUnblocked and fully specified; an agent may claim itsize:LCross-cutting or multi-worker; local waves only

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions