Repository navigation
docs: show the SQL each query and mutation sends, regenerated by a test (#725) - #775
Conversation
tests/test_docs_sql.py reads each sql fence's marker line (-- <example>.py:<snippet> (<dialect>)), runs the example once per dialect, records what every snippet region sends (a terminal's .sql(), the statement log for anything else) and compares byte for byte. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
The SQL the guides show is captured on SQLite and PostgreSQL, so every example they name must run on both: the populated-relations raw update uses $1 placeholders, the mutations example stops asserting a SQLite id, the relationships DDL asserts check names rather than SQLite's text, the aggregations partition moves into the script, and the merge example runs when the session is on PostgreSQL. The capture renders a terminal once, however many terminals it runs inside. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
Every sql fence on Queries, Aggregations, Relationships and Mutations opens with a -- <example>.py:<snippet> (<dialect>) marker and is regenerated by tests/test_docs_sql.py; PostgreSQL gets its own tab where it differs in more than placeholders. The hand-laid DDL blocks are replaced, the false exists() tip is removed, and the conventions page records the marker recipe. The CI docs job runs the new test. 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
No findings. Checked: Closes #725 in the body (closingIssuesReferences is empty only because the base is not the default branch); block counts 71 / 12 / 19 / 30, every one marked; one-character edits on real blocks (queries.md:178, mutations.md:163) fail naming page, line, example and dialect; an unmarked fence is refused, and test_every_page_shows_sql stops a page from passing with zero blocks; .github/workflows/ci.yml is required by test_docs_site.py::test_ci_docs_job_runs_every_docs_test_and_the_lint (globs tests/test_doc*.py); the backend-matrix job runs postgres_only with --db-backends=sqlite,postgres; nothing under src/; CHANGELOG untouched.
Cannot verify: none
Quality: FAIL
Important · tests/test_docs_sql.py:588 at 6487579 · except Exception: rendered = None swallows any error from a terminal's .sql() without checking that the call itself then raises. The comment says "the call raises it too, and sends nothing", but nothing asserts it. While a terminal runs, depth > 0 also turns off the statement log, so a terminal whose .sql() raised but whose call succeeded would leave no record at all. Its block would lose that statement and the test would still pass, which breaks I-6 ("fail loudly over degrading silently") and the ticket's rule that every block is regenerated. Today the only three swallowed errors are real refusals (delete() with include(), update() across a relation, delete() with limit()). I checked this by instrumenting Terminal.sql over all eight examples. · Fix: when .sql() raises, await the call and raise AssertionError naming the example line if the call succeeds.
Minor · PR body · the table says Queries has "9 regions with a PostgreSQL tab"; the page has 8 (postgres) markers (queries.md:152, 192, 258, 292, 417, 464, 509, 1096).
Depth check (tests/test_docs_sql.py): no question fired. check_block(block, sent) is a small interface over marker parsing, region lookup and byte compare. Its tests go through it with a fake sent, and the real blocks go through the real capture.
Worker declarations, checked: exists() sends SELECT COUNT(*) (queries.md:562 block, which matches the statement log), so removing the tip was right. Under seeds 1 and 12345 only mutations.py save-only / save-exclude change; every other region of all eight examples is the same, and mutations.md:328 says the order is not fixed. The raw $1/$2 execute() runs on SQLite (Verify green), and the snippet's own comment ("behind the ORM's back") gives the reason. Spot-check: the unlaid statements of queries.md:138, :562, :151 (pg), :416 (pg), aggregations.md:255, relationships.md:171, mutations.md:311 (pg) and :450 (pg) are each, byte for byte, a statement the ferro.sql log recorded Ferro sending.
Verify: PR head 6487579 passed (strict build clean; 359 passed / 107 skipped; --db-backends=sqlite,postgres 219 passed against Postgres at localhost:5432; lint exit 0)
Trial merge: not run (base unchanged)
A terminal whose .sql() raises must raise the same exception type when called, having sent nothing; one that then succeeds, or fails otherwise, fails the capture naming the example line instead of losing its statement behind the depth guard. 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.
Scoped re-review: 6487579..45e4f82
Spec: PASS
No findings in the fix diff.
Cannot verify: none
Quality: PASS
ADDRESSED · tests/test_docs_sql.py:571-582 at 45e4f82 · .sql() errors were swallowed (was :588 at 6487579). Now _Capture.run_terminal awaits the call when .sql() raises. It re-raises only if the call raises the same exception type (except type(refused): raise). Otherwise it raises AssertionError naming <example>:<line>, either because the call succeeded or because it raised a different type. The old rendered = None path is gone (_run_capture at :704 now delegates to run_terminal). Every rendering that does not raise is recorded before the call, so the depth guard does not lose one. Tests at :636-678 cover the four cases (call succeeds, types differ, both refuse, rendered and recorded). The real captures run all three refusals (populated_relations.py, traversal.py, mutations.py) on both dialects, and both runs passed.
No new Critical or Important breakage in the fix diff. The other hunks reformat lines and quotes.
Verify: PR head 45e4f82 passed (strict build clean; 363 passed / 107 skipped; --db-backends=sqlite,postgres 223 passed against Postgres at localhost:5432; lint exit 0)
Trial merge: not run (base unchanged)
Closes #725
What
Every query and mutation example on the Queries, Aggregations, Relationships and Mutations guides now has the SQL Ferro sends shown below it. Each
sqlfence opens with a marker such as-- predicates.py:first-query (sqlite). The newtests/test_docs_sql.pyreruns that example region and compares the block byte for byte. Where PostgreSQL differs in more than its placeholders (date andtimestamptzcasts,RETURNING "id",serial), the page shows SQLite and PostgreSQL tabs.sqlblocksguide/queries.mdguide/aggregations.mdguide/relationships.mdguide/mutations.md.merge()block)How a marker is resolved: the test runs
docs/examples/<example>.pyonce per dialect in a subprocess (python -m tests.test_docs_sql <example> <dialect> [url]).sys.monitoringtracks which example line is running. Each outermost query terminal is rendered with its own.sql(), using the same arguments, just before it runs. Every other statement comes from theferro.sqlstatement log. Schema, catalog, settings and transaction statements are left out. A region that sends nothing but prints statements, like thecreate_tables()report, shows what it prints. Each statement is laid out with one clause per line. Unindenting the lines and joining them with spaces gives back the exact rendering.docs/solutions/patterns/docs-page-conventions.mdnow holds the recipe.Verify
Ran the ticket's Verify block on
6487579withFERRO_POSTGRES_URL=postgresql://postgres:password@localhost:5432/postgres(Postgres 18): passed (strict build clean; 359 passed / 107 skipped, all of them Postgres-only tests, since that command does not select Postgres; 219 passed on--db-backends=sqlite,postgres; lint exit 0).TDD: RED
uv run --no-sync pytest tests/test_docs_sql.py -q --no-cov -p no:cacheproviderfailed as expected (guide/relationships.md:143: a sql block must open with a marker line ..., andtest_every_page_shows_sql); GREEN, the same command passed (114 passed, 105 Postgres-only tests skipped), and with--db-backends=sqlite,postgres219/219 passed. Changing<=to<in the first block fails withguide/queries.md:26 (predicates.py:first-query, sqlite): the block differs ....Verify block corrected: no
Interfaces
Interfaces changed: none
Notes for the reviewer
.github/workflows/ci.ymladdstests/test_docs_sql.pyto the docs job.test_docs_site.py::test_ci_docs_job_runs_every_docs_test_and_the_lintrequires this. The Postgres half runs in the existing backend-matrix job, which runs everypostgres_onlytest.exists()sendsSELECT COUNT(*)and compares the count with zero. The Queries page's tip "Preferexists()tocount() > 0: the database can stop at the first match" was false, so I removed it. The page now says whatexists()sends.save(only=...)andsave(exclude=...)write theirSETcolumns in Python set order, which changes withPYTHONHASHSEED. The capture runs withPYTHONHASHSEED=0so the blocks can be reproduced, and the Mutations page tells readers the order is not fixed. The rendering should probably sort these columns or keep them in field order; that needs its own ticket.update()in a session, a laterget()of the same row does not refresh an instance the session already holds. The rawexecute()inpopulated_relations.py:refresh-ruleis kept for that reason, now with$1/$2placeholders so it also runs on PostgreSQL (SQLite accepts$1).mutations.pyasserts thatfrank.idis set instead of== 6, because PostgreSQL's sequence skips the refused insert.relationships.pyDDL asserts now check names, and the page block pins the full text. The aggregations inline partition block moved intoaggregations.py:partitionso its SQL can be shown. The Mutations.merge()example is now a Conversation model region (both styles) plus amergeregion that runs only on PostgreSQL.create_tables()report for a table includesSET LOCAL lock_timeout = '5000ms'. The PostgreSQL DDL tab shows it and its lead-in names it.content.tabs.linkwas already on.🤖 Generated with Claude Code
https://claude.ai/code/session_01Hb9U9A7Zu9ypdHTsDvdScP
Fix report (review round 1)
Finding:
except Exception: rendered = Nonein the capture swallowed a.sql()error without checking that the call then raised. If the call had succeeded, its statement would have gone unrecorded and its block would still have passed.Change (
45e4f82,tests/test_docs_sql.py): rendering and running a terminal now live in_Capture.run_terminal. When.sql()raises, the call is awaited, with two outcomes:AssertionErrornaming the example line, for examplepredicates.py:42: .sql() raised ValueError('cannot render') but the call succeeded (returned [1, 2]), so what it sent cannot be shown.A terminal run inside another terminal is still not rendered twice, as before.
Covering tests:
test_a_terminal_whose_sql_raises_but_whose_call_succeeds_fails_the_capture(a stub.sql()raises while its call returns; the failure namespredicates.py:42)test_a_terminal_whose_sql_and_call_refuse_differently_fails_the_capturetest_a_terminal_that_refuses_in_both_raises_its_error_and_records_nothingtest_a_terminal_records_its_rendering_and_returns_its_resultRED: with the old swallowing branch put back,
pytest tests/test_docs_sql.py -k terminalfails 2 of the 4. GREEN: 4 passed.The three real refusals in the examples (
delete()withinclude(),update()across a relation,delete()withlimit()) raise the same type from.sql()and from the call on both databases.Commands (
FERRO_POSTGRES_URL=postgresql://postgres:password@localhost:5432/postgres), Verify block re-run on45e4f82:zensical build --clean --strictpytest tests/test_docs_examples.py tests/test_docs_sql.py tests/test_docs_site.py tests/test_docs_lint.py -n 8pytest tests/test_docs_sql.py --db-backends=sqlite,postgrespython -m tests._docs_checks ...