Skip to content

Commit fb6ee19

Browse files
authored
Page deck and card lists by id with limit and after (#73)
1 parent bd7fa71 commit fb6ee19

9 files changed

Lines changed: 142 additions & 21 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ class ListDecksUseCase:
4444
decks_repository: DecksRepository
4545

4646
@postgres_retry
47-
async def __call__(self) -> Sequence[models.Deck]:
47+
async def __call__(self, *, after: int | None, limit: int) -> Sequence[models.Deck]:
4848
async with self.transaction:
49-
decks: typing.Final = await self.decks_repository.get_many()
49+
decks: typing.Final = await self.decks_repository.list_decks(after=after, limit=limit)
5050
await self.transaction.commit()
5151
return decks
5252
```
@@ -69,7 +69,8 @@ class ListDecksUseCase:
6969
## Conventions
7070

7171
- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/<name>.py`, defining handlers + a `ROUTER`, and adding it to that list. Its database work goes in `app/use_cases/` with a provider in `ioc.UseCases`.
72-
- Pydantic schemas in `app/schemas.py` use `from_attributes=True` (via `Base`) so they validate directly from ORM instances (`schemas.X.model_validate(orm_instance)`). Collection responses go through `Collection[T].from_models(...)` (e.g. `schemas.Decks`, `schemas.Cards`).
72+
- Pydantic schemas in `app/schemas.py` use `from_attributes=True` (via `Base`) so they validate directly from ORM instances (`schemas.X.model_validate(orm_instance)`). Collection responses go through `Collection[T].from_models(...)` (e.g. `schemas.Cards`).
73+
- 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.
7374
- 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 use `noload` (no cards query), detail uses `joinedload` via `fetch_with_cards` — so the type states exactly what each endpoint loads.
7475
- Domain exceptions: register handlers in `application.build_app`'s `exception_handlers` dict (`DuplicateKeyError` → `exceptions.duplicate_key_error_handler` returns 400, `NotFoundError` → `exceptions.not_found_error_handler` returns 404). Handlers don't raise HTTP errors themselves: a missing row surfaces as advanced-alchemy's `NotFoundError`.
7576
- `ruff` is configured with `select = ["ALL"]` and a line length of 120 — expect strict lint. `app/resources/` is exempt from the `TC` rules because modern-di resolves DI creator annotations at runtime. Type-check with `ty`; suppressions already exist for `invalid-argument-type` around `LifespanManager` / `ASGITransport` in `tests/conftest.py`.

‎app/api/cards.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import typing
2+
from typing import Annotated
23

34
import litestar
45
from litestar.di import NamedDependency
5-
from litestar.params import FromPath
6+
from litestar.params import FromPath, QueryParameter
67

78
from app import schemas
89
from app.use_cases.create_cards import CreateCardsUseCase
@@ -12,8 +13,14 @@
1213

1314

1415
@litestar.get("/decks/{deck_id:int}/cards/")
15-
async def list_cards(deck_id: FromPath[int], list_cards_use_case: NamedDependency[ListCardsUseCase]) -> schemas.Cards:
16-
return schemas.Cards.from_models(await list_cards_use_case(deck_id=deck_id))
16+
async def list_cards(
17+
deck_id: FromPath[int],
18+
list_cards_use_case: NamedDependency[ListCardsUseCase],
19+
after: Annotated[int | None, QueryParameter(gt=0)] = None,
20+
limit: Annotated[int, QueryParameter(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT,
21+
) -> schemas.CardsPage:
22+
cards: typing.Final = await list_cards_use_case(deck_id=deck_id, after=after, limit=limit)
23+
return schemas.CardsPage.from_models(cards, limit=limit)
1724

1825

1926
@litestar.get("/cards/{card_id:int}/")

‎app/api/decks.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import typing
2+
from typing import Annotated
23

34
import litestar
45
from litestar.di import NamedDependency
5-
from litestar.params import FromPath
6+
from litestar.params import FromPath, QueryParameter
67

78
from app import schemas
89
from app.use_cases.create_deck import CreateDeckUseCase
@@ -12,8 +13,13 @@
1213

1314

1415
@litestar.get("/decks/")
15-
async def list_decks(list_decks_use_case: NamedDependency[ListDecksUseCase]) -> schemas.Decks:
16-
return schemas.Decks.from_models(await list_decks_use_case())
16+
async def list_decks(
17+
list_decks_use_case: NamedDependency[ListDecksUseCase],
18+
after: Annotated[int | None, QueryParameter(gt=0)] = None,
19+
limit: Annotated[int, QueryParameter(ge=1, le=schemas.MAX_PAGE_LIMIT)] = schemas.DEFAULT_PAGE_LIMIT,
20+
) -> schemas.DecksPage:
21+
decks: typing.Final = await list_decks_use_case(after=after, limit=limit)
22+
return schemas.DecksPage.from_models(decks, limit=limit)
1723

1824

1925
@litestar.get("/decks/{deck_id:int}/")

‎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_codes.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_update_cards_to_a_duplicate_front(client: AsyncClient) -> None:
188188

189189
response = await client.put(f"/api/decks/{deck.id}/cards/", json=[{"id": card2.id, "front": card1.front}])
190190
assert response.status_code == status_codes.HTTP_400_BAD_REQUEST
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_codes.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_codes.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_codes.HTTP_400_BAD_REQUEST

‎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_codes.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_codes.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_codes.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_codes.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_codes.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_codes.HTTP_400_BAD_REQUEST

0 commit comments

Comments
 (0)