Skip to content

Commit ee237fd

Browse files
authored
fix: bound card writes and ids, map missing decks to 404, cache the card update (#98)
Cap card lists at MAX_CARDS_PER_REQUEST and ids at bigint so oversized input is a 422 instead of a 500. Map ForeignKeyError to 404. Make LOG_LEVEL case-insensitive and pass it to app logging. Order deck detail cards by id. Send PUT cards through unnest so the statement is cached and prepared once. Add a (deck_id, id) index for card pagination.
1 parent feb742a commit ee237fd

16 files changed

Lines changed: 189 additions & 36 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,13 @@ class ListDecksUseCase:
8484
### Database layer
8585

8686
- `app/models.py` — `BigIntAuditBase` from `advanced_alchemy` (auto `id`, `created_at`, `updated_at`). The module aliases `orm_registry.metadata` onto `orm.DeclarativeBase.metadata` so Alembic autogenerate sees both. New models go here.
87-
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create_many`) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: its `update` reads the row twice and refreshes it after the flush, and `upsert_many` selects the rows before and after writing them. `update_deck` sends `UPDATE ... RETURNING` and `get_or_create` sends `INSERT ... ON CONFLICT ... RETURNING`. `update_cards` sends one `UPDATE cards ... FROM (VALUES ...) WHERE cards.id = updates.id AND cards.deck_id = :deck_id RETURNING *` and raises `NotFoundError` (404) if fewer rows come back than ids were sent, which rolls the whole request back. PUT never inserts. An `INSERT ... ON CONFLICT (id)` upsert would move a card in from another deck and would insert client-chosen ids without advancing `cards_id_seq`, so a later POST would collide on the primary key. `schemas.CardUpdates` rejects duplicate ids with a 422 before any query runs. A statement that can hit a unique constraint runs inside `wrap_sqlalchemy_exception` so that it still raises `DuplicateKeyError`.
87+
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create_many`) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: its `update` reads the row twice and refreshes it after the flush, and `upsert_many` selects the rows before and after writing them. `update_deck` sends `UPDATE ... RETURNING` and `get_or_create` sends `INSERT ... ON CONFLICT ... RETURNING`. `update_cards` sends one `UPDATE cards ... FROM unnest(:ids, :fronts, :backs, :hints) AS updates(...) WHERE cards.id = updates.id AND cards.deck_id = :deck_id RETURNING *`. It binds one array per column, so the SQL text stays the same for any number of cards: SQLAlchemy caches it and asyncpg prepares it once per connection, which a multi-row `VALUES` would not allow. It raises `NotFoundError` (404) if fewer rows come back than ids were sent, which rolls the whole request back. PUT never inserts. An `INSERT ... ON CONFLICT (id)` upsert would move a card in from another deck and would insert client-chosen ids without advancing `cards_id_seq`, so a later POST would collide on the primary key. `schemas.CardCreates` and `schemas.CardUpdates` cap a request at `MAX_CARDS_PER_REQUEST` cards, and `schemas.CardUpdates` rejects duplicate ids, both with a 422 before any query runs. Ids in paths, `after` and card bodies are bounded to Postgres `bigint` (`schemas.MAX_ID`), so an oversized id is a 422 and never reaches asyncpg. A POST to a deck that does not exist fails on the foreign key, and `ForeignKeyError` maps to 404. A statement that can hit a unique constraint runs inside `wrap_sqlalchemy_exception` so that it still raises `DuplicateKeyError`.
8888
- `app/resources/db.py` — engine factories live here and are DI creators: modern-di resolves their parameter annotations at runtime, so `pyproject.toml` exempts `app/resources/` from ruff's `TC` rules. `create_session` passes `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits — do not "fix" it.
8989
- `migrations/env.py` swaps the asyncpg driver for the sync `postgresql` driver and uses `app.models.METADATA` as `target_metadata`.
9090

9191
### Settings
9292

93-
`app/settings.py` — `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`.
93+
`app/settings.py` — `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger.
9494

9595
### Tests
9696

‎app/__main__.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import granian
22
from granian.constants import Interfaces, Loops
3-
from granian.log import LogLevels
43

54
from app.settings import settings
65

@@ -12,6 +11,6 @@
1211
address=settings.app_host,
1312
port=settings.app_port,
1413
interface=Interfaces.ASGI,
15-
log_level=LogLevels(settings.log_level),
14+
log_level=settings.log_level,
1615
loop=Loops.uvloop,
1716
).serve()

‎app/api/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import typing
2+
3+
import fastapi
4+
5+
from app import schemas
6+
7+
8+
PathId = typing.Annotated[int, fastapi.Path(gt=0, le=schemas.MAX_ID)]

‎app/api/cards.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from modern_di_fastapi import FromDI
55

66
from app import schemas
7+
from app.api import PathId
78
from app.use_cases.create_cards import CreateCardsUseCase
89
from app.use_cases.get_card import GetCardUseCase
910
from app.use_cases.list_cards import ListCardsUseCase
@@ -15,8 +16,8 @@
1516

1617
@ROUTER.get("/decks/{deck_id}/cards/")
1718
async def list_cards(
18-
deck_id: int,
19-
after: typing.Annotated[int | None, fastapi.Query(gt=0)] = None,
19+
deck_id: PathId,
20+
after: typing.Annotated[int | None, fastapi.Query(gt=0, le=schemas.MAX_ID)] = None,
2021
limit: typing.Annotated[int, fastapi.Query(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT,
2122
list_cards_use_case: ListCardsUseCase = FromDI(ListCardsUseCase),
2223
) -> schemas.CardsPage:
@@ -26,24 +27,24 @@ async def list_cards(
2627

2728
@ROUTER.get("/cards/{card_id}/")
2829
async def get_card(
29-
card_id: int,
30+
card_id: PathId,
3031
get_card_use_case: GetCardUseCase = FromDI(GetCardUseCase),
3132
) -> schemas.Card:
3233
return schemas.Card.model_validate(await get_card_use_case(card_id=card_id))
3334

3435

3536
@ROUTER.post("/decks/{deck_id}/cards/")
3637
async def create_cards(
37-
deck_id: int,
38-
data: list[schemas.CardCreate],
38+
deck_id: PathId,
39+
data: schemas.CardCreates,
3940
create_cards_use_case: CreateCardsUseCase = FromDI(CreateCardsUseCase),
4041
) -> schemas.Cards:
4142
return schemas.Cards.from_models(await create_cards_use_case(deck_id=deck_id, data=data))
4243

4344

4445
@ROUTER.put("/decks/{deck_id}/cards/")
4546
async def update_cards(
46-
deck_id: int,
47+
deck_id: PathId,
4748
data: schemas.CardUpdates,
4849
update_cards_use_case: UpdateCardsUseCase = FromDI(UpdateCardsUseCase),
4950
) -> schemas.Cards:

‎app/api/decks.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from modern_di_fastapi import FromDI
55

66
from app import schemas
7+
from app.api import PathId
78
from app.use_cases.create_deck import CreateDeckUseCase
89
from app.use_cases.get_deck import GetDeckUseCase
910
from app.use_cases.list_decks import ListDecksUseCase
@@ -15,7 +16,7 @@
1516

1617
@ROUTER.get("/decks/")
1718
async def list_decks(
18-
after: typing.Annotated[int | None, fastapi.Query(gt=0)] = None,
19+
after: typing.Annotated[int | None, fastapi.Query(gt=0, le=schemas.MAX_ID)] = None,
1920
limit: typing.Annotated[int, fastapi.Query(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT,
2021
list_decks_use_case: ListDecksUseCase = FromDI(ListDecksUseCase),
2122
) -> schemas.DecksPage:
@@ -25,15 +26,15 @@ async def list_decks(
2526

2627
@ROUTER.get("/decks/{deck_id}/")
2728
async def get_deck(
28-
deck_id: int,
29+
deck_id: PathId,
2930
get_deck_use_case: GetDeckUseCase = FromDI(GetDeckUseCase),
3031
) -> schemas.DeckWithCards:
3132
return schemas.DeckWithCards.model_validate(await get_deck_use_case(deck_id=deck_id))
3233

3334

3435
@ROUTER.put("/decks/{deck_id}/")
3536
async def update_deck(
36-
deck_id: int,
37+
deck_id: PathId,
3738
data: schemas.DeckUpdate,
3839
update_deck_use_case: UpdateDeckUseCase = FromDI(UpdateDeckUseCase),
3940
) -> schemas.Deck:

‎app/application.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import modern_di
55
import modern_di_fastapi
6-
from advanced_alchemy.exceptions import DuplicateKeyError, NotFoundError
6+
from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError
77
from lite_bootstrap import FastAPIBootstrapper
88
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
99

@@ -39,4 +39,8 @@ def build_app() -> fastapi.FastAPI:
3939
NotFoundError,
4040
exceptions.not_found_error_handler, # ty: ignore[invalid-argument-type]
4141
)
42+
app.add_exception_handler(
43+
ForeignKeyError,
44+
exceptions.not_found_error_handler, # ty: ignore[invalid-argument-type]
45+
)
4246
return app

‎app/exceptions.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
if TYPE_CHECKING:
8-
from advanced_alchemy.exceptions import DuplicateKeyError, NotFoundError
8+
from advanced_alchemy.exceptions import DuplicateKeyError, ForeignKeyError, NotFoundError
99
from starlette.requests import Request
1010

1111

@@ -16,7 +16,7 @@ async def duplicate_key_error_handler(_: Request, exc: DuplicateKeyError) -> JSO
1616
)
1717

1818

19-
async def not_found_error_handler(_: Request, __: NotFoundError) -> JSONResponse:
19+
async def not_found_error_handler(_: Request, __: NotFoundError | ForeignKeyError) -> JSONResponse:
2020
return JSONResponse(
2121
status_code=status.HTTP_404_NOT_FOUND,
2222
content={"detail": "Not found"},

‎app/models.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ class Deck(BigIntAuditBase):
1717
idempotency_key: orm.Mapped[uuid.UUID] = orm.mapped_column(sa.Uuid, nullable=False)
1818
name: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
1919
description: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True)
20-
cards: orm.Mapped[list[Card]] = orm.relationship("Card", lazy="noload", uselist=True)
20+
cards: orm.Mapped[list[Card]] = orm.relationship("Card", lazy="noload", uselist=True, order_by="Card.id")
2121

2222

2323
class Card(BigIntAuditBase):
2424
__tablename__ = "cards"
25-
__table_args__ = (sa.UniqueConstraint("deck_id", "front", name="card_deck_id_front_uc"),)
25+
__table_args__ = (
26+
sa.UniqueConstraint("deck_id", "front", name="card_deck_id_front_uc"),
27+
sa.Index("ix_cards_deck_id_id", "deck_id", "id"),
28+
)
2629

2730
front: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False)
2831
back: orm.Mapped[str | None] = orm.mapped_column(sa.String, nullable=True)

‎app/repositories.py‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,25 @@ async def add_cards(self, deck_id: int, cards: list[schemas.CardCreate]) -> Sequ
7575
async def update_cards(self, deck_id: int, cards: list[schemas.CardUpdate]) -> Sequence[models.Card]:
7676
if not cards:
7777
return []
78-
updates: typing.Final = sa.values(
79-
sa.column("id", sa.BigInteger),
80-
sa.column("front", sa.String),
81-
sa.column("back", sa.String),
82-
sa.column("hint", sa.String),
83-
name="updates",
84-
).data([(card.id, card.front, card.back, card.hint) for card in cards])
78+
updates: typing.Final = (
79+
sa.func.unnest(
80+
sa.bindparam("ids", [card.id for card in cards], type_=postgresql.ARRAY(sa.BigInteger)),
81+
sa.bindparam("fronts", [card.front for card in cards], type_=postgresql.ARRAY(sa.String)),
82+
sa.bindparam("backs", [card.back for card in cards], type_=postgresql.ARRAY(sa.String)),
83+
sa.bindparam("hints", [card.hint for card in cards], type_=postgresql.ARRAY(sa.String)),
84+
)
85+
.table_valued(
86+
sa.column("id", sa.BigInteger),
87+
sa.column("front", sa.String),
88+
sa.column("back", sa.String),
89+
sa.column("hint", sa.String),
90+
)
91+
.render_derived(name="updates")
92+
)
8593
statement: typing.Final = (
8694
sa.update(models.Card)
8795
.where(models.Card.id == updates.c.id, models.Card.deck_id == deck_id)
88-
.values(front=updates.c.front, back=updates.c.back, hint=updates.c.hint, updated_at=sa.func.now())
96+
.values(front=updates.c.front, back=updates.c.back, hint=updates.c.hint)
8997
.returning(models.Card)
9098
)
9199
with wrap_sqlalchemy_exception(error_messages=DEFAULT_ERROR_MESSAGE_TEMPLATES, dialect_name="postgresql"):

‎app/schemas.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import TYPE_CHECKING, Annotated, Final, Protocol, Self
33

44
import pydantic
5-
from pydantic import BaseModel, PositiveInt
5+
from pydantic import BaseModel, Field, PositiveInt
66

77

88
if TYPE_CHECKING:
@@ -11,6 +11,10 @@
1111

1212
DEFAULT_PAGE_LIMIT: Final = 50
1313
MAX_PAGE_LIMIT: Final = 100
14+
MAX_CARDS_PER_REQUEST: Final = 1000
15+
MAX_ID: Final = 2**63 - 1
16+
17+
Id = Annotated[int, Field(gt=0, le=MAX_ID)]
1418

1519

1620
class HasId(Protocol):
@@ -52,7 +56,7 @@ class CardCreate(CardBase):
5256

5357

5458
class CardUpdate(CardBase):
55-
id: PositiveInt
59+
id: Id
5660

5761

5862
def _ensure_unique_ids(cards: list[CardUpdate]) -> list[CardUpdate]:
@@ -62,7 +66,10 @@ def _ensure_unique_ids(cards: list[CardUpdate]) -> list[CardUpdate]:
6266
return cards
6367

6468

65-
CardUpdates = Annotated[list[CardUpdate], pydantic.AfterValidator(_ensure_unique_ids)]
69+
CardCreates = Annotated[list[CardCreate], Field(max_length=MAX_CARDS_PER_REQUEST)]
70+
CardUpdates = Annotated[
71+
list[CardUpdate], Field(max_length=MAX_CARDS_PER_REQUEST), pydantic.AfterValidator(_ensure_unique_ids)
72+
]
6673

6774

6875
class Card(CardUpdate):
@@ -97,7 +104,7 @@ class Deck(DeckBase):
97104

98105

99106
class DeckWithCards(Deck):
100-
"""Deck detail view; cards are eager-loaded (selectinload) and always present."""
107+
"""Deck detail view; cards are eager-loaded (joinedload) and always present."""
101108

102109
cards: list[Card]
103110

0 commit comments

Comments
 (0)