diff --git a/README.md b/README.md index 9687201..dedb33e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - ✅ Sorting - ✅ Pagination (limit/offset) - ✅ Field selection +- ✅ Grouping (with date granularity and timezone support) - ✅ Query parameter parsing - ✅ Async database support - ✅ Built-in serialization @@ -32,6 +33,7 @@ Built for teams that want to build robust APIs with FastAPI and SQLModel. | 🏗️ Query Building | Build SQL queries programmatically | | ⚡ Async Support | Full support for async database operations | | 📦 Serialization | Built-in serialization with support for relationships | +| 📁 Grouping | Group results by field with date granularity and timezone support | --- @@ -260,6 +262,154 @@ Query flag name and default behavior are configurable (see settings): - `PAGINATION_PARAM_NAME` (default: `include_pagination`) - `DEFAULT_RETURN_PAGINATION` (default: `False`) +### Grouping + +Group query results by any field, including dates with configurable granularity and timezone support. + +#### Basic Grouping by Field + +```python +# Group users by status +querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + limit=10, # Per-group limit +) +result = querymate.run_grouped(db, User) +# Or async: +# result = await querymate.run_grouped_async(db, User) +``` + +Query parameter example: +```text +/users?q={"select":["id","name","status"],"group_by":"status","limit":10} +``` + +#### Date Grouping with Granularity + +Group by date fields with configurable granularity: `year`, `month`, `day`, `hour`, or `minute`. + +```python +# Group by month +querymate = Querymate( + select=["id", "title", "created_at"], + group_by={"field": "created_at", "granularity": "month"}, + limit=10, +) +result = querymate.run_grouped(db, Post) +``` + +Query parameter examples: +```text +# Group by year +/posts?q={"group_by":{"field":"created_at","granularity":"year"}} + +# Group by day +/posts?q={"group_by":{"field":"created_at","granularity":"day"}} + +# Group by hour +/posts?q={"group_by":{"field":"created_at","granularity":"hour"}} +``` + +#### Timezone Support + +Apply timezone offset to date grouping using numeric offset or IANA timezone names. + +```python +# Using numeric offset (UTC-3) +querymate = Querymate( + select=["id", "title", "created_at"], + group_by={ + "field": "created_at", + "granularity": "day", + "tz_offset": -3 + }, + limit=10, +) + +# Using IANA timezone name +querymate = Querymate( + select=["id", "title", "created_at"], + group_by={ + "field": "created_at", + "granularity": "day", + "timezone": "America/Sao_Paulo" + }, + limit=10, +) +``` + +Query parameter examples: +```text +# With numeric offset +/posts?q={"group_by":{"field":"created_at","granularity":"day","tz_offset":-3}} + +# With IANA timezone +/posts?q={"group_by":{"field":"created_at","granularity":"day","timezone":"America/Sao_Paulo"}} +``` + +Supported IANA timezones include: `UTC`, `America/New_York`, `America/Los_Angeles`, `America/Sao_Paulo`, `Europe/London`, `Europe/Paris`, `Asia/Tokyo`, `Asia/Shanghai`, `Australia/Sydney`, and more. + +#### Grouped Response Structure + +```python +{ + "groups": [ + { + "key": "active", # or "2024-01" for month grouping + "items": [ + {"id": 1, "name": "Alice", "status": "active"}, + {"id": 2, "name": "Bob", "status": "active"} + ], + "pagination": { + "total": 15, + "page": 1, + "size": 10, + "pages": 2, + "previous_page": null, + "next_page": 2 + } + }, + { + "key": "inactive", + "items": [...], + "pagination": {...} + } + ], + "truncated": false # true if MAX_LIMIT was reached +} +``` + +#### Pagination Behavior + +- `limit` applies **per group** (each group returns up to `limit` items) +- `MAX_LIMIT` (default 200) caps the **total items across all groups combined** +- Groups are ordered naturally: alphabetically for strings, chronologically for dates + +```python +# 10 items per group, but total won't exceed MAX_LIMIT (200) +querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + limit=10, + sort=["-created_at"], # Sorting applies within each group +) +``` + +#### Combining with Filters and Sorting + +```python +# Group active users by status, sorted by age within each group +querymate = Querymate( + select=["id", "name", "status", "age"], + filter={"is_active": True}, + group_by="status", + sort=["-age"], + limit=10, +) +result = querymate.run_grouped(db, User) +``` + ### Serialization QueryMate includes built-in serialization capabilities that transform query results into dictionaries containing only the requested fields. This helps reduce payload size and improve performance. @@ -338,6 +488,7 @@ querymate/ │ ├── querymate.py # Main QueryMate class │ ├── filter.py # Filter handling │ ├── query_builder.py # Query building +│ ├── grouping.py # Grouping functionality │ └── __init__.py # Package initialization ├── tests/ # Test suite ├── docs/ # Documentation diff --git a/docs/source/usage/grouping.rst b/docs/source/usage/grouping.rst new file mode 100644 index 0000000..99df1c6 --- /dev/null +++ b/docs/source/usage/grouping.rst @@ -0,0 +1,317 @@ +Grouping +======== + +QueryMate supports grouping query results by any field, including date fields with configurable granularity and timezone support. This is useful for building dashboards, reports, and analytics views. + +Basic Grouping +-------------- + +To group results by a field, use the ``group_by`` parameter: + +.. code-block:: python + + from querymate import Querymate + + querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + limit=10, + ) + result = querymate.run_grouped(db, User) + +Query parameter: + +.. code-block:: text + + /users?q={"select":["id","name","status"],"group_by":"status","limit":10} + +Date Grouping with Granularity +------------------------------ + +For date fields, you can specify a granularity to group by time periods. + +Supported Granularities +~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + + * - Granularity + - Description + - Key Format Example + * - ``year`` + - Group by year + - ``2024`` + * - ``month`` + - Group by month + - ``2024-01`` + * - ``day`` + - Group by day + - ``2024-01-15`` + * - ``hour`` + - Group by hour + - ``2024-01-15T10`` + * - ``minute`` + - Group by minute + - ``2024-01-15T10:30`` + +Examples +~~~~~~~~ + +.. code-block:: python + + # Group by month + querymate = Querymate( + select=["id", "title", "created_at"], + group_by={"field": "created_at", "granularity": "month"}, + limit=10, + ) + result = querymate.run_grouped(db, Post) + +Query parameters: + +.. code-block:: text + + # Group by year + /posts?q={"group_by":{"field":"created_at","granularity":"year"}} + + # Group by day + /posts?q={"group_by":{"field":"created_at","granularity":"day"}} + + # Group by hour + /posts?q={"group_by":{"field":"created_at","granularity":"hour"}} + +Timezone Support +---------------- + +When grouping by date, you can apply a timezone offset to ensure grouping aligns with your users' local time. + +Numeric Offset +~~~~~~~~~~~~~~ + +Use ``tz_offset`` to specify the offset in hours from UTC: + +.. code-block:: python + + # UTC-3 (e.g., São Paulo, Brazil) + querymate = Querymate( + select=["id", "title", "created_at"], + group_by={ + "field": "created_at", + "granularity": "day", + "tz_offset": -3 + }, + limit=10, + ) + +.. code-block:: text + + /posts?q={"group_by":{"field":"created_at","granularity":"day","tz_offset":-3}} + +IANA Timezone Names +~~~~~~~~~~~~~~~~~~~ + +Use ``timezone`` to specify an IANA timezone name: + +.. code-block:: python + + querymate = Querymate( + select=["id", "title", "created_at"], + group_by={ + "field": "created_at", + "granularity": "day", + "timezone": "America/Sao_Paulo" + }, + limit=10, + ) + +.. code-block:: text + + /posts?q={"group_by":{"field":"created_at","granularity":"day","timezone":"America/Sao_Paulo"}} + +Supported Timezones +~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + + * - Timezone + - UTC Offset + * - ``UTC`` + - +0 + * - ``America/New_York`` + - -5 + * - ``America/Chicago`` + - -6 + * - ``America/Denver`` + - -7 + * - ``America/Los_Angeles`` + - -8 + * - ``America/Sao_Paulo`` + - -3 + * - ``America/Buenos_Aires`` + - -3 + * - ``Europe/London`` + - +0 + * - ``Europe/Paris`` + - +1 + * - ``Europe/Berlin`` + - +1 + * - ``Europe/Moscow`` + - +3 + * - ``Asia/Dubai`` + - +4 + * - ``Asia/Kolkata`` + - +5.5 + * - ``Asia/Shanghai`` + - +8 + * - ``Asia/Tokyo`` + - +9 + * - ``Australia/Sydney`` + - +10 + * - ``Pacific/Auckland`` + - +12 + +.. note:: + + You cannot specify both ``tz_offset`` and ``timezone`` at the same time. + +Response Structure +------------------ + +Grouped queries return a structured response with groups, items, and pagination metadata: + +.. code-block:: json + + { + "groups": [ + { + "key": "active", + "items": [ + {"id": 1, "name": "Alice", "status": "active"}, + {"id": 2, "name": "Bob", "status": "active"} + ], + "pagination": { + "total": 15, + "page": 1, + "size": 10, + "pages": 2, + "previous_page": null, + "next_page": 2 + } + }, + { + "key": "inactive", + "items": [...], + "pagination": {...} + } + ], + "truncated": false + } + +Response Fields +~~~~~~~~~~~~~~~ + +* ``groups``: List of group objects, each containing: + + * ``key``: The group key value (string representation) + * ``items``: Serialized items in this group + * ``pagination``: Pagination metadata for this group + +* ``truncated``: ``true`` if ``MAX_LIMIT`` was reached before all groups were filled + +Group Ordering +~~~~~~~~~~~~~~ + +Groups are ordered naturally: + +* **Strings**: Alphabetically (A-Z) +* **Dates**: Chronologically (oldest to newest) + +Pagination Behavior +------------------- + +Grouped queries have specific pagination semantics: + +* ``limit``: Applies **per group** (each group returns up to ``limit`` items) +* ``MAX_LIMIT`` (default 200): Caps the **total items across all groups combined** +* ``offset``: Applies within each group (for navigating within a single group) + +.. code-block:: python + + # Each group gets up to 10 items + # But total across all groups won't exceed 200 (MAX_LIMIT) + querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + limit=10, + ) + +When ``MAX_LIMIT`` is Reached +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the total number of items across all groups would exceed ``MAX_LIMIT``: + +1. Groups are processed in natural order +2. Each group receives up to ``limit`` items +3. When total reaches ``MAX_LIMIT``, processing stops +4. ``truncated`` is set to ``true`` in the response +5. Later groups may be partially filled or omitted + +Combining with Filters and Sorting +---------------------------------- + +Grouping works with filters and sorting: + +.. code-block:: python + + # Group active users by status, sorted by age within each group + querymate = Querymate( + select=["id", "name", "status", "age"], + filter={"is_active": True}, + group_by="status", + sort=["-age"], # Sorting applies within each group + limit=10, + ) + result = querymate.run_grouped(db, User) + +Query parameter: + +.. code-block:: text + + /users?q={"filter":{"is_active":true},"group_by":"status","sort":["-age"],"limit":10} + +Async Support +------------- + +Grouping is fully supported with async sessions: + +.. code-block:: python + + # Async version + result = await querymate.run_grouped_async(db, User) + +Database Dialect +---------------- + +By default, QueryMate uses PostgreSQL syntax for date operations. For SQLite (commonly used in testing), specify the dialect: + +.. code-block:: python + + # For SQLite + result = querymate.run_grouped(db, User, dialect="sqlite") + + # For PostgreSQL (default) + result = querymate.run_grouped(db, User, dialect="postgresql") + +Best Practices +-------------- + +* Use appropriate indexes on group-by fields for better performance +* Keep ``limit`` reasonable to avoid fetching too much data per group +* Use date granularity that matches your use case (don't use ``minute`` if ``day`` suffices) +* Consider timezone when displaying date-grouped data to users +* Monitor the ``truncated`` flag and adjust ``limit`` or use filters if frequently truncated + + + diff --git a/docs/source/usage/index.rst b/docs/source/usage/index.rst index bfcf4c1..c61d1ec 100644 --- a/docs/source/usage/index.rst +++ b/docs/source/usage/index.rst @@ -9,6 +9,7 @@ This guide covers the main features and usage patterns of QueryMate. filtering sorting pagination + grouping field_selection relationships serialization @@ -77,7 +78,8 @@ QueryMate accepts query parameters in JSON format through the ``q`` parameter. T "sort": ["-field1", "field2"], "limit": 10, "offset": 0, - "select": ["field1", "field2", {"relationship": ["field1", "field2"]}] + "select": ["field1", "field2", {"relationship": ["field1", "field2"]}], + "group_by": "status" } For example: @@ -86,6 +88,13 @@ For example: /users?q={"filter":{"age":{"gt":18}},"sort":["-name"],"limit":10,"offset":0,"select":["id","name",{"posts":["title"]}]} +For grouped queries: + +.. code-block:: text + + /users?q={"group_by":"status","limit":10} + /posts?q={"group_by":{"field":"created_at","granularity":"month"},"limit":10} + Serialization ------------ diff --git a/querymate/__init__.py b/querymate/__init__.py index dd9f21a..1b8972e 100644 --- a/querymate/__init__.py +++ b/querymate/__init__.py @@ -60,6 +60,11 @@ from .core.filter import StartPredicate as StartPredicate from .core.filter import StartsWithPredicate as StartsWithPredicate from .core.filter import TruePredicate as TruePredicate +from .core.grouping import DateGranularity as DateGranularity +from .core.grouping import GroupByConfig as GroupByConfig +from .core.grouping import GroupedResponse as GroupedResponse +from .core.grouping import GroupKeyExtractor as GroupKeyExtractor +from .core.grouping import GroupResult as GroupResult from .core.query_builder import QueryBuilder as QueryBuilder from .core.querymate import Querymate as Querymate from .types import PaginatedResponse as PaginatedResponse diff --git a/querymate/core/config.py b/querymate/core/config.py index 1f34a9a..fbf16d0 100644 --- a/querymate/core/config.py +++ b/querymate/core/config.py @@ -130,6 +130,15 @@ class QueryMateSettings(BaseSettings): default=True, description="Always include required fields" ) + # Grouping configuration + GROUP_BY_PARAM_NAME: str = Field( + default="group_by", description="Group by parameter name" + ) + SUPPORTED_DATE_GRANULARITIES: list[str] = Field( + default=["year", "month", "day", "hour", "minute"], + description="Supported date granularities for grouping", + ) + model_config = SettingsConfigDict(env_prefix="QUERYMATE_", case_sensitive=False) diff --git a/querymate/core/grouping.py b/querymate/core/grouping.py new file mode 100644 index 0000000..f994d32 --- /dev/null +++ b/querymate/core/grouping.py @@ -0,0 +1,349 @@ +"""Grouping functionality for QueryMate. + +This module provides support for grouping query results by field values, +including dynamic date grouping with timezone support. +""" + +from enum import Enum +from typing import Any, Literal, cast + +from pydantic import BaseModel, Field, field_validator, model_validator +from sqlalchemy import func, text +from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlmodel import SQLModel + +from querymate.core.config import settings + + +class DateGranularity(str, Enum): + """Supported date granularities for grouping.""" + + YEAR = "year" + MONTH = "month" + DAY = "day" + HOUR = "hour" + MINUTE = "minute" + + +# Format strings for each granularity (SQLite strftime format) +SQLITE_DATE_FORMATS: dict[DateGranularity, str] = { + DateGranularity.YEAR: "%Y", + DateGranularity.MONTH: "%Y-%m", + DateGranularity.DAY: "%Y-%m-%d", + DateGranularity.HOUR: "%Y-%m-%dT%H", + DateGranularity.MINUTE: "%Y-%m-%dT%H:%M", +} + +# PostgreSQL date_trunc precision values +POSTGRES_TRUNC_PRECISION: dict[DateGranularity, str] = { + DateGranularity.YEAR: "year", + DateGranularity.MONTH: "month", + DateGranularity.DAY: "day", + DateGranularity.HOUR: "hour", + DateGranularity.MINUTE: "minute", +} + +# Common IANA timezone to UTC offset mapping (hours) +IANA_TO_OFFSET: dict[str, float] = { + "UTC": 0, + "America/New_York": -5, + "America/Chicago": -6, + "America/Denver": -7, + "America/Los_Angeles": -8, + "America/Sao_Paulo": -3, + "America/Buenos_Aires": -3, + "Europe/London": 0, + "Europe/Paris": 1, + "Europe/Berlin": 1, + "Europe/Moscow": 3, + "Asia/Dubai": 4, + "Asia/Kolkata": 5.5, + "Asia/Shanghai": 8, + "Asia/Tokyo": 9, + "Australia/Sydney": 10, + "Pacific/Auckland": 12, +} + + +class GroupByConfig(BaseModel): + """Configuration for grouping query results. + + Supports both simple field grouping and date grouping with granularity. + + Examples: + Simple field grouping: + ```python + config = GroupByConfig(field="status") + ``` + + Date grouping with granularity: + ```python + config = GroupByConfig(field="created_at", granularity="month") + ``` + + Date grouping with timezone offset: + ```python + config = GroupByConfig(field="created_at", granularity="day", tz_offset=-3) + ``` + + Date grouping with IANA timezone: + ```python + config = GroupByConfig(field="created_at", granularity="hour", timezone="America/Sao_Paulo") + ``` + """ + + field: str = Field(..., description="Field name to group by") + granularity: DateGranularity | None = Field( + default=None, description="Date granularity for date field grouping" + ) + tz_offset: float | None = Field( + default=None, description="Timezone offset in hours (e.g., -3 for UTC-3)" + ) + timezone: str | None = Field( + default=None, description="IANA timezone name (e.g., 'America/Sao_Paulo')" + ) + + @field_validator("granularity", mode="before") + @classmethod + def validate_granularity(cls, v: Any) -> DateGranularity | None: + if v is None: + return None + if isinstance(v, DateGranularity): + return v + if isinstance(v, str): + v_lower = v.lower() + if v_lower not in settings.SUPPORTED_DATE_GRANULARITIES: + raise ValueError( + f"Unsupported granularity: {v}. " + f"Supported: {settings.SUPPORTED_DATE_GRANULARITIES}" + ) + return DateGranularity(v_lower) + raise ValueError(f"Invalid granularity type: {type(v)}") + + @model_validator(mode="after") + def validate_timezone_settings(self) -> "GroupByConfig": + if self.tz_offset is not None and self.timezone is not None: + raise ValueError("Cannot specify both tz_offset and timezone") + if self.timezone is not None and self.timezone not in IANA_TO_OFFSET: + raise ValueError( + f"Unsupported timezone: {self.timezone}. " + f"Supported: {list(IANA_TO_OFFSET.keys())}" + ) + return self + + @classmethod + def from_param(cls, param: str | dict[str, Any]) -> "GroupByConfig": + """Create a GroupByConfig from a query parameter. + + Args: + param: Either a string (simple field name) or a dict with field config. + + Returns: + GroupByConfig instance. + + Examples: + ```python + # Simple string + config = GroupByConfig.from_param("status") + + # Dict with granularity + config = GroupByConfig.from_param({ + "field": "created_at", + "granularity": "month", + "tz_offset": -3 + }) + ``` + """ + if isinstance(param, str): + return cls(field=param) + return cls.model_validate(param) + + def get_tz_offset_hours(self) -> float: + """Get the timezone offset in hours. + + Returns: + Timezone offset in hours (negative for west of UTC). + """ + if self.tz_offset is not None: + return self.tz_offset + if self.timezone is not None: + return IANA_TO_OFFSET.get(self.timezone, 0) + return 0 + + @property + def is_date_grouping(self) -> bool: + """Check if this is a date grouping configuration.""" + return self.granularity is not None + + +class GroupKeyExtractor: + """Generates SQL expressions for extracting group keys from columns.""" + + def __init__( + self, dialect: Literal["postgresql", "sqlite"] = "postgresql" + ) -> None: + """Initialize the extractor with the database dialect. + + Args: + dialect: Database dialect ('postgresql' or 'sqlite'). + """ + self.dialect = dialect + + def get_group_key_expression( + self, column: InstrumentedAttribute, config: GroupByConfig + ) -> Any: + """Get the SQL expression for extracting the group key. + + Args: + column: The SQLAlchemy column to group by. + config: The grouping configuration. + + Returns: + SQLAlchemy expression for the group key. + """ + if not config.is_date_grouping: + return column + + return self._get_date_group_expression(column, config) + + def _get_date_group_expression( + self, column: InstrumentedAttribute, config: GroupByConfig + ) -> Any: + """Get the SQL expression for date grouping. + + Args: + column: The datetime column to group by. + config: The grouping configuration with granularity. + + Returns: + SQLAlchemy expression for date truncation. + """ + tz_offset = config.get_tz_offset_hours() + granularity = config.granularity + + if granularity is None: + return column + + if self.dialect == "postgresql": + return self._postgres_date_trunc(column, granularity, tz_offset) + return self._sqlite_date_format(column, granularity, tz_offset) + + def _postgres_date_trunc( + self, + column: InstrumentedAttribute, + granularity: DateGranularity, + tz_offset: float, + ) -> Any: + """Generate PostgreSQL date_trunc expression with timezone offset. + + Args: + column: The datetime column. + granularity: The date granularity. + tz_offset: Timezone offset in hours. + + Returns: + SQLAlchemy expression using date_trunc. + """ + precision = POSTGRES_TRUNC_PRECISION[granularity] + + if tz_offset != 0: + # Apply timezone offset using interval + offset_interval = func.make_interval(0, 0, 0, 0, int(tz_offset), 0, 0) + adjusted_column = column + offset_interval + truncated = func.date_trunc(precision, adjusted_column) + else: + truncated = func.date_trunc(precision, column) + + # Format output based on granularity + if granularity == DateGranularity.YEAR: + return func.to_char(truncated, text("'YYYY'")) + elif granularity == DateGranularity.MONTH: + return func.to_char(truncated, text("'YYYY-MM'")) + elif granularity == DateGranularity.DAY: + return func.to_char(truncated, text("'YYYY-MM-DD'")) + elif granularity == DateGranularity.HOUR: + return func.to_char(truncated, text("'YYYY-MM-DD\"T\"HH24'")) + else: # MINUTE + return func.to_char(truncated, text("'YYYY-MM-DD\"T\"HH24:MI'")) + + def _sqlite_date_format( + self, + column: InstrumentedAttribute, + granularity: DateGranularity, + tz_offset: float, + ) -> Any: + """Generate SQLite strftime expression with timezone offset. + + Args: + column: The datetime column. + granularity: The date granularity. + tz_offset: Timezone offset in hours. + + Returns: + SQLAlchemy expression using strftime. + """ + format_str = SQLITE_DATE_FORMATS[granularity] + + if tz_offset != 0: + # SQLite datetime modifier for offset + offset_modifier = f"{tz_offset:+.0f} hours" + return func.strftime(format_str, column, offset_modifier) + + return func.strftime(format_str, column) + + +class DefaultFieldResolver: + """Resolves field paths to SQLAlchemy column objects.""" + + def resolve(self, model: type[SQLModel], field_path: str) -> InstrumentedAttribute: + """Resolve a field path to a SQLAlchemy column. + + Args: + model: The SQLModel class to start resolution from. + field_path: The dot-separated path to the field. + + Returns: + The resolved SQLAlchemy column. + + Raises: + AttributeError: If the field path cannot be resolved. + """ + parts = field_path.split(".") + current: Any = model + for part in parts: + if hasattr(current, part): + attr = getattr(current, part) + if hasattr(attr, "property") and hasattr(attr.property, "mapper"): + current = attr.property.mapper.class_ + else: + current = attr + else: + raise AttributeError(f"Field {part} not found in {current}") + return cast(InstrumentedAttribute[Any], current) + + +class GroupResult(BaseModel): + """Result for a single group in grouped query results.""" + + key: str | None = Field(..., description="The group key value") + 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" + ) + + +class GroupedResponse(BaseModel): + """Response structure for grouped queries.""" + + groups: list[GroupResult] = Field( + default_factory=list, description="List of groups with their items" + ) + truncated: bool = Field( + default=False, + description="True if MAX_LIMIT was reached before all groups were filled", + ) + + + diff --git a/querymate/core/query_builder.py b/querymate/core/query_builder.py index 64ab99b..a5d548a 100644 --- a/querymate/core/query_builder.py +++ b/querymate/core/query_builder.py @@ -1,6 +1,6 @@ from collections.abc import Sequence from logging import getLogger -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from sqlalchemy import Join, case, func from sqlalchemy.ext.asyncio import AsyncSession @@ -13,6 +13,9 @@ from querymate.core.config import settings from querymate.core.filter import FilterBuilder +if TYPE_CHECKING: + from querymate.core.grouping import GroupByConfig, GroupKeyExtractor + T = TypeVar("T", bound=SQLModel) # Type aliases for better readability @@ -71,7 +74,7 @@ class QueryBuilder: query: SelectOfScalar select: list[FieldSelection] filter: dict[str, Any] - sort: list[str] + sort: list[str | dict[str, Any]] limit: int | None = settings.DEFAULT_LIMIT offset: int | None = settings.DEFAULT_OFFSET @@ -286,7 +289,7 @@ def apply_sort( """ if not sort: return self - self.sort = sort # type: ignore[assignment] + self.sort = sort for sort_param in sort: # Custom value order: accept {"field": [values...]} or {"field": {"values": [...]} or {"field": {"order": [...]}} if isinstance(sort_param, dict): @@ -747,3 +750,278 @@ async def count_async(self, db: AsyncSession) -> int: except Exception: value_async = results.scalar() return int(value_async or 0) + + # ------------------------------------------------------------------------- + # Grouping Methods + # ------------------------------------------------------------------------- + + def _resolve_column(self, field_path: str) -> InstrumentedAttribute: + """Resolve a field path to a SQLAlchemy column. + + Args: + field_path: Dot-separated path to the field. + + Returns: + The resolved column attribute. + """ + parts = field_path.split(".") + current: Any = self.model + for part in parts: + if hasattr(current, part): + attr = getattr(current, part) + if hasattr(attr, "property") and hasattr(attr.property, "mapper"): + current = attr.property.mapper.class_ + else: + current = attr + else: + raise AttributeError(f"Field {part} not found in {current}") + return cast(InstrumentedAttribute[Any], current) + + def get_distinct_group_keys( + self, + db: Session, + group_config: "GroupByConfig", + extractor: "GroupKeyExtractor", + ) -> list[tuple[Any, int]]: + """Get distinct group keys with counts. + + Args: + db: Database session. + group_config: Grouping configuration. + extractor: Group key extractor for SQL expression generation. + + Returns: + List of (group_key, count) tuples ordered naturally. + """ + column = self._resolve_column(group_config.field) + group_expr = extractor.get_group_key_expression(column, group_config) + + mapper: Mapper = inspect(self.model) + pk_col = next(col for col in mapper.primary_key) + + # Build query for distinct keys with counts + keys_query = select( + group_expr.label("group_key"), + func.count(func.distinct(pk_col)).label("count"), + ).group_by(group_expr) + + # Apply existing filters + if self.filter: + filters = FilterBuilder(self.model).build(self.filter) + if filters: + keys_query = keys_query.where(*filters) + + # Order naturally (alphabetically for strings, chronologically for dates) + keys_query = keys_query.order_by(group_expr) + + results = db.exec(keys_query).all() + return [(row[0], row[1]) for row in results] + + async def get_distinct_group_keys_async( + self, + db: AsyncSession, + group_config: "GroupByConfig", + extractor: "GroupKeyExtractor", + ) -> list[tuple[Any, int]]: + """Get distinct group keys with counts asynchronously. + + Args: + db: Async database session. + group_config: Grouping configuration. + extractor: Group key extractor for SQL expression generation. + + Returns: + List of (group_key, count) tuples ordered naturally. + """ + column = self._resolve_column(group_config.field) + group_expr = extractor.get_group_key_expression(column, group_config) + + mapper: Mapper = inspect(self.model) + pk_col = next(col for col in mapper.primary_key) + + keys_query = select( + group_expr.label("group_key"), + func.count(func.distinct(pk_col)).label("count"), + ).group_by(group_expr) + + if self.filter: + filters = FilterBuilder(self.model).build(self.filter) + if filters: + keys_query = keys_query.where(*filters) + + keys_query = keys_query.order_by(group_expr) + + results = await db.execute(keys_query) + return [(row[0], row[1]) for row in results.all()] + + def fetch_for_group( + self, + db: Session, + model: type[T], + group_config: "GroupByConfig", + extractor: "GroupKeyExtractor", + group_key: Any, + limit: int, + offset: int = 0, + ) -> list[T]: + """Fetch items for a specific group. + + Args: + db: Database session. + model: The model class. + group_config: Grouping configuration. + extractor: Group key extractor. + group_key: The group key value to filter by. + limit: Maximum items to return. + offset: Number of items to skip. + + Returns: + List of model instances for the group. + """ + column = self._resolve_column(group_config.field) + group_expr = extractor.get_group_key_expression(column, group_config) + + # Build a fresh query for this group + group_builder = QueryBuilder(model) + group_builder.apply_select(self.select if self.select else None) + + # Combine existing filters with group filter + combined_filter = dict(self.filter) if self.filter else {} + + group_builder.apply_filter(combined_filter) + + # Add group key condition to the query + group_builder.query = group_builder.query.where(group_expr == group_key) + + # Apply sorting + if self.sort: + group_builder.apply_sort(self.sort) + + # Apply pagination + group_builder.apply_limit(limit) + group_builder.apply_offset(offset) + + return group_builder.fetch(db, model) + + async def fetch_for_group_async( + self, + db: AsyncSession, + model: type[T], + group_config: "GroupByConfig", + extractor: "GroupKeyExtractor", + group_key: Any, + limit: int, + offset: int = 0, + ) -> list[T]: + """Fetch items for a specific group asynchronously. + + Args: + db: Async database session. + model: The model class. + group_config: Grouping configuration. + extractor: Group key extractor. + group_key: The group key value to filter by. + limit: Maximum items to return. + offset: Number of items to skip. + + Returns: + List of model instances for the group. + """ + column = self._resolve_column(group_config.field) + group_expr = extractor.get_group_key_expression(column, group_config) + + group_builder = QueryBuilder(model) + group_builder.apply_select(self.select if self.select else None) + + combined_filter = dict(self.filter) if self.filter else {} + group_builder.apply_filter(combined_filter) + + group_builder.query = group_builder.query.where(group_expr == group_key) + + if self.sort: + group_builder.apply_sort(self.sort) + + group_builder.apply_limit(limit) + group_builder.apply_offset(offset) + + return await group_builder.fetch_async(db, model) + + def count_for_group( + self, + db: Session, + group_config: "GroupByConfig", + extractor: "GroupKeyExtractor", + group_key: Any, + ) -> int: + """Count items in a specific group. + + Args: + db: Database session. + group_config: Grouping configuration. + extractor: Group key extractor. + group_key: The group key value. + + Returns: + Total count of items in the group. + """ + column = self._resolve_column(group_config.field) + group_expr = extractor.get_group_key_expression(column, group_config) + + mapper: Mapper = inspect(self.model) + pk_col = next(col for col in mapper.primary_key) + + count_query = select(func.count(func.distinct(pk_col))) + + if self.filter: + filters = FilterBuilder(self.model).build(self.filter) + if filters: + count_query = count_query.where(*filters) + + count_query = count_query.where(group_expr == group_key) + + result = db.exec(count_query) + try: + return int(result.one()) + except Exception: + value = result.first() + return int(value or 0) + + async def count_for_group_async( + self, + db: AsyncSession, + group_config: "GroupByConfig", + extractor: "GroupKeyExtractor", + group_key: Any, + ) -> int: + """Count items in a specific group asynchronously. + + Args: + db: Async database session. + group_config: Grouping configuration. + extractor: Group key extractor. + group_key: The group key value. + + Returns: + Total count of items in the group. + """ + column = self._resolve_column(group_config.field) + group_expr = extractor.get_group_key_expression(column, group_config) + + mapper: Mapper = inspect(self.model) + pk_col = next(col for col in mapper.primary_key) + + count_query = select(func.count(func.distinct(pk_col))) + + if self.filter: + filters = FilterBuilder(self.model).build(self.filter) + if filters: + count_query = count_query.where(*filters) + + count_query = count_query.where(group_expr == group_key) + + result = await db.execute(count_query) + try: + return int(result.scalar_one()) + except Exception: + value = result.scalar() + return int(value or 0) diff --git a/querymate/core/querymate.py b/querymate/core/querymate.py index 2442f51..6ec8aac 100644 --- a/querymate/core/querymate.py +++ b/querymate/core/querymate.py @@ -1,5 +1,5 @@ import json -from typing import Any, TypeVar +from typing import Any, Literal, TypeVar from urllib.parse import quote, unquote, urlencode from fastapi import Request @@ -9,6 +9,12 @@ from sqlmodel import Session, SQLModel from querymate.core.config import settings +from querymate.core.grouping import ( + GroupByConfig, + GroupedResponse, + GroupKeyExtractor, + GroupResult, +) from querymate.core.query_builder import QueryBuilder T = TypeVar("T", bound=SQLModel) @@ -16,6 +22,7 @@ # Type aliases for better readability FieldSelection = str | dict[str, list[str]] FilterCondition = dict[str, Any] +GroupByParam = str | dict[str, Any] class Querymate(BaseModel): @@ -98,6 +105,11 @@ def get_users_raw( description="Include pagination metadata in response", alias=settings.PAGINATION_PARAM_NAME, ) + group_by: GroupByParam | None = Field( # type: ignore[literal-required] + default=None, + description="Group results by field. Can be a string or dict with field, granularity, tz_offset/timezone", + alias=settings.GROUP_BY_PARAM_NAME, + ) @classmethod def from_qs(cls, query_params: QueryParams) -> "Querymate": @@ -337,3 +349,265 @@ async def run_raw_async(self, db: AsyncSession, model: type[T]) -> list[T]: offset=self.offset, ) return await query_builder.fetch_async(db, model) + + # ------------------------------------------------------------------------- + # Grouped Query Methods + # ------------------------------------------------------------------------- + + def _get_group_config(self) -> GroupByConfig: + """Parse group_by parameter into GroupByConfig. + + Returns: + GroupByConfig instance. + + Raises: + ValueError: If group_by is not set. + """ + if self.group_by is None: + raise ValueError("group_by parameter is required for grouped queries") + return GroupByConfig.from_param(self.group_by) + + def run_grouped( + self, + db: Session, + model: type[T], + *, + dialect: Literal["postgresql", "sqlite"] = "postgresql", + ) -> dict[str, Any]: + """Build and execute a grouped query based on the parameters. + + Groups results by the specified field. Each group contains items paginated + by the limit parameter. The total items across all groups is capped by MAX_LIMIT. + + Args: + db (Session): The SQLModel database session. + model (type[T]): The SQLModel model class to query. + dialect: Database dialect for date grouping ('postgresql' or 'sqlite'). + + Returns: + dict: Grouped response with structure: + { + "groups": [ + { + "key": "group_value", + "items": [...], + "pagination": {...} + }, + ... + ], + "truncated": false + } + + Example: + ```python + querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + limit=10 + ) + results = querymate.run_grouped(db, Task) + ``` + """ + group_config = self._get_group_config() + extractor = GroupKeyExtractor(dialect=dialect) + + query_builder = QueryBuilder(model=model) + query_builder.build( + select=self.select, + filter=self.filter, + sort=self.sort, + ) + + # Get all distinct group keys with their counts + group_keys = query_builder.get_distinct_group_keys(db, group_config, extractor) + + per_group_limit = self.limit or settings.DEFAULT_LIMIT + max_total = settings.MAX_LIMIT + total_fetched = 0 + truncated = False + groups: list[GroupResult] = [] + + for group_key, group_total in group_keys: + if total_fetched >= max_total: + truncated = True + break + + # Calculate how many items we can fetch for this group + remaining = max_total - total_fetched + effective_limit = min(per_group_limit, remaining) + + if effective_limit <= 0: + truncated = True + break + + # Fetch items for this group + items = query_builder.fetch_for_group( + db, + model, + group_config, + extractor, + group_key, + limit=effective_limit, + offset=self.offset or 0, + ) + + serialized = query_builder.serialize(items) + total_fetched += len(serialized) + + # Build pagination for this group + pagination = self._pagination_for_group( + total=group_total, + limit=per_group_limit, + offset=self.offset or 0, + ) + + groups.append( + GroupResult( + key=str(group_key) if group_key is not None else None, + items=serialized, + pagination=pagination, + ) + ) + + # Check if we hit the limit mid-group + if len(serialized) < effective_limit and effective_limit < per_group_limit: + truncated = True + + response = GroupedResponse(groups=groups, truncated=truncated) + return response.model_dump() + + async def run_grouped_async( + self, + db: AsyncSession, + model: type[T], + *, + dialect: Literal["postgresql", "sqlite"] = "postgresql", + ) -> dict[str, Any]: + """Build and execute a grouped query asynchronously. + + Groups results by the specified field. Each group contains items paginated + by the limit parameter. The total items across all groups is capped by MAX_LIMIT. + + Args: + db (AsyncSession): The SQLModel async database session. + model (type[T]): The SQLModel model class to query. + dialect: Database dialect for date grouping ('postgresql' or 'sqlite'). + + Returns: + dict: Grouped response with structure: + { + "groups": [ + { + "key": "group_value", + "items": [...], + "pagination": {...} + }, + ... + ], + "truncated": false + } + + Example: + ```python + querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + limit=10 + ) + results = await querymate.run_grouped_async(db, Task) + ``` + """ + group_config = self._get_group_config() + extractor = GroupKeyExtractor(dialect=dialect) + + query_builder = QueryBuilder(model=model) + query_builder.build( + select=self.select, + filter=self.filter, + sort=self.sort, + ) + + group_keys = await query_builder.get_distinct_group_keys_async( + db, group_config, extractor + ) + + per_group_limit = self.limit or settings.DEFAULT_LIMIT + max_total = settings.MAX_LIMIT + total_fetched = 0 + truncated = False + groups: list[GroupResult] = [] + + for group_key, group_total in group_keys: + if total_fetched >= max_total: + truncated = True + break + + remaining = max_total - total_fetched + effective_limit = min(per_group_limit, remaining) + + if effective_limit <= 0: + truncated = True + break + + items = await query_builder.fetch_for_group_async( + db, + model, + group_config, + extractor, + group_key, + limit=effective_limit, + offset=self.offset or 0, + ) + + serialized = query_builder.serialize(items) + total_fetched += len(serialized) + + pagination = self._pagination_for_group( + total=group_total, + limit=per_group_limit, + offset=self.offset or 0, + ) + + groups.append( + GroupResult( + key=str(group_key) if group_key is not None else None, + items=serialized, + pagination=pagination, + ) + ) + + if len(serialized) < effective_limit and effective_limit < per_group_limit: + truncated = True + + response = GroupedResponse(groups=groups, truncated=truncated) + return response.model_dump() + + def _pagination_for_group( + self, total: int, limit: int, offset: int + ) -> dict[str, Any]: + """Build pagination metadata for a single group. + + Args: + total: Total items in the group. + limit: Per-group limit. + offset: Offset within the group. + + Returns: + Pagination metadata dict. + """ + size = limit + pages = (total + size - 1) // size if size > 0 else 1 + pages = max(1, pages) + computed_page = (offset // size) + 1 if size > 0 else 1 + page = max(1, min(computed_page, pages)) + 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, + } diff --git a/tests/models.py b/tests/models.py index b961be1..3286563 100644 --- a/tests/models.py +++ b/tests/models.py @@ -9,6 +9,7 @@ class User(SQLModel, table=True): email: str age: int is_active: bool + status: str = Field(default="active") created_at: datetime = Field(default_factory=datetime.utcnow) birth_date: date | None = None last_login: datetime | None = None @@ -19,6 +20,7 @@ class Post(SQLModel, table=True): id: int = Field(primary_key=True) title: str content: str + status: str = Field(default="draft") user_id: int = Field(foreign_key="user.id") created_at: datetime = Field(default_factory=datetime.utcnow) published_at: datetime | None = None diff --git a/tests/test_grouping.py b/tests/test_grouping.py new file mode 100644 index 0000000..f3406b9 --- /dev/null +++ b/tests/test_grouping.py @@ -0,0 +1,489 @@ +# type: ignore +"""Tests for grouped query functionality.""" + +from datetime import datetime + +import pytest +from sqlmodel import Session + +from querymate import Querymate +from querymate.core.grouping import ( + DateGranularity, + GroupByConfig, + GroupKeyExtractor, +) + +from .models import Post, User + +# ----------------------------------------------------------------------------- +# GroupByConfig Tests +# ----------------------------------------------------------------------------- + + +class TestGroupByConfig: + """Tests for GroupByConfig parsing and validation.""" + + def test_from_param_simple_string(self): + """Test creating config from simple field name string.""" + config = GroupByConfig.from_param("status") + assert config.field == "status" + assert config.granularity is None + assert config.tz_offset is None + assert config.timezone is None + assert not config.is_date_grouping + + def test_from_param_dict_with_granularity(self): + """Test creating config with date granularity.""" + 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 + }) + assert config.field == "created_at" + assert config.granularity == DateGranularity.DAY + assert config.tz_offset == -3 + assert config.get_tz_offset_hours() == -3 + + 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" + }) + assert config.field == "created_at" + assert config.granularity == DateGranularity.HOUR + assert config.timezone == "America/Sao_Paulo" + assert config.get_tz_offset_hours() == -3 + + 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" + }) + + 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" + }) + + 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" + }) + + 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 + }) + assert config.granularity == DateGranularity(granularity) + + +class TestGroupKeyExtractor: + """Tests for GroupKeyExtractor SQL expression generation.""" + + 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) + assert expr is column + + 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 + + +# ----------------------------------------------------------------------------- +# Grouped Query Integration Tests +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def populated_db(db: Session): + """Create test data with various statuses and dates.""" + # Create users with different statuses + users = [ + User( + id=1, + name="Alice", + email="alice@test.com", + age=25, + is_active=True, + status="active", + created_at=datetime(2024, 1, 15, 10, 30), + ), + User( + id=2, + name="Bob", + email="bob@test.com", + age=30, + is_active=True, + status="active", + created_at=datetime(2024, 1, 20, 14, 45), + ), + User( + id=3, + name="Charlie", + email="charlie@test.com", + age=35, + is_active=False, + status="inactive", + created_at=datetime(2024, 2, 10, 9, 0), + ), + User( + id=4, + name="Diana", + email="diana@test.com", + age=28, + is_active=True, + status="pending", + created_at=datetime(2024, 2, 15, 11, 30), + ), + User( + id=5, + name="Eve", + email="eve@test.com", + age=32, + is_active=False, + status="inactive", + created_at=datetime(2024, 3, 1, 8, 0), + ), + ] + + for user in users: + db.add(user) + + # Create posts with different statuses + posts = [ + Post( + id=1, + title="Post 1", + content="Content 1", + status="published", + user_id=1, + created_at=datetime(2024, 1, 15), + ), + Post( + id=2, + title="Post 2", + content="Content 2", + status="draft", + user_id=1, + created_at=datetime(2024, 1, 20), + ), + Post( + id=3, + title="Post 3", + content="Content 3", + status="published", + user_id=2, + created_at=datetime(2024, 2, 1), + ), + Post( + id=4, + title="Post 4", + content="Content 4", + status="draft", + user_id=3, + created_at=datetime(2024, 2, 15), + ), + Post( + id=5, + title="Post 5", + content="Content 5", + status="archived", + user_id=4, + created_at=datetime(2024, 3, 1), + ), + ] + + for post in posts: + db.add(post) + + db.commit() + return db + + +class TestGroupedQueries: + """Integration tests for grouped query functionality.""" + + def test_simple_grouping_by_status(self, populated_db: Session): + """Test grouping users by status field.""" + querymate = Querymate( + select=["id", "name", "status"], + 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 + assert active_group["pagination"]["total"] == 2 + + def test_grouping_with_filter(self, populated_db: Session): + """Test grouping with filter applied.""" + querymate = Querymate( + select=["id", "name", "status"], + filter={"is_active": True}, + 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] + assert "inactive" not in group_keys + + def test_grouping_with_sorting(self, populated_db: Session): + """Test that sorting is applied within each group.""" + querymate = Querymate( + select=["id", "name", "status", "age"], + group_by="status", + 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"] + if len(items) > 1: + ages = [item["age"] for item in items] + assert ages == sorted(ages, reverse=True) + + def test_per_group_pagination(self, populated_db: Session): + """Test that limit applies per group.""" + querymate = Querymate( + select=["id", "name", "status"], + 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 + # But pagination should show the real total + if group["key"] == "active": + assert group["pagination"]["total"] == 2 + elif group["key"] == "inactive": + assert group["pagination"]["total"] == 2 + + 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.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 + + 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" + }, + 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] + assert "2024-01" in group_keys + assert "2024-02" in group_keys + assert "2024-03" in group_keys + + 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" + }, + limit=10, + ) + + result = querymate.run_grouped(populated_db, User, dialect="sqlite") + + groups = result["groups"] + # All users are in 2024 + assert len(groups) == 1 + assert groups[0]["key"] == "2024" + assert len(groups[0]["items"]) == 5 + + 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" + }, + limit=10, + ) + + result = querymate.run_grouped(populated_db, User, dialect="sqlite") + + groups = result["groups"] + # Each user has a unique day + assert len(groups) == 5 + + def test_date_grouping_with_timezone_offset(self, populated_db: Session): + """Test date grouping with timezone offset.""" + querymate = Querymate( + select=["id", "name", "created_at"], + group_by={ + "field": "created_at", + "granularity": "day", + "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 + + def test_group_by_not_set_raises(self, populated_db: Session): + """Test that run_grouped raises if group_by is not set.""" + querymate = Querymate( + select=["id", "name"], + limit=10, + ) + + with pytest.raises(ValueError, match="group_by parameter is required"): + querymate.run_grouped(populated_db, User, dialect="sqlite") + + def test_post_grouping_by_status(self, populated_db: Session): + """Test grouping posts by status.""" + querymate = Querymate( + select=["id", "title", "status"], + 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 + assert "published" in group_keys + + def test_empty_result_grouping(self, populated_db: Session): + """Test grouping with filter that matches no records.""" + querymate = Querymate( + select=["id", "name", "status"], + filter={"age": {"gt": 1000}}, # No users this old + group_by="status", + limit=10, + ) + + result = querymate.run_grouped(populated_db, User, dialect="sqlite") + + assert result["groups"] == [] + assert result["truncated"] is False + + def test_pagination_metadata_per_group(self, populated_db: Session): + """Test that each group has correct pagination metadata.""" + querymate = Querymate( + select=["id", "name", "status"], + group_by="status", + 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 + assert "page" in pagination + assert "size" in pagination + 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 ba1acfe..0c73693 100644 --- a/tests/test_querymate.py +++ b/tests/test_querymate.py @@ -84,7 +84,7 @@ def test_to_qs() -> None: assert ( qs - == "q=%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%7D" + == "q=%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%2C%22group_by%22%3Anull%7D" ) @@ -99,7 +99,7 @@ def test_to_query_param() -> None: qp = querymate.to_query_param() assert ( qp - == "%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%7D" + == "%7B%22select%22%3A%5B%22id%22%2C%22name%22%5D%2C%22filter%22%3A%7B%22age%22%3A%7B%22gt%22%3A25%7D%7D%2C%22sort%22%3A%5B%22-age%22%5D%2C%22limit%22%3A10%2C%22offset%22%3A0%2C%22include_pagination%22%3Afalse%2C%22group_by%22%3Anull%7D" )