From 4bc4053e7624be5f4d476e572717dd78863cf3a2 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 13:20:58 +0300 Subject: [PATCH] refactor: split the Deck response contract from its detail view One Deck schema served two endpoints with different card-loading strategies, leaking the difference into the type: class Deck(DeckBase): id: PositiveInt cards: list[Card] | None - get_deck -> fetch_with_cards (selectinload) -> cards populated. - list_decks / create_deck / update_deck -> relationship is lazy="noload", so .cards returns [] without loading. So list_decks reported cards: [] for every deck even when the deck has cards. The | None was also dead: noload yields [], never None. Split the one schema into two: class Deck(DeckBase): """Light deck view for lists and writes; cards are not loaded.""" id: PositiveInt class DeckWithCards(Deck): """Deck detail view; cards are eager-loaded and always present.""" cards: list[Card] - get_deck -> DeckWithCards (cards always present, non-optional) - list_decks -> Decks (Collection[Deck]), create_deck / update_deck -> Deck Each endpoint's return type now states exactly what it loads. Contract change (intentional): the cards key disappears from list / create / update responses (they never loaded cards anyway). get_deck is unchanged. Ports modern-python/litestar-sqlalchemy-template#30. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + app/api/decks.py | 4 ++-- app/schemas.py | 9 ++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e81f023..d3f6880 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,4 +71,5 @@ Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi` - Type-ignore syntax is `# ty: ignore[error-code]` (this project uses `ty`, not mypy). See `app/application.py:39` for an example. - Ruff is configured with `select = ["ALL"]` and a curated ignore list in `pyproject.toml`. Don't sprinkle `# noqa`; prefer fixing or extending the project ignore list if a rule is genuinely wrong for the codebase. - Routes convert ORM objects to schemas explicitly, never via `typing.cast`. Single objects: `schemas.X.model_validate(instance)`. Collections: `schemas.Xs.from_models(objects)` (the `Collection[T]` seam in `app/schemas.py`). Both rely on `from_attributes=True`. +- Deck responses are deliberately two-shaped: `list_decks`/`create_deck`/`update_deck` return the light `schemas.Deck` (no `cards`), while `get_deck` returns `schemas.DeckWithCards`. The split mirrors loading — lists/writes use `noload` (no cards query), detail uses `selectinload` via `fetch_with_cards` — so the type states exactly what each endpoint loads. - Line length is 120. diff --git a/app/api/decks.py b/app/api/decks.py index 6b80f46..d17c154 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -22,9 +22,9 @@ async def list_decks( async def get_deck( deck_id: int, decks_repository: DecksRepository = FromDI(DecksRepository), -) -> schemas.Deck: +) -> schemas.DeckWithCards: instance = await decks_repository.fetch_with_cards(deck_id) - return schemas.Deck.model_validate(instance) + return schemas.DeckWithCards.model_validate(instance) @ROUTER.put("/decks/{deck_id}/") diff --git a/app/schemas.py b/app/schemas.py index a4b2d5a..2dc630d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -49,8 +49,15 @@ class DeckCreate(DeckBase): class Deck(DeckBase): + """Light deck view for lists and writes; cards are not loaded.""" + id: PositiveInt - cards: list[Card] | None + + +class DeckWithCards(Deck): + """Deck detail view; cards are eager-loaded (selectinload) and always present.""" + + cards: list[Card] class Decks(Collection[Deck]):