Repository navigation
How-tos: testing, pagination, timestamps and soft deletes #716
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
- Pagination builds keyset paging by hand and never mentions
Query.after()/.before(); its FastAPI endpoint raises because no session is open. It is rebuilt onafter()/before()and its endpoint opens a session and runs. - Timestamps: on SQLite the
update(... {"updated_at": now})recipe writes a different datetime format fromsave()(the I-13 hazard), losing order, precision and timezone. The recipe writes datetimes the waysave()does and a test proves ordering and round-trip on SQLite. - Timestamps and soft deletes give different reasons for the base-class pattern; both link to the one explanation on the models-and-fields page.
- Testing: the fixtures it tells readers to copy never run, and it catches
Exceptioninstead ofUniqueViolationError. A new test imports and uses those fixtures. - All four titles name the goal ("Paginate query results", "Record created and updated times", …).
Pages: howto/testing.md, howto/pagination.md, howto/timestamps.md, howto/soft-deletes.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
- Create:
tests/test_docs_testing_fixtures.py - Modify:
docs/pages/howto/testing.md,docs/pages/howto/pagination.md,docs/pages/howto/timestamps.md,docs/pages/howto/soft-deletes.md,docs/examples/testing_conftest.py,docs/examples/pagination.py,docs/examples/timestamps.py,docs/examples/soft_deletes.py,docs/examples/timestamps_annotated.py,docs/examples/soft_deletes_annotated.py
Interfaces
- Consumes:
#fields-on-a-model-base-class(models-and-fields ticket (Models and fields page with a generated field type table #708)) - Consumes:
#paging-with-after-and-before(Queries ticket (Queries guide and the typed predicates concept page: lambda-only story, results shown, one running example #709)) - Consumes: harness (harness ticket (Docs harness: run inline blocks with a prelude, run docstring examples, and check lambda names and error checks #704))
- 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. - Integration:
tests/test_docs_testing_fixtures.pyuses the testing how-to's fixtures to create and roll back a row. - Edge case (SQLite): rows updated with the timestamps recipe order correctly by
updated_atagainst rows written bysave(), and compare withoutTypeError.
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 pytest tests/test_docs_testing_fixtures.py -q --no-cov -p no:cacheprovider
uv run --no-sync python -m tests._docs_checks docs/pages/howto/testing.md docs/pages/howto/pagination.md docs/pages/howto/timestamps.md docs/pages/howto/soft-deletes.md docs/examples/testing_conftest.py docs/examples/pagination.py docs/examples/timestamps.py docs/examples/soft_deletes.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, Models and fields page with a generated field type table #708, 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/howto/testing.md
- Kind (rule 1): how-to, with explanation mixed in (the "How this works" bullets and the migrations section).
- Running example (rule 3):
User/Postimported frommyapp.models. These are never defined, anddocs/examples/has no backing for them. - Verdict: The recipe is sound and the facts check out (
reset_engine,ferro_search_pathvalidation,ferro migrate check). It breaks I-8, catches a bareExceptionwhen Ferro shipsUniqueViolationError, gives incomplete strict-mode advice, and none of its fixtures are executed by any test. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 59 | I-8 | User.where(lambda t: t.username == "testuser") names the parameter t. |
lambda user: user.username == "testuser" |
| H | 9–11, 136–163 | 9 | testing_conftest.py is snippeted, but its main() is a no-op, so test_docs_example_scripts never exercises db or db_transaction. The pg_db fixture is inline and only compiled. The page's core deliverable is untested. |
Add a real test that uses the fixtures, e.g. a docs/examples/testing_*.py that runs pytest on a tiny suite in a subprocess, or a tests/ module that imports the conftest. Back pg_db with a Postgres-gated test. |
| M | 75 | 18, correctness | pytest.raises(Exception) is too broad: it passes on any bug. Ferro raises ferro.UniqueViolationError (subclass of IntegrityError), confirmed live. |
from ferro import UniqueViolationError … with pytest.raises(UniqueViolationError):. Say in the lead-in that a unique violation raises UniqueViolationError. |
| M | 42 | correctness | "If you prefer asyncio_mode = strict, mark each test with @pytest.mark.asyncio." That is incomplete. In strict mode the async-generator fixtures also need @pytest_asyncio.fixture; plain @pytest.fixture on an async def fixture does not run. |
"In strict mode, also decorate each async fixture with @pytest_asyncio.fixture and mark each test with @pytest.mark.asyncio." |
| M | 1 | 24 | Title "Testing" is ambiguous: testing Ferro itself? Testing migrations? The nav also has a separate "Testing Migrations" page. | # How to test a Ferro app with pytest, and update the nav label to match. |
| M | 5, 20, 44, 126, 167, 173 | 5 | Title Case headings ("Test Setup", "Writing Your First Test", "Testing Against Postgres", "Tests in a Project with Migrations", "See Also"). "Writing Your First Test" is tutorial voice and names no thing. | Sentence case noun phrases: "The db fixture", "Configuring pytest-asyncio" → "pytest-asyncio configuration", "A first test", "Factories", "Postgres with ferro_search_path", "Projects with migrations", "See also". |
| M | 48–77 | 7, 8 | Both test blocks have a lead-in but no result sentence. No pytest output is shown anywhere on the page. |
After the first test, show uv run pytest -q output (2 passed in 0.05s) and add one sentence: "Each test sees an empty database, so the unique test does not collide with the first." |
| M | 49, 86 | 3, 9 | myapp.models is a phantom module. Readers can't run the blocks, and the fields the factory assumes (username, email, author, content) are never shown. |
Define User/Post once (both I-7 tabs) at the top, backed by docs/examples/testing_models.py / _annotated.py. Reuse them throughout. |
| M | 171 | 15 | Garbled sentence: "test those with the migration test harness, on a database it stands at a named migration." | "Test those with the migration test harness, which stands a database at a named migration. See Testing migrations." |
| L | 81 | 17 | "keeps setup terse without pulling in a library" is fine. Line 15's "without extra boilerplate" and "guaranteeing no state leaks" are filler and overclaim. | Cut "without extra boilerplate". Make the guarantee "so no rows or identity-map entries carry over to the next test." |
| L | 157 | 9 | f"{POSTGRES_URL}?ferro_search_path={schema}" breaks if the URL already has a query string (e.g. ?sslmode=require). |
Note it, or build the URL with urllib.parse. |
| L | 128–165 | 1 | The ferro_search_path explanation repeats guide/connections.md#postgres-schema-isolation almost verbatim. |
Keep the fixture and link to the guide section for the parameter's rules. |
| L | 173–177 | 22 | The See Also links use em-dash glosses. That is fine, but the API link text "Connection & Registry API" points at a page, not at reset_engine. |
Link reset_engine inline at line 15 to its anchor in api/connection.md. |
docs/pages/howto/pagination.md
- Kind (rule 1): mixed: how-to (the two recipes) and explanation (drift and cost discussion, the "Choosing a Strategy" table).
- Running example (rule 3):
Article(id, title), reused well. - Verdict: This page teaches a hand-rolled
where id > cursorkeyset while Ferro ships first-class keyset paging (Query.after()/.before(), documented inguide/queries.md). Its FastAPI example raisesRuntimeErroras written because no session is open. It needs rework around the real API. C
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 20–33, snippet pagination.py:keyset |
correctness, 1 | Stale/incomplete API. Query.after(position) and Query.before(position) (src/ferro/query/builder.py:1183,1208) are the built-in keyset API. They take a multi-key position tuple or a model instance, require the PK among the order keys, and before() gives the previous page. The page never mentions them. The hand-rolled where(id > after_id) only works for a single unique key, and "clients can only walk forward (or backward)" points at backward paging without showing it. |
Rewrite the keyset section on .order_by(lambda article: article.created_at).order_by(lambda article: article.id).limit(20).after(last) and show .before() for the previous page. Link to guide/queries.md (the .after() section) for the rules. Keep the plain where form only as the SQL it compiles to (rule 13). |
| H | 55–66, 87–98 | correctness | The FastAPI endpoint queries with no session and no using=. Since v0.13 that raises RuntimeError: No database route for this operation… (confirmed live). The example does not work. |
Wrap the body in async with engines.session(): (or a dependency/middleware that opens one, linking guide/connections.md#request-scoped-sessions). |
| H | 60–61, 92–93 | I-8 | Article.where(lambda t: t.id > cursor), order_by(lambda t: t.id) |
lambda article: article.id > cursor, order_by(lambda article: article.id) (the snippet file already does this). |
| M | 65, 97 | correctness | "has_more": len(items) == limit reports True on an exactly-full last page, so the client makes one extra empty request. |
Fetch limit + 1, set has_more = len(rows) > limit, return rows[:limit]. Or state the trade-off in the result sentence. |
| M | 39–99 | 9, I-7 | The two FastAPI tabs are inline and only compile-checked. They are not backed by docs/examples/ files that run, which is how the bug above survived. |
Move them to docs/examples/pagination_api.py / _annotated.py and drive them with fastapi.testclient or httpx.ASGITransport. |
| M | 7, 13, 116 | 17 | Minimising words: "The simplest approach", "easy to implement", "Implementation effort: Trivial". | "Order the rows, skip (page - 1) * per_page, take per_page:". Drop the effort row or say "One offset() call" / "Track the last row's position". |
| M | 1 | 24 | Title "Pagination" names a topic, not the goal. | # How to paginate query results |
| M | 9–11, 24–26 | 7, 8, 13 | Neither snippet shows output or the SQL. The offset block's result sentence jumps straight to trade-offs. | After each block, show the rows a call returns (e.g. get_page(page=2, per_page=10) → Article 11 … Article 20, which the script already asserts) and the generated SQL (… ORDER BY "id" LIMIT 10 OFFSET 10). |
| M | 103–117 | 1, 5 | "Tips" and "Choosing a Strategy" are explanation inside a how-to. The choice table sits at the end, after the reader has already chosen. | Move a two-line "which to use" to the top (rule 2). Keep the table only if it earns its place, and rename the headings to sentence case ("Choosing offset or keyset", drop "Tips" in favour of folding the three points into the recipes). |
| L | 5, 20, 35, 103, 109, 119 | 5 | Title Case headings ("Offset Pagination", "Keyset (Cursor) Pagination", "Using It in an API", "See Also"). | Sentence case; "Using It in an API" → "A paginated FastAPI endpoint". |
| L | 106 | I-7 | Inline created_at: datetime = Field(index=True) shows assignment style only. |
Add or Annotated[datetime, Field(index=True)], or link to the index docs. |
docs/pages/howto/timestamps.md
- Kind (rule 1): how-to, with explanation mixed in (the "Why a mixin" note and the timezone advice).
- Running example (rule 3):
Note(id, text, created_at, updated_at)plusTimestampMixin. Good, with both I-7 tabs backed by runnable files. - Verdict: The core recipe runs. The reason it gives for the mixin contradicts observed behaviour, and its database-clock
update()recipe corrupts ordering on SQLite. It also duplicatesguide/models-and-fields.md#reusing-fields-across-modelsnearly wholesale. C
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 26–28 | correctness | The stated reason is false. "Fields declared on a Model base class are not contributed to its subclasses' tables." Live check: class Timestamped(Model) with created_at / updated_at, then class Note(Timestamped), yields CREATE TABLE "note" with both columns, and save() updates them. The real cost is that the base becomes a table of its own (timestamped, even with no PK). The guide gives a third reason ("broken subclass defaults"). Three pages give three explanations. |
Establish the actual failure (an extra table for the base? defaults?), pin it with a test, and state that one reason in the guide only. Here, link to it: "Declare the fields on each model; a Model base class becomes a table of its own (see Reusing fields across models)." |
| H | 36–44 | correctness, I-13 | The update(lambda note: {"updated_at": now}) recipe on SQLite writes '2026-10-19 14:56:27', while save() writes '2026-10-19T14:56:27.736246Z' (confirmed live via fetch_all). The formats differ, so text ordering by updated_at puts the later row first (space < T) and sub-second precision is lost. The value also hydrates as a naive datetime, so comparing it with the mixin's aware utcnow() raises TypeError. |
Recommend update(updated_at=utcnow()). Query/update literals canonicalise to save()'s form (I-13), so it stays consistent with the mixin's clock. If now stays, scope it to Postgres in a Warning and explain the SQLite format/naive-datetime behaviour. |
| H | 41 | I-8 note, 9 | The inline block uses an undefined nid and is only compile-checked. Combined with the bug above, an untested recipe shipped broken. |
Back it with a block in timestamps.py that runs and asserts the result. |
| M | 19–28 vs guide/models-and-fields.md:542–600 |
1 | The guide already teaches the same TimestampMixin pattern with both tabs. This how-to repeats it, and the explanations drift. |
Make one the source: keep the recipe here and turn the guide section into a pointer, or the reverse. |
| M | 26 | 5 | The admonition title is a question: "Why a mixin instead of a Model base class?" |
!!! note "Fields go on each model, behaviour in the mixin" |
| M | 1 | 24 | Title "Timestamps" | # How to track created and updated times |
| M | 36 | 20 | The warning that batch update() skips the mixin is buried mid-paragraph after "Note that…". A missed updated_at is a silent data-correctness hazard. |
Split it into its own !!! warning "Batch updates skip the mixin". |
| M | 68–70 | correctness/staleness | "run a reviewed migration (Alembic)". Ferro now has its own ferro migrate new. Pointing only to Alembic is stale, though the core warning text in ferro_ddl_lowering::timestamp_tz_conversion_warning still says "Alembic" too. |
"write a migration (ferro migrate new, then edit the step's SQL) with an explicit source time zone…", and link guide/schema/migrations.md. Raise updating the core warning text as a separate item. |
| M | 32–36 | 7, 8 | The usage snippet's result sentence exists, but no values are shown. | Print note.created_at / note.updated_at before and after save() and show the output. |
| L | 48–56 | 3 | Re-shows utcnow(), which is already in the model snippet. |
Drop the block and refer to utcnow() above. |
| L | 3 | 17 | "a small mixin that hooks save()" |
"a mixin that sets updated_at in save()" |
| L | 5, 30, 46, 60, 72 | 5 | "The Pattern", "Usage", "Timezone Notes", "See Also" name nothing / Title Case. | "The TimestampMixin", "Updating a note", "Storing UTC", "Naive and aware columns", "See also". |
| L | 62–70 | 22 | Hard-wrapped prose here, unwrapped everywhere else in the page. | Unwrap for consistency. |
docs/pages/howto/soft-deletes.md
- Kind (rule 1): how-to, with a trade-offs section (explanation) at the end, which is acceptable as a short "before you use this".
- Running example (rule 3):
Invoice(id, number, is_deleted, deleted_at)plusSoftDeleteMixin. Used well, with I-7 tabs backed by runnable files. - Verdict: Tight and useful. It repeats the false "base class cannot contribute fields" reason, breaks I-8 three times, and one trade-off option is a race. B
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 22 | correctness | Same false reason as timestamps.md:28. "a Model base class cannot contribute fields to subclasses". Live check shows it does. |
Replace with the verified reason, or a link to the single guide section once fixed. |
| H | 37, 38, 50 | I-8 | lambda t: t.number.like(...), lambda t: t.is_deleted == True ×2 |
lambda invoice: invoice.number.like("INV-%"), lambda invoice: invoice.is_deleted == True |
| M | 55 | 18, correctness | "drop the database-level constraint and enforce uniqueness among active rows in application code" is a check-then-insert race under concurrency. Recommending it contradicts the rest of the docs' stance on DB-enforced integrity. | Say that it races, or drop the option. Keep "hard-delete in that flow" and "rename on soft delete" as the safe options. |
| M | 1 | 24 | Title "Soft Deletes" | # How to soft-delete rows |
| M | 36–39 | 7, 9 | The query block has a lead-in but no result sentence, and it is only compile-checked. | Add "unpaid holds the live invoices; trashed holds only the soft-deleted ones." Move it into soft_deletes.py with asserts. |
| L | 55 | 3 | The trade-off example uses number: str = Field(unique=True), but the running example's number is not unique. |
Either make number unique in the example model (both tabs), or say "if number were unique". |
| L | 31 (snippet) | — | The mixin's generic active() names its lambda invoice. It fits the example but misleads when the mixin is reused on other models. |
Note it, or name it row in the mixin and explain why (I-8's rule is about the model, and a mixin has none). |
| L | 5, 24, 32, 53, 59 | 5 | "The Pattern", "Usage", "Querying", "Trade-offs", "See Also" | "The SoftDeleteMixin", "Deleting and restoring", "Querying live rows", "Trade-offs", "See also". |
| L | 41 | 15 | "Two things to remember:" is a staged opener. | Drop it and let the bullets stand, or make the first bullet a Warning ("Nothing is filtered automatically"). |
docs/examples/testing_conftest.py
- Embedded by:
howto/testing.md(fixtures) - Verdict: C. The page tells readers to copy these fixtures, and no test ever runs them.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| H | 31–36 | 9 | main() is a no-op ("import smoke check only"). db and db_transaction are never run under pytest, so reset_engine() teardown, session scoping and the transaction fixture are untested. |
Use the fixtures from a real test, e.g. a tests/docs/test_testing_conftest.py that imports them, uses db and db_transaction with pytest-asyncio auto mode, and asserts isolation between two tests. |
| M | 21–25 | 18 / fact | The db_transaction docstring says "inside a transaction sharing one connection". The fixture commits on exit, and the page (line 16) sells it for "uncommitted state" visibility. Readers may assume it rolls back per test. |
State that it commits, or make it roll back explicitly and say so. |
| L | 1–4 | — | The docstring says the file "is not meant to be executed directly", yet the script test executes it. | Make it a real conftest exercised by a test (above). |
docs/examples/pagination.py
- Embedded by:
howto/pagination.md(offset,keyset) - Verdict: A. Both functions are named and typed, with keyword calls (
get_page(page=2, per_page=10)). The test calls (lines 46–51) would make good on-page usage plus output. The page's separate inline FastAPI block (lines 40–95, both tabs) duplicatesArticleand useslambda t:(I-8). That is a page issue, but it is the inline-gap pattern in action: compile-only, so the I-8 violation passes.
docs/examples/soft_deletes.py + soft_deletes_annotated.py
- Embedded by:
howto/soft-deletes.md(model,usage+ annotated) - Verdict: B. A clear recipe with visible asserts. The I-8 naming is wrong in the generic mixin, and it imports from a non-public module.
| Sev | Line | Rule | Finding | Suggested fix |
|---|---|---|---|---|
| M | 31 | I-8 | The mixin's active() uses lambda invoice:, but the mixin applies to any model, so the name is wrong for every other model that uses it. |
lambda row: row.is_deleted == False, and note on the page why the mixin is the exception to model-named parameters. |
| M | 9 | API | from ferro.query import Query: ferro.Query is not exported (hasattr(ferro, "Query") is False), so readers copy an import from an internal module. |
Export Query from ferro, or annotate the return type without it. |
| L | 49–57 | 10 | Invoice numbers are good. The annotated twin's body uses one invoice; fine. | — |
docs/examples/timestamps.py + timestamps_annotated.py
- Embedded by:
howto/timestamps.md - Verdict: A. Minimal, correct, UTC-aware, twins equivalent, asserts visible. Optionally show the two timestamps as output (rule 8).
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) (#…
docs(guide): Models and fields — one running example, generated field…
0x054 commentedon Oct 19, 2026
Status: verify-passed
Worker: worker-716
Branch:
sdd/716-short-howtosPR: #742
Verify: passed 5e00650
Scoped re-review (reviewer-716): Spec PASS, Quality PASS. Both Important findings are addressed in 5e00650 (
pagination.md:60now says the order keys include the primary key, andpagination.md:80uses{test=compile reason=...}). Two Minor findings remain and do not block.Verify passed on 5e00650 and on a trial merge into
feat/docs-rewriteat b0e5348 (strict build clean). Ready to merge.docs(howto): testing, pagination, timestamps and soft-delete how-tos …
0x054 commentedon Oct 19, 2026
Merged in #742 into
feat/docs-rewrite