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."
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
!!! info "Added in `v1.3`"
+67 -27
View File
@@ -44,6 +44,7 @@ from ..types import (
)
from .search import (
SearchConfig,
apply_search_joins,
build_facets,
build_filter_by,
build_search_filters,
@@ -128,17 +129,6 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
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]):
"""Generic async CRUD operations for SQLAlchemy models.
@@ -265,12 +255,12 @@ class AsyncCrud(Generic[ModelType]):
cls: type[Self],
filter_by: dict[str, Any] | BaseModel | None,
facet_fields: Sequence[FacetFieldType] | None,
) -> tuple[list[Any], list[Any]]:
"""Normalize filter_by and return (filters, joins) to apply to the query."""
) -> tuple[dict[str, Any], list[Any]]:
"""Normalize filter_by and return ({facet_key: filter}, joins) to apply to the query."""
if isinstance(filter_by, BaseModel):
filter_by = filter_by.model_dump(exclude_none=True)
if not filter_by:
return [], []
return {}, []
resolved = cls._resolve_facet_fields(facet_fields)
return build_filter_by(filter_by, resolved or [])
@@ -281,8 +271,13 @@ class AsyncCrud(Generic[ModelType]):
facet_fields: Sequence[FacetFieldType] | None,
filters: list[Any],
search_joins: list[Any],
*,
include_facets: bool = True,
own_filters: dict[str, Any] | None = 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)
if not resolved:
return None
@@ -292,6 +287,7 @@ class AsyncCrud(Generic[ModelType]):
resolved,
base_filters=filters,
base_joins=search_joins,
own_filters=own_filters,
)
@classmethod
@@ -475,6 +471,7 @@ class AsyncCrud(Generic[ModelType]):
default_page_size: int = 20,
max_page_size: int = 100,
include_total: bool = True,
include_facets: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
@@ -490,6 +487,7 @@ class AsyncCrud(Generic[ModelType]):
default_page_size: Default ``items_per_page`` value.
max_page_size: Maximum ``items_per_page`` value.
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.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
@@ -519,7 +517,10 @@ class AsyncCrud(Generic[ModelType]):
]
return cls._build_paginate_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",
search=search,
filter=filter,
@@ -537,6 +538,7 @@ class AsyncCrud(Generic[ModelType]):
*,
default_page_size: int = 20,
max_page_size: int = 100,
include_facets: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
@@ -551,6 +553,7 @@ class AsyncCrud(Generic[ModelType]):
Args:
default_page_size: Default ``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.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
@@ -582,7 +585,7 @@ class AsyncCrud(Generic[ModelType]):
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={},
pagination_fixed={"include_facets": include_facets},
dep_name=f"{cls.model.__name__}CursorPaginateParams",
search=search,
filter=filter,
@@ -602,6 +605,7 @@ class AsyncCrud(Generic[ModelType]):
max_page_size: int = 100,
default_pagination_type: PaginationType = PaginationType.OFFSET,
include_total: bool = True,
include_facets: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
@@ -618,6 +622,7 @@ class AsyncCrud(Generic[ModelType]):
max_page_size: Maximum ``items_per_page`` value.
default_pagination_type: Default pagination strategy.
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.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
@@ -666,7 +671,10 @@ class AsyncCrud(Generic[ModelType]):
]
return cls._build_paginate_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",
search=search,
filter=filter,
@@ -1271,6 +1279,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
include_facets: bool = True,
filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel],
) -> OffsetPaginatedResponse[Any]:
@@ -1292,6 +1301,9 @@ class AsyncCrud(Generic[ModelType]):
search_column: Restrict search to a single column key.
order_fields: Fields allowed for sorting (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.
Keys must match the column.key of a facet field. Scalar → equality,
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
@@ -1304,7 +1316,6 @@ class AsyncCrud(Generic[ModelType]):
offset = (page - 1) * items_per_page
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
filters.extend(fb_filters)
# Build search filters
if search:
@@ -1318,6 +1329,11 @@ class AsyncCrud(Generic[ModelType]):
filters.extend(search_filters)
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
q = select(cls.model)
@@ -1325,11 +1341,11 @@ class AsyncCrud(Generic[ModelType]):
q = _apply_joins(q, joins, outer_join)
# 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)
if order_joins:
q = _apply_search_joins(q, order_joins)
q = apply_search_joins(q, order_joins)
if filters:
q = q.where(and_(*filters))
@@ -1352,7 +1368,7 @@ class AsyncCrud(Generic[ModelType]):
count_q = _apply_joins(count_q, joins, outer_join)
# 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:
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]
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)
order_columns = cls._resolve_order_columns(order_fields)
@@ -1408,6 +1429,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
include_facets: bool = True,
filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel],
) -> CursorPaginatedResponse[Any]:
@@ -1430,6 +1452,8 @@ class AsyncCrud(Generic[ModelType]):
search_column: Restrict search to a single column key.
order_fields: Fields allowed for sorting (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.
Keys must match the column.key of a facet field. Scalar → equality,
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
@@ -1441,7 +1465,6 @@ class AsyncCrud(Generic[ModelType]):
filters = list(filters) if filters else []
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
filters.extend(fb_filters)
if cls.cursor_column is None:
raise ValueError(
@@ -1473,6 +1496,11 @@ class AsyncCrud(Generic[ModelType]):
filters.extend(search_filters)
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
q = select(cls.model)
@@ -1480,11 +1508,11 @@ class AsyncCrud(Generic[ModelType]):
q = _apply_joins(q, joins, outer_join)
# 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)
if order_joins:
q = _apply_search_joins(q, order_joins)
q = apply_search_joins(q, order_joins)
if 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]
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)
order_columns = cls._resolve_order_columns(order_fields)
@@ -1581,6 +1614,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = ...,
order_fields: Sequence[OrderFieldType] | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ...,
include_facets: bool = ...,
filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel],
) -> OffsetPaginatedResponse[Any]: ...
@@ -1607,6 +1641,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = ...,
order_fields: Sequence[OrderFieldType] | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ...,
include_facets: bool = ...,
filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel],
) -> CursorPaginatedResponse[Any]: ...
@@ -1632,6 +1667,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
include_facets: bool = True,
filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel],
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
@@ -1662,6 +1698,8 @@ class AsyncCrud(Generic[ModelType]):
order_fields: Fields allowed for sorting (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. Keys must match the ``column.key`` of a facet
field. Scalar → equality, list → IN clause. Raises
@@ -1692,6 +1730,7 @@ class AsyncCrud(Generic[ModelType]):
search_column=search_column,
order_fields=order_fields,
facet_fields=facet_fields,
include_facets=include_facets,
filter_by=filter_by,
schema=schema,
)
@@ -1714,6 +1753,7 @@ class AsyncCrud(Generic[ModelType]):
search_column=search_column,
order_fields=order_fields,
facet_fields=facet_fields,
include_facets=include_facets,
filter_by=filter_by,
schema=schema,
)
+82 -58
View File
@@ -1,12 +1,12 @@
"""Search utilities for AsyncCrud."""
import asyncio
import functools
from collections.abc import Sequence
from dataclasses import dataclass, replace
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.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -181,6 +181,21 @@ def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
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]:
"""Return a key for each facet field.
@@ -207,6 +222,7 @@ async def build_facets(
*,
base_filters: "list[ColumnElement[bool]] | None" = None,
base_joins: list[InstrumentedAttribute[Any]] | None = None,
own_filters: "dict[str, ColumnElement[bool]] | None" = None,
) -> dict[str, list[Any]]:
"""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
base_filters: Filter conditions already applied to the main query (search + caller filters)
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:
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)
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):
# Relationship chain: (User.role, Role.name) — last element is the column
rels = field[:-1]
@@ -235,51 +260,48 @@ async def build_facets(
col_type = column.property.columns[0].type
is_array = isinstance(col_type, ARRAY)
enum_classes[key] = getattr(col_type, "enum_class", None)
if is_array:
unnested = func.unnest(column).label(column.key)
q = select(unnested).select_from(model).distinct()
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
filters = [
*(base_filters or []),
*(f for k, f in own_filters.items() if k != key),
]
return key, values
joins = [*(base_joins or []), *rels]
pairs = await asyncio.gather(
*[_query_facet(f, k) for f, k in zip(facet_fields, keys)]
if is_array:
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)
@@ -301,7 +323,7 @@ def _coerce_bool(value: Any) -> bool:
def build_filter_by(
filter_by: dict[str, Any],
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.
Args:
@@ -309,7 +331,9 @@ def build_filter_by(
facet_fields: Declared facet fields to validate keys against
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:
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)
valid_keys = set(index)
filters: list[ColumnElement[bool]] = []
filters: dict[str, ColumnElement[bool]] = {}
joins: list[InstrumentedAttribute[Any]] = []
added_join_keys: set[str] = set()
@@ -347,14 +371,14 @@ def build_filter_by(
if isinstance(col_type, Boolean):
coerce = _coerce_bool
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:
filters.append(column == coerce(value))
filters[key] = column == coerce(value)
elif isinstance(col_type, ARRAY):
if isinstance(value, list):
filters.append(column.overlap(value))
filters[key] = column.overlap(value)
else:
filters.append(column.any(value))
filters[key] = column.any(value)
elif isinstance(col_type, Enum):
enum_class = col_type.enum_class
if enum_class is not None:
@@ -365,19 +389,19 @@ def build_filter_by(
return enum_class[v] # lookup by name: "PENDING", "RED"
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:
filters.append(column == _coerce_enum(value))
filters[key] = column == _coerce_enum(value)
else: # pragma: no cover
if isinstance(value, list):
filters.append(column.in_(value))
filters[key] = column.in_(value)
else:
filters.append(column == value)
filters[key] = column == value
elif isinstance(col_type, _EQUALITY_TYPES):
if isinstance(value, list):
filters.append(column.in_(value))
filters[key] = column.in_(value)
else:
filters.append(column == value)
filters[key] = column == value
else:
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
+135 -7
View File
@@ -531,6 +531,15 @@ class TestFacetsNotSet:
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:
"""Facets on direct model columns."""
@@ -606,6 +615,91 @@ class TestFacetsDirectColumn:
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:
"""Facets reflect the active filter conditions."""
@@ -630,6 +724,28 @@ class TestFacetsRespectFilters:
assert result.filter_attributes is not None
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:
"""Facets on relationship columns via tuple syntax."""
@@ -785,8 +901,8 @@ class TestFilterBy:
assert len(result.data) == 1
assert result.data[0].username == "alice"
# facet also scoped to the filter
assert result.filter_attributes == {"username": ["alice"]}
# facet excludes its own filter_by condition, so it isn't collapsed
assert result.filter_attributes == {"username": ["alice", "bob"]}
@pytest.mark.anyio
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
@@ -924,7 +1040,7 @@ class TestFilterBy:
assert len(result.data) == 1
assert result.data[0].username == "alice"
assert result.filter_attributes == {"username": ["alice"]}
assert result.filter_attributes == {"username": ["alice", "bob"]}
@pytest.mark.anyio
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
@@ -1085,8 +1201,10 @@ class TestFilterBy:
assert result.pagination.total_count == 2
titles = {a.title for a in result.data}
assert titles == {"Post 1", "Post 3"}
# facet returns individual unnested values, not whole arrays
assert result.filter_attributes == {"labels": ["django", "fastapi", "python"]}
# facet excludes its own filter_by condition (not collapsed to matching rows)
assert result.filter_attributes == {
"labels": ["axum", "django", "fastapi", "python", "rust"]
}
@pytest.mark.anyio
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
)
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
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
@@ -2290,7 +2413,11 @@ class TestCursorPaginateParamsSchema:
search=False, filter=False, order=False
)
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
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
@@ -2399,6 +2526,7 @@ class TestPaginateParamsSchema:
"cursor": None,
"items_per_page": 10,
"include_total": True,
"include_facets": True,
}
@pytest.mark.anyio
+3 -2
View File
@@ -199,8 +199,9 @@ class TestOffsetPagination:
resp = await client.get("/articles/offset?status=published")
body = resp.json()
# draft is filtered out → should not appear in filter_attributes
assert "draft" not in body["filter_attributes"]["status"]
# a facet excludes its own filter_by condition, so filtering by
# status=published still shows every status the facet offers
assert "draft" in body["filter_attributes"]["status"]
@pytest.mark.anyio
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):