Skip to content
2 changes: 1 addition & 1 deletion docs/module/crud.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ 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).
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 400). Values are converted to the column type, so query strings like `"42"` or `"2026-01-02"` work on integer and date columns; a value that does not convert, or does not fit the column (an integer out of range, a datetime with a UTC offset on a column without a timezone, or the reverse), raises [`InvalidFacetValueError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetValueError) (HTTP 400).

#### Skipping facet queries

Expand Down
1 change: 1 addition & 0 deletions docs/module/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ It also patches `app.openapi()` to replace the default Pydantic 422 schema with
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No Searchable Fields |
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid Facet Filter |
| [`InvalidFacetValueError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetValueError) | 400 | Invalid Facet Value |
| [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) | 422 | Invalid Order Field |
| [`PoolExhaustedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.PoolExhaustedError) | 503 | Service Unavailable |
| [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) | 503 | Service Unavailable |
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ from fastapi_toolsets.exceptions import (
NoSearchableFieldsError,
InvalidSearchColumnError,
InvalidFacetFilterError,
InvalidFacetValueError,
InvalidOrderFieldError,
PoolExhaustedError,
LockTimeoutError,
Expand Down Expand Up @@ -45,6 +46,8 @@ from fastapi_toolsets.exceptions import (

## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError

## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetValueError

## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError

## ::: fastapi_toolsets.exceptions.exceptions.PoolExhaustedError
Expand Down
18 changes: 17 additions & 1 deletion src/fastapi_toolsets/_orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,31 @@
from collections.abc import Iterable, Sequence
from typing import Any

from sqlalchemy import Table, tuple_
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import tuple_
from sqlalchemy.orm import Mapper


def key_expr(columns: Sequence[Any]) -> Any:
"""*columns* as one comparable expression: the column, or a tuple of them."""
return columns[0] if len(columns) == 1 else tuple_(*columns)


def joined_tables(targets: Iterable[Any]) -> list[Any]:
"""Tables that joining *targets*, mapped classes or tables, brings in.

An aliased target is left out: it can never repeat a table.
"""
tables: list[Any] = []
for target in targets:
info = sa_inspect(target, raiseerr=False)
if isinstance(info, Mapper):
tables.extend(info.tables)
elif isinstance(info, Table):
tables.append(info)
return tables


def is_to_many(rels: Iterable[Any]) -> bool:
"""True if the relationship path *rels* crosses a collection."""
return any(rel.property.uselist for rel in rels)
Expand Down
2 changes: 2 additions & 0 deletions src/fastapi_toolsets/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from ..exceptions import (
InvalidFacetFilterError,
InvalidFacetValueError,
InvalidSearchColumnError,
NoSearchableFieldsError,
UnsupportedFacetTypeError,
Expand All @@ -23,6 +24,7 @@
"CrudFactory",
"FacetFieldType",
"InvalidFacetFilterError",
"InvalidFacetValueError",
"InvalidSearchColumnError",
"JoinType",
"M2MFieldType",
Expand Down
101 changes: 54 additions & 47 deletions src/fastapi_toolsets/crud/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@
from sqlalchemy.sql.roles import WhereHavingRole
from sqlalchemy.sql.util import ClauseAdapter

from .._orm import is_expired, is_to_many, key_expr, loaded_relationships
from .._orm import (
is_expired,
is_to_many,
joined_tables,
key_expr,
loaded_relationships,
)
from ..db import transaction
from ..exceptions import InvalidOrderFieldError, NotFoundError
from ..schemas import (
Expand Down Expand Up @@ -487,38 +493,34 @@ def _query_plan(
filter_by: dict[str, Any] | BaseModel | None,
facet_fields: Sequence[FacetFieldType] | None,
order_joins: Sequence[Any] | None,
joins: JoinType | None,
) -> _QueryPlan:
"""Combine the caller's filters, the search and ``filter_by`` into a plan.

Facets combine the base filters with each facet's own filter
individually, so ``fb_filters`` is left out of ``facet_base``.
"""
filters = list(filters) if filters else []
order_joins = order_joins or ()
other_joins = [
*(order_joins or ()),
*joined_tables(target for target, _ in joins or ()),
]
fb_filters, fb_joins = cls._prepare_filter_by(
filter_by, facet_fields, other_joins=order_joins
filter_by, facet_fields, other_joins=other_joins
)
plan = (
build_search_plan(
cls.model,
search,
search_fields,
cls.searchable_fields,
search_column,
other_joins=[*order_joins, *fb_joins],
)
if search
else None
plan = build_search_plan(
cls.model,
search or "",
search_fields,
cls.searchable_fields,
search_column,
other_joins=[*other_joins, *fb_joins],
)
return _QueryPlan(
filters=[
*filters,
*(plan.page_filters if plan else []),
*fb_filters.values(),
],
joins=[*fb_joins, *(plan.page_joins if plan else [])],
facet_base=[*filters, *(plan.agg_filters if plan else [])],
search_joins=plan.agg_joins if plan else [],
filters=[*filters, *plan.page_filters, *fb_filters.values()],
joins=[*fb_joins, *plan.page_joins],
facet_base=[*filters, *plan.agg_filters],
search_joins=plan.agg_joins,
fb_filters=fb_filters,
fb_joins=fb_joins,
)
Expand Down Expand Up @@ -696,6 +698,11 @@ def _build_paginate_params(
)
reserved_names.update({"search", "search_column"})

# Reserved before the facets are checked, though added after them.
resolved_order = cls._resolve_order_fields(order_fields) if order else None
if resolved_order:
reserved_names.update({"order_by", "order"})

filter_keys: list[str] | None = None
if filter:
resolved_facets = cls._resolve_facet_fields(facet_fields)
Expand All @@ -720,32 +727,30 @@ def _build_paginate_params(

order_field_map: dict[str, OrderFieldType] | None = None
order_valid_keys: list[str] | None = None
if order:
resolved_order = cls._resolve_order_fields(order_fields)
if resolved_order:
keys = facet_keys(resolved_order)
order_field_map = dict(zip(keys, resolved_order))
order_valid_keys = sorted(order_field_map.keys())
all_params.extend(
[
inspect.Parameter(
"order_by",
inspect.Parameter.KEYWORD_ONLY,
annotation=str | None,
default=Query(
None,
description=f"Field to order by. Valid values: {order_valid_keys}",
json_schema_extra={"enum": order_valid_keys},
),
),
inspect.Parameter(
"order",
inspect.Parameter.KEYWORD_ONLY,
annotation=Literal["asc", "desc"],
default=Query(default_order, description="Sort direction"),
if resolved_order:
keys = facet_keys(resolved_order)
order_field_map = dict(zip(keys, resolved_order))
order_valid_keys = sorted(order_field_map.keys())
all_params.extend(
[
inspect.Parameter(
"order_by",
inspect.Parameter.KEYWORD_ONLY,
annotation=str | None,
default=Query(
None,
description=f"Field to order by. Valid values: {order_valid_keys}",
json_schema_extra={"enum": order_valid_keys},
),
]
)
),
inspect.Parameter(
"order",
inspect.Parameter.KEYWORD_ONLY,
annotation=Literal["asc", "desc"],
default=Query(default_order, description="Sort direction"),
),
]
)

fixed: dict[str, Any] = {
**pagination_fixed,
Expand Down Expand Up @@ -1679,6 +1684,7 @@ async def offset_paginate(
filter_by=filter_by,
facet_fields=facet_fields,
order_joins=order_joins,
joins=joins,
)
q = select(cls.model)
q = _apply_joins(q, joins, outer_join)
Expand Down Expand Up @@ -1811,6 +1817,7 @@ async def cursor_paginate(
filter_by=filter_by,
facet_fields=facet_fields,
order_joins=order_joins,
joins=joins,
)
# Rows sharing a cursor value are ordered, and resumed, by primary key.
tiebreak = cls._tiebreak_attrs(cursor_column)
Expand Down
Loading
Loading