Repository navigation
Relationships and raw SQL guides #712
Copy link
Copy link
Closed
Labels
documentationImprovements or additions to documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itUnblocked and fully specified; an agent may claim itsize:MNeeds a plan; one or two local workersNeeds a plan; one or two local workers
Description
Parent
What to build
Raw SQL examples query users and events, but Ferro creates user and event; the NO ACTION row says the constraint is not enforced (it is); the many-to-many join table's name and constraints are documented nowhere. Both pages get one running example shown on the page, real table names, the join-table schema shown as the DDL Ferro creates, and results beside each call.
Pages: guide/relationships.md, guide/raw-sql.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).
Files owned
- Modify:
docs/pages/guide/relationships.md,docs/pages/guide/raw-sql.md,docs/examples/raw_sql.py,docs/examples/relationships.py,docs/examples/relationships_annotated.py
Interfaces
- Consumes: 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))
- Consumes:
populated_relationspair as rewritten by the Queries ticket (Queries guide and the typed predicates concept page: lambda-only story, results shown, one running example #709) - Produces: nothing other tickets rely on
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/relationships.md docs/pages/guide/raw-sql.md docs/examples/relationships.py docs/examples/relationships_annotated.py docs/examples/raw_sql.pyBlocked 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, Queries guide and the typed predicates concept page: lambda-only story, results shown, one running example #709
Audit findings
Rows from the docs audit this ticket covers (file, line, rule, fix)
docs/pages/guide/relationships.md
- Kind (rule 1): how-to with reference tables (
ManyToManyparams,on_delete). The mix is acceptable. - Running example (rule 3): none. The page uses nine model sets: Team/Player, User/Profile, Student/Course, Employee, Category/Product, Library/Document, Org/Project, Post/Author in prose, and Transaction/Account in the populated-relations snippet.
- Verdict: Accurate and well tested. Every Assignment/Annotated pair is backed by
relationships_annotated.py. The self-referential claim holds:Annotated["Employee | None", …]fails withRelationship resolution failed. The weak spots are the scattered cast, the lack of SQL anywhere on a page about schema shape, a misleadingNO ACTIONrow, and I-8 lambda names. B - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 9, 36; snippet populated_relations.py:basic L82 |
I-8 | Three lambdas break the parameter-naming rule: include(lambda p: p.author), Player.where(lambda t: t.team_id == team.id) and Transaction.select().include(lambda t: t.account). |
lambda post: post.author, lambda player: player.team_id == team.id, lambda transaction: transaction.account. The last one needs the loop variable renamed in the example file. |
| M | 187 | fact | The "NO ACTION" row says "The constraint is not enforced at delete time". On both backends, NO ACTION still refuses the delete while children exist. The check runs at the end of the statement and can be deferred, unlike RESTRICT's immediate check. |
"Like RESTRICT, but checked at the end of the statement (deferrable on Postgres)." |
| M | 74–104 | 13, 24 | "Ferro synthesizes the join table automatically", but the page never says the table's name or shape. Verified output for the Student/Course example: table student_courses (course_id, student_id), uq_student_courses_student_id_course_id, idx_student_courses_course_id_student_id, two fk_* constraints. |
Show the generated CREATE TABLE / indexes under the M2M models. Do the same for one-to-many (player.team_id + fk_player_team_id_team), so the shadow-column section has something to point at. |
| M | whole page | 3 | Nine casts on one page. The populated-relations snippet brings in a tenth domain, Transaction/Account, and its loop variable transaction shadows the ferro.transaction import that readers see on the next page. |
Use one domain throughout, for example Team/Player plus Player/Profile for one-to-one, Player/Tournament for M2M, and Player.captain for self-reference. Point the populated snippet at Team/Player too. |
| M | 30, 68, 90, 122 | 7, 8 | The usage blocks carry no lead-in or result sentence, and their results are asserts. |
Add a lead-in ("Create a team and a player, then read the relation both ways:") and a result sentence ("crab.team_id holds team.id without a query; team.members is a query until you call .all()."). |
| M | 50 | 16 | "a separate future mechanism; until it lands, reverse relations stay chainable queries" is roadmap language in a guide. | "include() covers forward foreign keys only; load a BackRef or M2M collection with await author.posts.all()." |
| L | 5 | 5 | The heading "Overview" names nothing. | Fold the section into the intro, or rename it "Lazy and populated relations". |
| L | 54, 120 | — | The one-to-one reverse field is annotated profile: "Profile" = BackRef(), but await user.profile is awaited, so a type checker reads the annotation as wrong. The page doesn't say what await user.profile returns when no child exists. |
Say what a missing child returns (None?). Raise the annotation question with the concepts/type-safety auditor. |
| L | 12 | 22 | The pairing rule says "raises at connect time" but names no exception. | Name the exception type. |
| L | headings | 5 | The headings are Title Case. | Change them to sentence case. |
docs/pages/guide/raw-sql.md
- Kind (rule 1): how-to with a reference table (bind types). Good.
- Running example (rule 3):
Eventin the snippets, but the inline blocks query theusersandeventstables. - Verdict: The tightest page in the set. The
_marshaltable matchesraw.pyexactly. It leaves outautocommit=True, and its inline SQL uses plural table names that Ferro never creates. B - Findings:
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 43, 53, 83 | correctness, 9 | The inline SQL uses FROM users and UPDATE events. Ferro names tables model_name.lower(), which gives user and event, and the page's own snippet uses event. A reader copying the placeholder example against Ferro models gets "no such table". |
Use event and the Event model's columns everywhere: SELECT * FROM event WHERE kind = ? AND id >= ?. |
| H | whole page | correctness | Top-level execute/fetch_all/fetch_one outside a session raise RuntimeError (verified). The page says nothing about this. |
Add one line plus the session wrapper in the first example. |
| M | 14–20 | coverage | execute(..., autocommit=True) is not mentioned. It is needed for CREATE INDEX CONCURRENTLY / VACUUM in a settings session and is documented only in the docstring and the RLS guide. session= is also unmentioned. |
Add a short "Statements that refuse a transaction" subsection with the docstring's example, and link the RLS caveat. |
| M | 5 | 5 | "When to Reach for Raw SQL" is an instruction-shaped heading. | "Use cases" or fold into the intro. |
| M | 16, 26, 95 | 7, 8 | No block shows its returned value. execute returns 2; fetch_all returns [{"kind": "click", "n": 2}, {"kind": "signup", "n": 1}]. |
Add result sentences with the literal values, which are already asserted in raw_sql.py. |
| L | 7 | 16 | "which are ORM territory now" refers to history. | "…grouped queries, which the ORM handles". |
| L | 9–10 | 20 | The Warning box covers a correctness gotcha (types come back as strings), not data loss. Rule 20 puts surprising behaviour in a Note. | !!! note. |
docs/examples/relationships.py + relationships_annotated.py
- Embedded by:
guide/relationships.md(9 sections + 5 annotated) - Running example: Team/Player, User/Profile, Student/Course, Employee, Library/Document
- Verdict: B. Real-looking data (Ferris, Grace/Linus, Rust 101), and the asserts are visible inside the sections.
on-deleteis never exercised.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 79–94 | 9 | on_delete="CASCADE" is declared but never exercised. The assignment script creates no Library, and the annotated twin only creates one. The page's CASCADE claim is untested. |
Add an on-delete-usage section: delete the library, then assert await Document.select().count() == 0. |
| M | 135–136 | 8 / 11 | remove(python201) then clear() with no result between them. |
Assert [course.title for course in await sam.courses.all()] == ["Rust 101"] after remove. |
| L | 103 | 10 | Variable crab for a Player. |
ferris. |
| L | 70–72 | typing | Same "Employee"-without-None as traversal. |
As above. |
docs/examples/raw_sql.py
- Embedded by:
guide/raw-sql.md(3 sections) - Verdict: A. Small, checkable data (2 clicks, 1 signup) and one idea per block. The only gap: results are asserted outside the sections (
affected == 2,rows[0]["n"] == 2), so the page cannot show them. Move the asserts in.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
documentationImprovements or additions to documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itUnblocked and fully specified; an agent may claim itsize:MNeeds a plan; one or two local workersNeeds a plan; one or two local workers
Activity
docs(guide): Queries guide and typed predicates on one ledger (#709) (#…
0x054 commentedon Oct 19, 2026
Status: verify-passed
Worker: worker-712
Branch:
sdd/712-relationships-raw-sqlPR: #739
Verify: passed 1511130 (on
feat/docs-rewriteat a10707f; no trial merge needed)Re-review by reviewer-712: Spec PASS, Quality PASS. The fix round addresses both blocking findings: the three dead anchors point at
#sessionsandconcepts/row-level-security.md#connection-delivery, and the Postgres blocks use{test=compile reason="..."}. One Minor stays open: the DDL blocks on the Relationships page are hand-reflowed, and no test compares them. Ready for the orchestrator to merge.docs(guide): Relationships and Raw SQL on real table names, one runni…
0x054 commentedon Oct 19, 2026
Merged in #739 into
feat/docs-rewrite