Skip to content

Queries guide and the typed predicates concept page: lambda-only story, results shown, one running example #709

Closed

Description

@0x054

Parent

#698

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_checks reports 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

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 raise an owned example's error check expects makes that example's test fail.
  • Edge case: python -m tests._docs_checks over the owned files exits 0; renaming one lambda parameter to t makes 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

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 different Transaction models 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 t is 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 to Transaction/Account from 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 basic is the only example that prints. I-8 is violated throughout, the data is placeholder, internal jargon leaks, and the limits checks 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

  1. added
    documentationImprovements or additions to documentation
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:LCross-cutting or multi-worker; local waves only
    on Oct 19, 2026
  2. added theissue type on Oct 19, 2026
  3. self-assigned this
    on Oct 19, 2026
  4. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Status: verify-passed
    Worker: reviewer-709 (review of worker's PR)
    Branch: sdd/709-queries
    PR: #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.

  5. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Merged in #732 into feat/docs-rewrite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itsize:LCross-cutting or multi-worker; local waves only

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions