Skip to content

docs: show the SQL each query and mutation sends, regenerated by a test (#725) - #775

Merged
0x054 merged 4 commits into
feat/docs-rewritefrom
sdd/725-captured-sql
Oct 21, 2026
Merged

0x054 merged 4 commits into
feat/docs-rewritefrom
sdd/725-captured-sql

Conversation

@0x054

@0x054 0x054 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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 sql fence opens with a marker such as -- predicates.py:first-query (sqlite). The new tests/test_docs_sql.py reruns that example region and compares the block byte for byte. Where PostgreSQL differs in more than its placeholders (date and timestamptz casts, RETURNING "id", serial), the page shows SQLite and PostgreSQL tabs.

Page sql blocks
guide/queries.md 71 (9 regions with a PostgreSQL tab)
guide/aggregations.md 12
guide/relationships.md 19 (both DDL blocks now regenerated)
guide/mutations.md 30 (including a PostgreSQL-only .merge() block)

How a marker is resolved: the test runs docs/examples/<example>.py once per dialect in a subprocess (python -m tests.test_docs_sql <example> <dialect> [url]). sys.monitoring tracks 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 the ferro.sql statement log. Schema, catalog, settings and transaction statements are left out. A region that sends nothing but prints statements, like the create_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.md now holds the recipe.

Verify

Ran the ticket's Verify block on 6487579 with FERRO_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:cacheprovider failed as expected (guide/relationships.md:143: a sql block must open with a marker line ..., and test_every_page_shows_sql); GREEN, the same command passed (114 passed, 105 Postgres-only tests skipped), and with --db-backends=sqlite,postgres 219/219 passed. Changing <= to < in the first block fails with guide/queries.md:26 (predicates.py:first-query, sqlite): the block differs ....
Verify block corrected: no

Interfaces

Interfaces changed: none

Notes for the reviewer

  • Outside Files owned: .github/workflows/ci.yml adds tests/test_docs_sql.py to the docs job. test_docs_site.py::test_ci_docs_job_runs_every_docs_test_and_the_lint requires this. The Postgres half runs in the existing backend-matrix job, which runs every postgres_only test.
  • Findings in the ORM (not changed, per scope):
    • exists() sends SELECT COUNT(*) and compares the count with zero. The Queries page's tip "Prefer exists() to count() > 0: the database can stop at the first match" was false, so I removed it. The page now says what exists() sends.
    • save(only=...) and save(exclude=...) write their SET columns in Python set order, which changes with PYTHONHASHSEED. The capture runs with PYTHONHASHSEED=0 so 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.
    • After a batch update() in a session, a later get() of the same row does not refresh an instance the session already holds. The raw execute() in populated_relations.py:refresh-rule is kept for that reason, now with $1 / $2 placeholders so it also runs on PostgreSQL (SQLite accepts $1).
  • Example changes so both dialects run: mutations.py asserts that frank.id is set instead of == 6, because PostgreSQL's sequence skips the refused insert. relationships.py DDL asserts now check names, and the page block pins the full text. The aggregations inline partition block moved into aggregations.py:partition so its SQL can be shown. The Mutations .merge() example is now a Conversation model region (both styles) plus a merge region that runs only on PostgreSQL.
  • On PostgreSQL the create_tables() report for a table includes SET LOCAL lock_timeout = '5000ms'. The PostgreSQL DDL tab shows it and its lead-in names it.
  • Medium/Low audit rows: the I-8 lambda names and the hidden results were already fixed on the branch (the lint passes). AGENTS.md I-8 is outside this ticket. content.tabs.link was already on.

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

Fix report (review round 1)

Finding: except Exception: rendered = None in 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:

  • It raises the same exception type: that error is re-raised, as the example expects, and nothing is recorded.
  • It succeeds, or raises a different type: the capture fails with an AssertionError naming the example line, for example predicates.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 names predicates.py:42)
  • test_a_terminal_whose_sql_and_call_refuse_differently_fails_the_capture
  • test_a_terminal_that_refuses_in_both_raises_its_error_and_records_nothing
  • test_a_terminal_records_its_rendering_and_returns_its_result

RED: with the old swallowing branch put back, pytest tests/test_docs_sql.py -k terminal fails 2 of the 4. GREEN: 4 passed.

The three real refusals in the examples (delete() with include(), update() across a relation, delete() with limit()) 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 on 45e4f82:

Command Result
zensical build --clean --strict clean
pytest tests/test_docs_examples.py tests/test_docs_sql.py tests/test_docs_site.py tests/test_docs_lint.py -n 8 363 passed, 107 skipped (Postgres-only, since this command does not select Postgres)
pytest tests/test_docs_sql.py --db-backends=sqlite,postgres 223 passed
python -m tests._docs_checks ... exit 0

0x054 and others added 3 commits October 21, 2026 09:26
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 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

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 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: 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)

@0x054
0x054 merged commit 00dd767 into feat/docs-rewrite Oct 21, 2026
6 of 7 checks passed
@0x054
0x054 deleted the sdd/725-captured-sql branch October 21, 2026 13:47
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