Repository navigation
Queries guide and the typed predicates concept page: lambda-only story, results shown, one running example #709
Description
Parent
What to build
The Queries guide teaches "t by convention", tells a three-predicate-styles story that no longer exists, hides 27 results below the snippet markers, and uses four different Transaction models plus models no schema on the page defines.
The guide and the typed-predicates concept page describe lambda predicates only (the predicate-styles section explains that col() and operator style were removed in 0.14). Every lambda is named after its model. One running example covers the page; snippet ranges include their results; hand-written SQL blocks that do not match what Ferro sends are removed (captured SQL arrives with #700, in a later ticket). Paging documents after(), before() and position_of() with the rules for order keys. The five query example pairs are rewritten to match.
Pages: guide/queries.md, concepts/query-typing.md
Acceptance criteria
- Every fact on the page matches the code at the time of the PR; each High finding in the audit rows below is fixed, and each Medium/Low finding is fixed or answered in the PR body with the reason it was left.
- Each page is one kind (tutorial, how-to, reference or explanation) and opens with one sentence saying what it covers, then an example on the first screen (writing-for-humans rules 1–2).
- Each page uses one running example, declared once near the top and reused; each section shows only what changed (rule 3).
- Every code block has a lead-in sentence ending in a colon and a result sentence after it, and shows its result: printed output, or the asserted value inside the embedded snippet range (rules 7–8). Snippet
end:markers sit below the assert or print that holds the result. - Every copyable block either shows the
async with engines.session():wrapper or sits under a sentence that says it runs inside one and links to Connections › Sessions; the page's first block shows the wrapper. - Every inline Python block that does real work runs under the harness (
{test=run prelude=<name>}) or moves into an example script; a block left compile-only says why in its fence marker. - Every lambda passed to a query method names its parameter after the model in lowercase singular (AGENTS.md I-8);
python -m tests._docs_checksreports no violation for the owned files. - Every error check in an owned example fails when the expected error is not raised (
try/except/else: raise AssertionError, or the harness helper). - Field-declaring examples show both declaration styles as tabs, each backed by a runnable script (AGENTS.md I-7), and the twins use the one Annotated idiom recorded in the docs conventions.
- Headings are short sentence-case noun phrases naming the feature (rule 5); how-to titles name the goal (rule 24); no minimising words (rule 17); admonitions follow rule 20.
- No ADR numbers, issue numbers, codenames, dated or release-bound notes, or contributor-only references in page text; version markers are self-contained blocks at the end of a section.
- Reference material that has a home in Reference is linked, not repeated; each topic ends with a pointer to its reference entry (rule 4).
- No hand-written SQL block remains that claims to be what Ferro sends.
Files owned
- Modify:
docs/pages/guide/queries.md,docs/pages/concepts/query-typing.md,docs/examples/predicates.py,docs/examples/existence_tests.py,docs/examples/partial_selects.py,docs/examples/populated_relations.py,docs/examples/traversal.py,docs/examples/predicates_annotated.py,docs/examples/existence_tests_annotated.py,docs/examples/partial_selects_annotated.py,docs/examples/populated_relations_annotated.py,docs/examples/traversal_annotated.py
Interfaces
- Consumes: I-8, docs conventions (AGENTS.md ticket (AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703))
- Consumes: harness (harness ticket (Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704))
- Produces: Section anchors on the Queries guide for paging (
#paging-with-after-and-before), linked by the short how-tos ticket (How-tos: testing, pagination, timestamps and soft deletes #716).
Test scenarios
- Happy path: the strict site build succeeds and every snippet range on the owned pages renders without leading indentation.
- Happy path: every owned example script runs to completion under
tests/test_docs_examples.py, and every inline block marked{test=run ...}runs. - Error path: deleting the
raisean owned example's error check expects makes that example's test fail. - Edge case:
python -m tests._docs_checksover the owned files exits 0; renaming one lambda parameter totmakes it exit 1 naming the file and line.
Verify
uv run --no-sync zensical build --clean --strict
uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p no:cacheprovider -n 8
uv run --no-sync python -m tests._docs_checks docs/pages/guide/queries.md docs/pages/concepts/query-typing.md docs/examples/Blocked by
- Docs site config: Zensical features, Markdown extensions, API reference options and a strict build #701, AGENTS.md: rewrite I-8 for the lambda-only query API, add a docstring invariant, record the docs conventions #703, Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704
Audit findings
Rows from the docs audit this ticket covers (file, line, rule, fix)
docs/pages/guide/queries.md
- Kind (rule 1): mixed. It combines a guide (where/order/terminals), reference (operator table, terminal table, the "loud limits" lists) and a lot of explanation: ADR rationale for INNER joins, "why it isn't a
Transaction", the materialization plan, and the zero-validation path. At 876 lines it holds five chapters: basics, traversal, existence tests,include(), projection. - Running example (rule 3): none across the page.
User(setup) is used for the first chapter only. After that come Invoice, Card, the Ledger/Owner/Account/Transaction/Note domain, Airport/Flight, Employee, Author/Tag/Post, Category/Transfer/SplitLine, Tag/User. That is four differentTransactionmodels with different fields (traversal:amount, account; existence:amount, category, transfer_out/in, lines; populated:account→Owner.name; partial:amount, memo, account→Owner.email). Inline blocks also use models that no schema on the page defines (Post/author,user.deleted_at,user.posts). - Verdict: The facts hold up well: every error message I triggered matches, and every behaviour claim I tested is true. The page breaks I-8's parameter-naming rule in four of its six snippet files and in its own prose, which tells readers
tis the convention. One link description is stale. The page needs splitting and one shared domain. Grade C.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 62 | I-8 | "the parameter (t by convention)" teaches the opposite of I-8 (lowercase singular model name). |
"the parameter, named after the model in lowercase (user for User), is a query proxy…" |
| H | 173, 225, 249, 391, 476, 509, 609-612, 682, 688, 719, 842, 857 + snippets | I-8 | Single-letter lambda parameters: lambda u, lambda t, lambda a, t.lines…. Snippet counts: existence_tests.py 11 (t, c, u), populated_relations.py 16, partial_selects.py 17. predicates.py and traversal.py are clean (all user / transaction / note), so the page switches convention halfway through. |
Rename to transaction, category, user, account throughout the three example files and the inline text. |
| H | 874 | facts | "Typed Query Predicates — why three predicate styles exist". col() and the operator style were removed in 0.14, and query-typing.md no longer describes three styles. |
"— how lambda predicates are type-checked". |
| M | 779, snippets | I-8 | The page says "the lambda form remains the documented style" for select/order_by, but partial_selects.py uses order_by("id") / order_by("amount", "desc") 8 times, and existence_tests.py:140 and populated_relations.py:103 use it too, outside the "Column-name strings" section. |
Use order_by(lambda transaction: transaction.id) everywhere except the one section that demonstrates strings. |
| M | whole page | rule 1 / 3 / 6 | Five chapters on one page with no shared domain. | Split it: Queries (filter, order, page, terminals, on User); Querying across relationships (traversal + existence tests); Populating relations (include()); Selecting columns (projection; it already sits beside Aggregations). Give all of them one ledger domain (Owner/Account/Transaction), which aggregations.md already almost uses. |
| M | 65, 75, 107, 115, 126, 200, 208, 220, 234, 284, 316, 326, 334, 342, 352, 366, 372, 382, 388, 398, 423, 443, 463, 564, 572, 582, 602 | rule 8 | 27 snippets end their --8<-- range before the assertions that state the result. The reader never sees which rows came back, although the script knows (for example predicates.py:58 assert len(adults) == 3, traversal.py:167 {1, 2, 3, 4}). partial_selects and aggregations already include their asserts and read much better. |
Move each end: marker below its assert, or add a result comment: # -> ['alice', 'bob', 'carol']. |
| M | 101-104 | rule 3 | The operator table uses user.deleted_at and user.posts, but the User on this page has neither. |
Use real columns: lambda user: user.role == None (or add a nullable column to User), and point the .exists row at the existence-test schema's transaction.lines. |
| M | 360, 471-476 | rule 3 | Inline examples use Post/author/user.id, which do not appear in the traversal schema on screen. |
Transaction.where(lambda transaction: transaction.account_id == account.id) and account.transactions.where(...). |
| M | 256-263 vs 270 | rule 3 / 7 | The traversal example is shown before its schema. The basic snippet section exists in traversal.py but the page inlines a copy instead. Same order problem at 624 vs 635 (include) and 733 vs 742 (projection). |
Show the schema first, then the snippet via --8<-- "docs/examples/traversal.py:basic". |
| M | 263, 369 | facts | "a SELECT over transactions with a join to accounts" and "the joined accounts row". Tables are singular lowercase class names (transaction, account). |
Use transaction / account. |
| M | 131-133, 157-159, 538-542, 554-559 | rule 13 / facts | The SQL blocks are simplified (unquoted reserved words user / transaction, invented aliases t, l), but the text says the query "ships to the database as". |
Either render the engine's real SQL (quoted identifiers), or change the lead-in to "is equivalent to this SQL (simplified):". |
| M | 169-173 + 197 | rule 1 | The nulls= default is explained twice, 25 lines apart, in near-identical words. |
Keep the explanation at 197, beside the example; cut 171. |
| M | 615, 718, 861-866 | rule 1 | "Reverse/M2M population is a future mechanism" appears three times. | State it once, in "Not yet supported", and link there. |
| M | 223 | rule 15/16 | "On unbounded before(), first() and all()[0] disagree… That is accepted." reads like an internal design note. |
"first() returns the row just before the bound; all()[0] returns the earliest earlier row. Use first() for 'the previous row'." |
| M | 744-761 | rule 1 | "What you get back — and why it isn't a Transaction" spends three paragraphs on rationale (materialization plan, zero-validation path). |
Keep two sentences (a Row has only the selected columns and no save()). Move the why to concepts/query-typing.md#projected-queries. |
| M | 1-876 | rule 4 / 22 | Never links api/queries.md, which exists, has ::: Query, ProjectedQuery, Row, Rows, and covers include() and select() overloads. |
Link where(), order_by(), include(), select(), Rows on first use. End each chapter with its reference link. |
| L | 19, 41, 60, 70, 89, 110, 121, 167, 229, 251, 504, 620, 729, 859, 869 | rule 5 | H2s are Title Case and H3s sentence case. Several headings are sentences or slogans: "Queries Are Immutable", "Every traversed hop is an INNER join", "Results are plain root instances", "One verb, loud dead ends", "Rows is a list you can ship", "Refreshes drop populations that stopped being true". |
Use noun phrases: "Query immutability", "Join type for traversed relations", "Result type of traversed queries", "Unsupported reverse-relation forms", "The Rows container", "Population after a refresh". |
| L | 161, 313, 319, 408 | rule 17 | "~ just makes the rule visible", "simply does not appear", "trivial to reason about", "just work". | Drop them: "~ makes the rule visible…", "does not appear…", "an N-hop path is predictable", "two FKs to one model work without aliases". |
| L | whole page | style | 107 em dashes, often several per sentence (for example 21, 30, 512, 753). | Split into two sentences, or use a colon. |
| L | 33, 471, snippets | rule 9 | # noqa: E712 / E711 trails ten-plus example lines; it is reader noise. |
Ignore E711/E712 for docs/examples/ in ruff config and drop the comments. |
| L | 211 | facts | "after(row) is the same as after(position_of(row))". position_of is a query method. |
"query.after(row) is query.after(query.position_of(row))". |
| L | 843 | facts | The quoted error omits the real tail " Valid relations: account." | Paste the full message. |
| L | 189-195, 826-835 | rule 11 | Stacked code blocks with no sentence between them (snippet + SQL; three projection snippets back to back). | One lead-in per block. |
| L | 488-495, 499-500, 718-721, 848-853 | pymdownx | The long "guardrails / loud limits" lists would read better as a definition-style table, or collapsed in ??? note details. |
Snippet quality (rule 9): populated_relations.py:181-194 (limits) and partial_selects.py:143-147 (collision) use try: … except X: assert … with no else. If the call stops raising, the example still passes, so these "tested" examples cannot catch the regression they document. table_checks.py does it right (else: raise AssertionError). Severity M, fix in the example files.
docs/pages/concepts/query-typing.md
- Kind: explanation, ending in a hand-written reference list
- Running example:
User(archived/role/active), then switches toTransaction/Accountfrom l.43 - Verdict: Precise and correct. It repeats the PEP 827 paragraph three times, and its "Reference" list duplicates (and points at a missing) API entry. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 141–148 | 1, 22 | The hand-written "Reference" list names ferro.query.FieldProxy, which has no API entry (see the structure table), and restates the API docstrings. |
Replace it with links to the API entries ([QueryProxy][ferro.query.QueryProxy] …) once FieldProxy/QueryNode are documented. |
| M | 33, 102 (type-safety), 125–129 | 3, 18 | The PEP 827 / "zero runtime change" explanation appears three times on this page and once in type-safety.md. | Give it one section ("Where checking stops") and link to it. |
| M | 6–11 | 7 | The opening example has no lead-in colon sentence or result sentence. == False / == True would fail linters (E712) when readers copy them. |
Lead-in: "Pass a lambda that names the model:"; result: "Each lambda returns a QueryNode; where() combines them into the WHERE clause." Consider showing the SQL (rule 13). |
| M | 43 | 3 | The page switches from User to Transaction/Account mid-page without introducing the models. |
Introduce Transaction → Account once with a 4-line model block, or keep Post → Author. |
| M | 13, 41, 51, 102, 135, 141, 150 | 5 | Title Case and a slogan heading ("How It Works", "What This Doesn't Change"). | "Proxy validation", "Traversing relations", "Projected queries", "Included queries", "Unchanged behavior". |
| L | 110 | 15 | "That is a decision, not a gap, for two reasons:" is a not-X-but-Y opener. | "include() keeps the model type for two reasons:". |
| L | 60–72 | I-8 | lambda t: in select() selectors. I-8 binds predicates; selectors follow the guide's convention. |
Align with guide/queries.md whichever way it goes. |
docs/examples/existence_tests.py + existence_tests_annotated.py
- Embedded by:
guide/queries.md(8 sections + 2 annotated) - Running example: transfers/split-lines ledger, plus a Tag/User M2M pair
- Verdict: C. The domain and data are good (−100/100 transfers, "Groceries" split lines). I-8 is violated throughout, an internal ADR is cited in user-facing code, and no result is visible on the page.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 98–204 (11 lines) | I-8 | lambda t: (Transaction), lambda u: (User), lambda c: (Category). The inner lambda line: for SplitLine is close but not the model name. |
transaction, user, category, split_line. |
| M | 176–177 | I-11 | "joins render INSIDE the EXISTS subquery, ADR-0006 semantics unchanged" cites an internal ADR in a reader-facing snippet. | "The join to category happens inside the EXISTS subquery, so it filters lines, not transactions." |
| M | all 8 sections | 8 | Every assert sits after [end:…] (lines 106, 128, 145, 172, 186, 206), so the page shows queries without their answers. The answers are the lesson here (one_line_both → {-55} vs some_line_each → {-55, -60}). |
Move the asserts inside the sections. |
| M | — | 13 | The comments describe the SQL ("~ renders NOT EXISTS", "one EXISTS per side") without showing it. | Show the rendered WHERE EXISTS (SELECT 1 FROM transfer …) beside bare. |
| M | 140 | I-8 | .order_by("amount", "desc") uses a string for a model column; I-8 makes the lambda selector the documented style. |
.order_by(lambda transaction: transaction.amount, "desc"). |
| L | 154–164 | mechanics | Shouting in comments: "YOUR", "BOTH", "SOME", "INSIDE". | Use italics on the page or plain words. |
| L | 110 | 18 | # category vacated while split is opaque. |
"A split transaction carries its category on its lines, not on itself." |
docs/examples/partial_selects.py + partial_selects_annotated.py
- Embedded by:
guide/queries.md(13 sections + annotated schema) - Running example: Owner/Account/Transaction
- Verdict: C. The asserts sit inside the sections, so results are visible, which is good. I-8 is violated throughout, the data is placeholder, the collision check cannot fail, and a copy-pasted docstring is wrong.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 50–185 (16 lambdas) | I-8 | lambda t: for Transaction everywhere. |
lambda transaction:. |
| H | 143–147, collision |
9 | try/except ValueError with the asserts in the except only. If select() stops raising, the example passes silently. |
Add else: raise AssertionError(...), and show the error text as output. |
| M | 65, 71, 85, 93, 109, 134, 156, 162 | I-8 | Eight order_by("id")/order_by("amount") calls on model columns use strings. (strings deliberately demonstrates select("id", "amount"), which is fine.) |
Lambda selectors, except where a string names an output alias. |
| M | 43–44 | 10 | Labels "a1"/"b1". |
"Checking"/"Travel". |
| L | 4 | — | Docstring: "Seeds a small transactions table" is copied from aggregations. Fine, but it also says "Selecting a Column Subset", while the page heading may differ. | Keep the docstring in step with the page heading. |
| L | 50–53 | 8 / 11 | rows[0].amount as a bare expression statement on an unordered query: "first row" is undefined order. |
Add .order_by(...) and write rows[0].amount # 10. |
docs/examples/populated_relations.py + populated_relations_annotated.py
- Embedded by:
guide/queries.md(11 sections + annotated schema),guide/relationships.md(basic) - Running example: Owner/Account/Transaction
- Verdict: C. It covers the population contract well, and
basicis the only example that prints. I-8 is violated throughout, the data is placeholder, internal jargon leaks, and thelimitschecks cannot fail.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 82–192 (15 lambdas) | I-8 | lambda t:, lambda a: (Account). |
lambda transaction:, lambda account:. |
| H | 181–194, limits |
9 | Three try/except blocks with asserts only in except; a regression that stops raising goes unnoticed. |
else: raise AssertionError, and show the messages. |
| M | 48–60 | 10 | Owner "o1", accounts "a1", "a2", "b1". |
"Ada"; "Checking", "Savings", "Petty cash" (no owner). |
| M | 82–88 | 8 / 9 | basic prints amount, label for transactions[:3] of an unordered query, and the page does not show the printed lines. |
Add .order_by(lambda transaction: transaction.id) and show the output block (10 a1 …) on the page. |
| M | 148 | I-11 | "keeps its stage-1 INNER semantics" is internal design vocabulary. | "A where() on the same path still drops rows without an account; include() only attaches data." |
| L | 103 | I-8 | order_by("id"). |
Lambda. |
docs/examples/predicates.py + predicates_annotated.py
- Embedded by:
guide/queries.md(15 sections + 3 annotated) - Running example: User (alice/bob/carol/dave), Invoice, Card
- Verdict: B. This is the model the other query examples should copy: correct I-8 naming everywhere, four meaningful rows, and lambda
order_by. Results mostly sit outside the sections, and two blocks stack near-identical queries.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 164–192, before-paging |
10 / 11 | Three queries, and with this data previous_page and earlier return the same ["dave", "bob"], so the reader cannot see what .limit(2) changed. The assert results sit outside the section. |
Keep previous_page and adjacent, drop earlier or seed enough rows for the limit to bite, and move the asserts in. |
| M | 55–107, 135–142, 247–256 | 8 | 12 of 15 sections assert after [end:] (e.g. adults: 3; teens: 2). |
Move the asserts or add result comments inside. |
| M | 67–73, 94–104 | 11 | operators and negation stack three queries each. |
Fine if the page explains each line; otherwise split them or use numbered callouts (rule 12). |
| L | 25 | 10 | amount: float | None for money. |
Decimal | None. |
| L | 48–51 | 10 | Lowercase "alice" etc. as names. |
"Alice". |
docs/examples/traversal.py + traversal_annotated.py
- Embedded by:
guide/queries.md(20 sections + 5 annotated) - Running example: Ledger/Owner/Account/Transaction/Note, plus Airport/Flight, Employee and Author/Tag/Post
- Verdict: B. Correct I-8 naming throughout and well-scoped blocks. The data is placeholder, results are checked by bare ids, and one section is dead.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 120–148 | 10 | Ledgers "ledger-a", owners "o1@ferro.dev", accounts "a1", notes "attached"/"orphan". Asserts compare id sets ({1, 2, 3, 4}), which a reader cannot check against the code. |
Real names ("Household", "ada@example.com", "Checking"), and assert amounts or labels. |
| M | all query sections | 8 / 13 | Asserts sit outside every section, and no join SQL is shown, which is the point of "Querying Across Relationships". | Move the asserts in, and show INNER JOIN account … for basic/multi-hop and LEFT JOIN for left-join. |
| L | 161–166 | — | [start:basic] is embedded by no page. |
Embed it or delete the marker. |
| L | 87–89 | I-7 / typing | manager: Annotated["Employee", ForeignKey(..., nullable=True)] = None: the type says non-optional, the default says None. Other examples spell nullable FKs Account | None. |
Annotated["Employee | None", ForeignKey(related_name="reports")] = None, if supported. Otherwise explain nullable=True on the page. Same in relationships.py:70. |
| L | 307 | 10 | Employee named "boss", so the query reads employee.manager.name == "boss". |
"Grace", matching relationships.py. |
Activity
0x054 commentedon Oct 19, 2026
Status: verify-passed
Worker: reviewer-709 (review of worker's PR)
Branch:
sdd/709-queriesPR: #732
Verify: passed 602f38d (trial merge into feat/docs-rewrite at 4e49af3 also passed)
Review posted: Spec PASS, Quality PASS, no Critical or Important findings.
Minor only: "whole program" wording on the first block (queries.md:5), Title Case H1 tied to unowned nav (query-typing.md:1), split code span at query-typing.md:69.
Verify step 3 correction, the two new helpers, the deleted Annotated twins and the single page are all accepted; I-7 holds and relationships.md still builds.
docs(guide): Queries guide and typed predicates on one ledger (#709) (#…
0x054 commentedon Oct 19, 2026
Merged in #732 into
feat/docs-rewritedocs(guide): Relationships and Raw SQL on real table names, one runni…