Skip to content

Commit 84fd245

Browse files
committed
feat: implement cursor pagination
1 parent 3bd66ff commit 84fd245

7 files changed

Lines changed: 248 additions & 24 deletions

File tree

src/core/schemas/response.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,30 @@ class PaginatedResponse(BaseModel, Generic[T]):
3737
data: List[T]
3838

3939

40+
# ==========================================
41+
# Cursor-Based Pagination
42+
# ==========================================
43+
class CursorMeta(BaseModel):
44+
next_cursor: Optional[str] = Field(
45+
default=None, description="Cursor for the next page. None if no more items."
46+
)
47+
prev_cursor: Optional[str] = Field(
48+
default=None, description="Cursor for the previous page. None if at the start."
49+
)
50+
has_next: bool = Field(
51+
..., description="True if there are more items after this page"
52+
)
53+
has_prev: bool = Field(..., description="True if there are items before this page")
54+
limit: int = Field(..., description="Number of items per page")
55+
56+
57+
class CursorPaginatedResponse(BaseModel, Generic[T]):
58+
success: bool = Field(default=True)
59+
message: str = Field(default="Cursor-paginated data retrieved successfully")
60+
meta: CursorMeta
61+
data: List[T]
62+
63+
4064
# ==========================================
4165
# 3. Standard Error Response
4266
# ==========================================

src/core/utils/cursor.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import base64
2+
import json
3+
from datetime import datetime
4+
from enum import Enum
5+
from uuid import UUID
6+
7+
8+
class CursorDirection(Enum):
9+
DIRECTION_NEXT = "next"
10+
DIRECTION_PREV = "prev"
11+
12+
13+
def encode_cursor(created_at: datetime, id: UUID, dir: CursorDirection) -> str:
14+
"""
15+
Encode a cursor from timestamp and ID.
16+
Format: base64(json({"t": "ISO_TIMESTAMP", "id": "UUID"}))
17+
"""
18+
cursor_data = {"t": created_at.isoformat(), "id": str(id), "dir": dir.value}
19+
json_str = json.dumps(cursor_data)
20+
return base64.urlsafe_b64encode(json_str.encode()).decode()
21+
22+
23+
def decode_cursor(cursor: str) -> tuple[datetime, UUID, CursorDirection]:
24+
"""
25+
Decode a cursor back to timestamp and ID.
26+
Returns: (created_at, id)
27+
"""
28+
try:
29+
json_str = base64.urlsafe_b64decode(cursor.encode()).decode()
30+
cursor_data = json.loads(json_str)
31+
created_at = datetime.fromisoformat(cursor_data["t"])
32+
dir = CursorDirection(cursor_data["dir"])
33+
id = UUID(cursor_data["id"])
34+
return created_at, id, dir
35+
except Exception as e:
36+
raise ValueError(f"Invalid cursor format: {e}")

src/modules/todo/application/list_todo/handler.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from datetime import datetime
2+
from uuid import UUID
3+
14
from src.modules.todo.application.list_todo.query import GetTodosQuery
25
from src.modules.todo.application.list_todo.validation import validate_get_todos_query
36
from src.modules.todo.domain.entities.todo import Todo
@@ -12,3 +15,27 @@ async def execute(self, command: GetTodosQuery) -> list[Todo]:
1215
validate_get_todos_query(command)
1316

1417
return await self.todo_repo.get_all_by_user(command.user_id)
18+
19+
20+
class GetTodosCursorQuery:
21+
def __init__(self, todo_repo: TodoRepository):
22+
self.todo_repo = todo_repo
23+
24+
async def execute(
25+
self,
26+
user_id: UUID,
27+
cursor_created_at: datetime | None = None,
28+
cursor_id: UUID | None = None,
29+
limit: int = 10,
30+
direction: str = "next",
31+
) -> tuple[list[Todo], bool]:
32+
"""
33+
Returns: (items, has_more)
34+
"""
35+
return await self.todo_repo.get_by_user_cursor(
36+
user_id=user_id,
37+
cursor_created_at=cursor_created_at,
38+
cursor_id=cursor_id,
39+
limit=limit,
40+
direction=direction,
41+
)

src/modules/todo/domain/repositories/todo_repository.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from abc import ABC, abstractmethod
2+
from datetime import datetime
23
from uuid import UUID
34

5+
from src.core.utils.cursor import CursorDirection
46
from src.modules.todo.domain.entities.todo import Todo
57

68

@@ -13,6 +15,21 @@ async def get_by_id(self, todo_id: UUID) -> Todo | None:
1315
async def get_all_by_user(self, user_id: UUID) -> list[Todo]:
1416
pass
1517

18+
@abstractmethod
19+
async def get_by_user_cursor(
20+
self,
21+
user_id: UUID,
22+
cursor_created_at: datetime | None = None,
23+
cursor_id: UUID | None = None,
24+
limit: int = 10,
25+
direction: CursorDirection = CursorDirection.DIRECTION_NEXT,
26+
) -> tuple[list[Todo], bool]:
27+
"""
28+
Get todos with cursor pagination.
29+
Returns: (items, has_more)
30+
"""
31+
pass
32+
1633
@abstractmethod
1734
async def save(self, todo: Todo) -> Todo:
1835
pass

src/modules/todo/infrastructure/repositories/todo_repository.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from datetime import datetime
12
from uuid import UUID
23

3-
from sqlalchemy import delete, select
4+
from sqlalchemy import and_, delete, or_, select
45
from sqlalchemy.ext.asyncio import AsyncSession
56

7+
from src.core.utils.cursor import CursorDirection
68
from src.modules.todo.domain.entities.todo import Todo
79
from src.modules.todo.domain.repositories.todo_repository import TodoRepository
810
from src.modules.todo.infrastructure.models.todo_model import TodoModel
@@ -25,6 +27,70 @@ async def get_by_id(self, todo_id: UUID) -> Todo | None:
2527
user_id=model.user_id,
2628
)
2729

30+
async def get_by_user_cursor(
31+
self,
32+
user_id: UUID,
33+
cursor_created_at: datetime | None = None,
34+
cursor_id: UUID | None = None,
35+
limit: int = 10,
36+
direction: CursorDirection = CursorDirection.DIRECTION_NEXT,
37+
) -> tuple[list[Todo], bool]:
38+
"""
39+
Cursor pagination logic:
40+
- If direction="next": Get items AFTER the cursor (older items)
41+
- If direction="prev": Get items BEFORE the cursor (newer items)
42+
"""
43+
query = select(TodoModel).where(
44+
TodoModel.user_id == user_id,
45+
TodoModel.deleted_at.is_(None),
46+
)
47+
48+
# Apply cursor filter if provided
49+
if cursor_created_at and cursor_id:
50+
if direction == CursorDirection.DIRECTION_NEXT:
51+
# Get items older than cursor (created_at < cursor OR (created_at == cursor AND id < cursor_id))
52+
query = query.where(
53+
or_(
54+
TodoModel.created_at < cursor_created_at,
55+
and_(
56+
TodoModel.created_at == cursor_created_at,
57+
TodoModel.id < cursor_id,
58+
),
59+
)
60+
)
61+
query = query.order_by(TodoModel.created_at.desc(), TodoModel.id.desc())
62+
else:
63+
# Get items newer than cursor (created_at > cursor OR (created_at == cursor AND id > cursor_id))
64+
query = query.where(
65+
or_(
66+
TodoModel.created_at > cursor_created_at,
67+
and_(
68+
TodoModel.created_at == cursor_created_at,
69+
TodoModel.id > cursor_id,
70+
),
71+
)
72+
)
73+
query = query.order_by(TodoModel.created_at.asc(), TodoModel.id.asc())
74+
else:
75+
# No cursor, just get the first page
76+
query = query.order_by(TodoModel.created_at.desc(), TodoModel.id.desc())
77+
78+
# Fetch limit + 1 to check if there are more items
79+
query = query.limit(limit + 1)
80+
81+
result = await self.db.execute(query)
82+
models = result.scalars().all()
83+
84+
# Check if there are more items
85+
has_more = len(models) > limit
86+
models = models[:limit] # Trim to actual limit
87+
88+
# If we fetched "prev", reverse to maintain consistent order (newest first)
89+
if direction == CursorDirection.DIRECTION_PREV:
90+
models = list(reversed(models))
91+
92+
return [self._to_entity(m) for m in models], has_more
93+
2894
async def get_all_by_user(self, user_id: UUID) -> list[Todo]:
2995
result = await self.db.execute(
3096
select(TodoModel).where(TodoModel.user_id == user_id)

src/modules/todo/presentation/dependency.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from src.core.database.postgres.session import get_db, get_unit_of_work
55
from src.modules.todo.application.create_todo.handler import CreateTodoHandler
66
from src.modules.todo.application.delete_todo.handler import DeleteTodoHandler
7-
from src.modules.todo.application.list_todo.handler import GetTodosQueryHandler
7+
from src.modules.todo.application.list_todo.handler import (
8+
GetTodosCursorQuery,
9+
)
810
from src.modules.todo.application.update_todo.handler import UpdateTodoHandler
911
from src.modules.todo.domain.repositories.todo_repository import TodoRepository
1012
from src.modules.todo.infrastructure.repositories.todo_repository import (
@@ -38,7 +40,7 @@ def get_delete_todo_handler(
3840
return DeleteTodoHandler(repo, unit_of_work)
3941

4042

41-
def get_get_todos_query_handler(
43+
def get_todos_query_handler(
4244
repo: TodoRepository = Depends(get_todo_repository),
43-
) -> GetTodosQueryHandler:
44-
return GetTodosQueryHandler(repo)
45+
) -> GetTodosCursorQuery:
46+
return GetTodosCursorQuery(repo)

src/modules/todo/presentation/routers/todo_router.py

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1+
from typing import Optional
12
from uuid import UUID
23

3-
from fastapi import APIRouter, Depends, HTTPException, status
4+
from fastapi import APIRouter, Depends, HTTPException, Query, status
45

5-
from src.core.schemas.response import PaginatedResponse, SuccessResponse
6-
from src.modules.todo.presentation.schemas.response import TodoResponse
76
from src.core.authorization.dependencies import require_permission
87
from src.core.authorization.permissions import (
98
CREATE_ACTION,
@@ -12,10 +11,18 @@
1211
TODO_RESOURCE,
1312
UPDATE_ACTION,
1413
)
14+
from src.core.schemas.response import (
15+
CursorMeta,
16+
CursorPaginatedResponse,
17+
SuccessResponse,
18+
)
19+
from src.core.utils.cursor import CursorDirection, decode_cursor, encode_cursor
1520
from src.modules.todo.application.create_todo.command import CreateTodoCommand
1621
from src.modules.todo.application.create_todo.handler import CreateTodoHandler
1722
from src.modules.todo.application.delete_todo.handler import DeleteTodoHandler
18-
from src.modules.todo.application.list_todo.handler import GetTodosQueryHandler
23+
from src.modules.todo.application.list_todo.handler import (
24+
GetTodosCursorQuery,
25+
)
1926
from src.modules.todo.application.list_todo.query import GetTodosQuery
2027
from src.modules.todo.application.update_todo.command import UpdateTodoCommand
2128
from src.modules.todo.application.update_todo.handler import UpdateTodoHandler
@@ -26,9 +33,10 @@
2633
from src.modules.todo.presentation.dependency import (
2734
get_create_todo_handler,
2835
get_delete_todo_handler,
29-
get_get_todos_query_handler,
36+
get_todos_query_handler,
3037
get_update_todo_handler,
3138
)
39+
from src.modules.todo.presentation.schemas.response import TodoResponse
3240

3341
router = APIRouter(prefix="/todos", tags=["Todos"])
3442

@@ -55,24 +63,68 @@ async def create_todo(
5563
)
5664

5765

58-
@router.get("/", response_model=PaginatedResponse[TodoResponse])
66+
@router.get("/", response_model=CursorPaginatedResponse[TodoResponse])
5967
async def get_todos(
68+
cursor: Optional[str] = Query(
69+
None, description="Cursor for pagination (from previous response)"
70+
),
71+
limit: int = Query(10, ge=1, le=100, description="Number of items per page"),
6072
current_user: dict = Depends(require_permission(TODO_RESOURCE, READ_ACTION)),
61-
query: GetTodosQueryHandler = Depends(get_get_todos_query_handler),
73+
query: GetTodosCursorQuery = Depends(get_todos_query_handler),
6274
):
75+
cursor_created_at = None
76+
cursor_id = None
77+
if cursor:
78+
cursor_created_at, cursor_id, direction = decode_cursor(cursor)
79+
6380
command = GetTodosQuery(user_id=current_user.get("id"))
64-
todos = await query.execute(command=command)
65-
return PaginatedResponse(
66-
message="fetch todo success",
67-
success=True,
68-
data=[
69-
TodoResponse(
70-
id=str(todo.id),
71-
title=todo.title,
72-
is_completed=todo.is_completed,
73-
)
74-
for todo in todos
75-
],
81+
todos, has_more = await query.execute(
82+
user_id=command.user_id,
83+
cursor_created_at=cursor_created_at,
84+
cursor_id=cursor_id,
85+
limit=limit,
86+
direction=direction,
87+
)
88+
response_todos = [
89+
TodoResponse(
90+
id=str(t.id),
91+
title=t.title,
92+
description=t.description,
93+
is_completed=t.is_completed,
94+
created_at=t.created_at.isoformat(),
95+
)
96+
for t in todos
97+
]
98+
99+
next_cursor = None
100+
prev_cursor = None
101+
102+
if has_more and len(todos) > 0:
103+
last_item = todos[-1]
104+
next_cursor = encode_cursor(
105+
last_item.created_at,
106+
last_item.id,
107+
CursorDirection.DIRECTION_NEXT,
108+
)
109+
110+
if cursor and len(todos) > 0:
111+
first_item = todos[0]
112+
prev_cursor = encode_cursor(
113+
first_item.created_at,
114+
first_item.id,
115+
CursorDirection.DIRECTION_PREV,
116+
)
117+
118+
return CursorPaginatedResponse(
119+
message="Todos retrieved successfully",
120+
meta=CursorMeta(
121+
next_cursor=next_cursor,
122+
prev_cursor=prev_cursor,
123+
has_next=has_more,
124+
has_prev=cursor is not None,
125+
limit=limit,
126+
),
127+
data=response_todos,
76128
)
77129

78130

0 commit comments

Comments
 (0)