mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-04 15:44:09 +00:00
refactor: batch facet queries into a single round trip
This commit is contained in:
@@ -491,6 +491,16 @@ The distinct values for each facet field are returned in the `filter_attributes`
|
|||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
!!! info "Key format uses `__` as a separator for relationship chains."
|
||||||
A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. An unknown `filter_by` key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) (HTTP 422).
|
A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. An unknown `filter_by` key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) (HTTP 422).
|
||||||
|
|
||||||
|
#### Skipping facet queries
|
||||||
|
|
||||||
|
!!! info "Added in `v5.1.0`"
|
||||||
|
|
||||||
|
Facet values only change with the filters, not with the page. Pass `include_facets=False` to `offset_paginate_params()` / `cursor_paginate_params()` on pages 2..N to skip the facet queries entirely (`filter_attributes` will be `None`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_facets=False))]
|
||||||
|
```
|
||||||
|
|
||||||
## Sorting
|
## Sorting
|
||||||
|
|
||||||
!!! info "Added in `v1.3`"
|
!!! info "Added in `v1.3`"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from ..types import (
|
|||||||
)
|
)
|
||||||
from .search import (
|
from .search import (
|
||||||
SearchConfig,
|
SearchConfig,
|
||||||
|
apply_search_joins,
|
||||||
build_facets,
|
build_facets,
|
||||||
build_filter_by,
|
build_filter_by,
|
||||||
build_search_filters,
|
build_search_filters,
|
||||||
@@ -128,17 +129,6 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
|
||||||
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
for join_rel in search_joins:
|
|
||||||
key = str(join_rel)
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
q = q.outerjoin(join_rel)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncCrud(Generic[ModelType]):
|
class AsyncCrud(Generic[ModelType]):
|
||||||
"""Generic async CRUD operations for SQLAlchemy models.
|
"""Generic async CRUD operations for SQLAlchemy models.
|
||||||
|
|
||||||
@@ -265,12 +255,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
filter_by: dict[str, Any] | BaseModel | None,
|
filter_by: dict[str, Any] | BaseModel | None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
) -> tuple[list[Any], list[Any]]:
|
) -> tuple[dict[str, Any], list[Any]]:
|
||||||
"""Normalize filter_by and return (filters, joins) to apply to the query."""
|
"""Normalize filter_by and return ({facet_key: filter}, joins) to apply to the query."""
|
||||||
if isinstance(filter_by, BaseModel):
|
if isinstance(filter_by, BaseModel):
|
||||||
filter_by = filter_by.model_dump(exclude_none=True)
|
filter_by = filter_by.model_dump(exclude_none=True)
|
||||||
if not filter_by:
|
if not filter_by:
|
||||||
return [], []
|
return {}, []
|
||||||
resolved = cls._resolve_facet_fields(facet_fields)
|
resolved = cls._resolve_facet_fields(facet_fields)
|
||||||
return build_filter_by(filter_by, resolved or [])
|
return build_filter_by(filter_by, resolved or [])
|
||||||
|
|
||||||
@@ -281,8 +271,13 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
facet_fields: Sequence[FacetFieldType] | None,
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
search_joins: list[Any],
|
search_joins: list[Any],
|
||||||
|
*,
|
||||||
|
include_facets: bool = True,
|
||||||
|
own_filters: dict[str, Any] | None = None,
|
||||||
) -> dict[str, list[Any]] | None:
|
) -> dict[str, list[Any]] | None:
|
||||||
"""Build facet filter_attributes, or return None if no facet fields configured."""
|
"""Build facet filter_attributes, or None if disabled/no facet fields configured."""
|
||||||
|
if not include_facets:
|
||||||
|
return None
|
||||||
resolved = cls._resolve_facet_fields(facet_fields)
|
resolved = cls._resolve_facet_fields(facet_fields)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
return None
|
return None
|
||||||
@@ -292,6 +287,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
resolved,
|
resolved,
|
||||||
base_filters=filters,
|
base_filters=filters,
|
||||||
base_joins=search_joins,
|
base_joins=search_joins,
|
||||||
|
own_filters=own_filters,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -475,6 +471,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
default_page_size: int = 20,
|
default_page_size: int = 20,
|
||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
|
include_facets: bool = True,
|
||||||
search: bool = True,
|
search: bool = True,
|
||||||
filter: bool = True,
|
filter: bool = True,
|
||||||
order: bool = True,
|
order: bool = True,
|
||||||
@@ -490,6 +487,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default ``items_per_page`` value.
|
||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
include_total: Whether to include total count (not a query param).
|
include_total: Whether to include total count (not a query param).
|
||||||
|
include_facets: Whether to run facet queries (not a query param).
|
||||||
search: Enable search query parameters.
|
search: Enable search query parameters.
|
||||||
filter: Enable facet filter query parameters.
|
filter: Enable facet filter query parameters.
|
||||||
order: Enable order query parameters.
|
order: Enable order query parameters.
|
||||||
@@ -519,7 +517,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
]
|
]
|
||||||
return cls._build_paginate_params(
|
return cls._build_paginate_params(
|
||||||
pagination_params=pagination_params,
|
pagination_params=pagination_params,
|
||||||
pagination_fixed={"include_total": include_total},
|
pagination_fixed={
|
||||||
|
"include_total": include_total,
|
||||||
|
"include_facets": include_facets,
|
||||||
|
},
|
||||||
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
|
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
|
||||||
search=search,
|
search=search,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
@@ -537,6 +538,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
default_page_size: int = 20,
|
default_page_size: int = 20,
|
||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
|
include_facets: bool = True,
|
||||||
search: bool = True,
|
search: bool = True,
|
||||||
filter: bool = True,
|
filter: bool = True,
|
||||||
order: bool = True,
|
order: bool = True,
|
||||||
@@ -551,6 +553,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Args:
|
Args:
|
||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default ``items_per_page`` value.
|
||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
|
include_facets: Whether to run facet queries (not a query param).
|
||||||
search: Enable search query parameters.
|
search: Enable search query parameters.
|
||||||
filter: Enable facet filter query parameters.
|
filter: Enable facet filter query parameters.
|
||||||
order: Enable order query parameters.
|
order: Enable order query parameters.
|
||||||
@@ -582,7 +585,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
]
|
]
|
||||||
return cls._build_paginate_params(
|
return cls._build_paginate_params(
|
||||||
pagination_params=pagination_params,
|
pagination_params=pagination_params,
|
||||||
pagination_fixed={},
|
pagination_fixed={"include_facets": include_facets},
|
||||||
dep_name=f"{cls.model.__name__}CursorPaginateParams",
|
dep_name=f"{cls.model.__name__}CursorPaginateParams",
|
||||||
search=search,
|
search=search,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
@@ -602,6 +605,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
|
include_facets: bool = True,
|
||||||
search: bool = True,
|
search: bool = True,
|
||||||
filter: bool = True,
|
filter: bool = True,
|
||||||
order: bool = True,
|
order: bool = True,
|
||||||
@@ -618,6 +622,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
default_pagination_type: Default pagination strategy.
|
default_pagination_type: Default pagination strategy.
|
||||||
include_total: Whether to include total count (not a query param).
|
include_total: Whether to include total count (not a query param).
|
||||||
|
include_facets: Whether to run facet queries (not a query param).
|
||||||
search: Enable search query parameters.
|
search: Enable search query parameters.
|
||||||
filter: Enable facet filter query parameters.
|
filter: Enable facet filter query parameters.
|
||||||
order: Enable order query parameters.
|
order: Enable order query parameters.
|
||||||
@@ -666,7 +671,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
]
|
]
|
||||||
return cls._build_paginate_params(
|
return cls._build_paginate_params(
|
||||||
pagination_params=pagination_params,
|
pagination_params=pagination_params,
|
||||||
pagination_fixed={"include_total": include_total},
|
pagination_fixed={
|
||||||
|
"include_total": include_total,
|
||||||
|
"include_facets": include_facets,
|
||||||
|
},
|
||||||
dep_name=f"{cls.model.__name__}PaginateParams",
|
dep_name=f"{cls.model.__name__}PaginateParams",
|
||||||
search=search,
|
search=search,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
@@ -1271,6 +1279,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[OrderFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
include_facets: bool = True,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> OffsetPaginatedResponse[Any]:
|
) -> OffsetPaginatedResponse[Any]:
|
||||||
@@ -1292,6 +1301,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: Restrict search to a single column key.
|
search_column: Restrict search to a single column key.
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
order_fields: Fields allowed for sorting (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
|
include_facets: When ``False``, skip facet queries entirely;
|
||||||
|
``filter_attributes`` will be ``None``. Useful on pages 2..N
|
||||||
|
where the facet counts were already fetched on page 1.
|
||||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||||
Keys must match the column.key of a facet field. Scalar → equality,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
@@ -1304,7 +1316,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
offset = (page - 1) * items_per_page
|
offset = (page - 1) * items_per_page
|
||||||
|
|
||||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||||
filters.extend(fb_filters)
|
|
||||||
|
|
||||||
# Build search filters
|
# Build search filters
|
||||||
if search:
|
if search:
|
||||||
@@ -1318,6 +1329,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
search_joins.extend(new_search_joins)
|
||||||
|
|
||||||
|
# Facets combine these with each facet's own filter individually, so
|
||||||
|
# fb_filters is applied to the query below but excluded here.
|
||||||
|
facet_base_filters = list(filters)
|
||||||
|
filters.extend(fb_filters.values())
|
||||||
|
|
||||||
# Build query with joins
|
# Build query with joins
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
@@ -1325,11 +1341,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
# Apply search joins (always outer joins for search)
|
# Apply search joins (always outer joins for search)
|
||||||
q = _apply_search_joins(q, search_joins)
|
q = apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
# Apply order joins (relation joins required for order_by field)
|
# Apply order joins (relation joins required for order_by field)
|
||||||
if order_joins:
|
if order_joins:
|
||||||
q = _apply_search_joins(q, order_joins)
|
q = apply_search_joins(q, order_joins)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
@@ -1352,7 +1368,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_q = _apply_joins(count_q, joins, outer_join)
|
count_q = _apply_joins(count_q, joins, outer_join)
|
||||||
|
|
||||||
# Apply search joins to count query
|
# Apply search joins to count query
|
||||||
count_q = _apply_search_joins(count_q, search_joins)
|
count_q = apply_search_joins(count_q, search_joins)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
count_q = count_q.where(and_(*filters))
|
count_q = count_q.where(and_(*filters))
|
||||||
@@ -1372,7 +1388,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
session, facet_fields, filters, search_joins
|
session,
|
||||||
|
facet_fields,
|
||||||
|
facet_base_filters,
|
||||||
|
search_joins,
|
||||||
|
include_facets=include_facets,
|
||||||
|
own_filters=fb_filters,
|
||||||
)
|
)
|
||||||
search_columns = cls._resolve_search_columns(search_fields)
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
order_columns = cls._resolve_order_columns(order_fields)
|
||||||
@@ -1408,6 +1429,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[OrderFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
include_facets: bool = True,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> CursorPaginatedResponse[Any]:
|
) -> CursorPaginatedResponse[Any]:
|
||||||
@@ -1430,6 +1452,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: Restrict search to a single column key.
|
search_column: Restrict search to a single column key.
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
order_fields: Fields allowed for sorting (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default).
|
facet_fields: Columns to compute distinct values for (overrides class default).
|
||||||
|
include_facets: When ``False``, skip facet queries entirely;
|
||||||
|
``filter_attributes`` will be ``None``.
|
||||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||||
Keys must match the column.key of a facet field. Scalar → equality,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
@@ -1441,7 +1465,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters = list(filters) if filters else []
|
filters = list(filters) if filters else []
|
||||||
|
|
||||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||||
filters.extend(fb_filters)
|
|
||||||
|
|
||||||
if cls.cursor_column is None:
|
if cls.cursor_column is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -1473,6 +1496,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
search_joins.extend(new_search_joins)
|
||||||
|
|
||||||
|
# Facets combine these with each facet's own filter individually, so
|
||||||
|
# fb_filters is applied to the query below but excluded here.
|
||||||
|
facet_base_filters = list(filters)
|
||||||
|
filters.extend(fb_filters.values())
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
@@ -1480,11 +1508,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
# Apply search joins (always outer joins)
|
# Apply search joins (always outer joins)
|
||||||
q = _apply_search_joins(q, search_joins)
|
q = apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
# Apply order joins (relation joins required for order_by field)
|
# Apply order joins (relation joins required for order_by field)
|
||||||
if order_joins:
|
if order_joins:
|
||||||
q = _apply_search_joins(q, order_joins)
|
q = apply_search_joins(q, order_joins)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
@@ -1541,7 +1569,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
filter_attributes = await cls._build_filter_attributes(
|
||||||
session, facet_fields, filters, search_joins
|
session,
|
||||||
|
facet_fields,
|
||||||
|
facet_base_filters,
|
||||||
|
search_joins,
|
||||||
|
include_facets=include_facets,
|
||||||
|
own_filters=fb_filters,
|
||||||
)
|
)
|
||||||
search_columns = cls._resolve_search_columns(search_fields)
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
order_columns = cls._resolve_order_columns(order_fields)
|
||||||
@@ -1581,6 +1614,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = ...,
|
search_column: str | None = ...,
|
||||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
order_fields: Sequence[OrderFieldType] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
|
include_facets: bool = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> OffsetPaginatedResponse[Any]: ...
|
) -> OffsetPaginatedResponse[Any]: ...
|
||||||
@@ -1607,6 +1641,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = ...,
|
search_column: str | None = ...,
|
||||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
order_fields: Sequence[OrderFieldType] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
|
include_facets: bool = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> CursorPaginatedResponse[Any]: ...
|
) -> CursorPaginatedResponse[Any]: ...
|
||||||
@@ -1632,6 +1667,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[OrderFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
include_facets: bool = True,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
|
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
|
||||||
@@ -1662,6 +1698,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order_fields: Fields allowed for sorting (overrides class default).
|
order_fields: Fields allowed for sorting (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (overrides
|
facet_fields: Columns to compute distinct values for (overrides
|
||||||
class default).
|
class default).
|
||||||
|
include_facets: When ``False``, skip facet queries entirely;
|
||||||
|
``filter_attributes`` will be ``None``.
|
||||||
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
||||||
facet fields. Keys must match the ``column.key`` of a facet
|
facet fields. Keys must match the ``column.key`` of a facet
|
||||||
field. Scalar → equality, list → IN clause. Raises
|
field. Scalar → equality, list → IN clause. Raises
|
||||||
@@ -1692,6 +1730,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column=search_column,
|
search_column=search_column,
|
||||||
order_fields=order_fields,
|
order_fields=order_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
|
include_facets=include_facets,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
)
|
)
|
||||||
@@ -1714,6 +1753,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column=search_column,
|
search_column=search_column,
|
||||||
order_fields=order_fields,
|
order_fields=order_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
|
include_facets=include_facets,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""Search utilities for AsyncCrud."""
|
"""Search utilities for AsyncCrud."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import functools
|
import functools
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from sqlalchemy import String, and_, func, or_, select
|
from sqlalchemy import String, and_, distinct, func, or_, select
|
||||||
|
from sqlalchemy.dialects.postgresql import aggregate_order_by
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
@@ -181,6 +181,21 @@ def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
|
|||||||
return facet_keys(fields)
|
return facet_keys(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_search_joins(q: Any, joins: Sequence[Any]) -> Any:
|
||||||
|
"""Apply relationship-based outer joins (from search/filter_by/facets) to a query.
|
||||||
|
|
||||||
|
Deduplicates by relationship identity so a join used by several fields
|
||||||
|
(e.g. search + a facet on the same relation) is only applied once.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
for rel in joins:
|
||||||
|
rel_key = str(rel)
|
||||||
|
if rel_key not in seen:
|
||||||
|
seen.add(rel_key)
|
||||||
|
q = q.outerjoin(rel)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||||
"""Return a key for each facet field.
|
"""Return a key for each facet field.
|
||||||
|
|
||||||
@@ -207,6 +222,7 @@ async def build_facets(
|
|||||||
*,
|
*,
|
||||||
base_filters: "list[ColumnElement[bool]] | None" = None,
|
base_filters: "list[ColumnElement[bool]] | None" = None,
|
||||||
base_joins: list[InstrumentedAttribute[Any]] | None = None,
|
base_joins: list[InstrumentedAttribute[Any]] | None = None,
|
||||||
|
own_filters: "dict[str, ColumnElement[bool]] | None" = None,
|
||||||
) -> dict[str, list[Any]]:
|
) -> dict[str, list[Any]]:
|
||||||
"""Return distinct values for each facet field, respecting current filters.
|
"""Return distinct values for each facet field, respecting current filters.
|
||||||
|
|
||||||
@@ -216,15 +232,24 @@ async def build_facets(
|
|||||||
facet_fields: Columns or relationship tuples to facet on
|
facet_fields: Columns or relationship tuples to facet on
|
||||||
base_filters: Filter conditions already applied to the main query (search + caller filters)
|
base_filters: Filter conditions already applied to the main query (search + caller filters)
|
||||||
base_joins: Relationship joins already applied to the main query
|
base_joins: Relationship joins already applied to the main query
|
||||||
|
own_filters: Map of facet key -> the ``filter_by`` condition for that
|
||||||
|
same key (if any). Excluded from that facet's own subquery so
|
||||||
|
filtering on a facet doesn't collapse its own value list down to
|
||||||
|
just the filtered value.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping column key to sorted list of distinct non-None values
|
Dict mapping column key to sorted list of distinct non-None values
|
||||||
"""
|
"""
|
||||||
existing_join_keys: set[str] = {str(j) for j in (base_joins or [])}
|
if not facet_fields:
|
||||||
|
return {}
|
||||||
|
|
||||||
keys = facet_keys(facet_fields)
|
keys = facet_keys(facet_fields)
|
||||||
|
own_filters = own_filters or {}
|
||||||
|
|
||||||
async def _query_facet(field: FacetFieldType, key: str) -> tuple[str, list[Any]]:
|
scalars: list[Any] = []
|
||||||
|
enum_classes: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for field, key in zip(facet_fields, keys):
|
||||||
if isinstance(field, tuple):
|
if isinstance(field, tuple):
|
||||||
# Relationship chain: (User.role, Role.name) — last element is the column
|
# Relationship chain: (User.role, Role.name) — last element is the column
|
||||||
rels = field[:-1]
|
rels = field[:-1]
|
||||||
@@ -235,51 +260,48 @@ async def build_facets(
|
|||||||
|
|
||||||
col_type = column.property.columns[0].type
|
col_type = column.property.columns[0].type
|
||||||
is_array = isinstance(col_type, ARRAY)
|
is_array = isinstance(col_type, ARRAY)
|
||||||
|
enum_classes[key] = getattr(col_type, "enum_class", None)
|
||||||
|
|
||||||
if is_array:
|
filters = [
|
||||||
unnested = func.unnest(column).label(column.key)
|
*(base_filters or []),
|
||||||
q = select(unnested).select_from(model).distinct()
|
*(f for k, f in own_filters.items() if k != key),
|
||||||
else:
|
|
||||||
q = select(column).select_from(model).distinct()
|
|
||||||
|
|
||||||
# Apply base joins (deduplicated) — needed here independently
|
|
||||||
seen_joins: set[str] = set()
|
|
||||||
for rel in base_joins or []:
|
|
||||||
rel_key = str(rel)
|
|
||||||
if rel_key not in seen_joins:
|
|
||||||
seen_joins.add(rel_key)
|
|
||||||
q = q.outerjoin(rel)
|
|
||||||
|
|
||||||
# Add any extra joins required by this facet field that aren't already applied
|
|
||||||
for rel in rels:
|
|
||||||
rel_key = str(rel)
|
|
||||||
if rel_key not in existing_join_keys and rel_key not in seen_joins:
|
|
||||||
seen_joins.add(rel_key)
|
|
||||||
q = q.outerjoin(rel)
|
|
||||||
|
|
||||||
if base_filters:
|
|
||||||
q = q.where(and_(*base_filters))
|
|
||||||
|
|
||||||
if is_array:
|
|
||||||
q = q.order_by(unnested)
|
|
||||||
else:
|
|
||||||
q = q.order_by(column)
|
|
||||||
result = await session.execute(q)
|
|
||||||
col_type = column.property.columns[0].type
|
|
||||||
enum_class = getattr(col_type, "enum_class", None)
|
|
||||||
values = [
|
|
||||||
row[0].name
|
|
||||||
if (enum_class is not None and isinstance(row[0], enum_class))
|
|
||||||
else row[0]
|
|
||||||
for row in result.all()
|
|
||||||
if row[0] is not None
|
|
||||||
]
|
]
|
||||||
return key, values
|
joins = [*(base_joins or []), *rels]
|
||||||
|
|
||||||
pairs = await asyncio.gather(
|
if is_array:
|
||||||
*[_query_facet(f, k) for f, k in zip(facet_fields, keys)]
|
unnested = apply_search_joins(
|
||||||
)
|
select(func.unnest(column).label("v")).select_from(model), joins
|
||||||
return dict(pairs)
|
)
|
||||||
|
if filters:
|
||||||
|
unnested = unnested.where(and_(*filters))
|
||||||
|
unnested_sq = unnested.subquery()
|
||||||
|
v = unnested_sq.c.v
|
||||||
|
agg = (
|
||||||
|
select(func.array_agg(aggregate_order_by(distinct(v), v)))
|
||||||
|
.select_from(unnested_sq)
|
||||||
|
.where(v.isnot(None))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
agg = apply_search_joins(
|
||||||
|
select(
|
||||||
|
func.array_agg(aggregate_order_by(distinct(column), column))
|
||||||
|
).select_from(model),
|
||||||
|
joins,
|
||||||
|
)
|
||||||
|
agg = agg.where(and_(*filters, column.isnot(None)))
|
||||||
|
|
||||||
|
scalars.append(agg.scalar_subquery().label(key))
|
||||||
|
|
||||||
|
row = (await session.execute(select(*scalars))).one()
|
||||||
|
|
||||||
|
facets: dict[str, list[Any]] = {}
|
||||||
|
for key, values in zip(keys, row):
|
||||||
|
enum_class = enum_classes[key]
|
||||||
|
facets[key] = [
|
||||||
|
v.name if (enum_class is not None and isinstance(v, enum_class)) else v
|
||||||
|
for v in (values or [])
|
||||||
|
]
|
||||||
|
return facets
|
||||||
|
|
||||||
|
|
||||||
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
||||||
@@ -301,7 +323,7 @@ def _coerce_bool(value: Any) -> bool:
|
|||||||
def build_filter_by(
|
def build_filter_by(
|
||||||
filter_by: dict[str, Any],
|
filter_by: dict[str, Any],
|
||||||
facet_fields: Sequence[FacetFieldType],
|
facet_fields: Sequence[FacetFieldType],
|
||||||
) -> tuple["list[ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
|
) -> tuple["dict[str, ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
|
||||||
"""Translate a {column_key: value} dict into SQLAlchemy filter conditions.
|
"""Translate a {column_key: value} dict into SQLAlchemy filter conditions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -309,7 +331,9 @@ def build_filter_by(
|
|||||||
facet_fields: Declared facet fields to validate keys against
|
facet_fields: Declared facet fields to validate keys against
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (filter_conditions, joins_needed)
|
Tuple of ({facet_key: filter_condition}, joins_needed). One filter
|
||||||
|
condition per key, so callers can identify (and exclude) a facet's
|
||||||
|
own filter when computing that facet's distinct values.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InvalidFacetFilterError: If a key in filter_by is not a declared facet field
|
InvalidFacetFilterError: If a key in filter_by is not a declared facet field
|
||||||
@@ -327,7 +351,7 @@ def build_filter_by(
|
|||||||
index[key] = (column, rels)
|
index[key] = (column, rels)
|
||||||
|
|
||||||
valid_keys = set(index)
|
valid_keys = set(index)
|
||||||
filters: list[ColumnElement[bool]] = []
|
filters: dict[str, ColumnElement[bool]] = {}
|
||||||
joins: list[InstrumentedAttribute[Any]] = []
|
joins: list[InstrumentedAttribute[Any]] = []
|
||||||
added_join_keys: set[str] = set()
|
added_join_keys: set[str] = set()
|
||||||
|
|
||||||
@@ -347,14 +371,14 @@ def build_filter_by(
|
|||||||
if isinstance(col_type, Boolean):
|
if isinstance(col_type, Boolean):
|
||||||
coerce = _coerce_bool
|
coerce = _coerce_bool
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_([coerce(v) for v in value]))
|
filters[key] = column.in_([coerce(v) for v in value])
|
||||||
else:
|
else:
|
||||||
filters.append(column == coerce(value))
|
filters[key] = column == coerce(value)
|
||||||
elif isinstance(col_type, ARRAY):
|
elif isinstance(col_type, ARRAY):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.overlap(value))
|
filters[key] = column.overlap(value)
|
||||||
else:
|
else:
|
||||||
filters.append(column.any(value))
|
filters[key] = column.any(value)
|
||||||
elif isinstance(col_type, Enum):
|
elif isinstance(col_type, Enum):
|
||||||
enum_class = col_type.enum_class
|
enum_class = col_type.enum_class
|
||||||
if enum_class is not None:
|
if enum_class is not None:
|
||||||
@@ -365,19 +389,19 @@ def build_filter_by(
|
|||||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
return enum_class[v] # lookup by name: "PENDING", "RED"
|
||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_([_coerce_enum(v) for v in value]))
|
filters[key] = column.in_([_coerce_enum(v) for v in value])
|
||||||
else:
|
else:
|
||||||
filters.append(column == _coerce_enum(value))
|
filters[key] = column == _coerce_enum(value)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters[key] = column.in_(value)
|
||||||
else:
|
else:
|
||||||
filters.append(column == value)
|
filters[key] = column == value
|
||||||
elif isinstance(col_type, _EQUALITY_TYPES):
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters[key] = column.in_(value)
|
||||||
else:
|
else:
|
||||||
filters.append(column == value)
|
filters[key] = column == value
|
||||||
else:
|
else:
|
||||||
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
||||||
|
|
||||||
|
|||||||
+135
-7
@@ -531,6 +531,15 @@ class TestFacetsNotSet:
|
|||||||
|
|
||||||
assert result.filter_attributes is None
|
assert result.filter_attributes is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_build_facets_empty_field_list(self, db_session: AsyncSession):
|
||||||
|
"""build_facets([]) is a no-op that returns {} without querying — the escape hatch."""
|
||||||
|
from fastapi_toolsets.crud.search import build_facets
|
||||||
|
|
||||||
|
result = await build_facets(db_session, User, [])
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
class TestFacetsDirectColumn:
|
class TestFacetsDirectColumn:
|
||||||
"""Facets on direct model columns."""
|
"""Facets on direct model columns."""
|
||||||
@@ -606,6 +615,91 @@ class TestFacetsDirectColumn:
|
|||||||
assert "username" not in result.filter_attributes
|
assert "username" not in result.filter_attributes
|
||||||
|
|
||||||
|
|
||||||
|
class TestFacetsMixedTypes:
|
||||||
|
"""Facet values keep their native Python type through the batched query."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_enum_and_integer_facets_preserve_types(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Enum facets return member names (not raw DB values); Integer facets return ints."""
|
||||||
|
OrderMixedCrud = CrudFactory(
|
||||||
|
Order, facet_fields=[Order.status, Order.priority, Order.color]
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="order-1", status=OrderStatus.PENDING, priority=1, color=Color.RED
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="order-2", status=OrderStatus.SHIPPED, priority=3, color=Color.BLUE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderMixedCrud.offset_paginate(db_session, schema=OrderRead)
|
||||||
|
|
||||||
|
assert result.filter_attributes is not None
|
||||||
|
assert set(result.filter_attributes["status"]) == {"PENDING", "SHIPPED"}
|
||||||
|
assert all(isinstance(v, str) for v in result.filter_attributes["status"])
|
||||||
|
assert set(result.filter_attributes["priority"]) == {1, 3}
|
||||||
|
assert all(isinstance(v, int) for v in result.filter_attributes["priority"])
|
||||||
|
assert set(result.filter_attributes["color"]) == {"RED", "BLUE"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bool_facet_keeps_python_bool(self, db_session: AsyncSession):
|
||||||
|
"""A Boolean facet returns Python bool values, not stringified 'true'/'false'."""
|
||||||
|
UserBoolFacetCrud = CrudFactory(User, facet_fields=[User.is_active])
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="b@test.com", is_active=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserBoolFacetCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
|
assert result.filter_attributes is not None
|
||||||
|
assert set(result.filter_attributes["is_active"]) == {True, False}
|
||||||
|
assert all(isinstance(v, bool) for v in result.filter_attributes["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestIncludeFacets:
|
||||||
|
"""include_facets=False skips facet queries entirely."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_paginate_include_facets_false(self, db_session: AsyncSession):
|
||||||
|
"""filter_attributes is None when include_facets=False, even with facet_fields set."""
|
||||||
|
UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserFacetCrud.offset_paginate(
|
||||||
|
db_session, include_facets=False, schema=UserRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.filter_attributes is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_paginate_include_facets_false(self, db_session: AsyncSession):
|
||||||
|
"""filter_attributes is None when include_facets=False for cursor_paginate."""
|
||||||
|
UserFacetCursorCrud = CrudFactory(
|
||||||
|
User, cursor_column=User.id, facet_fields=[User.username]
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserFacetCursorCrud.cursor_paginate(
|
||||||
|
db_session, include_facets=False, schema=UserRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.filter_attributes is None
|
||||||
|
|
||||||
|
|
||||||
class TestFacetsRespectFilters:
|
class TestFacetsRespectFilters:
|
||||||
"""Facets reflect the active filter conditions."""
|
"""Facets reflect the active filter conditions."""
|
||||||
|
|
||||||
@@ -630,6 +724,28 @@ class TestFacetsRespectFilters:
|
|||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert result.filter_attributes["username"] == ["alice"]
|
assert result.filter_attributes["username"] == ["alice"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_array_facet_respects_unrelated_filter(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""An ARRAY facet is scoped by a filter on a different column (not self-collapse)."""
|
||||||
|
ArticleFacetCrud = CrudFactory(Article, facet_fields=[Article.labels])
|
||||||
|
await ArticleCrud.create(
|
||||||
|
db_session, ArticleCreate(title="Post 1", labels=["python", "fastapi"])
|
||||||
|
)
|
||||||
|
await ArticleCrud.create(
|
||||||
|
db_session, ArticleCreate(title="Post 2", labels=["rust", "axum"])
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await ArticleFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filters=[Article.title == "Post 1"],
|
||||||
|
schema=ArticleRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.filter_attributes is not None
|
||||||
|
assert result.filter_attributes["labels"] == ["fastapi", "python"]
|
||||||
|
|
||||||
|
|
||||||
class TestFacetsRelationship:
|
class TestFacetsRelationship:
|
||||||
"""Facets on relationship columns via tuple syntax."""
|
"""Facets on relationship columns via tuple syntax."""
|
||||||
@@ -785,8 +901,8 @@ class TestFilterBy:
|
|||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
assert result.data[0].username == "alice"
|
assert result.data[0].username == "alice"
|
||||||
# facet also scoped to the filter
|
# facet excludes its own filter_by condition, so it isn't collapsed
|
||||||
assert result.filter_attributes == {"username": ["alice"]}
|
assert result.filter_attributes == {"username": ["alice", "bob"]}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
|
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
|
||||||
@@ -924,7 +1040,7 @@ class TestFilterBy:
|
|||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
assert result.data[0].username == "alice"
|
assert result.data[0].username == "alice"
|
||||||
assert result.filter_attributes == {"username": ["alice"]}
|
assert result.filter_attributes == {"username": ["alice", "bob"]}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
|
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
|
||||||
@@ -1085,8 +1201,10 @@ class TestFilterBy:
|
|||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
titles = {a.title for a in result.data}
|
titles = {a.title for a in result.data}
|
||||||
assert titles == {"Post 1", "Post 3"}
|
assert titles == {"Post 1", "Post 3"}
|
||||||
# facet returns individual unnested values, not whole arrays
|
# facet excludes its own filter_by condition (not collapsed to matching rows)
|
||||||
assert result.filter_attributes == {"labels": ["django", "fastapi", "python"]}
|
assert result.filter_attributes == {
|
||||||
|
"labels": ["axum", "django", "fastapi", "python", "rust"]
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_array_overlap_list_value(self, db_session: AsyncSession):
|
async def test_array_overlap_list_value(self, db_session: AsyncSession):
|
||||||
@@ -2179,7 +2297,12 @@ class TestOffsetPaginateParamsSchema:
|
|||||||
include_total=False, search=False, filter=False, order=False
|
include_total=False, search=False, filter=False, order=False
|
||||||
)
|
)
|
||||||
result = await dep(page=2, items_per_page=10)
|
result = await dep(page=2, items_per_page=10)
|
||||||
assert result == {"page": 2, "items_per_page": 10, "include_total": False}
|
assert result == {
|
||||||
|
"page": 2,
|
||||||
|
"items_per_page": 10,
|
||||||
|
"include_total": False,
|
||||||
|
"include_facets": True,
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
||||||
@@ -2290,7 +2413,11 @@ class TestCursorPaginateParamsSchema:
|
|||||||
search=False, filter=False, order=False
|
search=False, filter=False, order=False
|
||||||
)
|
)
|
||||||
result = await dep(cursor=None, items_per_page=5)
|
result = await dep(cursor=None, items_per_page=5)
|
||||||
assert result == {"cursor": None, "items_per_page": 5}
|
assert result == {
|
||||||
|
"cursor": None,
|
||||||
|
"items_per_page": 5,
|
||||||
|
"include_facets": True,
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
||||||
@@ -2399,6 +2526,7 @@ class TestPaginateParamsSchema:
|
|||||||
"cursor": None,
|
"cursor": None,
|
||||||
"items_per_page": 10,
|
"items_per_page": 10,
|
||||||
"include_total": True,
|
"include_total": True,
|
||||||
|
"include_facets": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|||||||
@@ -199,8 +199,9 @@ class TestOffsetPagination:
|
|||||||
resp = await client.get("/articles/offset?status=published")
|
resp = await client.get("/articles/offset?status=published")
|
||||||
|
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
# draft is filtered out → should not appear in filter_attributes
|
# a facet excludes its own filter_by condition, so filtering by
|
||||||
assert "draft" not in body["filter_attributes"]["status"]
|
# status=published still shows every status the facet offers
|
||||||
|
assert "draft" in body["filter_attributes"]["status"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
||||||
|
|||||||
Reference in New Issue
Block a user