From a3a63b0364495838046c7b06a6c4b0a5fc6d910d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 13:08:07 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20deepen=20ORM=E2=86=92HTTP=20seriali?= =?UTF-8?q?zation=20behind=20a=20Collection=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of modern-python/litestar-sqlalchemy-template#29, adapted to this repo's idiom and extended to converge on the litestar template's end state: no casts anywhere, every handler routed through its schema. Introduce a deep generic `Collection[T]` seam in app/schemas.py so the ORM→schema coercion happens once behind a small interface. `Cards`/`Decks` become `Collection[Card]`/`Collection[Deck]` subclasses (schema names unchanged). List handlers call `schemas.Xs.from_models(objects)`. Kill every `typing.cast` in app/api/decks.py — the local equivalent of the leak the litestar PR removed. Single-object handlers now return via `schemas.X.model_validate(instance)`; collections via `from_models`. Update the CLAUDE.md convention to mandate explicit ORM→schema conversion and forbid casts. Wire contract unchanged; 19 tests pass, 100% coverage held. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- app/api/decks.py | 16 ++++++++-------- app/schemas.py | 22 ++++++++++++++++++---- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5156803..e81f023 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,5 +70,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 return `typing.cast("schemas.X", obj)` over ORM/dict objects rather than constructing Pydantic models — the schemas use `from_attributes=True`. +- 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`. - Line length is 120. diff --git a/app/api/decks.py b/app/api/decks.py index eda9b88..6b80f46 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -15,7 +15,7 @@ async def list_decks( decks_repository: DecksRepository = FromDI(DecksRepository), ) -> schemas.Decks: objects = await decks_repository.get_many() - return typing.cast("schemas.Decks", {"items": objects}) + return schemas.Decks.from_models(objects) @ROUTER.get("/decks/{deck_id}/") @@ -24,7 +24,7 @@ async def get_deck( decks_repository: DecksRepository = FromDI(DecksRepository), ) -> schemas.Deck: instance = await decks_repository.fetch_with_cards(deck_id) - return typing.cast("schemas.Deck", instance) + return schemas.Deck.model_validate(instance) @ROUTER.put("/decks/{deck_id}/") @@ -34,7 +34,7 @@ async def update_deck( decks_repository: DecksRepository = FromDI(DecksRepository), ) -> schemas.Deck: instance = await decks_repository.update(data=data.model_dump(), item_id=deck_id) - return typing.cast("schemas.Deck", instance) + return schemas.Deck.model_validate(instance) @ROUTER.post("/decks/") @@ -43,7 +43,7 @@ async def create_deck( decks_repository: DecksRepository = FromDI(DecksRepository), ) -> schemas.Deck: instance = await decks_repository.create(data.model_dump()) - return typing.cast("schemas.Deck", instance) + return schemas.Deck.model_validate(instance) @ROUTER.get("/decks/{deck_id}/cards/") @@ -52,7 +52,7 @@ async def list_cards( cards_repository: CardsRepository = FromDI(CardsRepository), ) -> schemas.Cards: objects = await cards_repository.list_for_deck(deck_id) - return typing.cast("schemas.Cards", {"items": objects}) + return schemas.Cards.from_models(objects) @ROUTER.get("/cards/{card_id}/") @@ -61,7 +61,7 @@ async def get_card( cards_repository: CardsRepository = FromDI(CardsRepository), ) -> schemas.Card: instance = await cards_repository.get_one(models.Card.id == card_id) - return typing.cast("schemas.Card", instance) + return schemas.Card.model_validate(instance) @ROUTER.post("/decks/{deck_id}/cards/") @@ -71,7 +71,7 @@ async def create_cards( cards_repository: CardsRepository = FromDI(CardsRepository), ) -> schemas.Cards: objects = await cards_repository.add_cards(deck_id, data) - return typing.cast("schemas.Cards", {"items": objects}) + return schemas.Cards.from_models(objects) @ROUTER.put("/decks/{deck_id}/cards/") @@ -81,4 +81,4 @@ async def update_cards( cards_repository: CardsRepository = FromDI(CardsRepository), ) -> schemas.Cards: objects = await cards_repository.upsert_cards(deck_id, data) - return typing.cast("schemas.Cards", {"items": objects}) + return schemas.Cards.from_models(objects) diff --git a/app/schemas.py b/app/schemas.py index 84b82f6..a4b2d5a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,11 +1,25 @@ +from typing import TYPE_CHECKING, Self + import pydantic from pydantic import BaseModel, PositiveInt +if TYPE_CHECKING: + from collections.abc import Iterable + + class Base(BaseModel): model_config = pydantic.ConfigDict(from_attributes=True) +class Collection[T: Base](Base): + items: list[T] + + @classmethod + def from_models(cls, objects: Iterable[object]) -> Self: + return cls.model_validate({"items": list(objects)}) + + class CardBase(Base): front: str back: str | None = None @@ -21,8 +35,8 @@ class Card(CardBase): deck_id: PositiveInt | None = None -class Cards(Base): - items: list[Card] +class Cards(Collection[Card]): + pass class DeckBase(Base): @@ -39,5 +53,5 @@ class Deck(DeckBase): cards: list[Card] | None -class Decks(Base): - items: list[Deck] +class Decks(Collection[Deck]): + pass