feat: add cursor based pagination in CrudFactory (#86)

This commit is contained in:
d3vyce
2026-02-23 13:51:34 +01:00
committed by GitHub
parent 7482bc5dad
commit 6cf7df55ef
7 changed files with 1003 additions and 41 deletions

View File

@@ -5,13 +5,12 @@ import uuid
import pytest
from pydantic import BaseModel
from sqlalchemy import Column, ForeignKey, String, Table, Uuid
from fastapi_toolsets.schemas import PydanticBase
from sqlalchemy import Column, ForeignKey, Integer, String, Table, Uuid
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.schemas import PydanticBase
DATABASE_URL = os.getenv(
key="DATABASE_URL",
@@ -71,6 +70,15 @@ post_tags = Table(
)
class IntRole(Base):
"""Test role model with auto-increment integer PK."""
__tablename__ = "int_roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), unique=True)
class Post(Base):
"""Test post model."""
@@ -116,7 +124,7 @@ class UserCreate(BaseModel):
class UserRead(PydanticBase):
"""Schema for reading a user (subset of fields)."""
"""Schema for reading a user (subset of fields — no email)."""
id: uuid.UUID
username: str
@@ -176,8 +184,17 @@ class PostM2MUpdate(BaseModel):
tag_ids: list[uuid.UUID] | None = None
class IntRoleCreate(BaseModel):
"""Schema for creating an IntRole."""
name: str
RoleCrud = CrudFactory(Role)
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
UserCrud = CrudFactory(User)
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
PostCrud = CrudFactory(Post)
TagCrud = CrudFactory(Tag)
PostM2MCrud = CrudFactory(Post, m2m_fields={"tag_ids": Post.tags})

View File

@@ -11,6 +11,8 @@ from fastapi_toolsets.crud.factory import AsyncCrud
from fastapi_toolsets.exceptions import NotFoundError
from .conftest import (
IntRoleCreate,
IntRoleCursorCrud,
Post,
PostCreate,
PostCrud,
@@ -20,6 +22,7 @@ from .conftest import (
Role,
RoleCreate,
RoleCrud,
RoleCursorCrud,
RoleRead,
RoleUpdate,
TagCreate,
@@ -27,6 +30,7 @@ from .conftest import (
User,
UserCreate,
UserCrud,
UserCursorCrud,
UserRead,
UserUpdate,
)
@@ -581,8 +585,11 @@ class TestCrudPaginate:
for i in range(25):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
from fastapi_toolsets.schemas import OffsetPagination
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
assert isinstance(result.pagination, OffsetPagination)
assert len(result.data) == 10
assert result.pagination.total_count == 25
assert result.pagination.page == 1
@@ -613,6 +620,8 @@ class TestCrudPaginate:
),
)
from fastapi_toolsets.schemas import OffsetPagination
result = await UserCrud.paginate(
db_session,
filters=[User.is_active == True], # noqa: E712
@@ -620,6 +629,7 @@ class TestCrudPaginate:
items_per_page=10,
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 5
@pytest.mark.anyio
@@ -835,6 +845,8 @@ class TestCrudJoins:
),
)
from fastapi_toolsets.schemas import OffsetPagination
# Paginate users with published posts
result = await UserCrud.paginate(
db_session,
@@ -844,6 +856,7 @@ class TestCrudJoins:
items_per_page=10,
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 3
assert len(result.data) == 3
@@ -866,6 +879,8 @@ class TestCrudJoins:
UserCreate(username="without_post", email="without@test.com"),
)
from fastapi_toolsets.schemas import OffsetPagination
# Paginate with outer join
result = await UserCrud.paginate(
db_session,
@@ -875,6 +890,7 @@ class TestCrudJoins:
items_per_page=10,
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
assert len(result.data) == 2
@@ -1512,3 +1528,494 @@ class TestSchemaResponse:
assert isinstance(result, Response)
assert isinstance(result.data, RoleRead)
class TestPaginateAlias:
"""Tests that paginate is a backward-compatible alias for offset_paginate."""
def test_paginate_is_alias_of_offset_paginate(self):
"""paginate and offset_paginate are the same underlying function."""
assert RoleCrud.paginate.__func__ is RoleCrud.offset_paginate.__func__
@pytest.mark.anyio
async def test_paginate_alias_returns_offset_pagination(
self, db_session: AsyncSession
):
"""paginate() still works and returns PaginatedResponse with OffsetPagination."""
from fastapi_toolsets.schemas import OffsetPagination, PaginatedResponse
for i in range(3):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCrud.paginate(db_session, page=1, items_per_page=10)
assert isinstance(result, PaginatedResponse)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 3
assert result.pagination.page == 1
class TestCursorPaginate:
"""Tests for cursor-based pagination via cursor_paginate()."""
@pytest.mark.anyio
async def test_first_page_no_cursor(self, db_session: AsyncSession):
"""cursor_paginate without cursor returns the first page."""
from fastapi_toolsets.schemas import CursorPagination, PaginatedResponse
for i in range(25):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
assert isinstance(result, PaginatedResponse)
assert isinstance(result.pagination, CursorPagination)
assert len(result.data) == 10
assert result.pagination.has_more is True
assert result.pagination.next_cursor is not None
assert result.pagination.prev_cursor is None
assert result.pagination.items_per_page == 10
@pytest.mark.anyio
async def test_last_page(self, db_session: AsyncSession):
"""cursor_paginate returns has_more=False and next_cursor=None on last page."""
from fastapi_toolsets.schemas import CursorPagination
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
assert isinstance(result.pagination, CursorPagination)
assert len(result.data) == 5
assert result.pagination.has_more is False
assert result.pagination.next_cursor is None
@pytest.mark.anyio
async def test_advances_correctly(self, db_session: AsyncSession):
"""Providing next_cursor from the first page returns the next page."""
for i in range(15):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
from fastapi_toolsets.schemas import CursorPagination
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
assert isinstance(page1.pagination, CursorPagination)
assert len(page1.data) == 10
assert page1.pagination.has_more is True
cursor = page1.pagination.next_cursor
page2 = await RoleCursorCrud.cursor_paginate(
db_session, cursor=cursor, items_per_page=10
)
assert isinstance(page2.pagination, CursorPagination)
assert len(page2.data) == 5
assert page2.pagination.has_more is False
assert page2.pagination.next_cursor is None
@pytest.mark.anyio
async def test_no_duplicates_across_pages(self, db_session: AsyncSession):
"""Items from consecutive cursor pages are non-overlapping and cover all rows."""
for i in range(7):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
from fastapi_toolsets.schemas import CursorPagination
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=4)
assert isinstance(page1.pagination, CursorPagination)
page2 = await RoleCursorCrud.cursor_paginate(
db_session,
cursor=page1.pagination.next_cursor,
items_per_page=4,
)
ids_page1 = {r.id for r in page1.data}
ids_page2 = {r.id for r in page2.data}
assert ids_page1.isdisjoint(ids_page2)
assert len(ids_page1 | ids_page2) == 7
@pytest.mark.anyio
async def test_empty_table(self, db_session: AsyncSession):
"""cursor_paginate on an empty table returns empty data with no cursor."""
from fastapi_toolsets.schemas import CursorPagination
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=10)
assert isinstance(result.pagination, CursorPagination)
assert result.data == []
assert result.pagination.has_more is False
assert result.pagination.next_cursor is None
assert result.pagination.prev_cursor is None
@pytest.mark.anyio
async def test_with_filters(self, db_session: AsyncSession):
"""cursor_paginate respects filters."""
for i in range(10):
await UserCrud.create(
db_session,
UserCreate(
username=f"user{i}",
email=f"user{i}@test.com",
is_active=i % 2 == 0,
),
)
result = await UserCursorCrud.cursor_paginate(
db_session,
filters=[User.is_active == True], # noqa: E712
items_per_page=20,
)
assert len(result.data) == 5
assert all(u.is_active for u in result.data)
@pytest.mark.anyio
async def test_with_schema(self, db_session: AsyncSession):
"""cursor_paginate with schema serializes items into the schema."""
from fastapi_toolsets.schemas import PaginatedResponse
for i in range(3):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCursorCrud.cursor_paginate(db_session, schema=RoleRead)
assert isinstance(result, PaginatedResponse)
assert all(isinstance(item, RoleRead) for item in result.data)
assert all(
hasattr(item, "id") and hasattr(item, "name") for item in result.data
)
@pytest.mark.anyio
async def test_with_cursor_column(self, db_session: AsyncSession):
"""cursor_paginate uses cursor_column set on CrudFactory."""
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.schemas import CursorPagination
RoleNameCrud = CrudFactory(Role, cursor_column=Role.name)
for i in range(5):
await RoleNameCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleNameCrud.cursor_paginate(db_session, items_per_page=3)
assert isinstance(result.pagination, CursorPagination)
assert len(result.data) == 3
assert result.pagination.has_more is True
assert result.pagination.next_cursor is not None
@pytest.mark.anyio
async def test_raises_without_cursor_column(self, db_session: AsyncSession):
"""cursor_paginate raises ValueError when cursor_column is not configured."""
with pytest.raises(ValueError, match="cursor_column is not set"):
await RoleCrud.cursor_paginate(db_session)
class TestCursorPaginatePrevCursor:
"""Tests for prev_cursor behavior in cursor_paginate()."""
@pytest.mark.anyio
async def test_prev_cursor_none_on_first_page(self, db_session: AsyncSession):
"""prev_cursor is None when no cursor was provided (first page)."""
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
from fastapi_toolsets.schemas import CursorPagination
result = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=3)
assert isinstance(result.pagination, CursorPagination)
assert result.pagination.prev_cursor is None
@pytest.mark.anyio
async def test_prev_cursor_set_on_subsequent_pages(self, db_session: AsyncSession):
"""prev_cursor is set when a cursor was provided (subsequent pages)."""
for i in range(10):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
from fastapi_toolsets.schemas import CursorPagination
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=5)
assert isinstance(page1.pagination, CursorPagination)
page2 = await RoleCursorCrud.cursor_paginate(
db_session,
cursor=page1.pagination.next_cursor,
items_per_page=5,
)
assert isinstance(page2.pagination, CursorPagination)
assert page2.pagination.prev_cursor is not None
@pytest.mark.anyio
async def test_prev_cursor_points_to_first_item(self, db_session: AsyncSession):
"""prev_cursor encodes the value of the first item on the current page."""
import base64
import json
for i in range(10):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
from fastapi_toolsets.schemas import CursorPagination
page1 = await RoleCursorCrud.cursor_paginate(db_session, items_per_page=5)
assert isinstance(page1.pagination, CursorPagination)
page2 = await RoleCursorCrud.cursor_paginate(
db_session,
cursor=page1.pagination.next_cursor,
items_per_page=5,
)
assert isinstance(page2.pagination, CursorPagination)
assert page2.pagination.prev_cursor is not None
# Decode prev_cursor and compare to first item's id
decoded = json.loads(
base64.b64decode(page2.pagination.prev_cursor.encode()).decode()
)
first_item_id = str(page2.data[0].id)
assert decoded == first_item_id
class TestCursorPaginateWithSearch:
"""Tests for cursor_paginate() combined with search."""
@pytest.mark.anyio
async def test_cursor_paginate_with_search(self, db_session: AsyncSession):
"""cursor_paginate respects search filters alongside cursor predicate."""
from fastapi_toolsets.crud import CrudFactory
# Create a CRUD with searchable fields and cursor column
SearchableRoleCrud = CrudFactory(
Role, searchable_fields=[Role.name], cursor_column=Role.id
)
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"admin{i:02d}"))
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"user{i:02d}"))
result = await SearchableRoleCrud.cursor_paginate(
db_session,
search="admin",
items_per_page=20,
)
assert len(result.data) == 5
assert all("admin" in r.name for r in result.data)
class TestCursorPaginateExtraOptions:
"""Tests for cursor_paginate() covering joins, load_options, and order_by."""
@pytest.mark.anyio
async def test_with_joins(self, db_session: AsyncSession):
"""cursor_paginate applies explicit inner joins."""
from fastapi_toolsets.schemas import CursorPagination
role = await RoleCrud.create(db_session, RoleCreate(name="member"))
for i in range(5):
await UserCrud.create(
db_session,
UserCreate(
username=f"u{i}",
email=f"u{i}@test.com",
role_id=role.id,
),
)
# One user without a role to confirm inner join excludes them
await UserCrud.create(
db_session,
UserCreate(username="norole", email="norole@test.com"),
)
result = await UserCursorCrud.cursor_paginate(
db_session,
joins=[(Role, User.role_id == Role.id)],
items_per_page=20,
)
assert isinstance(result.pagination, CursorPagination)
# Only users with a role are returned (inner join)
assert len(result.data) == 5
@pytest.mark.anyio
async def test_with_outer_join(self, db_session: AsyncSession):
"""cursor_paginate applies LEFT OUTER JOIN when outer_join=True."""
from fastapi_toolsets.schemas import CursorPagination
role = await RoleCrud.create(db_session, RoleCreate(name="member"))
for i in range(3):
await UserCrud.create(
db_session,
UserCreate(
username=f"u{i}",
email=f"u{i}@test.com",
role_id=role.id,
),
)
await UserCrud.create(
db_session,
UserCreate(username="norole", email="norole@test.com"),
)
result = await UserCursorCrud.cursor_paginate(
db_session,
joins=[(Role, User.role_id == Role.id)],
outer_join=True,
items_per_page=20,
)
assert isinstance(result.pagination, CursorPagination)
# All users are included (outer join)
assert len(result.data) == 4
@pytest.mark.anyio
async def test_with_load_options(self, db_session: AsyncSession):
"""cursor_paginate passes load_options to the query."""
from fastapi_toolsets.schemas import CursorPagination
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
for i in range(3):
await UserCrud.create(
db_session,
UserCreate(
username=f"u{i}",
email=f"u{i}@test.com",
role_id=role.id,
),
)
result = await UserCursorCrud.cursor_paginate(
db_session,
load_options=[selectinload(User.role)],
items_per_page=20,
)
assert isinstance(result.pagination, CursorPagination)
assert len(result.data) == 3
# Relationship was eagerly loaded
assert all(u.role is not None for u in result.data)
@pytest.mark.anyio
async def test_with_order_by(self, db_session: AsyncSession):
"""cursor_paginate applies additional order_by after the cursor column."""
from fastapi_toolsets.schemas import CursorPagination
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
result = await RoleCursorCrud.cursor_paginate(
db_session,
order_by=Role.name.desc(),
items_per_page=3,
)
assert isinstance(result.pagination, CursorPagination)
assert len(result.data) == 3
@pytest.mark.anyio
async def test_integer_cursor_column(self, db_session: AsyncSession):
"""cursor_paginate decodes Integer cursor values correctly."""
from fastapi_toolsets.schemas import CursorPagination
for i in range(5):
await IntRoleCursorCrud.create(db_session, IntRoleCreate(name=f"role{i}"))
page1 = await IntRoleCursorCrud.cursor_paginate(db_session, items_per_page=3)
assert isinstance(page1.pagination, CursorPagination)
assert len(page1.data) == 3
assert page1.pagination.has_more is True
page2 = await IntRoleCursorCrud.cursor_paginate(
db_session,
cursor=page1.pagination.next_cursor,
items_per_page=3,
)
assert isinstance(page2.pagination, CursorPagination)
assert len(page2.data) == 2
assert page2.pagination.has_more is False
@pytest.mark.anyio
async def test_string_cursor_column(self, db_session: AsyncSession):
"""cursor_paginate decodes non-UUID/non-Integer cursor values (string branch)."""
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.schemas import CursorPagination
RoleNameCursorCrud = CrudFactory(Role, cursor_column=Role.name)
for i in range(5):
await RoleCrud.create(db_session, RoleCreate(name=f"role{i:02d}"))
page1 = await RoleNameCursorCrud.cursor_paginate(db_session, items_per_page=3)
assert isinstance(page1.pagination, CursorPagination)
assert len(page1.data) == 3
assert page1.pagination.has_more is True
page2 = await RoleNameCursorCrud.cursor_paginate(
db_session,
cursor=page1.pagination.next_cursor,
items_per_page=3,
)
assert isinstance(page2.pagination, CursorPagination)
assert len(page2.data) == 2
assert page2.pagination.has_more is False
class TestCursorPaginateSearchJoins:
"""Tests for cursor_paginate() search that traverses relationships (search_joins)."""
@pytest.mark.anyio
async def test_search_via_relationship(self, db_session: AsyncSession):
"""cursor_paginate outerjoin search-join when searching through a relationship."""
from fastapi_toolsets.schemas import CursorPagination
role_admin = await RoleCrud.create(db_session, RoleCreate(name="administrator"))
role_user = await RoleCrud.create(db_session, RoleCreate(name="regularuser"))
for i in range(3):
await UserCrud.create(
db_session,
UserCreate(
username=f"admin_u{i}",
email=f"admin_u{i}@test.com",
role_id=role_admin.id,
),
)
for i in range(2):
await UserCrud.create(
db_session,
UserCreate(
username=f"reg_u{i}",
email=f"reg_u{i}@test.com",
role_id=role_user.id,
),
)
result = await UserCursorCrud.cursor_paginate(
db_session,
search="administrator",
search_fields=[(User.role, Role.name)],
items_per_page=20,
)
assert isinstance(result.pagination, CursorPagination)
assert len(result.data) == 3
class TestGetWithForUpdate:
"""Tests for get() with with_for_update=True."""
@pytest.mark.anyio
async def test_get_with_for_update(self, db_session: AsyncSession):
"""get() with with_for_update=True locks the row."""
role = await RoleCrud.create(db_session, RoleCreate(name="locked"))
result = await RoleCrud.get(
db_session,
filters=[Role.id == role.id],
with_for_update=True,
)
assert result.id == role.id
assert result.name == "locked"

View File

@@ -6,6 +6,7 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.crud import SearchConfig, get_searchable_fields
from fastapi_toolsets.schemas import OffsetPagination
from .conftest import (
Role,
@@ -39,6 +40,7 @@ class TestPaginateSearch:
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
@pytest.mark.anyio
@@ -57,6 +59,7 @@ class TestPaginateSearch:
search_fields=[User.username, User.email],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
@pytest.mark.anyio
@@ -84,6 +87,7 @@ class TestPaginateSearch:
search_fields=[(User.role, Role.name)],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
@pytest.mark.anyio
@@ -102,6 +106,7 @@ class TestPaginateSearch:
search_fields=[User.username, (User.role, Role.name)],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
@pytest.mark.anyio
@@ -117,6 +122,7 @@ class TestPaginateSearch:
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
@pytest.mark.anyio
@@ -132,6 +138,7 @@ class TestPaginateSearch:
search=SearchConfig(query="johndoe", case_sensitive=True),
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 0
# Should find (case match)
@@ -140,6 +147,7 @@ class TestPaginateSearch:
search=SearchConfig(query="JohnDoe", case_sensitive=True),
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
@pytest.mark.anyio
@@ -153,9 +161,11 @@ class TestPaginateSearch:
)
result = await UserCrud.paginate(db_session, search="")
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
result = await UserCrud.paginate(db_session, search=None)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
@pytest.mark.anyio
@@ -177,6 +187,7 @@ class TestPaginateSearch:
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
assert result.data[0].username == "active_john"
@@ -189,6 +200,7 @@ class TestPaginateSearch:
result = await UserCrud.paginate(db_session, search="findme")
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
@pytest.mark.anyio
@@ -204,6 +216,7 @@ class TestPaginateSearch:
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 0
assert result.data == []
@@ -224,6 +237,7 @@ class TestPaginateSearch:
items_per_page=5,
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 15
assert len(result.data) == 5
assert result.pagination.has_more is True
@@ -248,6 +262,7 @@ class TestPaginateSearch:
search_fields=[User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 2
@pytest.mark.anyio
@@ -270,6 +285,7 @@ class TestPaginateSearch:
order_by=User.username,
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 3
usernames = [u.username for u in result.data]
assert usernames == ["alice", "bob", "charlie"]
@@ -292,6 +308,7 @@ class TestPaginateSearch:
search_fields=[User.id, User.username],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
assert result.data[0].id == user_id
@@ -318,6 +335,7 @@ class TestSearchConfig:
search_fields=[User.username, User.email],
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1
assert result.data[0].username == "john_test"
@@ -333,6 +351,7 @@ class TestSearchConfig:
search=SearchConfig(query="findme", fields=[User.email]),
)
assert isinstance(result.pagination, OffsetPagination)
assert result.pagination.total_count == 1

View File

@@ -5,7 +5,9 @@ from pydantic import ValidationError
from fastapi_toolsets.schemas import (
ApiError,
CursorPagination,
ErrorResponse,
OffsetPagination,
PaginatedResponse,
Pagination,
Response,
@@ -154,12 +156,12 @@ class TestErrorResponse:
assert data["description"] == "Details"
class TestPagination:
"""Tests for Pagination schema."""
class TestOffsetPagination:
"""Tests for OffsetPagination schema (canonical name for offset-based pagination)."""
def test_create_pagination(self):
"""Create Pagination with all fields."""
pagination = Pagination(
"""Create OffsetPagination with all fields."""
pagination = OffsetPagination(
total_count=100,
items_per_page=10,
page=1,
@@ -173,7 +175,7 @@ class TestPagination:
def test_last_page_has_more_false(self):
"""Last page has has_more=False."""
pagination = Pagination(
pagination = OffsetPagination(
total_count=25,
items_per_page=10,
page=3,
@@ -183,8 +185,8 @@ class TestPagination:
assert pagination.has_more is False
def test_serialization(self):
"""Pagination serializes correctly."""
pagination = Pagination(
"""OffsetPagination serializes correctly."""
pagination = OffsetPagination(
total_count=50,
items_per_page=20,
page=2,
@@ -197,6 +199,77 @@ class TestPagination:
assert data["page"] == 2
assert data["has_more"] is True
def test_pagination_alias_is_offset_pagination(self):
"""Pagination is a backward-compatible alias for OffsetPagination."""
assert Pagination is OffsetPagination
def test_pagination_alias_constructs_offset_pagination(self):
"""Code using Pagination(...) still works unchanged."""
pagination = Pagination(
total_count=10,
items_per_page=5,
page=2,
has_more=False,
)
assert isinstance(pagination, OffsetPagination)
class TestCursorPagination:
"""Tests for CursorPagination schema."""
def test_create_with_next_cursor(self):
"""CursorPagination with a next cursor indicates more pages."""
pagination = CursorPagination(
next_cursor="eyJ2YWx1ZSI6ICIxMjMifQ==",
items_per_page=20,
has_more=True,
)
assert pagination.next_cursor == "eyJ2YWx1ZSI6ICIxMjMifQ=="
assert pagination.prev_cursor is None
assert pagination.items_per_page == 20
assert pagination.has_more is True
def test_create_last_page(self):
"""CursorPagination for the last page has next_cursor=None and has_more=False."""
pagination = CursorPagination(
next_cursor=None,
items_per_page=20,
has_more=False,
)
assert pagination.next_cursor is None
assert pagination.has_more is False
def test_prev_cursor_defaults_to_none(self):
"""prev_cursor defaults to None."""
pagination = CursorPagination(
next_cursor=None, items_per_page=10, has_more=False
)
assert pagination.prev_cursor is None
def test_prev_cursor_can_be_set(self):
"""prev_cursor can be explicitly set."""
pagination = CursorPagination(
next_cursor="next123",
prev_cursor="prev456",
items_per_page=10,
has_more=True,
)
assert pagination.prev_cursor == "prev456"
def test_serialization(self):
"""CursorPagination serializes correctly."""
pagination = CursorPagination(
next_cursor="abc123",
prev_cursor="xyz789",
items_per_page=20,
has_more=True,
)
data = pagination.model_dump()
assert data["next_cursor"] == "abc123"
assert data["prev_cursor"] == "xyz789"
assert data["items_per_page"] == 20
assert data["has_more"] is True
class TestPaginatedResponse:
"""Tests for PaginatedResponse schema."""
@@ -214,6 +287,7 @@ class TestPaginatedResponse:
pagination=pagination,
)
assert isinstance(response.pagination, OffsetPagination)
assert len(response.data) == 2
assert response.pagination.total_count == 30
assert response.status == ResponseStatus.SUCCESS
@@ -247,6 +321,7 @@ class TestPaginatedResponse:
pagination=pagination,
)
assert isinstance(response.pagination, OffsetPagination)
assert response.data == []
assert response.pagination.total_count == 0
@@ -290,6 +365,36 @@ class TestPaginatedResponse:
assert data["data"] == ["item1", "item2"]
assert data["pagination"]["page"] == 5
def test_pagination_field_accepts_offset_pagination(self):
"""PaginatedResponse.pagination accepts OffsetPagination."""
response = PaginatedResponse(
data=[1, 2],
pagination=OffsetPagination(
total_count=2, items_per_page=10, page=1, has_more=False
),
)
assert isinstance(response.pagination, OffsetPagination)
def test_pagination_field_accepts_cursor_pagination(self):
"""PaginatedResponse.pagination accepts CursorPagination."""
response = PaginatedResponse(
data=[1, 2],
pagination=CursorPagination(
next_cursor=None, items_per_page=10, has_more=False
),
)
assert isinstance(response.pagination, CursorPagination)
def test_pagination_alias_accepted(self):
"""Constructing PaginatedResponse with Pagination (alias) still works."""
response = PaginatedResponse(
data=[],
pagination=Pagination(
total_count=0, items_per_page=10, page=1, has_more=False
),
)
assert isinstance(response.pagination, OffsetPagination)
class TestFromAttributes:
"""Tests for from_attributes config (ORM mode)."""