Skip to content

feat(query): .sql() on every Query terminal, and the parity pin (#766) - #770

Merged
0x054 merged 4 commits into
feat/700-sql-inspectionfrom
sdd/766-rendered-statement
Oct 21, 2026
Merged

0x054 merged 4 commits into
feat/700-sql-inspectionfrom
sdd/766-rendered-statement

Conversation

@0x054

@0x054 0x054 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #766

What

Every Query terminal now has a .sql() that returns the statement the call would send, without sending it:

query = RsAccount.where(lambda account: account.token == "sk_live_1")
rendered = await query.all.sql()
rendered.sql      # 'SELECT ... FROM "rsaccount" WHERE "rsaccount"."token" = $1'
rendered.params   # (***,)
rendered.dialect  # 'postgres'
await query.update.sql(visits=19)
await query.update.sql(lambda account: {"visits": account.visits + 1})

In Rust, each terminal's build (plan checks, catalog read, joins, select list, order/paging, sea-query build) is now one build_<terminal> function in src/operations.rs. <terminal>_filtered sends what it builds and the new render_<terminal> entry returns it. In Python, @terminal (src/ferro/query/rendered.py) makes .sql() run the terminal's own body with its one send(execute, render) pair switched to the render twin. Argument checks, the mixed-forms TypeError, routing and the no-route RuntimeError are therefore the call's own.

Verify

Ran the ticket's Verify block on cbb0abb: passed, Postgres included (FERRO_POSTGRES_URL=postgresql://postgres:password@localhost:5432/postgres, --db-backends=sqlite,postgres).
TDD: RED uv run --no-sync pytest tests/test_rendered_statement.py -q --no-cov -p no:cacheprovider --db-backends=sqlite,postgres failed as expected (ImportError: cannot import name 'REDACTED' from 'ferro'); GREEN, same command: 53/53 passed. Full suite on both backends: 4873 passed, 77 skipped, 2 xfailed.
Verify block corrected: no. The tests._docs_checks line was skipped because tests/_docs_checks.py is not on this branch (it lands with #698).

Interfaces

Interfaces changed: render_exists is not a separate entry. render_count(..., operation="count"|"exists") mirrors count_filtered(operation=), and exists.sql() renders the statement exists() sends. The ticket allows either shape.
Interfaces changed: terminal returns a TerminalMethod[S, P, R] descriptor. Reading it from a query gives Terminal[P, R] with __call__(*P) -> Awaitable[R] and sql(*P) -> Awaitable[RenderedSql]. The descriptor needs the self type S so that ty can specialise R per query (Query[User].all → list[User]).

Notes for the reviewer

  • Outside Files owned: src/sql_log.rs. The masking (mask_of) and bind_to_py were private, and a second copy in operations.rs would be a second way of showing parameters. I added pub fn shown_params(py, binds, secrets) -> (params, mask), which emit now uses too, and made dialect_name pub. Log output is unchanged, and a cargo test covers the new function.
  • Outside Files owned: docs/pages/api/queries.md gets ::: ferro.RenderedSql, ::: ferro.REDACTED and ::: ferro.query.rendered.Terminal, so the docstrings' autorefs resolve and .sql appears in the API reference. This edits an existing reference page; it is not a new page. zensical build --strict reports one warning, the existing unresolved pydantic.fields.FieldInfo autoref in api/fields.md, which is not from this change.
  • update forms: at runtime update has one signature, update(recipe: Callable[[QueryProxy[T]], dict] | None = None, /, **fields), and mixing the forms is the same runtime TypeError as before. For type checkers, _update_terminal keeps the two forms apart on both the call and .sql() (see the round 1 fix report below). A second positional argument is now Python's own TypeError rather than "takes at most one positional recipe" (no test relied on that text).
  • Typing: each terminal annotates self (self: "Query[T]"). Without that annotation, ty cannot solve the decorator's ParamSpec and the return type comes out Unknown. Relation.all and Relation.first are now type-only descriptors with overloaded __get__ (Relation[list[E]] and Relation[E] both give rows of E); at runtime Relation inherits Query's terminals.
  • Rendering's connection: rendering opens OperationScope::unwrapped, so it never opens an implicit transaction. A pinned session connection or the enclosing transaction()'s connection still serves a first-touch catalog probe, which is logged as catalog the way the call's is.
  • REDACTED's docstring lives in src/ferro/_sql_log.py, which this ticket must not edit, so it is unchanged.
  • The {test=run prelude=reference} examples (RenderedSql, Terminal.sql) will run once Docs rewrite: every snippet runs, every fact matches the code, and the API reference renders cleanly #698's harness and prelude land; until then they only render.

Fix report: review round 1

Finding (Quality, Important): combining update's two overloads into one signature lost a static check. On this branch, await Query(M).update(lambda m: {...}, visits=1) passed ty, where on the base it was no-matching-overload.

What changed (23e9e22): Query.update is now decorated with _update_terminal in src/ferro/query/builder.py. At runtime it is terminal(method), so the body, the runtime TypeError and the parity pin are unchanged. For type checkers it is a _UpdateMethod descriptor. Reading it from a Query[E] binds an _UpdateTerminal[E], whose __call__ and sql each have two overloads: a positional recipe, or keyword literals. This is the same approach as _RelationAll / _RelationFirst. Mixing the forms is a static error again, on the call and on .sql():

tests/static_fixtures/bad_updates.py:22:11: error[no-matching-overload] No overload matches arguments
tests/static_fixtures/bad_updates.py:26:11: error[no-matching-overload] No overload of bound method `_UpdateTerminal.sql` matches arguments

Covering tests:

  • tests/static_fixtures/bad_updates.py, run by the new test_mixing_update_forms_fails_the_type_checker. ty must reject the mixed form on update() and on update.sql(), with exactly two no-matching-overload errors.
  • tests/static_fixtures/good_updates.py, run by the new test_each_update_form_passes_the_type_checker. Each form type-checks on the call (int) and on .sql() (RenderedSql).
  • test_mutating_query_methods_cannot_carry_pagination now accepts _update_terminal as update's terminal decorator. It still requires send(update_filtered, render_update) in the body.

TDD: RED uv run --no-sync pytest tests/test_static_contracts.py -q --no-cov -p no:cacheprovider -k update_form failed with AssertionError: bad_updates.py must fail ty (ty printed All checks passed!); GREEN, uv run --no-sync pytest tests/test_static_contracts.py tests/test_query_typing.py tests/test_rendered_statement.py -q --no-cov -p no:cacheprovider --db-backends=sqlite,postgres: 78 passed.

Verify on 23e9e22 (the pushed HEAD), Postgres included (FERRO_POSTGRES_URL=postgresql://postgres:password@localhost:5432/postgres):

  • uv run maturin develop: installed
  • 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 --db-backends=sqlite,postgres: 135 passed
  • uv run --no-sync ty check src/ferro/query tests/test_query_typing.py tests/test_static_contracts.py tests/test_alembic_typing.py: All checks passed!
  • cargo test --no-default-features --features testing: 382 passed
  • uv run --no-sync ruff check src/ferro tests: All checks passed!
  • python -m tests._docs_checks ...: skipped, because tests/_docs_checks.py is not on this branch (Docs rewrite: every snippet runs, every fact matches the code, and the API reference renders cleanly #698)

Verify block corrected: no.

🤖 Generated with Claude Code
https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP

0x054 and others added 3 commits October 21, 2026 02:50
…d render

Each query terminal's statement build (plan checks, catalog read, joins,
select list, order/paging, sea-query build) moves into one build_<terminal>
function that the executing entry sends and a new render_<terminal> entry
returns without sending. The statement log's parameter masking becomes
sql_log::shown_params so a rendered statement shows parameters the way a
record does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
all, first, count, exists, update (both forms) and delete become terminals:
calling one runs it, and its .sql() returns the RenderedSql the call would
send (sql, params, dialect) without sending it. .sql() runs the terminal's
own body with its one send swapped for the render twin, so argument checks,
routing and the statement build are the call's. Secrets show as REDACTED.

RenderedSql and REDACTED are exported from ferro. The parity pin in
tests/test_rendered_statement.py holds every terminal's rendering equal to
the ferro.sql record of the executed call on both dialects, and AGENTS.md
lists it under I-1's enforcement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec: PASS

  • Minor · src/operations.rs:3394 at cbb0abb · Misunderstood · build_fetch returns a BuiltFetch (statement plus decode state) and every build_* reads the catalog itself through engine/exec rather than taking it as a parameter; the ticket's Produces line reads build_<terminal>(plan, backend, catalog, …) -> BuiltStatement { sql, binds }, and the PR's Interfaces changed: lines do not mention this. The functions are private and no sibling consumes them, and reading the catalog inside the shared build is what keeps the casts identical on both doors, so this is a note for the record, not a blocker.
  • Minor · tests/test_rendered_statement.py:80 at cbb0abb · Missing · the parity pin's CASES cover ProjectedQuery.all and .count but not ProjectedQuery.first or .exists, both of which are @terminal overrides; the ticket asks for "every terminal", and AGENTS.md:249 now says the pin covers every terminal "through Relation and ProjectedQuery too". Add projected-first and projected-exists cases.
    Cannot verify: none. (closingIssuesReferences is empty only because the base is not the default branch; the body carries Closes #766.)

Quality: FAIL

  • Important · src/ferro/query/builder.py:1298 at cbb0abb · Collapsing update's two @overloads into update(recipe: Callable[[QueryProxy[T]], dict] | None = None, /, **fields) removed a static guarantee users had: await Query(M).update(lambda m: {"visits": 1}, visits=1) was error[no-matching-overload] under ty on the base (ca92eca) and passes ty cleanly on this head (checked both with the same file). The mixed form now only fails at runtime, for both update() and update.sql(), and no test pins either the old or the new static behaviour, so the loss went in unnoticed. "A ParamSpec cannot carry overloads" is true of TerminalMethod[S, P, R], but it does not force the regression: update can get its own typed descriptor (the way _RelationAll / _RelationFirst already are, at builder.py:1945) whose bound terminal declares overloaded __call__ and sql, one per form. Fix: restore the two forms statically on both the call and .sql, and add a tests/static_fixtures case that ty must reject (mixed form on update and on update.sql).

Checked and holding: every terminal's .sql() runs the terminal's own body with its single send(execute, render) pair switched by a context variable, so argument checks, the mixed-forms TypeError, routing and the no-route RuntimeError are the call's; on the Rust side fetch_filtered/render_fetch, count_filtered/render_count (both operations), update_filtered/render_update and delete_filtered/render_delete each call one build_* plus one shared plan-parsing function (fetch_request, count_plan, update_plan, delete_plan), and both doors compute the secret set with the same operation_secret_values(…, plan_secret_values(…)) and show parameters through the one sql_log::shown_params. The parity pin compares sql, params, sentinel positions and dialect against the call's ferro.sql record per case on both dialects, including the secret predicate, traversed secret, secret SET and include cases. The src/sql_log.rs edit is a pure extraction (emit output unchanged) and is accepted; the docs/pages/api/queries.md entries are accepted. The zensical build --strict failure is the pydantic.fields.FieldInfo autoref in api/fields.md, which fails identically on the base ca92eca. Depth check (rendered.py's terminal/send/Terminal, the render_* entries, shown_params): no check fired.

Verify: PR head cbb0abb passed (pytest 133 passed with --db-backends=sqlite,postgres against local Postgres, Postgres cases ran with none skipped; ty check clean; cargo test --no-default-features --features testing 382 passed; ruff check clean; python -m tests._docs_checks skipped as declared, tests/_docs_checks.py is absent until #698)
Trial merge: not run (base unchanged)

…nd .sql()

Query.update is now _update_terminal: terminal() at runtime, read by a type
checker through a descriptor whose bound terminal overloads __call__ and
sql per form (a recipe, or keyword literals). Mixing them is a static
no-matching-overload again, as it was before update became a terminal.
tests/static_fixtures/bad_updates.py and good_updates.py pin both sides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP

@0x054 0x054 left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped re-review: cbb0abb..23e9e22

Spec: PASS

No change to the spec verdict; the fix diff touches no acceptance criterion.

Quality: PASS

  • ADDRESSED · src/ferro/query/builder.py:1298 at cbb0abb → src/ferro/query/builder.py:1340 at 23e9e22 (@_update_terminal), with _UpdateTerminal / _UpdateMethod at src/ferro/query/builder.py:568-594 · update lost its static overloads. query.update now reads as _UpdateTerminal[E], whose __call__ and sql are each overloaded per form. ty rejects the mixed form on both: tests/static_fixtures/bad_updates.py:22 (call) and :26 (.sql) each give no-matching-overload, two in all. tests/test_static_contracts.py::test_mixing_update_forms_fails_the_type_checker requires exactly two, and good_updates.py pins both forms on the call and on .sql with assert_type. My original repro file, which passed ty on cbb0abb, is error[no-matching-overload] again. Runtime is unchanged because _update_terminal is cast(…, terminal(method)). The parity pin is unchanged and still passes on both dialects. bad_projections.py still gives its two invalid-argument-type errors.

No new Critical or Important breakage in the fix diff.

Out-of-scope observations: none.

Verify: PR head 23e9e22 passed (pytest 135 passed with --db-backends=sqlite,postgres against local Postgres, none skipped; ty check clean; cargo test --no-default-features --features testing 382 passed; ruff check clean; python -m tests._docs_checks skipped, tests/_docs_checks.py absent until #698)
Trial merge: not run (base unchanged)

@0x054
0x054 merged commit bcb9931 into feat/700-sql-inspection Oct 21, 2026
6 checks passed
@0x054
0x054 deleted the sdd/766-rendered-statement branch October 21, 2026 07:07
@0x054 0x054 mentioned this pull request Oct 21, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant