Repository navigation
feat(query): .sql() on every Query terminal, and the parity pin (#766) - #770
Conversation
…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
left a comment
There was a problem hiding this comment.
Spec: PASS
- Minor · src/operations.rs:3394 at cbb0abb · Misunderstood ·
build_fetchreturns aBuiltFetch(statement plus decode state) and everybuild_*reads the catalog itself throughengine/execrather than taking it as a parameter; the ticket's Produces line readsbuild_<terminal>(plan, backend, catalog, …) -> BuiltStatement { sql, binds }, and the PR'sInterfaces 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
CASEScoverProjectedQuery.alland.countbut notProjectedQuery.firstor.exists, both of which are@terminaloverrides; the ticket asks for "every terminal", and AGENTS.md:249 now says the pin covers every terminal "throughRelationandProjectedQuerytoo". Addprojected-firstandprojected-existscases.
Cannot verify: none. (closingIssuesReferencesis empty only because the base is not the default branch; the body carriesCloses #766.)
Quality: FAIL
- Important · src/ferro/query/builder.py:1298 at cbb0abb · Collapsing
update's two@overloads intoupdate(recipe: Callable[[QueryProxy[T]], dict] | None = None, /, **fields)removed a static guarantee users had:await Query(M).update(lambda m: {"visits": 1}, visits=1)waserror[no-matching-overload]undertyon the base (ca92eca) and passestycleanly on this head (checked both with the same file). The mixed form now only fails at runtime, for bothupdate()andupdate.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 ofTerminalMethod[S, P, R], but it does not force the regression:updatecan get its own typed descriptor (the way_RelationAll/_RelationFirstalready are, at builder.py:1945) whose bound terminal declares overloaded__call__andsql, one per form. Fix: restore the two forms statically on both the call and.sql, and add atests/static_fixturescase thattymust reject (mixed form onupdateand onupdate.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
There was a problem hiding this comment.
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/_UpdateMethodat src/ferro/query/builder.py:568-594 ·updatelost its static overloads.query.updatenow reads as_UpdateTerminal[E], whose__call__andsqlare each overloaded per form.tyrejects the mixed form on both:tests/static_fixtures/bad_updates.py:22(call) and:26(.sql) each giveno-matching-overload, two in all.tests/test_static_contracts.py::test_mixing_update_forms_fails_the_type_checkerrequires exactly two, andgood_updates.pypins both forms on the call and on.sqlwithassert_type. My original repro file, which passedtyon cbb0abb, iserror[no-matching-overload]again. Runtime is unchanged because_update_terminaliscast(…, terminal(method)). The parity pin is unchanged and still passes on both dialects.bad_projections.pystill gives its twoinvalid-argument-typeerrors.
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)
Closes #766
What
Every
Queryterminal now has a.sql()that returns the statement the call would send, without sending it:In Rust, each terminal's build (plan checks, catalog read, joins, select list, order/paging, sea-query build) is now one
build_<terminal>function insrc/operations.rs.<terminal>_filteredsends what it builds and the newrender_<terminal>entry returns it. In Python,@terminal(src/ferro/query/rendered.py) makes.sql()run the terminal's own body with its onesend(execute, render)pair switched to the render twin. Argument checks, the mixed-formsTypeError, routing and the no-routeRuntimeErrorare 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,postgresfailed 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_checksline was skipped becausetests/_docs_checks.pyis not on this branch (it lands with #698).Interfaces
Interfaces changed:
render_existsis not a separate entry.render_count(..., operation="count"|"exists")mirrorscount_filtered(operation=), andexists.sql()renders the statementexists()sends. The ticket allows either shape.Interfaces changed:
terminalreturns aTerminalMethod[S, P, R]descriptor. Reading it from a query givesTerminal[P, R]with__call__(*P) -> Awaitable[R]andsql(*P) -> Awaitable[RenderedSql]. The descriptor needs the self typeSso thattycan specialiseRper query (Query[User].all→list[User]).Notes for the reviewer
src/sql_log.rs. The masking (mask_of) andbind_to_pywere private, and a second copy inoperations.rswould be a second way of showing parameters. I addedpub fn shown_params(py, binds, secrets) -> (params, mask), whichemitnow uses too, and madedialect_namepub. Log output is unchanged, and a cargo test covers the new function.docs/pages/api/queries.mdgets::: ferro.RenderedSql,::: ferro.REDACTEDand::: ferro.query.rendered.Terminal, so the docstrings' autorefs resolve and.sqlappears in the API reference. This edits an existing reference page; it is not a new page.zensical build --strictreports one warning, the existing unresolvedpydantic.fields.FieldInfoautoref inapi/fields.md, which is not from this change.updateforms: at runtimeupdatehas one signature,update(recipe: Callable[[QueryProxy[T]], dict] | None = None, /, **fields), and mixing the forms is the same runtimeTypeErroras before. For type checkers,_update_terminalkeeps 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 ownTypeErrorrather than "takes at most one positional recipe" (no test relied on that text).self(self: "Query[T]"). Without that annotation,tycannot solve the decorator's ParamSpec and the return type comes outUnknown.Relation.allandRelation.firstare now type-only descriptors with overloaded__get__(Relation[list[E]]andRelation[E]both give rows ofE); at runtimeRelationinheritsQuery's terminals.OperationScope::unwrapped, so it never opens an implicit transaction. A pinned session connection or the enclosingtransaction()'s connection still serves a first-touch catalog probe, which is logged ascatalogthe way the call's is.REDACTED's docstring lives insrc/ferro/_sql_log.py, which this ticket must not edit, so it is unchanged.{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)passedty, where on the base it wasno-matching-overload.What changed (
23e9e22):Query.updateis now decorated with_update_terminalinsrc/ferro/query/builder.py. At runtime it isterminal(method), so the body, the runtimeTypeErrorand the parity pin are unchanged. For type checkers it is a_UpdateMethoddescriptor. Reading it from aQuery[E]binds an_UpdateTerminal[E], whose__call__andsqleach 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():Covering tests:
tests/static_fixtures/bad_updates.py, run by the newtest_mixing_update_forms_fails_the_type_checker.tymust reject the mixed form onupdate()and onupdate.sql(), with exactly twono-matching-overloaderrors.tests/static_fixtures/good_updates.py, run by the newtest_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_paginationnow accepts_update_terminalasupdate's terminal decorator. It still requiressend(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_formfailed withAssertionError: bad_updates.py must fail ty(typrintedAll 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: installeduv 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 passeduv 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 passeduv run --no-sync ruff check src/ferro tests: All checks passed!python -m tests._docs_checks ...: skipped, becausetests/_docs_checks.pyis 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