Skip to content

How-tos: testing, pagination, timestamps and soft deletes #716

Closed

Description

@0x054

Parent

#698

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 on after()/before() and its endpoint opens a session and runs.
  • Timestamps: on SQLite the update(... {"updated_at": now}) recipe writes a different datetime format from save() (the I-13 hazard), losing order, precision and timezone. The recipe writes datetimes the way save() 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 Exception instead of UniqueViolationError. 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_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).

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

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.
  • Integration: tests/test_docs_testing_fixtures.py uses 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_at against rows written by save(), and compare without TypeError.

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.py

Blocked by

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 / Post imported from myapp.models. These are never defined, and docs/examples/ has no backing for them.
  • Verdict: The recipe is sound and the facts check out (reset_engine, ferro_search_path validation, ferro migrate check). It breaks I-8, catches a bare Exception when Ferro ships UniqueViolationError, 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 > cursor keyset while Ferro ships first-class keyset paging (Query.after() / .before(), documented in guide/queries.md). Its FastAPI example raises RuntimeError as 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) plus TimestampMixin. 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 duplicates guide/models-and-fields.md#reusing-fields-across-models nearly 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) plus SoftDeleteMixin. 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) duplicates Article and uses lambda 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).

Activity

  1. added
    documentationImprovements or additions to documentation
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:MNeeds a plan; one or two local workers
    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: worker-716
    Branch: sdd/716-short-howtos
    PR: #742
    Verify: passed 5e00650

    Scoped re-review (reviewer-716): Spec PASS, Quality PASS. Both Important findings are addressed in 5e00650 (pagination.md:60 now says the order keys include the primary key, and pagination.md:80 uses {test=compile reason=...}). Two Minor findings remain and do not block.
    Verify passed on 5e00650 and on a trial merge into feat/docs-rewrite at b0e5348 (strict build clean). Ready to merge.

  5. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Merged in #742 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:MNeeds a plan; one or two local workers

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions