diff --git a/README.md b/README.md index dedb33e..2437dc4 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ def get_users( query: QueryMate = Depends(QueryMate.fastapi_dependency), db: Session = Depends(get_db) ): - # Returns serialized results (dictionaries) + # Returns serialized results as a list + if query.include_pagination: + return query.run_paginated(db, User) return query.run(db, User) @app.get("/users/raw") @@ -142,7 +144,9 @@ async def get_users( query: QueryMate = Depends(QueryMate.fastapi_dependency), db: AsyncSession = Depends(get_db) ): - # Returns serialized results (dictionaries) + # Returns serialized results + if query.include_pagination: + return await query.run_async_paginated(db, User) return await query.run_async(db, User) @app.get("/users/raw") @@ -232,18 +236,16 @@ Querymate(sort=[{"posts.visibility": ["private", "internal", "public"]}]).run_ra ### Pagination Metadata Response In addition to plain lists, you can include pagination metadata alongside items. -Enable it via the query flag or force it via the method parameter: +Use the dedicated paginated methods: ```python -# Force via method -result = query.run(db, User, force_pagination=True) -# Or async: -# result = await query.run_async(db, User, force_pagination=True) +# Sync paginated response +result = query.run_paginated(db, User) -# Respect the query flag / instance setting -result2 = Querymate(include_pagination=True).run(db, User) +# Async paginated response +result = await query.run_async_paginated(db, User) -# Response shape +# Response shape (PaginatedResponse object) # { # "items": [{"id": 1, "name": "John"}, ...], # "pagination": { @@ -257,10 +259,12 @@ result2 = Querymate(include_pagination=True).run(db, User) # } ``` -Query flag name and default behavior are configurable (see settings): +The standard `run` and `run_async` methods always return a plain list of items: -- `PAGINATION_PARAM_NAME` (default: `include_pagination`) -- `DEFAULT_RETURN_PAGINATION` (default: `False`) +```python +# Always returns a list[dict[str, Any]] +result = query.run(db, User) +``` ### Grouping diff --git a/docs/source/api/querymate.rst b/docs/source/api/querymate.rst index 2c29d44..bf8aa00 100644 --- a/docs/source/api/querymate.rst +++ b/docs/source/api/querymate.rst @@ -102,7 +102,7 @@ Convert the QueryMate instance to a query parameter string. run ~~~ -Build and execute the query, returning serialized results. +Build and execute the query, returning a plain list of serialized results. .. code-block:: python @@ -117,29 +117,30 @@ Build and execute the query, returning serialized results. results = querymate.run(db, User) # Returns: [{"id": 1, "name": "John", "posts": [{"id": 1, "title": "Post 1"}]}, ...] -Return pagination metadata -~~~~~~~~~~~~~~~~~~~~~~~~~~ +run_paginated +~~~~~~~~~~~~~ -`run` can optionally return structured pagination metadata along with items. You can enable it via the query payload or force it via method parameter. +Build and execute the query, returning items along with pagination metadata in a typed ``PaginatedResponse`` object. .. code-block:: python - # Option 1: force via method call - results = querymate.run(db, User, force_pagination=True) - # Option 2: respect the query flag - querymate = Querymate(include_pagination=True) - results = querymate.run(db, User) # will include pagination + # Sync paginated response + result = querymate.run_paginated(db, User) + + # Accessing results + print(result.items) # list[dict[str, Any]] + print(result.pagination.total) # int # Response shape: # { # "items": [{"id": 1, "name": "John"}, ...], # "pagination": { - # "total": 57, # total matching records (ignores limit/offset) - # "page": 2, # current page number (1-based) - # "size": 10, # requested page size (limit) - # "pages": 6, # total pages (ceil(total/size), minimum 1) - # "previous_page": 1, # previous page number or None - # "next_page": 3 # next page number or None + # "total": 57, # total matching records + # "page": 1, # current page number + # "size": 10, # requested page size + # "pages": 6, # total pages + # "previous_page": None, + # "next_page": 2 # } # } @@ -154,9 +155,9 @@ Build and execute the query, returning raw model instances. # Returns: [, ...] run_async -~~~~~~~~ +~~~~~~~~~ -Build and execute the query asynchronously, returning serialized results. +Build and execute the query asynchronously, returning a plain list of serialized results. .. code-block:: python @@ -164,19 +165,18 @@ Build and execute the query asynchronously, returning serialized results. results = await querymate.run_async(db, User) # Returns: [{"id": 1, "name": "John"}, ...] -Return pagination metadata (async) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +run_async_paginated +~~~~~~~~~~~~~~~~~~~ -The async variant also supports returning pagination data. +Build and execute the query asynchronously, returning items along with pagination metadata in a typed ``PaginatedResponse`` object. .. code-block:: python async def get_users(): - # Force - result = await querymate.run_async(db, User, force_pagination=True) - # Or respect query flag - result2 = await Querymate(include_pagination=True).run_async(db, User) - # Same shape as the sync variant: + # Async paginated response + result = await querymate.run_async_paginated(db, User) + + # Same shape as sync variant: # { # "items": [...], # "pagination": {"total": ..., "page": ..., "size": ..., "pages": ..., "previous_page": ..., "next_page": ...} diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index ae064a7..5e929cf 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -84,7 +84,7 @@ Next Steps Pagination Quickstart (Side by Side) ------------------------------------ -Enable pagination metadata via the query flag or force it via method parameter. +QueryMate provides dedicated methods for plain lists and paginated responses. .. list-table:: :header-rows: 1 @@ -105,13 +105,14 @@ Enable pagination metadata via the query flag or force it via method parameter. - .. code-block:: python - # Option A: force via method call - result = querymate.run(db, User, force_pagination=True) + # Returns a typed PaginatedResponse + result = querymate.run_paginated(db, User) - # Option B: respect query flag - result2 = Querymate(include_pagination=True).run(db, User) + # Access items and metadata + items = result.items + total = result.pagination.total - # Example + # Example Response # { # "items": [ # {"id": 1, "name": "John"}, @@ -119,10 +120,10 @@ Enable pagination metadata via the query flag or force it via method parameter. # ], # "pagination": { # "total": 57, - # "page": 2, + # "page": 1, # "size": 10, # "pages": 6, - # "previous_page": 1, - # "next_page": 3 + # "previous_page": null, + # "next_page": 2 # } # } diff --git a/docs/source/usage/grouping.rst b/docs/source/usage/grouping.rst index 99df1c6..88d0fa6 100644 --- a/docs/source/usage/grouping.rst +++ b/docs/source/usage/grouping.rst @@ -216,7 +216,7 @@ Response Fields * ``key``: The group key value (string representation) * ``items``: Serialized items in this group - * ``pagination``: Pagination metadata for this group + * ``pagination``: Pagination metadata for this group (using the same ``PaginationInfo`` structure as standard paginated queries) * ``truncated``: ``true`` if ``MAX_LIMIT`` was reached before all groups were filled diff --git a/docs/source/usage/pagination.rst b/docs/source/usage/pagination.rst index 1344a52..74a171a 100644 --- a/docs/source/usage/pagination.rst +++ b/docs/source/usage/pagination.rst @@ -65,24 +65,28 @@ Response Shape With Metadata ---------------------------- When building UIs, you often need the total number of records and page navigation data. -QueryMate can return a structured response with items and pagination metadata. You can enable it via: +QueryMate provides dedicated methods to return a typed ``PaginatedResponse`` object containing items and pagination metadata. -* Query parameter (respected by default): ``{"include_pagination": true}`` -* Instance flag: ``Querymate(include_pagination=True)`` -* Method override: ``force_pagination=True/False`` on ``run``/``run_async`` +Use the following methods for paginated responses: + +* Sync: ``run_paginated(db, model)`` +* Async: ``run_async_paginated(db, model)`` .. code-block:: python - # Sync: force pagination regardless of instance flag - result = querymate.run(db, User, force_pagination=True) + # Sync paginated response + result = querymate.run_paginated(db, User) + + # Async paginated response + result = await querymate.run_async_paginated(db, User) - # Async: force pagination - result = await querymate.run_async(db, User, force_pagination=True) + # Accessing results + print(len(result.items)) + print(result.pagination.total) - # Respect query flag (no force): - result2 = Querymate(include_pagination=True).run(db, User) +The standard ``run`` and ``run_async`` methods always return a plain list of items. -The returned object has the following shape: +The returned ``PaginatedResponse`` object has the following shape: .. code-block:: json @@ -109,9 +113,25 @@ Field semantics: * ``previous_page``: Previous page number or ``null`` on first page * ``next_page``: Next page number or ``null`` on last page -Precedence ----------- - -* ``force_pagination=True``: always include pagination -* ``force_pagination=False``: never include pagination -* ``force_pagination=None`` (default): respect ``include_pagination`` (default is configurable) +Methods Summary +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 40 30 + + * - Method + - Return Type + - Description + * - ``run`` + - ``list[dict[str, Any]]`` + - Plain list of serialized items. + * - ``run_paginated`` + - ``PaginatedResponse`` + - Items with pagination metadata. + * - ``run_async`` + - ``list[dict[str, Any]]`` + - Async plain list of items. + * - ``run_async_paginated`` + - ``PaginatedResponse`` + - Async items with pagination. diff --git a/pyproject.toml b/pyproject.toml index 7ad08b0..9bf7166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ asyncio_mode = "auto" [tool.ruff.lint.per-file-ignores] "tests/*" = [ "F811", "B008", "E721",] +"examples/*" = [ "B008",] [tool.ruff.lint.isort] known-first-party = [ "querymate",] diff --git a/querymate/core/grouping.py b/querymate/core/grouping.py index f994d32..2ad2ae8 100644 --- a/querymate/core/grouping.py +++ b/querymate/core/grouping.py @@ -13,6 +13,7 @@ from sqlmodel import SQLModel from querymate.core.config import settings +from querymate.types import PaginationInfo class DateGranularity(str, Enum): @@ -179,9 +180,7 @@ def is_date_grouping(self) -> bool: class GroupKeyExtractor: """Generates SQL expressions for extracting group keys from columns.""" - def __init__( - self, dialect: Literal["postgresql", "sqlite"] = "postgresql" - ) -> None: + def __init__(self, dialect: Literal["postgresql", "sqlite"] = "postgresql") -> None: """Initialize the extractor with the database dialect. Args: @@ -329,8 +328,8 @@ class GroupResult(BaseModel): items: list[dict[str, Any]] = Field( default_factory=list, description="Items in this group" ) - pagination: dict[str, Any] = Field( - default_factory=dict, description="Pagination metadata for this group" + pagination: PaginationInfo = Field( + ..., description="Pagination metadata for this group" ) @@ -344,6 +343,3 @@ class GroupedResponse(BaseModel): default=False, description="True if MAX_LIMIT was reached before all groups were filled", ) - - - diff --git a/querymate/core/querymate.py b/querymate/core/querymate.py index 6ec8aac..0db34d7 100644 --- a/querymate/core/querymate.py +++ b/querymate/core/querymate.py @@ -16,8 +16,11 @@ GroupResult, ) from querymate.core.query_builder import QueryBuilder +from querymate.types import PaginatedResponse, PaginationInfo T = TypeVar("T", bound=SQLModel) +R = TypeVar("R") + # Type aliases for better readability FieldSelection = str | dict[str, list[str]] @@ -175,14 +178,14 @@ def to_query_param(self) -> str: """ return quote(self.model_dump_json(by_alias=True)) - def _pagination(self, total: int) -> dict[str, Any]: + def _pagination(self, total: int) -> PaginationInfo: """Build a pagination dictionary from current state and total count. Args: total (int): Total number of matching records. Returns: - dict[str, Any]: Pagination metadata with total, page, size, pages, previous_page, next_page. + PaginationInfo: Pagination metadata with total, page, size, pages, previous_page, next_page. """ size = self.limit or settings.DEFAULT_LIMIT offset_val = self.offset or settings.DEFAULT_OFFSET @@ -195,14 +198,14 @@ def _pagination(self, total: int) -> dict[str, Any]: previous_page = page - 1 if page > 1 else None next_page = page + 1 if page < pages else None - return { - "total": total, - "page": page, - "size": size, - "pages": pages, - "previous_page": previous_page, - "next_page": next_page, - } + return PaginationInfo( + total=total, + page=page, + size=size, + pages=pages, + previous_page=previous_page, + next_page=next_page, + ) def run_raw(self, db: Session, model: type[T]) -> list[T]: """Build and execute the query based on the parameters. @@ -231,9 +234,7 @@ def run( self, db: Session, model: type[T], - *, - force_pagination: bool | None = None, - ) -> list[dict[str, Any]] | dict[str, Any]: + ) -> list[dict[str, Any]]: """Build and execute the query based on the parameters. This method combines filtering, sorting, pagination, and field selection @@ -263,27 +264,44 @@ def run( offset=self.offset, ) data = query_builder.fetch(db, model) - serialized = query_builder.serialize(data) + return query_builder.serialize(data) - effective_pagination = ( - force_pagination - if force_pagination is not None - else self.include_pagination - ) + def run_paginated( + self, + db: Session, + model: type[T], + ) -> PaginatedResponse[dict[str, Any]]: + """Build and execute the query with pagination metadata. - if not effective_pagination: - return serialized + Args: + db (Session): The SQLModel database session. + model (type[SQLModel]): The SQLModel model class to query. + Returns: + PaginatedResponse[dict[str, Any]]: Serialized results with pagination metadata. + """ + query_builder = QueryBuilder(model=model) + query_builder.build( + select=self.select, + filter=self.filter, + sort=self.sort, + limit=self.limit, + offset=self.offset, + ) + data = query_builder.fetch(db, model) + serialized = query_builder.serialize(data) total = query_builder.count(db) - return {"items": serialized, "pagination": self._pagination(total)} + + return PaginatedResponse( + items=serialized, + pagination=self._pagination(total), + ) async def run_async( self, db: AsyncSession, model: type[T], - *, - force_pagination: bool | None = None, - ) -> list[dict[str, Any]] | dict[str, Any]: + ) -> list[dict[str, Any]]: """Build and execute the query asynchronously based on the parameters. This method combines filtering, sorting, pagination, and field selection @@ -313,19 +331,38 @@ async def run_async( offset=self.offset, ) data = await query_builder.fetch_async(db, model) - serialized = query_builder.serialize(data) + return query_builder.serialize(data) - effective_pagination = ( - force_pagination - if force_pagination is not None - else self.include_pagination - ) + async def run_async_paginated( + self, + db: AsyncSession, + model: type[T], + ) -> PaginatedResponse[dict[str, Any]]: + """Build and execute the query asynchronously with pagination metadata. - if not effective_pagination: - return serialized + Args: + db (AsyncSession): The SQLModel async database session. + model (type[SQLModel]): The SQLModel model class to query. + Returns: + PaginatedResponse[dict[str, Any]]: Serialized results with pagination metadata. + """ + query_builder = QueryBuilder(model=model) + query_builder.build( + select=self.select, + filter=self.filter, + sort=self.sort, + limit=self.limit, + offset=self.offset, + ) + data = await query_builder.fetch_async(db, model) + serialized = query_builder.serialize(data) total = await query_builder.count_async(db) - return {"items": serialized, "pagination": self._pagination(total)} + + return PaginatedResponse( + items=serialized, + pagination=self._pagination(total), + ) async def run_raw_async(self, db: AsyncSession, model: type[T]) -> list[T]: """Build and execute the query asynchronously based on the parameters. @@ -584,7 +621,7 @@ async def run_grouped_async( def _pagination_for_group( self, total: int, limit: int, offset: int - ) -> dict[str, Any]: + ) -> PaginationInfo: """Build pagination metadata for a single group. Args: @@ -593,7 +630,7 @@ def _pagination_for_group( offset: Offset within the group. Returns: - Pagination metadata dict. + PaginationInfo metadata. """ size = limit pages = (total + size - 1) // size if size > 0 else 1 @@ -603,11 +640,11 @@ def _pagination_for_group( previous_page = page - 1 if page > 1 else None next_page = page + 1 if page < pages else None - return { - "total": total, - "page": page, - "size": size, - "pages": pages, - "previous_page": previous_page, - "next_page": next_page, - } + return PaginationInfo( + total=total, + page=page, + size=size, + pages=pages, + previous_page=previous_page, + next_page=next_page, + ) diff --git a/querymate/types.py b/querymate/types.py index 12743ce..d886e83 100644 --- a/querymate/types.py +++ b/querymate/types.py @@ -14,8 +14,8 @@ class PaginationInfo(BaseModel): page: int size: int pages: int - previous_page: int | None - next_page: int | None + previous_page: int | None = None + next_page: int | None = None class PaginatedResponse(BaseModel, Generic[T]): diff --git a/tests/test_grouping.py b/tests/test_grouping.py index f3406b9..e8804e3 100644 --- a/tests/test_grouping.py +++ b/tests/test_grouping.py @@ -34,21 +34,18 @@ def test_from_param_simple_string(self): def test_from_param_dict_with_granularity(self): """Test creating config with date granularity.""" - config = GroupByConfig.from_param({ - "field": "created_at", - "granularity": "month" - }) + config = GroupByConfig.from_param( + {"field": "created_at", "granularity": "month"} + ) assert config.field == "created_at" assert config.granularity == DateGranularity.MONTH assert config.is_date_grouping def test_from_param_dict_with_tz_offset(self): """Test creating config with timezone offset.""" - config = GroupByConfig.from_param({ - "field": "created_at", - "granularity": "day", - "tz_offset": -3 - }) + config = GroupByConfig.from_param( + {"field": "created_at", "granularity": "day", "tz_offset": -3} + ) assert config.field == "created_at" assert config.granularity == DateGranularity.DAY assert config.tz_offset == -3 @@ -56,11 +53,13 @@ def test_from_param_dict_with_tz_offset(self): def test_from_param_dict_with_timezone(self): """Test creating config with IANA timezone.""" - config = GroupByConfig.from_param({ - "field": "created_at", - "granularity": "hour", - "timezone": "America/Sao_Paulo" - }) + config = GroupByConfig.from_param( + { + "field": "created_at", + "granularity": "hour", + "timezone": "America/Sao_Paulo", + } + ) assert config.field == "created_at" assert config.granularity == DateGranularity.HOUR assert config.timezone == "America/Sao_Paulo" @@ -69,37 +68,37 @@ def test_from_param_dict_with_timezone(self): def test_invalid_granularity(self): """Test that invalid granularity raises ValueError.""" with pytest.raises(ValueError, match="Unsupported granularity"): - GroupByConfig.from_param({ - "field": "created_at", - "granularity": "invalid" - }) + GroupByConfig.from_param({"field": "created_at", "granularity": "invalid"}) def test_both_tz_offset_and_timezone_raises(self): """Test that specifying both tz_offset and timezone raises error.""" with pytest.raises(ValueError, match="Cannot specify both"): - GroupByConfig.from_param({ - "field": "created_at", - "granularity": "day", - "tz_offset": -3, - "timezone": "America/Sao_Paulo" - }) + GroupByConfig.from_param( + { + "field": "created_at", + "granularity": "day", + "tz_offset": -3, + "timezone": "America/Sao_Paulo", + } + ) def test_unsupported_timezone(self): """Test that unsupported timezone raises error.""" with pytest.raises(ValueError, match="Unsupported timezone"): - GroupByConfig.from_param({ - "field": "created_at", - "granularity": "day", - "timezone": "Invalid/Timezone" - }) + GroupByConfig.from_param( + { + "field": "created_at", + "granularity": "day", + "timezone": "Invalid/Timezone", + } + ) def test_all_granularities(self): """Test all supported granularities.""" for granularity in ["year", "month", "day", "hour", "minute"]: - config = GroupByConfig.from_param({ - "field": "created_at", - "granularity": granularity - }) + config = GroupByConfig.from_param( + {"field": "created_at", "granularity": granularity} + ) assert config.granularity == DateGranularity(granularity) @@ -110,7 +109,7 @@ def test_simple_field_expression(self): """Test that simple field returns column directly.""" extractor = GroupKeyExtractor(dialect="sqlite") config = GroupByConfig(field="status") - + # For non-date grouping, should return the column itself column = User.status expr = extractor.get_group_key_expression(column, config) @@ -120,10 +119,10 @@ def test_date_grouping_sqlite(self): """Test SQLite date grouping expression generation.""" extractor = GroupKeyExtractor(dialect="sqlite") config = GroupByConfig(field="created_at", granularity=DateGranularity.MONTH) - + column = User.created_at expr = extractor.get_group_key_expression(column, config) - + # Should return a strftime expression assert expr is not column @@ -184,10 +183,10 @@ def populated_db(db: Session): created_at=datetime(2024, 3, 1, 8, 0), ), ] - + for user in users: db.add(user) - + # Create posts with different statuses posts = [ Post( @@ -231,10 +230,10 @@ def populated_db(db: Session): created_at=datetime(2024, 3, 1), ), ] - + for post in posts: db.add(post) - + db.commit() return db @@ -249,21 +248,21 @@ def test_simple_grouping_by_status(self, populated_db: Session): group_by="status", limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + assert "groups" in result assert "truncated" in result assert result["truncated"] is False - + # Should have 3 groups: active, inactive, pending groups = result["groups"] assert len(groups) == 3 - + # Groups should be ordered alphabetically group_keys = [g["key"] for g in groups] assert group_keys == ["active", "inactive", "pending"] - + # Check active group has 2 users active_group = next(g for g in groups if g["key"] == "active") assert len(active_group["items"]) == 2 @@ -277,9 +276,9 @@ def test_grouping_with_filter(self, populated_db: Session): group_by="status", limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + groups = result["groups"] # Should only have active and pending (where is_active=True) group_keys = [g["key"] for g in groups] @@ -293,9 +292,9 @@ def test_grouping_with_sorting(self, populated_db: Session): sort=["-age"], # Sort by age descending limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + # Check that items within each group are sorted by age descending for group in result["groups"]: items = group["items"] @@ -310,9 +309,9 @@ def test_per_group_pagination(self, populated_db: Session): group_by="status", limit=1, # Only 1 item per group ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + # Each group should have at most 1 item for group in result["groups"]: assert len(group["items"]) <= 1 @@ -326,25 +325,27 @@ def test_max_limit_truncation(self, populated_db: Session): """Test that MAX_LIMIT truncates total results.""" # Add more users to exceed max limit for i in range(10, 250): - populated_db.add(User( - id=i, - name=f"User{i}", - email=f"user{i}@test.com", - age=20 + (i % 50), - is_active=True, - status=f"status_{i % 5}", - created_at=datetime.now(), - )) + populated_db.add( + User( + id=i, + name=f"User{i}", + email=f"user{i}@test.com", + age=20 + (i % 50), + is_active=True, + status=f"status_{i % 5}", + created_at=datetime.now(), + ) + ) populated_db.commit() - + querymate = Querymate( select=["id", "name", "status"], group_by="status", limit=100, # High per-group limit ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + # Total items should not exceed MAX_LIMIT (200) total_items = sum(len(g["items"]) for g in result["groups"]) assert total_items <= 200 @@ -353,15 +354,12 @@ def test_date_grouping_by_month(self, populated_db: Session): """Test grouping by date with month granularity.""" querymate = Querymate( select=["id", "name", "created_at"], - group_by={ - "field": "created_at", - "granularity": "month" - }, + group_by={"field": "created_at", "granularity": "month"}, limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + groups = result["groups"] # Should have groups for 2024-01, 2024-02, 2024-03 group_keys = [g["key"] for g in groups] @@ -373,15 +371,12 @@ def test_date_grouping_by_year(self, populated_db: Session): """Test grouping by date with year granularity.""" querymate = Querymate( select=["id", "name", "created_at"], - group_by={ - "field": "created_at", - "granularity": "year" - }, + group_by={"field": "created_at", "granularity": "year"}, limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + groups = result["groups"] # All users are in 2024 assert len(groups) == 1 @@ -392,15 +387,12 @@ def test_date_grouping_by_day(self, populated_db: Session): """Test grouping by date with day granularity.""" querymate = Querymate( select=["id", "name", "created_at"], - group_by={ - "field": "created_at", - "granularity": "day" - }, + group_by={"field": "created_at", "granularity": "day"}, limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + groups = result["groups"] # Each user has a unique day assert len(groups) == 5 @@ -412,13 +404,13 @@ def test_date_grouping_with_timezone_offset(self, populated_db: Session): group_by={ "field": "created_at", "granularity": "day", - "tz_offset": -3 # UTC-3 + "tz_offset": -3, # UTC-3 }, limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + # Should complete without error assert "groups" in result assert result["truncated"] is False @@ -429,7 +421,7 @@ def test_group_by_not_set_raises(self, populated_db: Session): select=["id", "name"], limit=10, ) - + with pytest.raises(ValueError, match="group_by parameter is required"): querymate.run_grouped(populated_db, User, dialect="sqlite") @@ -440,12 +432,12 @@ def test_post_grouping_by_status(self, populated_db: Session): group_by="status", limit=10, ) - + result = querymate.run_grouped(populated_db, Post, dialect="sqlite") - + groups = result["groups"] group_keys = [g["key"] for g in groups] - + # Should have archived, draft, published assert "archived" in group_keys assert "draft" in group_keys @@ -459,9 +451,9 @@ def test_empty_result_grouping(self, populated_db: Session): group_by="status", limit=10, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + assert result["groups"] == [] assert result["truncated"] is False @@ -473,9 +465,9 @@ def test_pagination_metadata_per_group(self, populated_db: Session): limit=1, offset=0, ) - + result = querymate.run_grouped(populated_db, User, dialect="sqlite") - + for group in result["groups"]: pagination = group["pagination"] assert "total" in pagination @@ -484,6 +476,3 @@ def test_pagination_metadata_per_group(self, populated_db: Session): assert "pages" in pagination assert pagination["page"] == 1 assert pagination["size"] == 1 - - - diff --git a/tests/test_querymate.py b/tests/test_querymate.py index 0c73693..340e8ff 100644 --- a/tests/test_querymate.py +++ b/tests/test_querymate.py @@ -644,59 +644,6 @@ async def test_serialize_with_non_list_relationships_async( } -def test_pagination_precedence_force_true_over_include_false(db: Session) -> None: - users = [ - User(id=i, name=f"U{i}", is_active=True, email=f"u{i}@ex.com", age=20 + i) - for i in range(1, 5) - ] - db.add_all(users) - db.commit() - - q = Querymate(select=["id", "name"], limit=2, offset=0, include_pagination=False) - result = q.run(db, User, force_pagination=True) - assert isinstance(result, dict) - assert set(result.keys()) == {"items", "pagination"} - - -def test_pagination_precedence_force_false_over_include_true(db: Session) -> None: - users = [ - User(id=i, name=f"U{i}", is_active=True, email=f"u{i}@ex.com", age=20 + i) - for i in range(1, 5) - ] - db.add_all(users) - db.commit() - - q = Querymate(select=["id", "name"], limit=2, offset=0, include_pagination=True) - result = q.run(db, User, force_pagination=False) - assert isinstance(result, list) - - -def test_pagination_respects_include_when_force_none(db: Session) -> None: - users = [ - User(id=i, name=f"U{i}", is_active=True, email=f"u{i}@ex.com", age=20 + i) - for i in range(1, 5) - ] - db.add_all(users) - db.commit() - - # include_pagination=True should return pagination when force_pagination is None - q1 = Querymate(select=["id", "name"], limit=2, offset=0, include_pagination=True) - r1 = q1.run(db, User) - assert isinstance(r1, dict) - - # include_pagination=False should return list when force_pagination is None - q2 = Querymate(select=["id", "name"], limit=2, offset=0, include_pagination=False) - r2 = q2.run(db, User) - assert isinstance(r2, list) - - -def test_include_pagination_via_query_param() -> None: - # Build query string with include_pagination flag - qp = QueryParams({"q": '{"include_pagination": true}'}) - query = Querymate.from_qs(qp) - assert query.include_pagination is True - - def test_run_with_pagination_sync(db: Session) -> None: """Verify sync run returns items+pagination when requested.""" # Seed 7 users @@ -709,17 +656,15 @@ def test_run_with_pagination_sync(db: Session) -> None: # Page 1, size 3 q = Querymate(select=["id", "name"], limit=3, offset=0) - result = q.run(db, User, force_pagination=True) - assert isinstance(result, dict) - assert set(result.keys()) == {"items", "pagination"} - assert len(result["items"]) == 3 - p = result["pagination"] - assert p["total"] == 7 - assert p["size"] == 3 - assert p["pages"] == 3 - assert p["page"] == 1 - assert p["previous_page"] is None - assert p["next_page"] == 2 + result = q.run_paginated(db, User) + assert len(result.items) == 3 + p = result.pagination + assert p.total == 7 + assert p.size == 3 + assert p.pages == 3 + assert p.page == 1 + assert p.previous_page is None + assert p.next_page == 2 def test_run_with_pagination_last_page_sync(db: Session) -> None: @@ -733,31 +678,29 @@ def test_run_with_pagination_last_page_sync(db: Session) -> None: # Page 3 (offset 6), size 3 q = Querymate(select=["id", "name"], limit=3, offset=6) - result = q.run(db, User, force_pagination=True) - assert isinstance(result, dict) - assert len(result["items"]) == 1 - p = result["pagination"] - assert p["total"] == 7 - assert p["pages"] == 3 - assert p["page"] == 3 - assert p["previous_page"] == 2 - assert p["next_page"] is None + result = q.run_paginated(db, User) + assert len(result.items) == 1 + p = result.pagination + assert p.total == 7 + assert p.pages == 3 + assert p.page == 3 + assert p.previous_page == 2 + assert p.next_page is None def test_run_with_pagination_empty_sync(db: Session) -> None: # No data q = Querymate(select=["id", "name"], limit=5, offset=0, filter={"age": {"gt": 999}}) - result = q.run(db, User, force_pagination=True) - assert isinstance(result, dict) - assert result["items"] == [] - p = result["pagination"] + result = q.run_paginated(db, User) + assert result.items == [] + p = result.pagination # With no items, we still report at least 1 page and page=1 - assert p["total"] == 0 - assert p["size"] == 5 - assert p["pages"] == 1 - assert p["page"] == 1 - assert p["previous_page"] is None - assert p["next_page"] is None + assert p.total == 0 + assert p.size == 5 + assert p.pages == 1 + assert p.page == 1 + assert p.previous_page is None + assert p.next_page is None def test_run_with_pagination_offset_beyond_total_sync(db: Session) -> None: @@ -770,16 +713,15 @@ def test_run_with_pagination_offset_beyond_total_sync(db: Session) -> None: # Offset far beyond total q = Querymate(select=["id", "name"], limit=3, offset=300) - result = q.run(db, User, force_pagination=True) - assert isinstance(result, dict) - assert result["items"] == [] - p = result["pagination"] - assert p["total"] == 7 - assert p["pages"] == 3 + result = q.run_paginated(db, User) + assert result.items == [] + p = result.pagination + assert p.total == 7 + assert p.pages == 3 # Page clamped to last page - assert p["page"] == 3 - assert p["previous_page"] == 2 - assert p["next_page"] is None + assert p.page == 3 + assert p.previous_page == 2 + assert p.next_page is None @pytest.mark.asyncio @@ -792,13 +734,12 @@ async def test_run_with_pagination_async(async_db: AsyncSession) -> None: await async_db.commit() q = Querymate(select=["id", "name"], limit=2, offset=2) - result = await q.run_async(async_db, User, force_pagination=True) - assert isinstance(result, dict) - assert len(result["items"]) == 2 - p = result["pagination"] - assert p["total"] == 5 - assert p["size"] == 2 - assert p["pages"] == 3 - assert p["page"] == 2 - assert p["previous_page"] == 1 - assert p["next_page"] == 3 + result = await q.run_async_paginated(async_db, User) + assert len(result.items) == 2 + p = result.pagination + assert p.total == 5 + assert p.size == 2 + assert p.pages == 3 + assert p.page == 2 + assert p.previous_page == 1 + assert p.next_page == 3