refactor: batch facet queries into a single round trip

This commit is contained in:
2026-07-26 15:49:58 +02:00
committed by d3vyce
parent c15aee22b0
commit 19a823dc8b
5 changed files with 298 additions and 95 deletions
+10
View File
@@ -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`"
+67 -27
View File
@@ -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,
) )
+83 -59
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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):