Skip to content

Commit 976b2fc

Browse files
lesnik512claude
andauthored
refactor: split the Deck response contract from its detail view (#55)
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) <noreply@anthropic.com>
1 parent a8c9572 commit 976b2fc

3 files changed

Lines changed: 11 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,5 @@ Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi`
7171
- Type-ignore syntax is `# ty: ignore[error-code]` (this project uses `ty`, not mypy). See `app/application.py:39` for an example.
7272
- 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.
7373
- 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`.
74+
- 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.
7475
- Line length is 120.

app/api/decks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ async def list_decks(
2222
async def get_deck(
2323
deck_id: int,
2424
decks_repository: DecksRepository = FromDI(DecksRepository),
25-
) -> schemas.Deck:
25+
) -> schemas.DeckWithCards:
2626
instance = await decks_repository.fetch_with_cards(deck_id)
27-
return schemas.Deck.model_validate(instance)
27+
return schemas.DeckWithCards.model_validate(instance)
2828

2929

3030
@ROUTER.put("/decks/{deck_id}/")

app/schemas.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,15 @@ class DeckCreate(DeckBase):
4949

5050

5151
class Deck(DeckBase):
52+
"""Light deck view for lists and writes; cards are not loaded."""
53+
5254
id: PositiveInt
53-
cards: list[Card] | None
55+
56+
57+
class DeckWithCards(Deck):
58+
"""Deck detail view; cards are eager-loaded (selectinload) and always present."""
59+
60+
cards: list[Card]
5461

5562

5663
class Decks(Collection[Deck]):

0 commit comments

Comments
 (0)