mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
24 Commits
168345756f
...
feat/add-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
70c4f4154a
|
|||
|
fcde227ecd
|
|||
|
5a62032550
|
|||
|
59231bd5d0
|
|||
|
e1f96ad7fe
|
|||
|
0ed93d62c8
|
|||
|
|
2a49814818 | ||
|
|
f8e090c7c3 | ||
|
|
54decaf3e1 | ||
|
6b127d9645
|
|||
|
|
8bed96f4bf | ||
|
|
74d15e13bc | ||
|
|
e38d8d2d4f | ||
|
9b74f162ab
|
|||
|
|
ab125c6ea1 | ||
|
|
e388e26858 | ||
|
|
04da241294 | ||
|
|
bbe63edc46 | ||
|
|
0b17c77dee | ||
|
|
bce71bfd42 | ||
|
|
2f1eb4d468 | ||
|
|
1f06eab11d | ||
|
|
fac9aa6f60 | ||
|
|
f310466697 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -93,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: coverage
|
report_type: coverage
|
||||||
@@ -102,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
|
|||||||
21
.github/workflows/docs.yml
vendored
21
.github/workflows/docs.yml
vendored
@@ -34,26 +34,17 @@ jobs:
|
|||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
||||||
|
|
||||||
# On new major: consolidate previous major's feature versions into vX
|
# On new major: keep only the latest feature version of the previous major
|
||||||
PREV_MAJOR=$((MAJOR - 1))
|
PREV_MAJOR=$((MAJOR - 1))
|
||||||
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
||||||
|
|
||||||
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
||||||
LATEST_PREV_TAG=$(git tag -l "v${PREV_MAJOR}.*" | sort -V | tail -1)
|
LATEST_PREV=$(echo "$OLD_FEATURE_VERSIONS" | sort -t. -k2 -n | tail -1)
|
||||||
|
|
||||||
if [ -n "$LATEST_PREV_TAG" ]; then
|
|
||||||
git checkout "$LATEST_PREV_TAG" -- docs/ src/ zensical.toml
|
|
||||||
if ! grep -q '\[project\.extra\.version\]' zensical.toml; then
|
|
||||||
printf '\n[project.extra.version]\nprovider = "mike"\ndefault = "stable"\nalias = true\n' >> zensical.toml
|
|
||||||
fi
|
|
||||||
uv run mike deploy "v${PREV_MAJOR}"
|
|
||||||
git checkout HEAD -- docs/ src/ zensical.toml
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete old feature versions
|
|
||||||
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
||||||
echo "Deleting $OLD_V"
|
if [ "$OLD_V" != "$LATEST_PREV" ]; then
|
||||||
uv run mike delete "$OLD_V"
|
echo "Deleting $OLD_V"
|
||||||
|
uv run mike delete "$OLD_V"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,93 @@ This page covers every breaking change introduced in **v3.0** and the steps requ
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
### Facet keys now always use the full relationship chain
|
||||||
|
|
||||||
|
In `v2`, relationship facet fields used only the terminal column key (e.g. `"name"` for `Role.name`) and only prepended the relationship name when two facet fields shared the same column key. In `v3`, facet keys **always** include the full relationship chain joined by `__`, regardless of collisions.
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```
|
||||||
|
User.status -> status
|
||||||
|
(User.role, Role.name) -> name
|
||||||
|
(User.role, Role.permission, Permission.name) -> name
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```
|
||||||
|
User.status -> status
|
||||||
|
(User.role, Role.name) -> role__name
|
||||||
|
(User.role, Role.permission, Permission.name) -> role__permission__name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `*_params` dependencies consolidated into per-paginate methods
|
||||||
|
|
||||||
|
The six individual dependency methods (`offset_params`, `cursor_params`, `paginate_params`, `filter_params`, `search_params`, `order_params`) have been **removed** and replaced by three consolidated methods that bundle pagination, search, filter, and order into a single `Depends()` call.
|
||||||
|
|
||||||
|
| Removed | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `offset_params()` + `filter_params()` + `search_params()` + `order_params()` | `offset_paginate_params()` |
|
||||||
|
| `cursor_params()` + `filter_params()` + `search_params()` + `order_params()` | `cursor_paginate_params()` |
|
||||||
|
| `paginate_params()` + `filter_params()` + `search_params()` + `order_params()` | `paginate_params()` |
|
||||||
|
|
||||||
|
Each new method accepts `search`, `filter`, and `order` boolean toggles (all `True` by default) to disable features you don't need.
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
|
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
||||||
|
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
||||||
|
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
|
||||||
|
search: str | None = None,
|
||||||
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
**params,
|
||||||
|
search=search,
|
||||||
|
filter_by=filter_by or None,
|
||||||
|
order_by=order_by,
|
||||||
|
schema=ArticleRead,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(
|
||||||
|
ArticleCrud.offset_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# No search or ordering, only pagination + filtering
|
||||||
|
ArticleCrud.offset_paginate_params(search=False, order=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
The lifecycle event system has been rewritten. Callbacks are now registered with a module-level [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession), replacing the mixin-based approach from `v2`.
|
The lifecycle event system has been rewritten. Callbacks are now registered with a module-level [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession), replacing the mixin-based approach from `v2`.
|
||||||
|
|||||||
@@ -324,6 +324,12 @@ 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
|
||||||
@@ -344,13 +350,37 @@ 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.
|
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`:
|
||||||
|
|
||||||
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserCrud = CrudFactory(
|
UserCrud = CrudFactory(
|
||||||
@@ -372,7 +402,47 @@ result = await UserCrud.offset_paginate(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
Or via the dependency to narrow which fields are exposed as query parameters:
|
||||||
|
|
||||||
|
```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
|
||||||
{
|
{
|
||||||
@@ -387,50 +457,14 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
!!! 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).
|
||||||
!!! 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 to expose client-driven column ordering via `order_by` and `order` query parameters.
|
Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserCrud = CrudFactory(
|
UserCrud = CrudFactory(
|
||||||
@@ -438,11 +472,27 @@ 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
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
You can override `order_fields` per call:
|
||||||
|
|
||||||
|
```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
|
||||||
@@ -452,33 +502,50 @@ 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=name&order=desc → ORDER BY users.name DESC
|
GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC
|
||||||
```
|
```
|
||||||
|
|
||||||
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
!!! info "Relationship tuples are joined automatically."
|
||||||
|
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:
|
|
||||||
|
|
||||||
```python
|
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:
|
||||||
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`"
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ The examples above are already compatible with parallel test execution with `pyt
|
|||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
!!! warning
|
|
||||||
Since `V2.1.0` `cleanup_tables` now live in `fastapi_toolsets.db`. For backward compatibility the function is still available in `fastapi_toolsets.pytest`, but this will be remove in `V3.0.0`.
|
|
||||||
|
|
||||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.3"
|
version = "3.0.3"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.4.3"
|
__version__ = "3.0.3"
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ from ..types import (
|
|||||||
JoinType,
|
JoinType,
|
||||||
M2MFieldType,
|
M2MFieldType,
|
||||||
OrderByClause,
|
OrderByClause,
|
||||||
|
OrderFieldType,
|
||||||
SearchFieldType,
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
from .factory import AsyncCrud, CrudFactory
|
from .factory import AsyncCrud, CrudFactory, lateral_load
|
||||||
from .search import SearchConfig, get_searchable_fields
|
from .search import SearchConfig, get_searchable_fields
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AsyncCrud",
|
"AsyncCrud",
|
||||||
"CrudFactory",
|
"CrudFactory",
|
||||||
|
"lateral_load",
|
||||||
"FacetFieldType",
|
"FacetFieldType",
|
||||||
"get_searchable_fields",
|
"get_searchable_fields",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
@@ -28,6 +30,7 @@ __all__ = [
|
|||||||
"M2MFieldType",
|
"M2MFieldType",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"OrderByClause",
|
"OrderByClause",
|
||||||
|
"OrderFieldType",
|
||||||
"PaginationType",
|
"PaginationType",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
"SearchFieldType",
|
"SearchFieldType",
|
||||||
|
|||||||
@@ -10,15 +10,32 @@ from collections.abc import Awaitable, Callable, Sequence
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
from typing import Any, ClassVar, Generic, Literal, NamedTuple, Self, cast, overload
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import Date, DateTime, Float, Integer, Numeric, Uuid, and_, func, select
|
from sqlalchemy import (
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
Uuid,
|
||||||
|
and_,
|
||||||
|
func,
|
||||||
|
select,
|
||||||
|
true,
|
||||||
|
)
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
from sqlalchemy.exc import NoResultFound
|
from sqlalchemy.exc import NoResultFound
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
from sqlalchemy.orm import (
|
||||||
|
DeclarativeBase,
|
||||||
|
QueryableAttribute,
|
||||||
|
RelationshipProperty,
|
||||||
|
contains_eager,
|
||||||
|
selectinload,
|
||||||
|
)
|
||||||
from sqlalchemy.sql.base import ExecutableOption
|
from sqlalchemy.sql.base import ExecutableOption
|
||||||
from sqlalchemy.sql.roles import WhereHavingRole
|
from sqlalchemy.sql.roles import WhereHavingRole
|
||||||
|
|
||||||
@@ -35,9 +52,11 @@ from ..schemas import (
|
|||||||
from ..types import (
|
from ..types import (
|
||||||
FacetFieldType,
|
FacetFieldType,
|
||||||
JoinType,
|
JoinType,
|
||||||
|
LateralJoinType,
|
||||||
M2MFieldType,
|
M2MFieldType,
|
||||||
ModelType,
|
ModelType,
|
||||||
OrderByClause,
|
OrderByClause,
|
||||||
|
OrderFieldType,
|
||||||
SchemaType,
|
SchemaType,
|
||||||
SearchFieldType,
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
@@ -114,10 +133,86 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
class _ResolvedLateral(NamedTuple):
|
||||||
|
joins: LateralJoinType
|
||||||
|
eager: list[ExecutableOption]
|
||||||
|
|
||||||
|
|
||||||
|
class _LateralLoad:
|
||||||
|
"""Marker used inside ``default_load_options`` for lateral join loading.
|
||||||
|
|
||||||
|
Supports only Many:One and One:One relationships (single row per parent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("rel_attr",)
|
||||||
|
|
||||||
|
def __init__(self, rel_attr: QueryableAttribute) -> None:
|
||||||
|
prop = rel_attr.property
|
||||||
|
if not isinstance(prop, RelationshipProperty):
|
||||||
|
raise TypeError(
|
||||||
|
f"lateral_load() requires a relationship attribute, got {type(prop).__name__}. "
|
||||||
|
"Example: lateral_load(User.team)"
|
||||||
|
)
|
||||||
|
if prop.secondary is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"lateral_load({rel_attr}) does not support Many:Many relationships. "
|
||||||
|
"Use selectinload() instead."
|
||||||
|
)
|
||||||
|
if prop.uselist:
|
||||||
|
raise ValueError(
|
||||||
|
f"lateral_load({rel_attr}) does not support One:Many relationships. "
|
||||||
|
"Use selectinload() instead."
|
||||||
|
)
|
||||||
|
self.rel_attr = rel_attr
|
||||||
|
|
||||||
|
|
||||||
|
def lateral_load(rel_attr: QueryableAttribute) -> _LateralLoad:
|
||||||
|
"""Mark a Many:One or One:One relationship for lateral join loading.
|
||||||
|
|
||||||
|
Raises ``ValueError`` for One:Many or Many:Many relationships.
|
||||||
|
"""
|
||||||
|
return _LateralLoad(rel_attr)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_lateral_from_relationship(
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
) -> tuple[Any, Any, ExecutableOption]:
|
||||||
|
"""Introspect a Many:One relationship and build (lateral_subquery, true(), contains_eager)."""
|
||||||
|
prop = rel_attr.property
|
||||||
|
target_class = prop.mapper.class_
|
||||||
|
parent_class = prop.parent.class_
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
getattr(target_class, remote_col.key) == getattr(parent_class, local_col.key)
|
||||||
|
for local_col, remote_col in prop.local_remote_pairs
|
||||||
|
]
|
||||||
|
|
||||||
|
lateral_sub = (
|
||||||
|
select(target_class)
|
||||||
|
.where(and_(*conditions))
|
||||||
|
.correlate(parent_class)
|
||||||
|
.lateral(f"_lateral_{prop.key}")
|
||||||
|
)
|
||||||
|
return lateral_sub, true(), contains_eager(rel_attr, alias=lateral_sub)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_lateral_joins(q: Any, lateral_joins: LateralJoinType | None) -> Any:
|
||||||
|
"""Apply lateral subqueries as LEFT JOIN LATERAL to preserve all parent rows."""
|
||||||
|
if not lateral_joins:
|
||||||
|
return q
|
||||||
|
for subquery, condition in lateral_joins:
|
||||||
|
q = q.outerjoin(subquery, condition)
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
||||||
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
||||||
|
seen: set[str] = set()
|
||||||
for join_rel in search_joins:
|
for join_rel in search_joins:
|
||||||
q = q.outerjoin(join_rel)
|
key = str(join_rel)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
q = q.outerjoin(join_rel)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
@@ -127,12 +222,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Subclass this and set the `model` class variable, or use `CrudFactory`.
|
Subclass this and set the `model` class variable, or use `CrudFactory`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_resolved_lateral: ClassVar[_ResolvedLateral | None] = None
|
||||||
|
|
||||||
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[QueryableAttribute[Any]] | None] = None
|
order_fields: ClassVar[Sequence[OrderFieldType] | 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 | _LateralLoad] | None] = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
lateral_joins: ClassVar[LateralJoinType | None] = None
|
||||||
cursor_column: ClassVar[Any | None] = None
|
cursor_column: ClassVar[Any | None] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -156,14 +256,60 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
):
|
):
|
||||||
cls.searchable_fields = [pk_col, *raw_fields]
|
cls.searchable_fields = [pk_col, *raw_fields]
|
||||||
|
|
||||||
|
raw_default_opts = cls.__dict__.get("default_load_options", None)
|
||||||
|
if raw_default_opts:
|
||||||
|
joins: LateralJoinType = []
|
||||||
|
eager: list[ExecutableOption] = []
|
||||||
|
clean: list[ExecutableOption] = []
|
||||||
|
for opt in raw_default_opts:
|
||||||
|
if isinstance(opt, _LateralLoad):
|
||||||
|
lat_sub, condition, eager_opt = _build_lateral_from_relationship(
|
||||||
|
opt.rel_attr
|
||||||
|
)
|
||||||
|
joins.append((lat_sub, condition))
|
||||||
|
eager.append(eager_opt)
|
||||||
|
else:
|
||||||
|
clean.append(opt)
|
||||||
|
if joins:
|
||||||
|
cls._resolved_lateral = _ResolvedLateral(joins=joins, eager=eager)
|
||||||
|
cls.default_load_options = clean or None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_lateral_joins(cls) -> LateralJoinType | None:
|
||||||
|
"""Merge manual lateral_joins with ones resolved from default_load_options."""
|
||||||
|
resolved = cls._resolved_lateral
|
||||||
|
all_lateral = [
|
||||||
|
*(cls.lateral_joins or []),
|
||||||
|
*(resolved.joins if resolved else []),
|
||||||
|
]
|
||||||
|
return all_lateral or None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_load_options(
|
def _resolve_load_options(
|
||||||
cls, load_options: Sequence[ExecutableOption] | None
|
cls, load_options: Sequence[ExecutableOption] | None
|
||||||
) -> Sequence[ExecutableOption] | None:
|
) -> Sequence[ExecutableOption] | None:
|
||||||
"""Return load_options if provided, else fall back to default_load_options."""
|
"""Return merged load options."""
|
||||||
if load_options is not None:
|
if load_options is not None:
|
||||||
return load_options
|
return list(load_options) or None
|
||||||
return cls.default_load_options
|
resolved = cls._resolved_lateral
|
||||||
|
# default_load_options is cleaned of _LateralLoad markers in __init_subclass__,
|
||||||
|
# but its declared type still includes them — cast to reflect the runtime invariant.
|
||||||
|
base = cast(list[ExecutableOption], cls.default_load_options or [])
|
||||||
|
lateral = resolved.eager if resolved else []
|
||||||
|
merged = [*base, *lateral]
|
||||||
|
return merged or None
|
||||||
|
|
||||||
|
@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(
|
||||||
@@ -274,6 +420,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return None
|
return None
|
||||||
return search_field_keys(fields)
|
return search_field_keys(fields)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_order_columns(
|
||||||
|
cls: type[Self],
|
||||||
|
order_fields: Sequence[OrderFieldType] | None,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Return sort column keys, or None if no order fields configured."""
|
||||||
|
fields = order_fields if order_fields is not None else cls.order_fields
|
||||||
|
if not fields:
|
||||||
|
return None
|
||||||
|
return sorted(facet_keys(fields))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_paginate_params(
|
def _build_paginate_params(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -286,7 +443,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[QueryableAttribute[Any]] | None,
|
order_fields: Sequence[OrderFieldType] | 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]]]:
|
||||||
@@ -345,14 +502,15 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
)
|
)
|
||||||
reserved_names.update(filter_keys)
|
reserved_names.update(filter_keys)
|
||||||
|
|
||||||
order_field_map: dict[str, QueryableAttribute[Any]] | None = None
|
order_field_map: dict[str, OrderFieldType] | 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:
|
||||||
order_field_map = {f.key: f for f in resolved_order}
|
keys = facet_keys(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(
|
||||||
[
|
[
|
||||||
@@ -404,9 +562,16 @@ 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:
|
||||||
result["order_by"] = (
|
if isinstance(field, tuple):
|
||||||
field.asc() if order_dir == "asc" else field.desc()
|
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"] = (
|
||||||
|
field.asc() if order_dir == "asc" else field.desc()
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result["order_by"] = None
|
result["order_by"] = None
|
||||||
|
|
||||||
@@ -430,7 +595,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[QueryableAttribute[Any]] | None = None,
|
order_fields: Sequence[OrderFieldType] | 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]]]:
|
||||||
@@ -492,7 +657,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[QueryableAttribute[Any]] | None = None,
|
order_fields: Sequence[OrderFieldType] | 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]]]:
|
||||||
@@ -557,7 +722,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[QueryableAttribute[Any]] | None = None,
|
order_fields: Sequence[OrderFieldType] | 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]]]:
|
||||||
@@ -681,6 +846,8 @@ 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))
|
||||||
@@ -823,6 +990,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
if load_options is None:
|
||||||
|
q = _apply_lateral_joins(q, cls._get_lateral_joins())
|
||||||
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):
|
||||||
q = q.options(*resolved)
|
q = q.options(*resolved)
|
||||||
@@ -895,6 +1064,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
if load_options is None:
|
||||||
|
q = _apply_lateral_joins(q, cls._get_lateral_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):
|
||||||
@@ -940,6 +1111,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
"""
|
"""
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
if load_options is None:
|
||||||
|
q = _apply_lateral_joins(q, cls._get_lateral_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):
|
||||||
@@ -1036,6 +1209,8 @@ 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
|
||||||
@@ -1198,12 +1373,14 @@ 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,
|
||||||
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],
|
||||||
@@ -1224,6 +1401,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search: Search query string or SearchConfig object
|
search: Search query string or SearchConfig object
|
||||||
search_fields: Fields to search in (overrides class default)
|
search_fields: Fields to search in (overrides class default)
|
||||||
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).
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||||
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,
|
||||||
@@ -1257,9 +1435,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
# Apply explicit joins
|
# Apply explicit joins
|
||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
|
# Apply lateral joins (Many:One relationship loading, excluded from count query)
|
||||||
|
if load_options is None:
|
||||||
|
q = _apply_lateral_joins(q, cls._get_lateral_joins())
|
||||||
|
|
||||||
# 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):
|
||||||
@@ -1304,6 +1490,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)
|
||||||
|
|
||||||
return OffsetPaginatedResponse(
|
return OffsetPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1315,6 +1502,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1328,10 +1516,12 @@ 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,
|
||||||
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],
|
||||||
@@ -1347,12 +1537,15 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
tables.
|
tables.
|
||||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
|
||||||
load_options: SQLAlchemy loader options. Falls back to
|
load_options: SQLAlchemy loader options. Falls back to
|
||||||
``default_load_options`` when not provided.
|
``default_load_options`` (including any lateral joins) when not
|
||||||
|
provided. When explicitly supplied, the caller takes full control
|
||||||
|
and lateral joins are skipped.
|
||||||
order_by: Additional ordering applied after the cursor column.
|
order_by: Additional ordering applied after the cursor column.
|
||||||
items_per_page: Number of items per page (default 20).
|
items_per_page: Number of items per page (default 20).
|
||||||
search: Search query string or SearchConfig object.
|
search: Search query string or SearchConfig object.
|
||||||
search_fields: Fields to search in (overrides class default).
|
search_fields: Fields to search in (overrides class default).
|
||||||
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).
|
||||||
facet_fields: Columns to compute distinct values for (overrides class default).
|
facet_fields: Columns to compute distinct values for (overrides class default).
|
||||||
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,
|
||||||
@@ -1403,9 +1596,17 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
# Apply explicit joins
|
# Apply explicit joins
|
||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
|
# Apply lateral joins (Many:One relationship loading)
|
||||||
|
if load_options is None:
|
||||||
|
q = _apply_lateral_joins(q, cls._get_lateral_joins())
|
||||||
|
|
||||||
# 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):
|
||||||
@@ -1464,6 +1665,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)
|
||||||
|
|
||||||
return CursorPaginatedResponse(
|
return CursorPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1475,6 +1677,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -1489,6 +1692,7 @@ 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 = ...,
|
||||||
@@ -1496,6 +1700,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 = ...,
|
||||||
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],
|
||||||
@@ -1513,6 +1718,7 @@ 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 = ...,
|
||||||
@@ -1520,6 +1726,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 = ...,
|
||||||
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],
|
||||||
@@ -1536,6 +1743,7 @@ 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,
|
||||||
@@ -1543,6 +1751,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,
|
||||||
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],
|
||||||
@@ -1571,6 +1780,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search: Search query string or :class:`.SearchConfig` object.
|
search: Search query string or :class:`.SearchConfig` object.
|
||||||
search_fields: Fields to search in (overrides class default).
|
search_fields: Fields to search in (overrides class default).
|
||||||
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).
|
||||||
facet_fields: Columns to compute distinct values for (overrides
|
facet_fields: Columns to compute distinct values for (overrides
|
||||||
class default).
|
class default).
|
||||||
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
||||||
@@ -1596,10 +1806,12 @@ 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,
|
||||||
search_column=search_column,
|
search_column=search_column,
|
||||||
|
order_fields=order_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -1614,12 +1826,14 @@ 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,
|
||||||
search=search,
|
search=search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
search_column=search_column,
|
search_column=search_column,
|
||||||
|
order_fields=order_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
@@ -1634,7 +1848,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[QueryableAttribute[Any]] | None = None,
|
order_fields: Sequence[OrderFieldType] | 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,
|
||||||
|
|||||||
@@ -242,13 +242,19 @@ async def build_facets(
|
|||||||
else:
|
else:
|
||||||
q = select(column).select_from(model).distinct()
|
q = select(column).select_from(model).distinct()
|
||||||
|
|
||||||
# Apply base joins (already done on main query, but needed here independently)
|
# Apply base joins (deduplicated) — needed here independently
|
||||||
|
seen_joins: set[str] = set()
|
||||||
for rel in base_joins or []:
|
for rel in base_joins or []:
|
||||||
q = q.outerjoin(rel)
|
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 in base_joins
|
# Add any extra joins required by this facet field that aren't already applied
|
||||||
for rel in rels:
|
for rel in rels:
|
||||||
if str(rel) not in existing_join_keys:
|
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)
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
if base_filters:
|
if base_filters:
|
||||||
@@ -259,7 +265,15 @@ async def build_facets(
|
|||||||
else:
|
else:
|
||||||
q = q.order_by(column)
|
q = q.order_by(column)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
values = [row[0] for row in result.all() if row[0] is not None]
|
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
|
return key, values
|
||||||
|
|
||||||
pairs = await asyncio.gather(
|
pairs = await asyncio.gather(
|
||||||
@@ -272,6 +286,18 @@ _EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
|||||||
"""Column types that support equality / IN filtering in build_filter_by."""
|
"""Column types that support equality / IN filtering in build_filter_by."""
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(value: Any) -> bool:
|
||||||
|
"""Coerce a string value to a Python bool for Boolean column filtering."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.lower() == "true":
|
||||||
|
return True
|
||||||
|
if value.lower() == "false":
|
||||||
|
return False
|
||||||
|
raise ValueError(f"Cannot coerce {value!r} to 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],
|
||||||
@@ -318,16 +344,35 @@ def build_filter_by(
|
|||||||
added_join_keys.add(rel_key)
|
added_join_keys.add(rel_key)
|
||||||
|
|
||||||
col_type = column.property.columns[0].type
|
col_type = column.property.columns[0].type
|
||||||
if isinstance(col_type, ARRAY):
|
if isinstance(col_type, Boolean):
|
||||||
|
coerce = _coerce_bool
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_([coerce(v) for v in value]))
|
||||||
|
else:
|
||||||
|
filters.append(column == coerce(value))
|
||||||
|
elif isinstance(col_type, ARRAY):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.overlap(value))
|
filters.append(column.overlap(value))
|
||||||
else:
|
else:
|
||||||
filters.append(column.any(value))
|
filters.append(column.any(value))
|
||||||
elif isinstance(col_type, Boolean):
|
elif isinstance(col_type, Enum):
|
||||||
if isinstance(value, list):
|
enum_class = col_type.enum_class
|
||||||
filters.append(column.in_(value))
|
if enum_class is not None:
|
||||||
else:
|
|
||||||
filters.append(column.is_(value))
|
def _coerce_enum(v: Any) -> Any:
|
||||||
|
if isinstance(v, enum_class):
|
||||||
|
return v
|
||||||
|
return enum_class[v] # lookup by name: "PENDING", "RED"
|
||||||
|
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_([_coerce_enum(v) for v in value]))
|
||||||
|
else:
|
||||||
|
filters.append(column == _coerce_enum(value))
|
||||||
|
else: # pragma: no cover
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_(value))
|
||||||
|
else:
|
||||||
|
filters.append(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.append(column.in_(value))
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ def _format_validation_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Prometheus metrics endpoint for FastAPI applications."""
|
"""Prometheus metrics endpoint for FastAPI applications."""
|
||||||
|
|
||||||
import asyncio
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -55,10 +55,10 @@ def init_metrics(
|
|||||||
|
|
||||||
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||||
async_collectors = [
|
async_collectors = [
|
||||||
c for c in registry.get_collectors() if asyncio.iscoroutinefunction(c.func)
|
c for c in registry.get_collectors() if inspect.iscoroutinefunction(c.func)
|
||||||
]
|
]
|
||||||
sync_collectors = [
|
sync_collectors = [
|
||||||
c for c in registry.get_collectors() if not asyncio.iscoroutinefunction(c.func)
|
c for c in registry.get_collectors() if not inspect.iscoroutinefunction(c.func)
|
||||||
]
|
]
|
||||||
multiprocess_mode = _is_multiprocess()
|
multiprocess_mode = _is_multiprocess()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Pytest helper utilities for FastAPI testing."""
|
"""Pytest helper utilities for FastAPI testing."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import warnings
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -16,31 +15,10 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import cleanup_tables as _cleanup_tables
|
from ..db import cleanup_tables, create_database
|
||||||
from ..db import create_database
|
|
||||||
from ..models.watched import EventSession
|
from ..models.watched import EventSession
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Import ``cleanup_tables`` from ``fastapi_toolsets.db`` instead.
|
|
||||||
This re-export will be removed in v3.0.0.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
|
||||||
"Importing cleanup_tables from fastapi_toolsets.pytest is deprecated "
|
|
||||||
"and will be removed in v3.0.0. "
|
|
||||||
"Use 'from fastapi_toolsets.db import cleanup_tables' instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
await _cleanup_tables(session=session, base=base)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_xdist_worker(default_test_db: str) -> str:
|
def _get_xdist_worker(default_test_db: str) -> str:
|
||||||
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
||||||
|
|
||||||
@@ -273,7 +251,7 @@ async def create_db_session(
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
if cleanup:
|
if cleanup:
|
||||||
await _cleanup_tables(session=session, base=base)
|
await cleanup_tables(session=session, base=base)
|
||||||
|
|
||||||
if drop_tables:
|
if drop_tables:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
|||||||
@@ -163,6 +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
|
||||||
|
|
||||||
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
|||||||
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||||
|
|
||||||
# CRUD type aliases
|
# CRUD type aliases
|
||||||
JoinType = list[tuple[type[DeclarativeBase], Any]]
|
JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
|
||||||
|
LateralJoinType = list[tuple[Any, Any]]
|
||||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||||
|
|
||||||
# Search / facet type aliases
|
# Search / facet / order 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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -12,6 +13,7 @@ from sqlalchemy import (
|
|||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Enum as SAEnum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -139,6 +141,46 @@ class Post(Base):
|
|||||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(int, Enum):
|
||||||
|
"""Integer-backed enum for order status."""
|
||||||
|
|
||||||
|
PENDING = 1
|
||||||
|
PROCESSING = 2
|
||||||
|
SHIPPED = 3
|
||||||
|
CANCELLED = 4
|
||||||
|
|
||||||
|
|
||||||
|
class Color(str, Enum):
|
||||||
|
"""String-backed enum for color."""
|
||||||
|
|
||||||
|
RED = "red"
|
||||||
|
GREEN = "green"
|
||||||
|
BLUE = "blue"
|
||||||
|
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
"""Test model with an IntEnum column (Enum(int, Enum)) and a raw Integer column."""
|
||||||
|
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus))
|
||||||
|
priority: Mapped[int] = mapped_column(Integer)
|
||||||
|
color: Mapped[Color] = mapped_column(SAEnum(Color))
|
||||||
|
|
||||||
|
|
||||||
|
class Transfer(Base):
|
||||||
|
"""Test model with two FKs to the same table (users)."""
|
||||||
|
|
||||||
|
__tablename__ = "transfers"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
amount: Mapped[str] = mapped_column(String(50))
|
||||||
|
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||||
|
receiver_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||||
|
|
||||||
|
|
||||||
class Article(Base):
|
class Article(Base):
|
||||||
"""Test article model with ARRAY and JSON columns."""
|
"""Test article model with ARRAY and JSON columns."""
|
||||||
|
|
||||||
@@ -300,6 +342,44 @@ class ArticleRead(PydanticBase):
|
|||||||
labels: list[str]
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCreate(BaseModel):
|
||||||
|
"""Schema for creating an order."""
|
||||||
|
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
name: str
|
||||||
|
status: OrderStatus
|
||||||
|
priority: int = 0
|
||||||
|
color: Color = Color.RED
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRead(PydanticBase):
|
||||||
|
"""Schema for reading an order."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
status: OrderStatus
|
||||||
|
priority: int
|
||||||
|
color: Color
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCreate(BaseModel):
|
||||||
|
"""Schema for creating a transfer."""
|
||||||
|
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
amount: str
|
||||||
|
sender_id: uuid.UUID
|
||||||
|
receiver_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class TransferRead(PydanticBase):
|
||||||
|
"""Schema for reading a transfer."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
amount: str
|
||||||
|
|
||||||
|
|
||||||
|
OrderCrud = CrudFactory(Order)
|
||||||
|
TransferCrud = CrudFactory(Transfer)
|
||||||
ArticleCrud = CrudFactory(Article)
|
ArticleCrud = CrudFactory(Article)
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import pytest
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from fastapi_toolsets.crud import CrudFactory, PaginationType
|
from fastapi_toolsets.crud import CrudFactory, PaginationType, lateral_load
|
||||||
from fastapi_toolsets.crud.factory import AsyncCrud, _CursorDirection
|
from fastapi_toolsets.crud.factory import (
|
||||||
|
AsyncCrud,
|
||||||
|
_CursorDirection,
|
||||||
|
_LateralLoad,
|
||||||
|
_ResolvedLateral,
|
||||||
|
)
|
||||||
from fastapi_toolsets.exceptions import NotFoundError
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
|
from fastapi_toolsets.schemas import PydanticBase
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
EventCreate,
|
EventCreate,
|
||||||
@@ -38,6 +44,10 @@ from .conftest import (
|
|||||||
Tag,
|
Tag,
|
||||||
TagCreate,
|
TagCreate,
|
||||||
TagCrud,
|
TagCrud,
|
||||||
|
Transfer,
|
||||||
|
TransferCreate,
|
||||||
|
TransferCrud,
|
||||||
|
TransferRead,
|
||||||
User,
|
User,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserCrud,
|
UserCrud,
|
||||||
@@ -47,6 +57,12 @@ from .conftest import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserWithRoleRead(PydanticBase):
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
role: RoleRead | None = None
|
||||||
|
|
||||||
|
|
||||||
class TestCrudFactory:
|
class TestCrudFactory:
|
||||||
"""Tests for CrudFactory."""
|
"""Tests for CrudFactory."""
|
||||||
|
|
||||||
@@ -204,11 +220,11 @@ class TestResolveLoadOptions:
|
|||||||
assert crud._resolve_load_options(None) is None
|
assert crud._resolve_load_options(None) is None
|
||||||
|
|
||||||
def test_empty_list_overrides_default(self):
|
def test_empty_list_overrides_default(self):
|
||||||
"""An empty list is a valid override and disables default_load_options."""
|
"""An explicit empty list disables default_load_options (no options applied)."""
|
||||||
default = [selectinload(User.role)]
|
default = [selectinload(User.role)]
|
||||||
crud = CrudFactory(User, default_load_options=default)
|
crud = CrudFactory(User, default_load_options=default)
|
||||||
# Empty list is not None, so it should replace default
|
# Empty list replaces default; None and [] are both falsy → no options applied
|
||||||
assert crud._resolve_load_options([]) == []
|
assert not crud._resolve_load_options([])
|
||||||
|
|
||||||
|
|
||||||
class TestResolveSearchColumns:
|
class TestResolveSearchColumns:
|
||||||
@@ -243,6 +259,60 @@ class TestResolveSearchColumns:
|
|||||||
assert "username" not in result
|
assert "username" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveOrderColumns:
|
||||||
|
"""Tests for _resolve_order_columns logic."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_order_fields(self):
|
||||||
|
"""Returns None when cls.order_fields is None and no order_fields passed."""
|
||||||
|
|
||||||
|
class AbstractCrud(AsyncCrud[User]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert AbstractCrud._resolve_order_columns(None) is None
|
||||||
|
|
||||||
|
def test_returns_none_when_empty_order_fields_passed(self):
|
||||||
|
"""Returns None when an empty list is passed explicitly."""
|
||||||
|
crud = CrudFactory(User)
|
||||||
|
assert crud._resolve_order_columns([]) is None
|
||||||
|
|
||||||
|
def test_returns_keys_from_class_order_fields(self):
|
||||||
|
"""Returns sorted column keys from cls.order_fields when no override passed."""
|
||||||
|
crud = CrudFactory(User, order_fields=[User.username])
|
||||||
|
result = crud._resolve_order_columns(None)
|
||||||
|
assert result is not None
|
||||||
|
assert "username" in result
|
||||||
|
|
||||||
|
def test_order_fields_override_takes_priority(self):
|
||||||
|
"""Explicit order_fields override cls.order_fields."""
|
||||||
|
crud = CrudFactory(User, order_fields=[User.username])
|
||||||
|
result = crud._resolve_order_columns([User.email])
|
||||||
|
assert result is not None
|
||||||
|
assert "email" in result
|
||||||
|
assert "username" not in result
|
||||||
|
|
||||||
|
def test_returns_sorted_keys(self):
|
||||||
|
"""Keys are returned in sorted order."""
|
||||||
|
crud = CrudFactory(User, order_fields=[User.email, User.username])
|
||||||
|
result = crud._resolve_order_columns(None)
|
||||||
|
assert result is not None
|
||||||
|
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."""
|
||||||
|
|
||||||
@@ -301,13 +371,6 @@ class TestDefaultLoadOptionsIntegration:
|
|||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""default_load_options loads relationships automatically on offset_paginate()."""
|
"""default_load_options loads relationships automatically on offset_paginate()."""
|
||||||
from fastapi_toolsets.schemas import PydanticBase
|
|
||||||
|
|
||||||
class UserWithRoleRead(PydanticBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
role: RoleRead | None = None
|
|
||||||
|
|
||||||
UserWithDefaultLoad = CrudFactory(
|
UserWithDefaultLoad = CrudFactory(
|
||||||
User, default_load_options=[selectinload(User.role)]
|
User, default_load_options=[selectinload(User.role)]
|
||||||
)
|
)
|
||||||
@@ -322,6 +385,43 @@ 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
|
||||||
@@ -1282,6 +1382,128 @@ class TestCrudJoins:
|
|||||||
assert users[0].username == "multi_join"
|
assert users[0].username == "multi_join"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrudAliasedJoins:
|
||||||
|
"""Tests for CRUD operations with aliased joins (same table joined twice)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_multi_with_aliased_joins(self, db_session: AsyncSession):
|
||||||
|
"""Aliased joins allow joining the same table twice."""
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
alice = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="alice@test.com")
|
||||||
|
)
|
||||||
|
bob = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="bob@test.com")
|
||||||
|
)
|
||||||
|
await TransferCrud.create(
|
||||||
|
db_session,
|
||||||
|
TransferCreate(amount="100", sender_id=alice.id, receiver_id=bob.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
Sender = aliased(User)
|
||||||
|
Receiver = aliased(User)
|
||||||
|
|
||||||
|
results = await TransferCrud.get_multi(
|
||||||
|
db_session,
|
||||||
|
joins=[
|
||||||
|
(Sender, Transfer.sender_id == Sender.id),
|
||||||
|
(Receiver, Transfer.receiver_id == Receiver.id),
|
||||||
|
],
|
||||||
|
filters=[Sender.username == "alice", Receiver.username == "bob"],
|
||||||
|
)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].amount == "100"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_multi_aliased_no_match(self, db_session: AsyncSession):
|
||||||
|
"""Aliased joins correctly filter out non-matching rows."""
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
alice = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="alice@test.com")
|
||||||
|
)
|
||||||
|
bob = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="bob@test.com")
|
||||||
|
)
|
||||||
|
await TransferCrud.create(
|
||||||
|
db_session,
|
||||||
|
TransferCreate(amount="100", sender_id=alice.id, receiver_id=bob.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
Sender = aliased(User)
|
||||||
|
Receiver = aliased(User)
|
||||||
|
|
||||||
|
# bob is receiver, not sender — should return nothing
|
||||||
|
results = await TransferCrud.get_multi(
|
||||||
|
db_session,
|
||||||
|
joins=[
|
||||||
|
(Sender, Transfer.sender_id == Sender.id),
|
||||||
|
(Receiver, Transfer.receiver_id == Receiver.id),
|
||||||
|
],
|
||||||
|
filters=[Sender.username == "bob", Receiver.username == "alice"],
|
||||||
|
)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_paginate_with_aliased_joins(self, db_session: AsyncSession):
|
||||||
|
"""Aliased joins work with offset_paginate."""
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
alice = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="alice@test.com")
|
||||||
|
)
|
||||||
|
bob = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="bob@test.com")
|
||||||
|
)
|
||||||
|
await TransferCrud.create(
|
||||||
|
db_session,
|
||||||
|
TransferCreate(amount="50", sender_id=alice.id, receiver_id=bob.id),
|
||||||
|
)
|
||||||
|
await TransferCrud.create(
|
||||||
|
db_session,
|
||||||
|
TransferCreate(amount="75", sender_id=bob.id, receiver_id=alice.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
Sender = aliased(User)
|
||||||
|
result = await TransferCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
joins=[(Sender, Transfer.sender_id == Sender.id)],
|
||||||
|
filters=[Sender.username == "alice"],
|
||||||
|
schema=TransferRead,
|
||||||
|
)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].amount == "50"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_count_with_aliased_join(self, db_session: AsyncSession):
|
||||||
|
"""Aliased joins work with count."""
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
alice = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="alice@test.com")
|
||||||
|
)
|
||||||
|
bob = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="bob@test.com")
|
||||||
|
)
|
||||||
|
await TransferCrud.create(
|
||||||
|
db_session,
|
||||||
|
TransferCreate(amount="10", sender_id=alice.id, receiver_id=bob.id),
|
||||||
|
)
|
||||||
|
await TransferCrud.create(
|
||||||
|
db_session,
|
||||||
|
TransferCreate(amount="20", sender_id=alice.id, receiver_id=bob.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
Sender = aliased(User)
|
||||||
|
count = await TransferCrud.count(
|
||||||
|
db_session,
|
||||||
|
joins=[(Sender, Transfer.sender_id == Sender.id)],
|
||||||
|
filters=[Sender.username == "alice"],
|
||||||
|
)
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
class TestCrudFactoryM2M:
|
class TestCrudFactoryM2M:
|
||||||
"""Tests for CrudFactory with m2m_fields parameter."""
|
"""Tests for CrudFactory with m2m_fields parameter."""
|
||||||
|
|
||||||
@@ -2245,12 +2467,7 @@ class TestCursorPaginateExtraOptions:
|
|||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_with_load_options(self, db_session: AsyncSession):
|
async def test_with_load_options(self, db_session: AsyncSession):
|
||||||
"""cursor_paginate passes load_options to the query."""
|
"""cursor_paginate passes load_options to the query."""
|
||||||
from fastapi_toolsets.schemas import CursorPagination, PydanticBase
|
from fastapi_toolsets.schemas import CursorPagination
|
||||||
|
|
||||||
class UserWithRoleRead(PydanticBase):
|
|
||||||
id: uuid.UUID
|
|
||||||
username: str
|
|
||||||
role: RoleRead | None = None
|
|
||||||
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
|
role = await RoleCrud.create(db_session, RoleCreate(name="manager"))
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
@@ -2616,3 +2833,445 @@ class TestPaginate:
|
|||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count is None
|
assert result.pagination.total_count is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestLateralLoadValidation:
|
||||||
|
"""lateral_load() raises immediately for bad relationship types."""
|
||||||
|
|
||||||
|
def test_valid_many_to_one_returns_marker(self):
|
||||||
|
"""lateral_load() on a Many:One rel returns a _LateralLoad with rel_attr set."""
|
||||||
|
marker = lateral_load(User.role)
|
||||||
|
assert isinstance(marker, _LateralLoad)
|
||||||
|
assert marker.rel_attr is User.role
|
||||||
|
|
||||||
|
def test_raises_type_error_for_plain_column(self):
|
||||||
|
"""lateral_load() raises TypeError when passed a plain column."""
|
||||||
|
with pytest.raises(TypeError, match="relationship attribute"):
|
||||||
|
lateral_load(User.username)
|
||||||
|
|
||||||
|
def test_raises_value_error_for_many_to_many(self):
|
||||||
|
"""lateral_load() raises ValueError for Many:Many (secondary table)."""
|
||||||
|
with pytest.raises(ValueError, match="Many:Many"):
|
||||||
|
lateral_load(Post.tags)
|
||||||
|
|
||||||
|
def test_raises_value_error_for_one_to_many(self):
|
||||||
|
"""lateral_load() raises ValueError for One:Many (uselist=True)."""
|
||||||
|
with pytest.raises(ValueError, match="One:Many"):
|
||||||
|
lateral_load(Role.users)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLateralLoadInSubclass:
|
||||||
|
"""lateral_load() markers in default_load_options are processed at class definition."""
|
||||||
|
|
||||||
|
def test_marker_extracted_from_default_load_options(self):
|
||||||
|
"""_LateralLoad is removed from default_load_options and stored in _resolved_lateral."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
assert UserLateralCrud.default_load_options is None
|
||||||
|
assert UserLateralCrud._resolved_lateral is not None
|
||||||
|
|
||||||
|
def test_resolved_lateral_has_one_join_and_eager(self):
|
||||||
|
"""_resolved_lateral contains exactly one join and one eager option."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
resolved = UserLateralCrud._resolved_lateral
|
||||||
|
assert isinstance(resolved, _ResolvedLateral)
|
||||||
|
assert len(resolved.joins) == 1
|
||||||
|
assert len(resolved.eager) == 1
|
||||||
|
|
||||||
|
def test_regular_options_preserved_alongside_lateral(self):
|
||||||
|
"""Non-lateral opts stay in default_load_options; lateral marker is extracted."""
|
||||||
|
regular = selectinload(User.role)
|
||||||
|
|
||||||
|
class UserMixedCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role), regular]
|
||||||
|
|
||||||
|
assert UserMixedCrud._resolved_lateral is not None
|
||||||
|
assert UserMixedCrud.default_load_options == [regular]
|
||||||
|
|
||||||
|
def test_no_lateral_leaves_default_load_options_untouched(self):
|
||||||
|
"""When no lateral marker is present, default_load_options is unchanged."""
|
||||||
|
opts = [selectinload(User.role)]
|
||||||
|
|
||||||
|
class UserNormalCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = opts
|
||||||
|
|
||||||
|
assert UserNormalCrud.default_load_options is opts
|
||||||
|
assert UserNormalCrud._resolved_lateral is None
|
||||||
|
|
||||||
|
def test_no_default_load_options_leaves_resolved_lateral_none(self):
|
||||||
|
"""_resolved_lateral stays None when default_load_options is not set."""
|
||||||
|
|
||||||
|
class UserPlainCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
|
||||||
|
assert UserPlainCrud._resolved_lateral is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveLoadOptionsWithLateral:
|
||||||
|
"""_resolve_load_options always appends lateral eager options."""
|
||||||
|
|
||||||
|
def test_lateral_eager_included_when_no_call_site_opts(self):
|
||||||
|
"""contains_eager from lateral_load is returned when load_options=None."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
resolved = UserLateralCrud._resolve_load_options(None)
|
||||||
|
assert resolved is not None
|
||||||
|
assert len(resolved) == 1 # the contains_eager
|
||||||
|
|
||||||
|
def test_call_site_opts_bypass_lateral_eager(self):
|
||||||
|
"""When call-site load_options are provided, lateral eager is NOT appended."""
|
||||||
|
extra = selectinload(User.role)
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
resolved = UserLateralCrud._resolve_load_options([extra])
|
||||||
|
assert resolved is not None
|
||||||
|
assert len(resolved) == 1 # only the call-site option; lateral eager skipped
|
||||||
|
|
||||||
|
def test_lateral_eager_appended_to_default_load_options(self):
|
||||||
|
"""default_load_options (regular) + lateral eager are both returned."""
|
||||||
|
regular = selectinload(User.role)
|
||||||
|
|
||||||
|
class UserMixedCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role), regular]
|
||||||
|
|
||||||
|
resolved = UserMixedCrud._resolve_load_options(None)
|
||||||
|
assert resolved is not None
|
||||||
|
assert len(resolved) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLateralJoins:
|
||||||
|
"""_get_lateral_joins merges auto-resolved and manual lateral_joins."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_lateral_configured(self):
|
||||||
|
"""Returns None when neither lateral_joins nor lateral_load is set."""
|
||||||
|
|
||||||
|
class UserPlainCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
|
||||||
|
assert UserPlainCrud._get_lateral_joins() is None
|
||||||
|
|
||||||
|
def test_returns_resolved_lateral_joins(self):
|
||||||
|
"""Returns the join tuple built from lateral_load()."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
joins = UserLateralCrud._get_lateral_joins()
|
||||||
|
assert joins is not None
|
||||||
|
assert len(joins) == 1
|
||||||
|
|
||||||
|
def test_manual_lateral_joins_included(self):
|
||||||
|
"""Manual lateral_joins class var is included in _get_lateral_joins."""
|
||||||
|
from sqlalchemy import select, true
|
||||||
|
|
||||||
|
manual_sub = select(Role).where(Role.id == User.role_id).lateral("_manual_role")
|
||||||
|
|
||||||
|
class UserManualCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
lateral_joins = [(manual_sub, true())]
|
||||||
|
|
||||||
|
joins = UserManualCrud._get_lateral_joins()
|
||||||
|
assert joins is not None
|
||||||
|
assert len(joins) == 1
|
||||||
|
|
||||||
|
def test_manual_and_auto_lateral_joins_merged(self):
|
||||||
|
"""Both manual lateral_joins and auto-resolved from lateral_load are combined."""
|
||||||
|
from sqlalchemy import select, true
|
||||||
|
|
||||||
|
manual_sub = select(Role).where(Role.id == User.role_id).lateral("_manual_role")
|
||||||
|
|
||||||
|
class UserBothCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
lateral_joins = [(manual_sub, true())]
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
joins = UserBothCrud._get_lateral_joins()
|
||||||
|
assert joins is not None
|
||||||
|
assert len(joins) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestLateralLoadIntegration:
|
||||||
|
"""lateral_load() in real DB queries: relationship loaded, pagination correct."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_loads_relationship(self, db_session: AsyncSession):
|
||||||
|
"""get() populates the relationship via lateral join."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
user = await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = await UserLateralCrud.get(db_session, [User.id == user.id])
|
||||||
|
assert fetched.role is not None
|
||||||
|
assert fetched.role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_null_fk_preserved(self, db_session: AsyncSession):
|
||||||
|
"""User with null role_id still returned (LEFT JOIN behaviour)."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
user = await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="bob@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = await UserLateralCrud.get(db_session, [User.id == user.id])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.role is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_first_loads_relationship(self, db_session: AsyncSession):
|
||||||
|
"""first() populates the relationship via lateral join."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="carol", email="carol@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await UserLateralCrud.first(db_session)
|
||||||
|
assert user is not None
|
||||||
|
assert user.role is not None
|
||||||
|
assert user.role.name == "editor"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_multi_loads_relationship(self, db_session: AsyncSession):
|
||||||
|
"""get_multi() populates the relationship via lateral join for all rows."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="member"))
|
||||||
|
for i in range(3):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"user{i}", email=f"u{i}@test.com", role_id=role.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
users = await UserLateralCrud.get_multi(db_session)
|
||||||
|
assert len(users) == 3
|
||||||
|
assert all(u.role is not None and u.role.name == "member" for u in users)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_paginate_correct_count(self, db_session: AsyncSession):
|
||||||
|
"""offset_paginate total_count is not inflated by the lateral join."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
for i in range(5):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"user{i}", email=f"u{i}@test.com", role_id=role.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserLateralCrud.offset_paginate(
|
||||||
|
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||||
|
)
|
||||||
|
assert result.pagination.total_count == 5
|
||||||
|
assert len(result.data) == 5
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_paginate_loads_relationship(self, db_session: AsyncSession):
|
||||||
|
"""offset_paginate serializes relationship data loaded via lateral."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserLateralCrud.offset_paginate(
|
||||||
|
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||||
|
)
|
||||||
|
assert result.data[0].role is not None
|
||||||
|
assert result.data[0].role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_paginate_mixed_null_fk(self, db_session: AsyncSession):
|
||||||
|
"""offset_paginate returns all users including those with null role_id."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="with_role", email="a@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="no_role", email="b@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserLateralCrud.offset_paginate(
|
||||||
|
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||||
|
)
|
||||||
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_paginate_loads_relationship(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate populates the relationship via lateral join."""
|
||||||
|
|
||||||
|
class UserLateralCursorCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
cursor_column = User.id
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
for i in range(3):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(
|
||||||
|
username=f"user{i}", email=f"u{i}@test.com", role_id=role.id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserLateralCursorCrud.cursor_paginate(
|
||||||
|
db_session, schema=UserWithRoleRead, items_per_page=10
|
||||||
|
)
|
||||||
|
assert len(result.data) == 3
|
||||||
|
assert all(item.role is not None for item in result.data)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_paginate_with_search_and_lateral(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""search filter works alongside lateral join."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
searchable_fields = [User.username]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="a@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="bob", email="b@test.com", role_id=role.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserLateralCrud.offset_paginate(
|
||||||
|
db_session, schema=UserWithRoleRead, search="alice", items_per_page=10
|
||||||
|
)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].username == "alice"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_first_call_site_load_options_bypasses_lateral(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""When load_options is provided, lateral join is skipped (no conflict)."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
user = await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Passing explicit load_options bypasses the lateral join — role loaded via selectinload
|
||||||
|
fetched = await UserLateralCrud.first(
|
||||||
|
db_session,
|
||||||
|
filters=[User.id == user.id],
|
||||||
|
load_options=[selectinload(User.role)],
|
||||||
|
)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.role is not None
|
||||||
|
assert fetched.role.name == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_multi_call_site_load_options_bypasses_lateral(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""When load_options is provided, lateral join is skipped (no conflict)."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="viewer"))
|
||||||
|
for i in range(2):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username=f"u{i}", email=f"u{i}@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Passing explicit load_options bypasses the lateral join — role loaded via selectinload
|
||||||
|
users = await UserLateralCrud.get_multi(
|
||||||
|
db_session, load_options=[selectinload(User.role)]
|
||||||
|
)
|
||||||
|
assert len(users) == 2
|
||||||
|
assert all(u.role is not None and u.role.name == "viewer" for u in users)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_paginate_call_site_load_options_bypasses_lateral(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""When load_options is provided, lateral join is skipped (no conflict)."""
|
||||||
|
|
||||||
|
class UserLateralCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
default_load_options = [lateral_load(User.role)]
|
||||||
|
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||||
|
for i in range(3):
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username=f"e{i}", email=f"e{i}@test.com", role_id=role.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Passing explicit load_options bypasses the lateral join — role loaded via selectinload
|
||||||
|
result = await UserLateralCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
schema=UserWithRoleRead,
|
||||||
|
items_per_page=10,
|
||||||
|
load_options=[selectinload(User.role)],
|
||||||
|
)
|
||||||
|
assert result.pagination.total_count == 3
|
||||||
|
assert all(item.role is not None for item in result.data)
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ from .conftest import (
|
|||||||
ArticleCreate,
|
ArticleCreate,
|
||||||
ArticleCrud,
|
ArticleCrud,
|
||||||
ArticleRead,
|
ArticleRead,
|
||||||
|
Color,
|
||||||
|
Order,
|
||||||
|
OrderCreate,
|
||||||
|
OrderCrud,
|
||||||
|
OrderRead,
|
||||||
|
OrderStatus,
|
||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
@@ -697,6 +703,67 @@ class TestFacetsRelationship:
|
|||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert result.filter_attributes["role__name"] == ["admin"]
|
assert result.filter_attributes["role__name"] == ["admin"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_relationship_search_and_filter_by_same_join(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Search + filter_by on the same relationship must not duplicate the JOIN."""
|
||||||
|
UserSearchFacetCrud = CrudFactory(
|
||||||
|
User,
|
||||||
|
searchable_fields=[(User.role, Role.name)],
|
||||||
|
facet_fields=[(User.role, Role.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
editor = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="a@test.com", role_id=admin.id),
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="bob", email="b@test.com", role_id=editor.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search by role name AND filter by role name — both need the same join
|
||||||
|
result = await UserSearchFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
search="admin",
|
||||||
|
filter_by={"role__name": "admin"},
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.data) == 1
|
||||||
|
assert result.data[0].username == "alice"
|
||||||
|
assert result.filter_attributes is not None
|
||||||
|
assert result.filter_attributes["role__name"] == ["admin"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_paginate_duplicate_join(self, db_session: AsyncSession):
|
||||||
|
"""cursor_paginate with overlapping search + facet joins must not fail."""
|
||||||
|
UserSearchFacetCursorCrud = CrudFactory(
|
||||||
|
User,
|
||||||
|
searchable_fields=[(User.role, Role.name)],
|
||||||
|
facet_fields=[(User.role, Role.name)],
|
||||||
|
cursor_column=User.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session,
|
||||||
|
UserCreate(username="alice", email="a@test.com", role_id=admin.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserSearchFacetCursorCrud.cursor_paginate(
|
||||||
|
db_session,
|
||||||
|
search="admin",
|
||||||
|
filter_by={"role__name": "admin"},
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.data) == 1
|
||||||
|
assert result.data[0].username == "alice"
|
||||||
|
|
||||||
|
|
||||||
class TestFilterBy:
|
class TestFilterBy:
|
||||||
"""Tests for the filter_by parameter on offset_paginate and cursor_paginate."""
|
"""Tests for the filter_by parameter on offset_paginate and cursor_paginate."""
|
||||||
@@ -910,7 +977,7 @@ class TestFilterBy:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_bool_filter_false(self, db_session: AsyncSession):
|
async def test_bool_filter_false(self, db_session: AsyncSession):
|
||||||
"""filter_by with a boolean False value correctly filters rows."""
|
"""filter_by with a string 'false' value correctly filters rows."""
|
||||||
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
||||||
@@ -921,7 +988,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserBoolCrud.offset_paginate(
|
result = await UserBoolCrud.offset_paginate(
|
||||||
db_session, filter_by={"is_active": False}, schema=UserRead
|
db_session, filter_by={"is_active": "false"}, schema=UserRead
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -930,7 +997,7 @@ class TestFilterBy:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_bool_filter_true(self, db_session: AsyncSession):
|
async def test_bool_filter_true(self, db_session: AsyncSession):
|
||||||
"""filter_by with a boolean True value correctly filters rows."""
|
"""filter_by with a string 'true' value correctly filters rows."""
|
||||||
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
||||||
@@ -941,7 +1008,7 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserBoolCrud.offset_paginate(
|
result = await UserBoolCrud.offset_paginate(
|
||||||
db_session, filter_by={"is_active": True}, schema=UserRead
|
db_session, filter_by={"is_active": "true"}, schema=UserRead
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
@@ -950,7 +1017,7 @@ class TestFilterBy:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_bool_filter_list(self, db_session: AsyncSession):
|
async def test_bool_filter_list(self, db_session: AsyncSession):
|
||||||
"""filter_by with a list of booleans produces an IN clause."""
|
"""filter_by with a list of string booleans produces an IN clause."""
|
||||||
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
||||||
@@ -961,12 +1028,41 @@ class TestFilterBy:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await UserBoolCrud.offset_paginate(
|
result = await UserBoolCrud.offset_paginate(
|
||||||
db_session, filter_by={"is_active": [True, False]}, schema=UserRead
|
db_session, filter_by={"is_active": ["true", "false"]}, schema=UserRead
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bool_filter_native_bool(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a native Python bool passes through coercion."""
|
||||||
|
UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active])
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserBoolCrud.offset_paginate(
|
||||||
|
db_session, filter_by={"is_active": True}, schema=UserRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
|
||||||
|
def test_bool_coerce_invalid_value(self):
|
||||||
|
"""_coerce_bool raises ValueError for non-bool, non-string values."""
|
||||||
|
from fastapi_toolsets.crud.search import _coerce_bool
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Cannot coerce"):
|
||||||
|
_coerce_bool(42)
|
||||||
|
|
||||||
|
def test_bool_coerce_invalid_string(self):
|
||||||
|
"""_coerce_bool raises ValueError for unrecognized string values."""
|
||||||
|
from fastapi_toolsets.crud.search import _coerce_bool
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Cannot coerce"):
|
||||||
|
_coerce_bool("maybe")
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_array_contains_single_value(self, db_session: AsyncSession):
|
async def test_array_contains_single_value(self, db_session: AsyncSession):
|
||||||
"""filter_by on an ARRAY column with a scalar checks containment."""
|
"""filter_by on an ARRAY column with a scalar checks containment."""
|
||||||
@@ -1031,6 +1127,253 @@ class TestFilterBy:
|
|||||||
assert "JSON" in exc_info.value.col_type
|
assert "JSON" in exc_info.value.col_type
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterByIntEnum:
|
||||||
|
"""Tests for filter_by on columns typed as (int, Enum) / IntEnum."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_intenum_member(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with an IntEnum member value filters correctly."""
|
||||||
|
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-3", status=OrderStatus.PENDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"status": OrderStatus.PENDING},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 2
|
||||||
|
names = {o.name for o in result.data}
|
||||||
|
assert names == {"order-1", "order-3"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_plain_int_value_raises(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a plain int on an IntEnum column raises KeyError — use name or member."""
|
||||||
|
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await OrderFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"status": 1},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_intenum_list(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a list of IntEnum members produces an IN filter."""
|
||||||
|
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-3", status=OrderStatus.CANCELLED)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"status": [OrderStatus.PENDING, OrderStatus.SHIPPED]},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 2
|
||||||
|
names = {o.name for o in result.data}
|
||||||
|
assert names == {"order-1", "order-2"}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_plain_int_list_raises(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a list of plain ints on an IntEnum column raises KeyError — use names or members."""
|
||||||
|
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await OrderFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"status": [1, 3]},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_intenum_name_string(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with the enum member name as a string filters correctly."""
|
||||||
|
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={
|
||||||
|
"status": "PENDING"
|
||||||
|
}, # name as string, e.g. from HTTP query param
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].name == "order-1"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_intenum_name_string_list(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a list of enum name strings produces an IN filter."""
|
||||||
|
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session, OrderCreate(name="order-3", status=OrderStatus.CANCELLED)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderFacetCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"status": ["PENDING", "SHIPPED"]},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 2
|
||||||
|
names = {o.name for o in result.data}
|
||||||
|
assert names == {"order-1", "order-2"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterByStrEnum:
|
||||||
|
"""Tests for filter_by on columns typed as (str, Enum) / StrEnum (lines 364-367)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_strenum_member(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a StrEnum member on a string Enum column filters correctly."""
|
||||||
|
OrderColorCrud = CrudFactory(Order, facet_fields=[Order.color])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(name="red-order", status=OrderStatus.PENDING, color=Color.RED),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="blue-order", status=OrderStatus.PENDING, color=Color.BLUE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderColorCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"color": Color.RED},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].name == "red-order"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_strenum_list(self, db_session: AsyncSession):
|
||||||
|
"""filter_by with a list of StrEnum members produces an IN filter."""
|
||||||
|
OrderColorCrud = CrudFactory(Order, facet_fields=[Order.color])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(name="red-order", status=OrderStatus.PENDING, color=Color.RED),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="green-order", status=OrderStatus.PENDING, color=Color.GREEN
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="blue-order", status=OrderStatus.PENDING, color=Color.BLUE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderColorCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"color": [Color.RED, Color.BLUE]},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 2
|
||||||
|
names = {o.name for o in result.data}
|
||||||
|
assert names == {"red-order", "blue-order"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterByIntegerColumn:
|
||||||
|
"""Tests for filter_by on plain Integer columns with IntEnum values."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_integer_column_with_intenum_member(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""filter_by with an IntEnum member on an Integer column works correctly."""
|
||||||
|
OrderPriorityCrud = CrudFactory(Order, facet_fields=[Order.priority])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="order-1", status=OrderStatus.PENDING, priority=OrderStatus.PENDING
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(
|
||||||
|
name="order-2", status=OrderStatus.SHIPPED, priority=OrderStatus.SHIPPED
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderPriorityCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={
|
||||||
|
"priority": OrderStatus.PENDING
|
||||||
|
}, # IntEnum member on Integer col
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].name == "order-1"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_filter_by_integer_column_with_plain_int(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""filter_by with a plain int on an Integer column works correctly."""
|
||||||
|
OrderPriorityCrud = CrudFactory(Order, facet_fields=[Order.priority])
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(name="order-1", status=OrderStatus.PENDING, priority=1),
|
||||||
|
)
|
||||||
|
await OrderCrud.create(
|
||||||
|
db_session,
|
||||||
|
OrderCreate(name="order-2", status=OrderStatus.SHIPPED, priority=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await OrderPriorityCrud.offset_paginate(
|
||||||
|
db_session,
|
||||||
|
filter_by={"priority": 1},
|
||||||
|
schema=OrderRead,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.pagination, OffsetPagination)
|
||||||
|
assert result.pagination.total_count == 1
|
||||||
|
assert result.data[0].name == "order-1"
|
||||||
|
|
||||||
|
|
||||||
class TestFilterParamsViaConsolidated:
|
class TestFilterParamsViaConsolidated:
|
||||||
"""Tests for filter params via consolidated offset_paginate_params()."""
|
"""Tests for filter params via consolidated offset_paginate_params()."""
|
||||||
|
|
||||||
@@ -1426,6 +1769,111 @@ class TestSearchColumns:
|
|||||||
assert result.data[0].username == "bob"
|
assert result.data[0].username == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderColumns:
|
||||||
|
"""Tests for order_columns in paginated responses."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_columns_returned_in_offset_paginate(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""offset_paginate response includes order_columns."""
|
||||||
|
UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
|
assert result.order_columns is not None
|
||||||
|
assert "username" in result.order_columns
|
||||||
|
assert "email" in result.order_columns
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_columns_returned_in_cursor_paginate(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""cursor_paginate response includes order_columns."""
|
||||||
|
UserSortCursorCrud = CrudFactory(
|
||||||
|
User,
|
||||||
|
cursor_column=User.id,
|
||||||
|
order_fields=[User.username, User.email],
|
||||||
|
)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
|
assert result.order_columns is not None
|
||||||
|
assert "username" in result.order_columns
|
||||||
|
assert "email" in result.order_columns
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_columns_none_when_no_order_fields(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""order_columns is None when no order_fields are configured."""
|
||||||
|
result = await UserCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
|
assert result.order_columns is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_columns_override_in_offset_paginate(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""order_fields override in offset_paginate is reflected in order_columns."""
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCrud.offset_paginate(
|
||||||
|
db_session, order_fields=[User.email], schema=UserRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.order_columns == ["email"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_columns_override_in_cursor_paginate(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""order_fields override in cursor_paginate is reflected in order_columns."""
|
||||||
|
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserCursorCrud.cursor_paginate(
|
||||||
|
db_session, order_fields=[User.username], schema=UserRead
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.order_columns == ["username"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_order_columns_are_sorted_alphabetically(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""order_columns keys are returned in alphabetical order."""
|
||||||
|
UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username])
|
||||||
|
await UserCrud.create(
|
||||||
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
|
assert result.order_columns is not None
|
||||||
|
assert result.order_columns == sorted(result.order_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:
|
||||||
"""Tests for order params via consolidated offset_paginate_params()."""
|
"""Tests for order params via consolidated offset_paginate_params()."""
|
||||||
|
|
||||||
@@ -1580,6 +2028,92 @@ 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()."""
|
||||||
|
|||||||
@@ -374,19 +374,6 @@ class TestCreateDbSession:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TestDeprecatedCleanupTables:
|
|
||||||
"""Tests for the deprecated cleanup_tables re-export in fastapi_toolsets.pytest."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_emits_deprecation_warning(self):
|
|
||||||
"""cleanup_tables imported from fastapi_toolsets.pytest emits DeprecationWarning."""
|
|
||||||
from fastapi_toolsets.pytest.utils import cleanup_tables
|
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
with pytest.warns(DeprecationWarning, match="fastapi_toolsets.db"):
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetXdistWorker:
|
class TestGetXdistWorker:
|
||||||
"""Tests for _get_xdist_worker helper."""
|
"""Tests for _get_xdist_worker helper."""
|
||||||
|
|
||||||
|
|||||||
110
uv.lock
generated
110
uv.lock
generated
@@ -305,7 +305,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.135.1"
|
version = "0.135.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -314,14 +314,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.3"
|
version = "3.0.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -1140,27 +1140,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.7"
|
version = "0.15.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1304,26 +1304,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.25"
|
version = "0.0.27"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1400,7 +1400,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.30"
|
version = "0.0.31"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1410,18 +1410,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/53/5e551f8912718816733a75adcb53a0787b2d2edca5869c156325aaf82e24/zensical-0.0.30.tar.gz", hash = "sha256:408b531683f6bcb6cc5ab928146d2c68afbc16fac4eda87ae3dd20af1498180f", size = 3844287, upload-time = "2026-03-28T17:55:52.836Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/e3/ac0eb77a8a7f793613813de68bde26776d0da68d8041fa9eb8d0b986a449/zensical-0.0.30-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b67fca8bfcd71c94b331045a591bf6e24fe123a66fba94587aa3379faf521a16", size = 12313786, upload-time = "2026-03-28T17:55:18.839Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/db/cc4e555d2e816f2d91304ff969d62cc3a401ee477dbb7c720b874bec67d6/zensical-0.0.31-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b489936d670733dd204f16b689a2acc0e45b69e42cc4901f5131ae57658b8fbc", size = 12419980, upload-time = "2026-04-01T11:29:44.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/6a/73e461dfa27d3bc415e48396f83a3287b43df2fd3361e25146bc86360aab/zensical-0.0.30-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8ceadfece1153edc26506e8ddf68d9818afe8517cf3bcdb6bfe4cb2793ae247b", size = 12186136, upload-time = "2026-03-28T17:55:21.836Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/c1/6789f73164c7f5821f5defb8a80b1dba8d5af24bdec7db36876793c5afd9/zensical-0.0.31-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9f678efc0d9918e45eeb8bc62847b2cce23db7393c8c59c1be6d1c064bbaacd", size = 12292301, upload-time = "2026-04-01T11:29:47.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/bc/9022156b4c28c1b95209acb64319b1e5cd0af2e97035bdd461e58408cb46/zensical-0.0.30-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e100b2b654337ac5306ba12818f3c5336c66d0d34c593ef05e316c124a5819cb", size = 12556115, upload-time = "2026-03-28T17:55:24.849Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/9a/6a83ad209081a953e0285d5056e5452c4fbcabd2f104f3797d53e4bdd96f/zensical-0.0.31-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b50ecf674997f818e53f12f2a67875a21b0c79ed74c151dfaef2f1475e5bf", size = 12661472, upload-time = "2026-04-01T11:29:50.706Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/29/9e8f5bd6d33b35f4c368ae8b13d431dc42b2de17ea6eccbd71d48122eba6/zensical-0.0.30-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdf641ffddaf21c6971b91a4426b81cd76271c5b1adb7176afcce3f1508328b1", size = 12498121, upload-time = "2026-03-28T17:55:27.637Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/4a/a82f5c81893b7a607cf9d439b75c3c3894b4ef4d3e92d5d818b4fa5c6f23/zensical-0.0.31-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6fb5c634fe88254770a2d4db5c05b06f1c3ee5e29d2ae3e7efdae8905e435b1d", size = 12603784, upload-time = "2026-04-01T11:29:53.623Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/e1/b8dfa0769050e62cd731358145fdeb67af35e322197bd7e7727250596e7b/zensical-0.0.30-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fd909a0c2116e26190c7f3ec4fb55837c417b7a8d99ebf4f3deb26b07b97e49", size = 12854142, upload-time = "2026-03-28T17:55:30.54Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/1c/79c198628b8e006be32dfb1c5b73561757a349a6cf3069600a67ffa62495/zensical-0.0.31-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e64630552793274db1ec66c971e49a15ad351536d5d12de67ec6da7358ac50", size = 12959832, upload-time = "2026-04-01T11:29:56.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/11/62a36cfb81522b6108db8f9e96d36da8cccb306b02c15ad19e1b333fa7c8/zensical-0.0.30-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16fd2da09fe4e5cbec2ca74f31abc70f32f7330d56593b647e0a114bb329171a", size = 12598341, upload-time = "2026-03-28T17:55:32.988Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/9d/45839d9ca0f69622e8a3b944f2d8d7f7d2b7c2da78201079c4feb275feb6/zensical-0.0.31-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:738a2fd5832e3b3c10ff642eebaf89c89ca1d28e4451dad0f36fdac53c415577", size = 12704024, upload-time = "2026-04-01T11:29:59.836Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/a4/8c7a6725fb226aa71d19209403d974e45f39d757e725f9558c6ed8d350a5/zensical-0.0.30-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:896b36eaef7fed5f8fc6f2c8264b2751aad63c2d66d3d8650e38481b6b4f6f7b", size = 12732307, upload-time = "2026-03-28T17:55:35.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/5f/451d7f4d94092bc38bd8d514826fb7b0329c188db506795b1d20bd07d517/zensical-0.0.31-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bd601f6132e285ef6c3e4c3852be2094fc0473295a8080003db76a79760f84fb", size = 12837788, upload-time = "2026-04-01T11:30:03.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/a1/7858fb3f6ac67d7d24a8acbe834cbe26851d6bd151ece6fba3fc88b0f878/zensical-0.0.30-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a1f515ec67a0d0250e53846327bf0c69635a1f39749da3b04feb68431188d3c6", size = 12770962, upload-time = "2026-03-28T17:55:38.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/39/390a8fc384fb174ebd4450343a0aa2877b3a31ddcedf5ef0b8d26944e12c/zensical-0.0.31-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc3b6a9dfb5903c0aa779ef65cd6185add2b8aa1db237be840874b8c9db761b8", size = 12876822, upload-time = "2026-04-01T11:30:06.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/b7/228298112a69d0b74e6e93041bffcf1fc96d03cf252be94a354f277d4789/zensical-0.0.30-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ce33d1002438838a35fa43358a1f43d74f874586596d3d116999d3756cded00e", size = 12919256, upload-time = "2026-03-28T17:55:41.413Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/60/640da2f095782cf38974cd851fb7afa62651d09a36543a1d8942b31aabdc/zensical-0.0.31-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ddd4321b275e82c4897aa45b05038ce204b88fb311ad55f8c2af572173a9b56c", size = 13024036, upload-time = "2026-04-01T11:30:09.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/c7/5b4ea036f7f7d84abf907f7f7a3e8420b054c89279c5273ca248d3bc9f48/zensical-0.0.30-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:029dad561568f4ae3056dde16a81012efd92c426d4eb7101f960f448c1168196", size = 12869760, upload-time = "2026-03-28T17:55:44.474Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/06/0564377cbfccea3653254adfa851c1b20d1696e4b16770c7b2e1dd1ef1d7/zensical-0.0.31-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:147ab4bc17f3088f703aa6c4b9c416411f4ea8ca64d26f6586beae49d97fd3c7", size = 12975505, upload-time = "2026-04-01T11:30:12.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/b4/77bef2132e43108db718ae014a5961fc511e88fc446c11f1c3483def429e/zensical-0.0.30-cp310-abi3-win32.whl", hash = "sha256:0105672850f053c326fba9fdd95adf60e9f90308f8cc1c08e3a00e15a8d5e90f", size = 11905658, upload-time = "2026-03-28T17:55:47.416Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/4b/b8a0c4e5937cb05882dcce667798403e00897135080a69f92363e5e3ff9f/zensical-0.0.31-cp310-abi3-win32.whl", hash = "sha256:03fa11e629a308507693489541f43e751697784e94365e7435b02104aefd1c2c", size = 12011233, upload-time = "2026-04-01T11:30:15.496Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/59/23b6c7ff062e2b299cc60e333095e853f9d38d1b5abe743c7b94c4ac432c/zensical-0.0.30-cp310-abi3-win_amd64.whl", hash = "sha256:b879dbf4c69d3ea41694bae33e1b948847e635dcbcd6ec8c522920833379dd48", size = 12101867, upload-time = "2026-03-28T17:55:50.083Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/99/0eacdb466d344c0c86596932201268517be42f3e0bb6c78b2b0cd84c55f6/zensical-0.0.31-cp310-abi3-win_amd64.whl", hash = "sha256:d6621d4bb46af4143560045d4a18c8c76302db56bf1dbb6e2ce107d7fb643e09", size = 12207545, upload-time = "2026-04-01T11:30:19.054Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
site_name = "FastAPI Toolsets"
|
site_name = "FastAPI Toolsets"
|
||||||
site_description = "Production-ready utilities for FastAPI applications."
|
site_description = "Production-ready utilities for FastAPI applications."
|
||||||
site_author = "d3vyce"
|
site_author = "d3vyce"
|
||||||
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
site_url = "https://fastapi-toolsets.d3vyce.fr/"
|
||||||
copyright = "Copyright © 2026 d3vyce"
|
copyright = "Copyright © 2026 d3vyce"
|
||||||
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||||
|
|
||||||
|
[project.extra.version]
|
||||||
|
provider = "mike"
|
||||||
|
default = "stable"
|
||||||
|
alias = true
|
||||||
|
|
||||||
[project.theme]
|
[project.theme]
|
||||||
custom_dir = "docs/overrides"
|
custom_dir = "docs/overrides"
|
||||||
language = "en"
|
language = "en"
|
||||||
|
|||||||
Reference in New Issue
Block a user