Skip to content

Commit c64450c

Browse files
authored
Page deck and card lists by id with limit and after (#96)
1 parent c70a2e3 commit c64450c

9 files changed

Lines changed: 132 additions & 18 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ class ListDecksUseCase:
6565
decks_repository: DecksRepository
6666

6767
@postgres_retry
68-
async def __call__(self) -> Sequence[models.Deck]:
68+
async def __call__(self, *, after: int | None, limit: int) -> Sequence[models.Deck]:
6969
async with self.transaction:
70-
decks: typing.Final = await self.decks_repository.get_many()
70+
decks: typing.Final = await self.decks_repository.list_decks(after=after, limit=limit)
7171
await self.transaction.commit()
7272
return decks
7373
```
@@ -106,6 +106,7 @@ class ListDecksUseCase:
106106

107107
- 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.
108108
- 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`.
109+
- List endpoints page by id (keyset). They take `limit` (1 to `schemas.MAX_PAGE_LIMIT`, default `schemas.DEFAULT_PAGE_LIMIT`) and `after`, the last id the client has seen, and return a `Page[T]` subclass (`schemas.DecksPage`, `schemas.CardsPage`) with `items` and `next_after`. The repository filters `id > after` and fetches `limit + 1` rows ordered by id. `Page.from_models(rows, limit=limit)` drops the extra row and sets `next_after` to the last returned id when that row existed, or `null` on the last page. Don't page with OFFSET: Postgres reads and discards every skipped row.
109110
- 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 `joinedload` via `fetch_with_cards` — so the type states exactly what each endpoint loads.
110111
- Line length is 120.
111112

‎app/api/cards.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
@ROUTER.get("/decks/{deck_id}/cards/")
1717
async def list_cards(
1818
deck_id: int,
19+
after: typing.Annotated[int | None, fastapi.Query(gt=0)] = None,
20+
limit: typing.Annotated[int, fastapi.Query(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT,
1921
list_cards_use_case: ListCardsUseCase = FromDI(ListCardsUseCase),
20-
) -> schemas.Cards:
21-
return schemas.Cards.from_models(await list_cards_use_case(deck_id=deck_id))
22+
) -> schemas.CardsPage:
23+
cards: typing.Final = await list_cards_use_case(deck_id=deck_id, after=after, limit=limit)
24+
return schemas.CardsPage.from_models(cards, limit=limit)
2225

2326

2427
@ROUTER.get("/cards/{card_id}/")

‎app/api/decks.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515

1616
@ROUTER.get("/decks/")
1717
async def list_decks(
18+
after: typing.Annotated[int | None, fastapi.Query(gt=0)] = None,
19+
limit: typing.Annotated[int, fastapi.Query(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT,
1820
list_decks_use_case: ListDecksUseCase = FromDI(ListDecksUseCase),
19-
) -> schemas.Decks:
20-
return schemas.Decks.from_models(await list_decks_use_case())
21+
) -> schemas.DecksPage:
22+
decks: typing.Final = await list_decks_use_case(after=after, limit=limit)
23+
return schemas.DecksPage.from_models(decks, limit=limit)
2124

2225

2326
@ROUTER.get("/decks/{deck_id}/")

‎app/repositories.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import sqlalchemy as sa
66
from advanced_alchemy.exceptions import NotFoundError, wrap_sqlalchemy_exception
7+
from advanced_alchemy.filters import LimitOffset
78
from advanced_alchemy.repository import DEFAULT_ERROR_MESSAGE_TEMPLATES, SQLAlchemyAsyncRepository
89
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
910
from sqlalchemy import orm
@@ -22,8 +23,12 @@ class BaseRepository(SQLAlchemyAsyncRepository[models.Deck]):
2223

2324
repository_type = BaseRepository
2425

25-
async def list_decks(self) -> Sequence[models.Deck]:
26-
return await self.get_many(order_by=models.Deck.id.asc())
26+
async def list_decks(self, *, after: int | None, limit: int) -> Sequence[models.Deck]:
27+
return await self.get_many(
28+
*([] if after is None else [models.Deck.id > after]),
29+
LimitOffset(limit=limit + 1, offset=0),
30+
order_by=models.Deck.id.asc(),
31+
)
2732

2833
async def get_or_create(self, data: schemas.DeckCreate) -> models.Deck:
2934
statement = postgresql.insert(models.Deck).values(**data.model_dump())
@@ -56,8 +61,13 @@ class BaseRepository(SQLAlchemyAsyncRepository[models.Card]):
5661

5762
repository_type = BaseRepository
5863

59-
async def list_for_deck(self, deck_id: int) -> Sequence[models.Card]:
60-
return await self.get_many(models.Card.deck_id == deck_id, order_by=models.Card.id.asc())
64+
async def list_for_deck(self, deck_id: int, *, after: int | None, limit: int) -> Sequence[models.Card]:
65+
return await self.get_many(
66+
models.Card.deck_id == deck_id,
67+
*([] if after is None else [models.Card.id > after]),
68+
LimitOffset(limit=limit + 1, offset=0),
69+
order_by=models.Card.id.asc(),
70+
)
6171

6272
async def add_cards(self, deck_id: int, cards: list[schemas.CardCreate]) -> Sequence[models.Card]:
6373
return await self.create_many([models.Card(**card.model_dump(), deck_id=deck_id) for card in cards])

‎app/schemas.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import uuid
2-
from typing import TYPE_CHECKING, Self
2+
from typing import TYPE_CHECKING, Final, Protocol, Self
33

44
import pydantic
55
from pydantic import BaseModel, PositiveInt
66

77

88
if TYPE_CHECKING:
9-
from collections.abc import Iterable
9+
from collections.abc import Iterable, Sequence
10+
11+
12+
DEFAULT_PAGE_LIMIT: Final = 50
13+
MAX_PAGE_LIMIT: Final = 100
14+
15+
16+
class HasId(Protocol):
17+
id: int
1018

1119

1220
class Base(BaseModel):
@@ -21,6 +29,18 @@ def from_models(cls, objects: Iterable[object]) -> Self:
2129
return cls.model_validate({"items": list(objects)})
2230

2331

32+
class Page[T: Base](Base):
33+
items: list[T]
34+
next_after: PositiveInt | None
35+
36+
@classmethod
37+
def from_models(cls, objects: Sequence[HasId], *, limit: int) -> Self:
38+
has_next: Final = len(objects) > limit
39+
return cls.model_validate(
40+
{"items": list(objects[:limit]), "next_after": objects[limit - 1].id if has_next else None}
41+
)
42+
43+
2444
class CardBase(Base):
2545
front: str
2646
back: str | None = None
@@ -43,6 +63,10 @@ class Cards(Collection[Card]):
4363
pass
4464

4565

66+
class CardsPage(Page[Card]):
67+
pass
68+
69+
4670
class DeckBase(Base):
4771
name: str
4872
description: str | None = None
@@ -68,5 +92,5 @@ class DeckWithCards(Deck):
6892
cards: list[Card]
6993

7094

71-
class Decks(Collection[Deck]):
95+
class DecksPage(Page[Deck]):
7296
pass

‎app/use_cases/list_cards.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ class ListCardsUseCase:
1414
cards_repository: CardsRepository
1515

1616
@postgres_retry
17-
async def __call__(self, *, deck_id: int) -> Sequence[models.Card]:
17+
async def __call__(self, *, deck_id: int, after: int | None, limit: int) -> Sequence[models.Card]:
1818
async with self.transaction:
19-
cards: typing.Final = await self.cards_repository.list_for_deck(deck_id)
19+
cards: typing.Final = await self.cards_repository.list_for_deck(deck_id, after=after, limit=limit)
2020
await self.transaction.commit()
2121
return cards

‎app/use_cases/list_decks.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ class ListDecksUseCase:
1414
decks_repository: DecksRepository
1515

1616
@postgres_retry
17-
async def __call__(self) -> Sequence[models.Deck]:
17+
async def __call__(self, *, after: int | None, limit: int) -> Sequence[models.Deck]:
1818
async with self.transaction:
19-
decks: typing.Final = await self.decks_repository.list_decks()
19+
decks: typing.Final = await self.decks_repository.list_decks(after=after, limit=limit)
2020
await self.transaction.commit()
2121
return decks

‎tests/test_cards.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async def test_create_cards(client: AsyncClient) -> None:
7070
response = await client.get(f"/api/decks/{deck.id}/cards/")
7171
assert response.status_code == status.HTTP_200_OK
7272
data = response.json()
73-
assert created_data == data
73+
assert created_data["items"] == data["items"]
7474
assert len(data["items"]) == len(cards_to_create)
7575
for k, v in cards_to_create[0].model_dump().items():
7676
assert data["items"][0][k] == v
@@ -188,3 +188,30 @@ async def test_openapi_card_deck_id_required(client: AsyncClient) -> None:
188188
card_schema = response.json()["components"]["schemas"]["Card"]
189189
assert "deck_id" in card_schema["required"]
190190
assert "null" not in {x.get("type") for x in card_schema["properties"]["deck_id"].get("anyOf", [])}
191+
192+
193+
async def test_list_cards_pages_by_id_within_deck(client: AsyncClient) -> None:
194+
deck, other_deck = await factories.DeckModelFactory.create_batch_async(size=2)
195+
first, second = await factories.CardModelFactory.create_batch_async(size=2, deck_id=deck.id)
196+
await factories.CardModelFactory.create_async(deck_id=other_deck.id)
197+
third = await factories.CardModelFactory.create_async(deck_id=deck.id)
198+
199+
response = await client.get(f"/api/decks/{deck.id}/cards/", params={"limit": 2})
200+
assert response.status_code == status.HTTP_200_OK
201+
data = response.json()
202+
assert [x["id"] for x in data["items"]] == [first.id, second.id]
203+
assert data["next_after"] == second.id
204+
205+
response = await client.get(f"/api/decks/{deck.id}/cards/", params={"limit": 2, "after": data["next_after"]})
206+
assert response.status_code == status.HTTP_200_OK
207+
data = response.json()
208+
assert [x["id"] for x in data["items"]] == [third.id]
209+
assert data["next_after"] is None
210+
211+
212+
@pytest.mark.parametrize("params", [{"limit": 0}, {"limit": 101}, {"after": 0}])
213+
async def test_list_cards_rejects_bad_page_params(client: AsyncClient, params: dict[str, int | str]) -> None:
214+
deck = await factories.DeckModelFactory.create_async()
215+
216+
response = await client.get(f"/api/decks/{deck.id}/cards/", params=params)
217+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT

‎tests/test_decks.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,49 @@ async def test_put_decks_moves_updated_at(client: AsyncClient, db_session: Async
204204
assert response.status_code == status.HTTP_200_OK
205205
updated_at = (await db_session.scalars(sa.select(models.Deck.updated_at).where(models.Deck.id == deck.id))).one()
206206
assert updated_at > long_ago
207+
208+
209+
async def test_list_decks_first_page(client: AsyncClient) -> None:
210+
decks = await factories.DeckModelFactory.create_batch_async(size=3)
211+
212+
response = await client.get("/api/decks/", params={"limit": 2})
213+
assert response.status_code == status.HTTP_200_OK
214+
data = response.json()
215+
assert [x["id"] for x in data["items"]] == [decks[0].id, decks[1].id]
216+
assert data["next_after"] == decks[1].id
217+
218+
219+
async def test_list_decks_after_cursor(client: AsyncClient) -> None:
220+
decks = await factories.DeckModelFactory.create_batch_async(size=3)
221+
222+
response = await client.get("/api/decks/", params={"limit": 2, "after": decks[1].id})
223+
assert response.status_code == status.HTTP_200_OK
224+
data = response.json()
225+
assert [x["id"] for x in data["items"]] == [decks[2].id]
226+
assert data["next_after"] is None
227+
228+
229+
async def test_list_decks_exactly_limit_has_no_next_page(client: AsyncClient) -> None:
230+
decks = await factories.DeckModelFactory.create_batch_async(size=2)
231+
232+
response = await client.get("/api/decks/", params={"limit": 2})
233+
assert response.status_code == status.HTTP_200_OK
234+
data = response.json()
235+
assert [x["id"] for x in data["items"]] == [x.id for x in decks]
236+
assert data["next_after"] is None
237+
238+
239+
async def test_list_decks_default_limit(client: AsyncClient) -> None:
240+
decks = await factories.DeckModelFactory.create_batch_async(size=51)
241+
242+
response = await client.get("/api/decks/")
243+
assert response.status_code == status.HTTP_200_OK
244+
data = response.json()
245+
assert [x["id"] for x in data["items"]] == [x.id for x in decks[:50]]
246+
assert data["next_after"] == decks[49].id
247+
248+
249+
@pytest.mark.parametrize("params", [{"limit": 0}, {"limit": 101}, {"after": 0}, {"after": "x"}])
250+
async def test_list_decks_rejects_bad_page_params(client: AsyncClient, params: dict[str, int | str]) -> None:
251+
response = await client.get("/api/decks/", params=params)
252+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT

0 commit comments

Comments
 (0)