Compare commits

..

4 Commits

Author SHA1 Message Date
6e999985c0 fix: cleanup + simplify 2026-04-04 08:47:42 -04:00
c3d1fe977d docs: add authentication example 2026-04-04 08:47:42 -04:00
92036d6b88 feat(security): add oauth helpers 2026-04-04 08:47:42 -04:00
ba6c267897 feat: add security module 2026-04-04 08:47:42 -04:00
7 changed files with 111 additions and 369 deletions

View File

@@ -324,12 +324,6 @@ result = await UserCrud.offset_paginate(
) )
``` ```
Or via the dependency to narrow which fields are exposed as query parameters:
```python
params = UserCrud.offset_paginate_params(search_fields=[Post.title])
```
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate): This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
```python ```python
@@ -350,37 +344,13 @@ async def get_users(
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead) return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
``` ```
The dependency adds two query parameters to the endpoint:
| Parameter | Type |
| --------------- | ------------- |
| `search` | `str \| null` |
| `search_column` | `str \| null` |
```
GET /posts?search=hello → search all configured columns
GET /posts?search=hello&search_column=title → search only Post.title
```
The available search column keys are returned in the `search_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a column picker in the UI, or to validate `search_column` values on the client side:
```json
{
"status": "SUCCESS",
"data": ["..."],
"pagination": { "..." },
"search_columns": ["content", "author__username", "title"]
}
```
!!! info "Key format uses `__` as a separator for relationship chains."
A direct column `Post.title` produces `"title"`. A relationship tuple `(Post.author, User.username)` produces `"author__username"`. An unknown `search_column` value raises [`InvalidSearchColumnError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError) (HTTP 422).
### Faceted search ### Faceted search
!!! info "Added in `v1.2`" !!! info "Added in `v1.2`"
Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields`: Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs.
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
```python ```python
UserCrud = CrudFactory( UserCrud = CrudFactory(
@@ -402,47 +372,7 @@ result = await UserCrud.offset_paginate(
) )
``` ```
Or via the dependency to narrow which fields are exposed as query parameters: The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
```python
params = UserCrud.offset_paginate_params(facet_fields=[User.country])
```
Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), each facet field is exposed as a query parameter and values are collected into `filter_by` automatically:
```python
from typing import Annotated
from fastapi import Depends
@router.get("", response_model_exclude_none=True)
async def list_users(
session: SessionDep,
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
```
```python
@router.get("", response_model_exclude_none=True)
async def list_users(
session: SessionDep,
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
) -> CursorPaginatedResponse[UserRead]:
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
```
Both single-value and multi-value query parameters work:
```
GET /users?status=active → filter_by={"status": ["active"]}
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
```
`filter_by` and `filters` can be combined — both are applied with AND logic.
The distinct values for each facet field are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate filter dropdowns in the UI, or to validate `filter_by` keys on the client side:
```json ```json
{ {
@@ -457,14 +387,50 @@ The distinct values for each facet field are returned in the `filter_attributes`
} }
``` ```
!!! info "Key format uses `__` as a separator for relationship chains." Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
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).
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
Keys use `__` as a separator for the full relationship chain. 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"`.
`filter_by` and `filters` can be combined — both are applied with AND logic.
Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), facet fields are exposed as query parameters and collected into `filter_by` automatically:
```python
from typing import Annotated
from fastapi import Depends
UserCrud = CrudFactory(
model=User,
facet_fields=[User.status, User.country, (User.role, Role.name)],
)
@router.get("", response_model_exclude_none=True)
async def list_users(
session: SessionDep,
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(
session=session,
**params,
schema=UserRead,
)
```
Both single-value and multi-value query parameters work:
```
GET /users?status=active → filter_by={"status": ["active"]}
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
```
## Sorting ## Sorting
!!! info "Added in `v1.3`" !!! info "Added in `v1.3`"
Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`: Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
```python ```python
UserCrud = CrudFactory( UserCrud = CrudFactory(
@@ -472,27 +438,11 @@ UserCrud = CrudFactory(
order_fields=[ order_fields=[
User.name, User.name,
User.created_at, User.created_at,
(User.role, Role.name), # sort by a related model column
], ],
) )
``` ```
You can override `order_fields` per call: Ordering is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically:
```python
result = await UserCrud.offset_paginate(
session=session,
order_fields=[User.name],
)
```
Or via the dependency to narrow which fields are exposed as query parameters:
```python
params = UserCrud.offset_paginate_params(order_fields=[User.name])
```
Sorting is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically:
```python ```python
from typing import Annotated from typing import Annotated
@@ -502,50 +452,33 @@ from fastapi import Depends
@router.get("") @router.get("")
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())], params: Annotated[dict, Depends(UserCrud.offset_paginate_params(
default_order_field=User.created_at,
))],
) -> OffsetPaginatedResponse[UserRead]: ) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead) return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
``` ```
```python
@router.get("")
async def list_users(
session: SessionDep,
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
) -> CursorPaginatedResponse[UserRead]:
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
```
The dependency adds two query parameters to the endpoint: The dependency adds two query parameters to the endpoint:
| Parameter | Type | | Parameter | Type |
| ---------- | --------------- | | ---------- | --------------- |
| `order_by` | `str \| null` | | `order_by` | `str | null` |
| `order` | `asc` or `desc` | | `order` | `asc` or `desc` |
``` ```
GET /users?order_by=name&order=asc → ORDER BY users.name ASC GET /users?order_by=name&order=asc → ORDER BY users.name ASC
GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC GET /users?order_by=name&order=desc → ORDER BY users.name DESC
``` ```
!!! info "Relationship tuples are joined automatically." An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
When a relation field is selected, the related table is LEFT OUTER JOINed automatically. An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
You can also pass `order_fields` directly to override the class-level defaults:
The available sort keys are returned in the `order_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a sort picker in the UI, or to validate `order_by` values on the client side: ```python
params = UserCrud.offset_paginate_params(order_fields=[User.name])
```json
{
"status": "SUCCESS",
"data": ["..."],
"pagination": { "..." },
"order_columns": ["created_at", "name", "role__name"]
}
``` ```
!!! info "Key format uses `__` as a separator for relationship chains."
A direct column `User.name` produces `"name"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`.
## Relationship loading ## Relationship loading
!!! info "Added in `v1.1`" !!! info "Added in `v1.1`"

View File

@@ -12,7 +12,6 @@ from ..types import (
JoinType, JoinType,
M2MFieldType, M2MFieldType,
OrderByClause, OrderByClause,
OrderFieldType,
SearchFieldType, SearchFieldType,
) )
from .factory import AsyncCrud, CrudFactory from .factory import AsyncCrud, CrudFactory
@@ -29,7 +28,6 @@ __all__ = [
"M2MFieldType", "M2MFieldType",
"NoSearchableFieldsError", "NoSearchableFieldsError",
"OrderByClause", "OrderByClause",
"OrderFieldType",
"PaginationType", "PaginationType",
"SearchConfig", "SearchConfig",
"SearchFieldType", "SearchFieldType",

View File

@@ -38,7 +38,6 @@ from ..types import (
M2MFieldType, M2MFieldType,
ModelType, ModelType,
OrderByClause, OrderByClause,
OrderFieldType,
SchemaType, SchemaType,
SearchFieldType, SearchFieldType,
) )
@@ -135,7 +134,7 @@ class AsyncCrud(Generic[ModelType]):
model: ClassVar[type[DeclarativeBase]] model: ClassVar[type[DeclarativeBase]]
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
order_fields: ClassVar[Sequence[OrderFieldType] | None] = None order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
m2m_fields: ClassVar[M2MFieldType | None] = None m2m_fields: ClassVar[M2MFieldType | None] = None
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
cursor_column: ClassVar[Any | None] = None cursor_column: ClassVar[Any | None] = None
@@ -170,18 +169,6 @@ class AsyncCrud(Generic[ModelType]):
return load_options return load_options
return cls.default_load_options return cls.default_load_options
@classmethod
async def _reload_with_options(
cls: type[Self], session: AsyncSession, instance: ModelType
) -> ModelType:
"""Re-query instance by PK with default_load_options applied."""
mapper = cls.model.__mapper__
pk_filters = [
getattr(cls.model, col.key) == getattr(instance, col.key)
for col in mapper.primary_key
]
return await cls.get(session, filters=pk_filters)
@classmethod @classmethod
async def _resolve_m2m( async def _resolve_m2m(
cls: type[Self], cls: type[Self],
@@ -292,15 +279,15 @@ class AsyncCrud(Generic[ModelType]):
return search_field_keys(fields) return search_field_keys(fields)
@classmethod @classmethod
def _resolve_order_columns( def _resolve_sort_columns(
cls: type[Self], cls: type[Self],
order_fields: Sequence[OrderFieldType] | None, order_fields: Sequence[QueryableAttribute[Any]] | None,
) -> list[str] | None: ) -> list[str] | None:
"""Return sort column keys, or None if no order fields configured.""" """Return sort column keys, or None if no order fields configured."""
fields = order_fields if order_fields is not None else cls.order_fields fields = order_fields if order_fields is not None else cls.order_fields
if not fields: if not fields:
return None return None
return sorted(facet_keys(fields)) return sorted(f.key for f in fields)
@classmethod @classmethod
def _build_paginate_params( def _build_paginate_params(
@@ -314,7 +301,7 @@ class AsyncCrud(Generic[ModelType]):
order: bool, order: bool,
search_fields: Sequence[SearchFieldType] | None, search_fields: Sequence[SearchFieldType] | None,
facet_fields: Sequence[FacetFieldType] | None, facet_fields: Sequence[FacetFieldType] | None,
order_fields: Sequence[OrderFieldType] | None, order_fields: Sequence[QueryableAttribute[Any]] | None,
default_order_field: QueryableAttribute[Any] | None, default_order_field: QueryableAttribute[Any] | None,
default_order: Literal["asc", "desc"], default_order: Literal["asc", "desc"],
) -> Callable[..., Awaitable[dict[str, Any]]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
@@ -373,15 +360,14 @@ class AsyncCrud(Generic[ModelType]):
) )
reserved_names.update(filter_keys) reserved_names.update(filter_keys)
order_field_map: dict[str, OrderFieldType] | None = None order_field_map: dict[str, QueryableAttribute[Any]] | None = None
order_valid_keys: list[str] | None = None order_valid_keys: list[str] | None = None
if order: if order:
resolved_order = ( resolved_order = (
order_fields if order_fields is not None else cls.order_fields order_fields if order_fields is not None else cls.order_fields
) )
if resolved_order: if resolved_order:
keys = facet_keys(resolved_order) order_field_map = {f.key: f for f in resolved_order}
order_field_map = dict(zip(keys, resolved_order))
order_valid_keys = sorted(order_field_map.keys()) order_valid_keys = sorted(order_field_map.keys())
all_params.extend( all_params.extend(
[ [
@@ -433,13 +419,6 @@ class AsyncCrud(Generic[ModelType]):
else: else:
field = order_field_map[order_by_val] field = order_field_map[order_by_val]
if field is not None: if field is not None:
if isinstance(field, tuple):
col = field[-1]
result["order_by"] = (
col.asc() if order_dir == "asc" else col.desc()
)
result["order_joins"] = list(field[:-1])
else:
result["order_by"] = ( result["order_by"] = (
field.asc() if order_dir == "asc" else field.desc() field.asc() if order_dir == "asc" else field.desc()
) )
@@ -466,7 +445,7 @@ class AsyncCrud(Generic[ModelType]):
order: bool = True, order: bool = True,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
default_order_field: QueryableAttribute[Any] | None = None, default_order_field: QueryableAttribute[Any] | None = None,
default_order: Literal["asc", "desc"] = "asc", default_order: Literal["asc", "desc"] = "asc",
) -> Callable[..., Awaitable[dict[str, Any]]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
@@ -528,7 +507,7 @@ class AsyncCrud(Generic[ModelType]):
order: bool = True, order: bool = True,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
default_order_field: QueryableAttribute[Any] | None = None, default_order_field: QueryableAttribute[Any] | None = None,
default_order: Literal["asc", "desc"] = "asc", default_order: Literal["asc", "desc"] = "asc",
) -> Callable[..., Awaitable[dict[str, Any]]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
@@ -593,7 +572,7 @@ class AsyncCrud(Generic[ModelType]):
order: bool = True, order: bool = True,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
default_order_field: QueryableAttribute[Any] | None = None, default_order_field: QueryableAttribute[Any] | None = None,
default_order: Literal["asc", "desc"] = "asc", default_order: Literal["asc", "desc"] = "asc",
) -> Callable[..., Awaitable[dict[str, Any]]]: ) -> Callable[..., Awaitable[dict[str, Any]]]:
@@ -717,8 +696,6 @@ class AsyncCrud(Generic[ModelType]):
session.add(db_model) session.add(db_model)
await session.refresh(db_model) await session.refresh(db_model)
if cls.default_load_options:
db_model = await cls._reload_with_options(session, db_model)
result = cast(ModelType, db_model) result = cast(ModelType, db_model)
if schema: if schema:
return Response(data=schema.model_validate(result)) return Response(data=schema.model_validate(result))
@@ -1074,8 +1051,6 @@ class AsyncCrud(Generic[ModelType]):
for rel_attr, related_instances in m2m_resolved.items(): for rel_attr, related_instances in m2m_resolved.items():
setattr(db_model, rel_attr, related_instances) setattr(db_model, rel_attr, related_instances)
await session.refresh(db_model) await session.refresh(db_model)
if cls.default_load_options:
db_model = await cls._reload_with_options(session, db_model)
if schema: if schema:
return Response(data=schema.model_validate(db_model)) return Response(data=schema.model_validate(db_model))
return db_model return db_model
@@ -1238,14 +1213,13 @@ class AsyncCrud(Generic[ModelType]):
outer_join: bool = False, outer_join: bool = False,
load_options: Sequence[ExecutableOption] | None = None, load_options: Sequence[ExecutableOption] | None = None,
order_by: OrderByClause | None = None, order_by: OrderByClause | None = None,
order_joins: list[Any] | None = None,
page: int = 1, page: int = 1,
items_per_page: int = 20, items_per_page: int = 20,
include_total: bool = True, include_total: bool = True,
search: str | SearchConfig | None = None, search: str | SearchConfig | None = None,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
search_column: str | None = None, search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
filter_by: dict[str, Any] | BaseModel | None = None, filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1303,10 +1277,6 @@ class AsyncCrud(Generic[ModelType]):
# 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)
if order_joins:
q = _apply_search_joins(q, order_joins)
if filters: if filters:
q = q.where(and_(*filters)) q = q.where(and_(*filters))
if resolved := cls._resolve_load_options(load_options): if resolved := cls._resolve_load_options(load_options):
@@ -1351,7 +1321,7 @@ class AsyncCrud(Generic[ModelType]):
session, facet_fields, filters, search_joins session, facet_fields, filters, search_joins
) )
search_columns = cls._resolve_search_columns(search_fields) search_columns = cls._resolve_search_columns(search_fields)
order_columns = cls._resolve_order_columns(order_fields) sort_columns = cls._resolve_sort_columns(order_fields)
return OffsetPaginatedResponse( return OffsetPaginatedResponse(
data=items, data=items,
@@ -1363,7 +1333,7 @@ class AsyncCrud(Generic[ModelType]):
), ),
filter_attributes=filter_attributes, filter_attributes=filter_attributes,
search_columns=search_columns, search_columns=search_columns,
order_columns=order_columns, sort_columns=sort_columns,
) )
@classmethod @classmethod
@@ -1377,12 +1347,11 @@ class AsyncCrud(Generic[ModelType]):
outer_join: bool = False, outer_join: bool = False,
load_options: Sequence[ExecutableOption] | None = None, load_options: Sequence[ExecutableOption] | None = None,
order_by: OrderByClause | None = None, order_by: OrderByClause | None = None,
order_joins: list[Any] | None = None,
items_per_page: int = 20, items_per_page: int = 20,
search: str | SearchConfig | None = None, search: str | SearchConfig | None = None,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
search_column: str | None = None, search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
filter_by: dict[str, Any] | BaseModel | None = None, filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1458,10 +1427,6 @@ class AsyncCrud(Generic[ModelType]):
# 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)
if order_joins:
q = _apply_search_joins(q, order_joins)
if filters: if filters:
q = q.where(and_(*filters)) q = q.where(and_(*filters))
if resolved := cls._resolve_load_options(load_options): if resolved := cls._resolve_load_options(load_options):
@@ -1520,7 +1485,7 @@ class AsyncCrud(Generic[ModelType]):
session, facet_fields, filters, search_joins session, facet_fields, filters, search_joins
) )
search_columns = cls._resolve_search_columns(search_fields) search_columns = cls._resolve_search_columns(search_fields)
order_columns = cls._resolve_order_columns(order_fields) sort_columns = cls._resolve_sort_columns(order_fields)
return CursorPaginatedResponse( return CursorPaginatedResponse(
data=items, data=items,
@@ -1532,7 +1497,7 @@ class AsyncCrud(Generic[ModelType]):
), ),
filter_attributes=filter_attributes, filter_attributes=filter_attributes,
search_columns=search_columns, search_columns=search_columns,
order_columns=order_columns, sort_columns=sort_columns,
) )
@overload @overload
@@ -1547,7 +1512,6 @@ class AsyncCrud(Generic[ModelType]):
outer_join: bool = ..., outer_join: bool = ...,
load_options: Sequence[ExecutableOption] | None = ..., load_options: Sequence[ExecutableOption] | None = ...,
order_by: OrderByClause | None = ..., order_by: OrderByClause | None = ...,
order_joins: list[Any] | None = ...,
page: int = ..., page: int = ...,
cursor: str | None = ..., cursor: str | None = ...,
items_per_page: int = ..., items_per_page: int = ...,
@@ -1555,7 +1519,7 @@ class AsyncCrud(Generic[ModelType]):
search: str | SearchConfig | None = ..., search: str | SearchConfig | None = ...,
search_fields: Sequence[SearchFieldType] | None = ..., search_fields: Sequence[SearchFieldType] | None = ...,
search_column: str | None = ..., search_column: str | None = ...,
order_fields: Sequence[OrderFieldType] | None = ..., order_fields: Sequence[QueryableAttribute[Any]] | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ...,
filter_by: dict[str, Any] | BaseModel | None = ..., filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1573,7 +1537,6 @@ class AsyncCrud(Generic[ModelType]):
outer_join: bool = ..., outer_join: bool = ...,
load_options: Sequence[ExecutableOption] | None = ..., load_options: Sequence[ExecutableOption] | None = ...,
order_by: OrderByClause | None = ..., order_by: OrderByClause | None = ...,
order_joins: list[Any] | None = ...,
page: int = ..., page: int = ...,
cursor: str | None = ..., cursor: str | None = ...,
items_per_page: int = ..., items_per_page: int = ...,
@@ -1581,7 +1544,7 @@ class AsyncCrud(Generic[ModelType]):
search: str | SearchConfig | None = ..., search: str | SearchConfig | None = ...,
search_fields: Sequence[SearchFieldType] | None = ..., search_fields: Sequence[SearchFieldType] | None = ...,
search_column: str | None = ..., search_column: str | None = ...,
order_fields: Sequence[OrderFieldType] | None = ..., order_fields: Sequence[QueryableAttribute[Any]] | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ...,
filter_by: dict[str, Any] | BaseModel | None = ..., filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1598,7 +1561,6 @@ class AsyncCrud(Generic[ModelType]):
outer_join: bool = False, outer_join: bool = False,
load_options: Sequence[ExecutableOption] | None = None, load_options: Sequence[ExecutableOption] | None = None,
order_by: OrderByClause | None = None, order_by: OrderByClause | None = None,
order_joins: list[Any] | None = None,
page: int = 1, page: int = 1,
cursor: str | None = None, cursor: str | None = None,
items_per_page: int = 20, items_per_page: int = 20,
@@ -1606,7 +1568,7 @@ class AsyncCrud(Generic[ModelType]):
search: str | SearchConfig | None = None, search: str | SearchConfig | None = None,
search_fields: Sequence[SearchFieldType] | None = None, search_fields: Sequence[SearchFieldType] | None = None,
search_column: str | None = None, search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
filter_by: dict[str, Any] | BaseModel | None = None, filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel], schema: type[BaseModel],
@@ -1661,7 +1623,6 @@ class AsyncCrud(Generic[ModelType]):
outer_join=outer_join, outer_join=outer_join,
load_options=load_options, load_options=load_options,
order_by=order_by, order_by=order_by,
order_joins=order_joins,
items_per_page=items_per_page, items_per_page=items_per_page,
search=search, search=search,
search_fields=search_fields, search_fields=search_fields,
@@ -1681,7 +1642,6 @@ class AsyncCrud(Generic[ModelType]):
outer_join=outer_join, outer_join=outer_join,
load_options=load_options, load_options=load_options,
order_by=order_by, order_by=order_by,
order_joins=order_joins,
page=page, page=page,
items_per_page=items_per_page, items_per_page=items_per_page,
include_total=include_total, include_total=include_total,
@@ -1703,7 +1663,7 @@ def CrudFactory(
base_class: type[AsyncCrud[Any]] = AsyncCrud, base_class: type[AsyncCrud[Any]] = AsyncCrud,
searchable_fields: Sequence[SearchFieldType] | None = None, searchable_fields: Sequence[SearchFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None,
order_fields: Sequence[OrderFieldType] | None = None, order_fields: Sequence[QueryableAttribute[Any]] | None = None,
m2m_fields: M2MFieldType | None = None, m2m_fields: M2MFieldType | None = None,
default_load_options: Sequence[ExecutableOption] | None = None, default_load_options: Sequence[ExecutableOption] | None = None,
cursor_column: Any | None = None, cursor_column: Any | None = None,

View File

@@ -163,7 +163,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
pagination_type: PaginationType | None = None pagination_type: PaginationType | None = None
filter_attributes: dict[str, list[Any]] | None = None filter_attributes: dict[str, list[Any]] | None = None
search_columns: list[str] | None = None search_columns: list[str] | None = None
order_columns: list[str] | None = None sort_columns: list[str] | None = None
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {} _discriminated_union_cache: ClassVar[dict[Any, Any]] = {}

View File

@@ -19,10 +19,9 @@ JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
M2MFieldType = Mapping[str, QueryableAttribute[Any]] M2MFieldType = Mapping[str, QueryableAttribute[Any]]
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any] OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
# Search / facet / order type aliases # Search / facet type aliases
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...] SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
FacetFieldType = SearchFieldType FacetFieldType = SearchFieldType
OrderFieldType = SearchFieldType
# Dependency type aliases # Dependency type aliases
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any

View File

@@ -247,8 +247,8 @@ class TestResolveSearchColumns:
assert "username" not in result assert "username" not in result
class TestResolveOrderColumns: class TestResolveSortColumns:
"""Tests for _resolve_order_columns logic.""" """Tests for _resolve_sort_columns logic."""
def test_returns_none_when_no_order_fields(self): def test_returns_none_when_no_order_fields(self):
"""Returns None when cls.order_fields is None and no order_fields passed.""" """Returns None when cls.order_fields is None and no order_fields passed."""
@@ -256,24 +256,24 @@ class TestResolveOrderColumns:
class AbstractCrud(AsyncCrud[User]): class AbstractCrud(AsyncCrud[User]):
pass pass
assert AbstractCrud._resolve_order_columns(None) is None assert AbstractCrud._resolve_sort_columns(None) is None
def test_returns_none_when_empty_order_fields_passed(self): def test_returns_none_when_empty_order_fields_passed(self):
"""Returns None when an empty list is passed explicitly.""" """Returns None when an empty list is passed explicitly."""
crud = CrudFactory(User) crud = CrudFactory(User)
assert crud._resolve_order_columns([]) is None assert crud._resolve_sort_columns([]) is None
def test_returns_keys_from_class_order_fields(self): def test_returns_keys_from_class_order_fields(self):
"""Returns sorted column keys from cls.order_fields when no override passed.""" """Returns sorted column keys from cls.order_fields when no override passed."""
crud = CrudFactory(User, order_fields=[User.username]) crud = CrudFactory(User, order_fields=[User.username])
result = crud._resolve_order_columns(None) result = crud._resolve_sort_columns(None)
assert result is not None assert result is not None
assert "username" in result assert "username" in result
def test_order_fields_override_takes_priority(self): def test_order_fields_override_takes_priority(self):
"""Explicit order_fields override cls.order_fields.""" """Explicit order_fields override cls.order_fields."""
crud = CrudFactory(User, order_fields=[User.username]) crud = CrudFactory(User, order_fields=[User.username])
result = crud._resolve_order_columns([User.email]) result = crud._resolve_sort_columns([User.email])
assert result is not None assert result is not None
assert "email" in result assert "email" in result
assert "username" not in result assert "username" not in result
@@ -281,25 +281,10 @@ class TestResolveOrderColumns:
def test_returns_sorted_keys(self): def test_returns_sorted_keys(self):
"""Keys are returned in sorted order.""" """Keys are returned in sorted order."""
crud = CrudFactory(User, order_fields=[User.email, User.username]) crud = CrudFactory(User, order_fields=[User.email, User.username])
result = crud._resolve_order_columns(None) result = crud._resolve_sort_columns(None)
assert result is not None assert result is not None
assert result == sorted(result) assert result == sorted(result)
def test_relation_tuple_produces_dunder_key(self):
"""A (rel, column) tuple produces a 'rel__column' key."""
crud = CrudFactory(User, order_fields=[(User.role, Role.name)])
result = crud._resolve_order_columns(None)
assert result == ["role__name"]
def test_mixed_flat_and_relation_fields(self):
"""Flat and relation fields can be mixed; keys are sorted."""
crud = CrudFactory(User, order_fields=[User.username, (User.role, Role.name)])
result = crud._resolve_order_columns(None)
assert result is not None
assert "username" in result
assert "role__name" in result
assert result == sorted(result)
class TestDefaultLoadOptionsIntegration: class TestDefaultLoadOptionsIntegration:
"""Integration tests for default_load_options with real DB queries.""" """Integration tests for default_load_options with real DB queries."""
@@ -380,43 +365,6 @@ class TestDefaultLoadOptionsIntegration:
assert result.data[0].role is not None assert result.data[0].role is not None
assert result.data[0].role.name == "admin" assert result.data[0].role.name == "admin"
@pytest.mark.anyio
async def test_default_load_options_applied_to_create(
self, db_session: AsyncSession
):
"""default_load_options loads relationships after create()."""
UserWithDefaultLoad = CrudFactory(
User, default_load_options=[selectinload(User.role)]
)
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
user = await UserWithDefaultLoad.create(
db_session,
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
)
assert user.role is not None
assert user.role.name == "admin"
@pytest.mark.anyio
async def test_default_load_options_applied_to_update(
self, db_session: AsyncSession
):
"""default_load_options loads relationships after update()."""
UserWithDefaultLoad = CrudFactory(
User, default_load_options=[selectinload(User.role)]
)
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
user = await UserCrud.create(
db_session,
UserCreate(username="alice", email="alice@test.com"),
)
updated = await UserWithDefaultLoad.update(
db_session,
UserUpdate(role_id=role.id),
filters=[User.id == user.id],
)
assert updated.role is not None
assert updated.role.name == "admin"
@pytest.mark.anyio @pytest.mark.anyio
async def test_load_options_overrides_default_load_options( async def test_load_options_overrides_default_load_options(
self, db_session: AsyncSession self, db_session: AsyncSession

View File

@@ -1516,14 +1516,14 @@ class TestSearchColumns:
assert result.data[0].username == "bob" assert result.data[0].username == "bob"
class TestOrderColumns: class TestSortColumns:
"""Tests for order_columns in paginated responses.""" """Tests for sort_columns in paginated responses."""
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_columns_returned_in_offset_paginate( async def test_sort_columns_returned_in_offset_paginate(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""offset_paginate response includes order_columns.""" """offset_paginate response includes sort_columns."""
UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email]) UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email])
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
@@ -1531,15 +1531,15 @@ class TestOrderColumns:
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead) result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
assert result.order_columns is not None assert result.sort_columns is not None
assert "username" in result.order_columns assert "username" in result.sort_columns
assert "email" in result.order_columns assert "email" in result.sort_columns
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_columns_returned_in_cursor_paginate( async def test_sort_columns_returned_in_cursor_paginate(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""cursor_paginate response includes order_columns.""" """cursor_paginate response includes sort_columns."""
UserSortCursorCrud = CrudFactory( UserSortCursorCrud = CrudFactory(
User, User,
cursor_column=User.id, cursor_column=User.id,
@@ -1551,24 +1551,24 @@ class TestOrderColumns:
result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead) result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead)
assert result.order_columns is not None assert result.sort_columns is not None
assert "username" in result.order_columns assert "username" in result.sort_columns
assert "email" in result.order_columns assert "email" in result.sort_columns
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_columns_none_when_no_order_fields( async def test_sort_columns_none_when_no_order_fields(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""order_columns is None when no order_fields are configured.""" """sort_columns is None when no order_fields are configured."""
result = await UserCrud.offset_paginate(db_session, schema=UserRead) result = await UserCrud.offset_paginate(db_session, schema=UserRead)
assert result.order_columns is None assert result.sort_columns is None
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_columns_override_in_offset_paginate( async def test_sort_columns_override_in_offset_paginate(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""order_fields override in offset_paginate is reflected in order_columns.""" """order_fields override in offset_paginate is reflected in sort_columns."""
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
) )
@@ -1577,13 +1577,13 @@ class TestOrderColumns:
db_session, order_fields=[User.email], schema=UserRead db_session, order_fields=[User.email], schema=UserRead
) )
assert result.order_columns == ["email"] assert result.sort_columns == ["email"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_columns_override_in_cursor_paginate( async def test_sort_columns_override_in_cursor_paginate(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""order_fields override in cursor_paginate is reflected in order_columns.""" """order_fields override in cursor_paginate is reflected in sort_columns."""
UserCursorCrud = CrudFactory(User, cursor_column=User.id) UserCursorCrud = CrudFactory(User, cursor_column=User.id)
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
@@ -1593,13 +1593,13 @@ class TestOrderColumns:
db_session, order_fields=[User.username], schema=UserRead db_session, order_fields=[User.username], schema=UserRead
) )
assert result.order_columns == ["username"] assert result.sort_columns == ["username"]
@pytest.mark.anyio @pytest.mark.anyio
async def test_order_columns_are_sorted_alphabetically( async def test_sort_columns_are_sorted_alphabetically(
self, db_session: AsyncSession self, db_session: AsyncSession
): ):
"""order_columns keys are returned in alphabetical order.""" """sort_columns keys are returned in alphabetical order."""
UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username]) UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username])
await UserCrud.create( await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com") db_session, UserCreate(username="alice", email="a@test.com")
@@ -1607,18 +1607,8 @@ class TestOrderColumns:
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead) result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
assert result.order_columns is not None assert result.sort_columns is not None
assert result.order_columns == sorted(result.order_columns) assert result.sort_columns == sorted(result.sort_columns)
@pytest.mark.anyio
async def test_relation_order_field_in_order_columns(
self, db_session: AsyncSession
):
"""A relation tuple order field produces 'rel__column' key in order_columns."""
UserSortCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
assert result.order_columns == ["role__name"]
class TestOrderParamsViaConsolidated: class TestOrderParamsViaConsolidated:
@@ -1775,92 +1765,6 @@ class TestOrderParamsViaConsolidated:
assert result.data[0].username == "alice" assert result.data[0].username == "alice"
assert result.data[1].username == "charlie" assert result.data[1].username == "charlie"
def test_relation_order_field_key_in_enum(self):
"""A relation tuple field produces a 'rel__column' key in the order_by enum."""
UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
sig = inspect.signature(dep)
description = sig.parameters["order_by"].default.description
assert "role__name" in description
@pytest.mark.anyio
async def test_relation_order_field_produces_order_joins(self):
"""Selecting a relation order field emits order_by and order_joins."""
UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
result = await dep(
page=1, items_per_page=20, order_by="role__name", order="asc"
)
assert "order_by" in result
assert "order_joins" in result
assert result["order_joins"] == [User.role]
@pytest.mark.anyio
async def test_relation_order_integrates_with_offset_paginate(
self, db_session: AsyncSession
):
"""Relation order field joins the related table and sorts correctly."""
UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
role_b = await RoleCrud.create(db_session, RoleCreate(name="beta"))
role_a = await RoleCrud.create(db_session, RoleCreate(name="alpha"))
await UserCrud.create(
db_session,
UserCreate(username="u1", email="u1@test.com", role_id=role_b.id),
)
await UserCrud.create(
db_session,
UserCreate(username="u2", email="u2@test.com", role_id=role_a.id),
)
await UserCrud.create(
db_session, UserCreate(username="u3", email="u3@test.com")
)
dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
params = await dep(
page=1, items_per_page=20, order_by="role__name", order="asc"
)
result = await UserOrderCrud.offset_paginate(
db_session, **params, schema=UserRead
)
usernames = [u.username for u in result.data]
# u2 (alpha) before u1 (beta); u3 (no role, NULL) comes last or first depending on DB
assert usernames.index("u2") < usernames.index("u1")
@pytest.mark.anyio
async def test_relation_order_integrates_with_cursor_paginate(
self, db_session: AsyncSession
):
"""Relation order field works with cursor_paginate (order_joins applied)."""
UserOrderCrud = CrudFactory(
User,
order_fields=[(User.role, Role.name)],
cursor_column=User.id,
)
role_b = await RoleCrud.create(db_session, RoleCreate(name="zeta"))
role_a = await RoleCrud.create(db_session, RoleCreate(name="alpha"))
await UserCrud.create(
db_session,
UserCreate(username="cx1", email="cx1@test.com", role_id=role_b.id),
)
await UserCrud.create(
db_session,
UserCreate(username="cx2", email="cx2@test.com", role_id=role_a.id),
)
dep = UserOrderCrud.cursor_paginate_params(search=False, filter=False)
params = await dep(
cursor=None, items_per_page=20, order_by="role__name", order="asc"
)
result = await UserOrderCrud.cursor_paginate(
db_session, **params, schema=UserRead
)
assert result.data is not None
assert len(result.data) == 2
class TestOffsetPaginateParamsSchema: class TestOffsetPaginateParamsSchema:
"""Tests for AsyncCrud.offset_paginate_params().""" """Tests for AsyncCrud.offset_paginate_params()."""