fastapi-sqlalchemy-toolkit 0.8.1__tar.gz → 0.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/PKG-INFO +1 -1
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/filtering.md +74 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/filtering.md +76 -1
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/model_manager.py +66 -1
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/pyproject.toml +1 -1
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/test_public_methods.py +270 -9
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.flake8 +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.github/workflows/docker-test.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.github/workflows/python-publish.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.gitignore +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.pylintrc +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/AUTHORS +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/LICENSE +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/Makefile +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/README.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/benefits.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/db_validation.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/extension.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/index.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/benefits.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/db_validation.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/extension.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/index.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/sorting.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/transactions.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/usage.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/utils.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/sorting.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/transactions.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/usage.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/utils.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/.env +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/api.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/deps.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/endpoints/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/endpoints/child.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/config.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/db.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/main.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/managers.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/models.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/schemas.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/mkdocs.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/poetry.lock +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/base.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/docs.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/lint.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/test.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/Dockerfile +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/conftest.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/db.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/docker-compose.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/models.py +0 -0
|
@@ -206,6 +206,80 @@ Unlike the `list` method, the `filter` method:
|
|
|
206
206
|
2. Does not have the `filter_expressions` parameter, i.e., it will not perform `join`,
|
|
207
207
|
necessary for filtering by fields of related models.
|
|
208
208
|
|
|
209
|
+
### Filtering with optional expressions
|
|
210
|
+
|
|
211
|
+
The `optional_where` parameter of the `list` and `paginated_list` methods accepts
|
|
212
|
+
SQLAlchemy expressions that may contain `None` values. Filters with a `None` value
|
|
213
|
+
are automatically skipped.
|
|
214
|
+
|
|
215
|
+
This is useful in list API endpoints where filtering is optional — if the query parameter
|
|
216
|
+
is not provided (i.e., its value is `None`), the filter is not applied.
|
|
217
|
+
|
|
218
|
+
The `optional_where` parameter supports three kinds of expressions:
|
|
219
|
+
|
|
220
|
+
**Case 1: Simple expression** (`MyModel.field == value`)
|
|
221
|
+
|
|
222
|
+
If `value` is `None`, the filter is skipped entirely. Otherwise it is applied as-is.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
@router.get("/parents")
|
|
226
|
+
async def get_parents(
|
|
227
|
+
session: Session,
|
|
228
|
+
title: str | None = None,
|
|
229
|
+
) -> list[ParentListSchema]:
|
|
230
|
+
return await parent_manager.list(
|
|
231
|
+
session,
|
|
232
|
+
optional_where=(Parent.title == title),
|
|
233
|
+
)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`GET /parents` — no filter applied, all `Parent` objects are returned.
|
|
237
|
+
|
|
238
|
+
`GET /parents?title=foo` — only `Parent` objects with `title = 'foo'` are returned.
|
|
239
|
+
|
|
240
|
+
**Case 2: Function or operator expression** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
|
|
241
|
+
|
|
242
|
+
Same behaviour as Case 1 — if `value` is `None`, the filter is skipped.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
@router.get("/parents")
|
|
246
|
+
async def get_parents(
|
|
247
|
+
session: Session,
|
|
248
|
+
created_at_date: date | None = None,
|
|
249
|
+
) -> list[ParentListSchema]:
|
|
250
|
+
return await parent_manager.list(
|
|
251
|
+
session,
|
|
252
|
+
optional_where=(func.date(Parent.created_at) == created_at_date),
|
|
253
|
+
)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Case 3: Compound expression with `&` or `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
|
|
257
|
+
|
|
258
|
+
Sub-expressions whose values are `None` are excluded. The remaining sub-expressions
|
|
259
|
+
are combined using the original operator (`&` or `|`). If all values are `None`,
|
|
260
|
+
the filter is skipped entirely.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
@router.get("/parents")
|
|
264
|
+
async def get_parents(
|
|
265
|
+
session: Session,
|
|
266
|
+
title: str | None = None,
|
|
267
|
+
slug: str | None = None,
|
|
268
|
+
) -> list[ParentListSchema]:
|
|
269
|
+
return await parent_manager.list(
|
|
270
|
+
session,
|
|
271
|
+
optional_where=(Parent.title == title) & (Parent.slug == slug),
|
|
272
|
+
)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`GET /parents` — no filter applied, all `Parent` objects are returned.
|
|
276
|
+
|
|
277
|
+
`GET /parents?title=foo` — only the `title` filter is applied.
|
|
278
|
+
|
|
279
|
+
`GET /parents?title=foo&slug=bar` — both filters are applied with `AND`.
|
|
280
|
+
|
|
281
|
+
> **Note**: nesting of compound expressions (e.g. `(a & b) | c`) is not supported.
|
|
282
|
+
|
|
209
283
|
### Filtering by `null` via API
|
|
210
284
|
|
|
211
285
|
If in a list API endpoint, you need to be able to filter the field value
|
|
@@ -77,7 +77,9 @@ WHERE child.slug = :slug_1
|
|
|
77
77
|
а не только те, у которых `slug is null`. Поэтому метод `list` (`paginated_list`) отбрасывает фильтрацию
|
|
78
78
|
по этому параметру, если его значение не передано.
|
|
79
79
|
|
|
80
|
-
##
|
|
80
|
+
## Фильтрация с выражениями
|
|
81
|
+
|
|
82
|
+
### filter_expressions
|
|
81
83
|
|
|
82
84
|
Чтобы использовать фильтрацию не только по точному соответствию атрибуту модели,
|
|
83
85
|
в методах `list` и `paginated_list` можно передать параметр `filter_expressions`.
|
|
@@ -182,6 +184,79 @@ WHERE lower(parent.title) LIKE lower(:title_1)
|
|
|
182
184
|
**Важно**: работает только для моделей, напрямую связанных с основной, и только тогда, когда
|
|
183
185
|
эти модели связывает единственный внешний ключ.
|
|
184
186
|
|
|
187
|
+
### optional_where
|
|
188
|
+
|
|
189
|
+
Параметр `optional_where` методов `list` и `paginated_list` принимает выражения SQLAlchemy,
|
|
190
|
+
в которых значения фильтров могут быть `None`. Фильтры со значением `None` автоматически пропускаются.
|
|
191
|
+
|
|
192
|
+
Это удобно использовать в списочных API эндпоинтах, где фильтрация необязательна —
|
|
193
|
+
если параметр запроса не передан (т. е. его значение `None`), фильтр не применяется.
|
|
194
|
+
|
|
195
|
+
Параметр `optional_where` поддерживает три вида выражений:
|
|
196
|
+
|
|
197
|
+
**Кейс 1: Простое выражение** (`MyModel.field == value`)
|
|
198
|
+
|
|
199
|
+
Если `value` равно `None`, фильтр пропускается. Иначе применяется как есть.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
@router.get("/parents")
|
|
203
|
+
async def get_parents(
|
|
204
|
+
session: Session,
|
|
205
|
+
title: str | None = None,
|
|
206
|
+
) -> list[ParentListSchema]:
|
|
207
|
+
return await parent_manager.list(
|
|
208
|
+
session,
|
|
209
|
+
optional_where=(Parent.title == title),
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
214
|
+
|
|
215
|
+
Запрос `GET /parents?title=foo` — возвращаются только объекты `Parent` с `title = 'foo'`.
|
|
216
|
+
|
|
217
|
+
**Кейс 2: Выражение с функцией или оператором** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
|
|
218
|
+
|
|
219
|
+
Аналогично кейсу 1 — если `value` равно `None`, фильтр пропускается.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
@router.get("/parents")
|
|
223
|
+
async def get_parents(
|
|
224
|
+
session: Session,
|
|
225
|
+
created_at_date: date | None = None,
|
|
226
|
+
) -> list[ParentListSchema]:
|
|
227
|
+
return await parent_manager.list(
|
|
228
|
+
session,
|
|
229
|
+
optional_where=(func.date(Parent.created_at) == created_at_date),
|
|
230
|
+
)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Кейс 3: Составное выражение через `&` или `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
|
|
234
|
+
|
|
235
|
+
Части выражения, значения которых равны `None`, исключаются. Оставшиеся части объединяются
|
|
236
|
+
с использованием исходного оператора (`&` или `|`). Если все значения `None`,
|
|
237
|
+
фильтр пропускается полностью.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
@router.get("/parents")
|
|
241
|
+
async def get_parents(
|
|
242
|
+
session: Session,
|
|
243
|
+
title: str | None = None,
|
|
244
|
+
slug: str | None = None,
|
|
245
|
+
) -> list[ParentListSchema]:
|
|
246
|
+
return await parent_manager.list(
|
|
247
|
+
session,
|
|
248
|
+
optional_where=(Parent.title == title) & (Parent.slug == slug),
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
253
|
+
|
|
254
|
+
Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
|
|
255
|
+
|
|
256
|
+
Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
|
|
257
|
+
|
|
258
|
+
> **Примечание**: вложенные составные выражения (например, `(a & b) | c`) не поддерживаются.
|
|
259
|
+
|
|
185
260
|
## Фильтрация без дополнительной обработки
|
|
186
261
|
|
|
187
262
|
Для фильтрации без дополнительной обработки в методах `list` и `paginated_list` можно
|
|
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
|
|
16
16
|
delete,
|
|
17
17
|
func,
|
|
18
18
|
insert,
|
|
19
|
+
or_,
|
|
19
20
|
select,
|
|
20
21
|
update,
|
|
21
22
|
)
|
|
@@ -25,7 +26,7 @@ from sqlalchemy.orm import DeclarativeBase, contains_eager, load_only
|
|
|
25
26
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
26
27
|
from sqlalchemy.orm.relationships import Relationship
|
|
27
28
|
from sqlalchemy.sql import Select
|
|
28
|
-
from sqlalchemy.sql.elements import UnaryExpression
|
|
29
|
+
from sqlalchemy.sql.elements import Null, UnaryExpression
|
|
29
30
|
from sqlalchemy.sql.expression import BinaryExpression, ColumnElement
|
|
30
31
|
from sqlalchemy.sql.functions import Function
|
|
31
32
|
from sqlalchemy.sql.schema import ScalarElementColumnDefault
|
|
@@ -532,6 +533,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
532
533
|
) = ...,
|
|
533
534
|
options: List[Any] | Any | None = ...,
|
|
534
535
|
where: Any | None = ...,
|
|
536
|
+
optional_where: Any | None = ...,
|
|
535
537
|
base_stmt: None = ...,
|
|
536
538
|
transformer: Callable | None = ...,
|
|
537
539
|
**simple_filters: Any,
|
|
@@ -549,6 +551,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
549
551
|
) = ...,
|
|
550
552
|
options: List[Any] | Any | None = ...,
|
|
551
553
|
where: Any | None = ...,
|
|
554
|
+
optional_where: Any | None = ...,
|
|
552
555
|
base_stmt: Select = ...,
|
|
553
556
|
transformer: Callable | None = ...,
|
|
554
557
|
**simple_filters: Any,
|
|
@@ -565,6 +568,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
565
568
|
) = None,
|
|
566
569
|
options: List[Any] | Any | None = None,
|
|
567
570
|
where: Any | None = None,
|
|
571
|
+
optional_where: Any | None = None,
|
|
568
572
|
base_stmt: Select | None = None,
|
|
569
573
|
transformer: Callable | None = None,
|
|
570
574
|
**simple_filters: Any,
|
|
@@ -590,6 +594,12 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
590
594
|
|
|
591
595
|
:param where: выражение, которое будет передано в метод .where() SQLAlchemy
|
|
592
596
|
|
|
597
|
+
:param optional_where: выражение SQLAlchemy, в котором фильтры со значением None
|
|
598
|
+
пропускаются. Поддерживает простые выражения (MyModel.field == value),
|
|
599
|
+
выражения с функциями (func.date(MyModel.field) == value) и составные
|
|
600
|
+
выражения через & или | ((expr1) & (expr2)). См. раздел "фильтрация"
|
|
601
|
+
в документации.
|
|
602
|
+
|
|
593
603
|
:param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт
|
|
594
604
|
страницу Row, а не ModelT.
|
|
595
605
|
Примечание: фильтрация и сортировка по связанным моделям скорее всего
|
|
@@ -630,6 +640,10 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
630
640
|
else:
|
|
631
641
|
stmt = stmt.filter(filter_expression(value))
|
|
632
642
|
|
|
643
|
+
processed_optional_where = self.handle_optional_where(optional_where)
|
|
644
|
+
if processed_optional_where is not None:
|
|
645
|
+
stmt = stmt.where(processed_optional_where)
|
|
646
|
+
|
|
633
647
|
return await paginate(session, stmt, transformer=transformer)
|
|
634
648
|
|
|
635
649
|
@overload
|
|
@@ -730,6 +744,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
730
744
|
) = ...,
|
|
731
745
|
options: List[Any] | Any | None = ...,
|
|
732
746
|
where: Any | None = ...,
|
|
747
|
+
optional_where: Any | None = ...,
|
|
733
748
|
base_stmt: None = ...,
|
|
734
749
|
limit: int | None = ...,
|
|
735
750
|
offset: int | None = ...,
|
|
@@ -750,6 +765,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
750
765
|
) = ...,
|
|
751
766
|
options: List[Any] | Any | None = ...,
|
|
752
767
|
where: Any | None = ...,
|
|
768
|
+
optional_where: Any | None = ...,
|
|
753
769
|
base_stmt: Select = ...,
|
|
754
770
|
limit: int | None = ...,
|
|
755
771
|
offset: int | None = ...,
|
|
@@ -769,6 +785,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
769
785
|
) = None,
|
|
770
786
|
options: List[Any] | Any | None = None,
|
|
771
787
|
where: Any | None = None,
|
|
788
|
+
optional_where: Any | None = None,
|
|
772
789
|
base_stmt: Select | None = None,
|
|
773
790
|
limit: int | None = None,
|
|
774
791
|
offset: int | None = None,
|
|
@@ -797,6 +814,12 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
797
814
|
|
|
798
815
|
:param where: выражение, которое будет передано в метод .where() SQLAlchemy
|
|
799
816
|
|
|
817
|
+
:param optional_where: выражение SQLAlchemy, в котором фильтры со значением None
|
|
818
|
+
пропускаются. Поддерживает простые выражения (MyModel.field == value),
|
|
819
|
+
выражения с функциями (func.date(MyModel.field) == value) и составные
|
|
820
|
+
выражения через & или | ((expr1) & (expr2)). См. раздел "фильтрация"
|
|
821
|
+
в документации.
|
|
822
|
+
|
|
800
823
|
:param unique: определяет необходимость вызова метода .unique()
|
|
801
824
|
у результата SQLAlchemy
|
|
802
825
|
|
|
@@ -848,6 +871,10 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
848
871
|
else:
|
|
849
872
|
stmt = stmt.filter(filter_expression(value))
|
|
850
873
|
|
|
874
|
+
processed_optional_where = self.handle_optional_where(optional_where)
|
|
875
|
+
if processed_optional_where is not None:
|
|
876
|
+
stmt = stmt.where(processed_optional_where)
|
|
877
|
+
|
|
851
878
|
result = await session.execute(stmt)
|
|
852
879
|
|
|
853
880
|
if base_stmt is None:
|
|
@@ -1208,6 +1235,44 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1208
1235
|
related_pk = _get_model_pk(related_model)
|
|
1209
1236
|
return relationship.any(related_pk.in_(value))
|
|
1210
1237
|
|
|
1238
|
+
@staticmethod
|
|
1239
|
+
def handle_optional_where(optional_where: Any) -> Any:
|
|
1240
|
+
"""
|
|
1241
|
+
Обрабатывает выражение optional_where, пропуская фильтры, значения которых None.
|
|
1242
|
+
|
|
1243
|
+
Поддерживает:
|
|
1244
|
+
1. Простые выражения вида MyModel.field == value
|
|
1245
|
+
2. Выражения с функциями/операторами вида func.date(MyModel.field) == value
|
|
1246
|
+
3. Составные выражения вида (expr1) & (expr2) или (expr1) | (expr2)
|
|
1247
|
+
(без вложенности)
|
|
1248
|
+
|
|
1249
|
+
Если value равно None, фильтр не применяется.
|
|
1250
|
+
В составных выражениях исключаются части с value == None,
|
|
1251
|
+
при этом оператор & или | сохраняется.
|
|
1252
|
+
"""
|
|
1253
|
+
if optional_where is None:
|
|
1254
|
+
return None
|
|
1255
|
+
|
|
1256
|
+
# compound expression (& or |) — has .clauses attribute
|
|
1257
|
+
if hasattr(optional_where, "clauses"):
|
|
1258
|
+
remaining = [
|
|
1259
|
+
clause
|
|
1260
|
+
for clause in optional_where.clauses
|
|
1261
|
+
if not isinstance(clause.right, Null)
|
|
1262
|
+
]
|
|
1263
|
+
if not remaining:
|
|
1264
|
+
return None
|
|
1265
|
+
if len(remaining) == 1:
|
|
1266
|
+
return remaining[0]
|
|
1267
|
+
if optional_where.operator.__name__ == "and_":
|
|
1268
|
+
return and_(*remaining)
|
|
1269
|
+
return or_(*remaining)
|
|
1270
|
+
|
|
1271
|
+
# simple binary expression
|
|
1272
|
+
if isinstance(getattr(optional_where, "right", None), Null):
|
|
1273
|
+
return None
|
|
1274
|
+
return optional_where
|
|
1275
|
+
|
|
1211
1276
|
def assemble_stmt(
|
|
1212
1277
|
self,
|
|
1213
1278
|
base_stmt: Select | None = None,
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/test_public_methods.py
RENAMED
|
@@ -31,9 +31,9 @@ async def test_get(session: AsyncSession):
|
|
|
31
31
|
category_to_check = await session.execute(
|
|
32
32
|
select(Category).where(Category.title == "test-get-category-title")
|
|
33
33
|
)
|
|
34
|
-
assert
|
|
35
|
-
|
|
36
|
-
)
|
|
34
|
+
assert (
|
|
35
|
+
category == category_to_check.scalars().first()
|
|
36
|
+
), "Got not equal to object in database"
|
|
37
37
|
|
|
38
38
|
nonexistent = await session.execute(
|
|
39
39
|
select(Category).where(Category.title == "nonexistent-test-get-category-title")
|
|
@@ -279,9 +279,9 @@ async def test_create(session: AsyncSession):
|
|
|
279
279
|
category_to_check = await session.execute(
|
|
280
280
|
select(Category).where(Category.title == "test-create-category-title")
|
|
281
281
|
)
|
|
282
|
-
assert
|
|
283
|
-
|
|
284
|
-
)
|
|
282
|
+
assert (
|
|
283
|
+
created == category_to_check.scalars().first()
|
|
284
|
+
), "Created not equal to object in database"
|
|
285
285
|
|
|
286
286
|
|
|
287
287
|
async def test_create_unique_filed_validation(session: AsyncSession):
|
|
@@ -339,9 +339,9 @@ async def test_update(session: AsyncSession):
|
|
|
339
339
|
category_to_check = await session.execute(
|
|
340
340
|
select(Category).where(Category.title == "UPDATED-test-update-category-title")
|
|
341
341
|
)
|
|
342
|
-
assert
|
|
343
|
-
|
|
344
|
-
)
|
|
342
|
+
assert (
|
|
343
|
+
updated == category_to_check.scalars().first()
|
|
344
|
+
), "Updated not equal to object in database"
|
|
345
345
|
|
|
346
346
|
|
|
347
347
|
async def test_update_unique_filed_validation(session: AsyncSession):
|
|
@@ -729,6 +729,267 @@ async def test_list_with_where(session: AsyncSession):
|
|
|
729
729
|
assert parent.title == same_title
|
|
730
730
|
|
|
731
731
|
|
|
732
|
+
# ########################################################################
|
|
733
|
+
# Tests for optional_where parameter
|
|
734
|
+
# ########################################################################
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
async def test_list_with_optional_where_case1_applied(session: AsyncSession):
|
|
738
|
+
"""Case 1: simple expression, value is not None — filter is applied."""
|
|
739
|
+
target_title = "optional-where-title-target"
|
|
740
|
+
await session.execute(
|
|
741
|
+
insert(Parent),
|
|
742
|
+
[
|
|
743
|
+
{
|
|
744
|
+
"title": target_title,
|
|
745
|
+
"slug": "optional-where-slug1",
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
"title": "optional-where-title-other",
|
|
749
|
+
"slug": "optional-where-slug2",
|
|
750
|
+
},
|
|
751
|
+
],
|
|
752
|
+
)
|
|
753
|
+
await session.commit()
|
|
754
|
+
|
|
755
|
+
parents = await parent_manager.list(
|
|
756
|
+
session=session,
|
|
757
|
+
optional_where=(Parent.title == target_title),
|
|
758
|
+
)
|
|
759
|
+
assert len(parents) == 1
|
|
760
|
+
assert parents[0].title == target_title
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
async def test_list_with_optional_where_case1_skipped(session: AsyncSession):
|
|
764
|
+
"""Case 1: simple expression, value is None — filter is skipped (all returned)."""
|
|
765
|
+
await session.execute(
|
|
766
|
+
insert(Parent),
|
|
767
|
+
[
|
|
768
|
+
{
|
|
769
|
+
"title": "optional-where-title-1",
|
|
770
|
+
"slug": "optional-where-slug1",
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
"title": "optional-where-title-2",
|
|
774
|
+
"slug": "optional-where-slug2",
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
)
|
|
778
|
+
await session.commit()
|
|
779
|
+
|
|
780
|
+
none_value = None
|
|
781
|
+
parents = await parent_manager.list(
|
|
782
|
+
session=session,
|
|
783
|
+
optional_where=(Parent.title == none_value),
|
|
784
|
+
)
|
|
785
|
+
assert len(parents) == 2
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
async def test_list_with_optional_where_case2_applied(session: AsyncSession):
|
|
789
|
+
"""Case 2: func expression, value is not None — filter is applied."""
|
|
790
|
+
from sqlalchemy import func
|
|
791
|
+
|
|
792
|
+
target_slug = "optional-where-func-slug1"
|
|
793
|
+
await session.execute(
|
|
794
|
+
insert(Parent),
|
|
795
|
+
[
|
|
796
|
+
{
|
|
797
|
+
"title": "optional-where-func-title-1",
|
|
798
|
+
"slug": target_slug,
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
"title": "optional-where-func-title-2",
|
|
802
|
+
"slug": "optional-where-func-slug2",
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
)
|
|
806
|
+
await session.commit()
|
|
807
|
+
|
|
808
|
+
# Use func.lower() on the slug to simulate a function expression
|
|
809
|
+
parents = await parent_manager.list(
|
|
810
|
+
session=session,
|
|
811
|
+
optional_where=(func.lower(Parent.slug) == target_slug),
|
|
812
|
+
)
|
|
813
|
+
assert len(parents) == 1
|
|
814
|
+
assert parents[0].slug == target_slug
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
async def test_list_with_optional_where_case2_skipped(session: AsyncSession):
|
|
818
|
+
"""Case 2: func expression, value is None — filter is skipped (all returned)."""
|
|
819
|
+
from sqlalchemy import func
|
|
820
|
+
|
|
821
|
+
await session.execute(
|
|
822
|
+
insert(Parent),
|
|
823
|
+
[
|
|
824
|
+
{
|
|
825
|
+
"title": "optional-where-func-title-1",
|
|
826
|
+
"slug": "optional-where-func-slug1",
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
"title": "optional-where-func-title-2",
|
|
830
|
+
"slug": "optional-where-func-slug2",
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
)
|
|
834
|
+
await session.commit()
|
|
835
|
+
|
|
836
|
+
none_value = None
|
|
837
|
+
parents = await parent_manager.list(
|
|
838
|
+
session=session,
|
|
839
|
+
optional_where=(func.lower(Parent.slug) == none_value),
|
|
840
|
+
)
|
|
841
|
+
assert len(parents) == 2
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
async def test_list_with_optional_where_case3_and_both_applied(session: AsyncSession):
|
|
845
|
+
"""Case 3: compound & expression, both values not None — both filters applied."""
|
|
846
|
+
target_title = "optional-where-and-title"
|
|
847
|
+
target_slug = "optional-where-and-slug1"
|
|
848
|
+
await session.execute(
|
|
849
|
+
insert(Parent),
|
|
850
|
+
[
|
|
851
|
+
{
|
|
852
|
+
"title": target_title,
|
|
853
|
+
"slug": target_slug,
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
"title": target_title,
|
|
857
|
+
"slug": "optional-where-and-slug2",
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
"title": "optional-where-and-title-other",
|
|
861
|
+
"slug": "optional-where-and-slug3",
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
)
|
|
865
|
+
await session.commit()
|
|
866
|
+
|
|
867
|
+
parents = await parent_manager.list(
|
|
868
|
+
session=session,
|
|
869
|
+
optional_where=(Parent.title == target_title) & (Parent.slug == target_slug),
|
|
870
|
+
)
|
|
871
|
+
assert len(parents) == 1
|
|
872
|
+
assert parents[0].title == target_title
|
|
873
|
+
assert parents[0].slug == target_slug
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
async def test_list_with_optional_where_case3_and_one_none(session: AsyncSession):
|
|
877
|
+
"""Case 3: compound & expression, one value is None — only non-None filter applied."""
|
|
878
|
+
target_title = "optional-where-one-none-title"
|
|
879
|
+
await session.execute(
|
|
880
|
+
insert(Parent),
|
|
881
|
+
[
|
|
882
|
+
{
|
|
883
|
+
"title": target_title,
|
|
884
|
+
"slug": "optional-where-one-none-slug1",
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
"title": target_title,
|
|
888
|
+
"slug": "optional-where-one-none-slug2",
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
"title": "optional-where-one-none-title-other",
|
|
892
|
+
"slug": "optional-where-one-none-slug3",
|
|
893
|
+
},
|
|
894
|
+
],
|
|
895
|
+
)
|
|
896
|
+
await session.commit()
|
|
897
|
+
|
|
898
|
+
none_value = None
|
|
899
|
+
parents = await parent_manager.list(
|
|
900
|
+
session=session,
|
|
901
|
+
optional_where=(Parent.title == target_title) & (Parent.slug == none_value),
|
|
902
|
+
)
|
|
903
|
+
assert len(parents) == 2
|
|
904
|
+
for parent in parents:
|
|
905
|
+
assert parent.title == target_title
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
async def test_list_with_optional_where_case3_all_none(session: AsyncSession):
|
|
909
|
+
"""Case 3: compound expression, all values are None — filter skipped (all returned)."""
|
|
910
|
+
await session.execute(
|
|
911
|
+
insert(Parent),
|
|
912
|
+
[
|
|
913
|
+
{
|
|
914
|
+
"title": "optional-where-all-none-title-1",
|
|
915
|
+
"slug": "optional-where-all-none-slug1",
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
"title": "optional-where-all-none-title-2",
|
|
919
|
+
"slug": "optional-where-all-none-slug2",
|
|
920
|
+
},
|
|
921
|
+
],
|
|
922
|
+
)
|
|
923
|
+
await session.commit()
|
|
924
|
+
|
|
925
|
+
none_value = None
|
|
926
|
+
parents = await parent_manager.list(
|
|
927
|
+
session=session,
|
|
928
|
+
optional_where=(Parent.title == none_value) & (Parent.slug == none_value),
|
|
929
|
+
)
|
|
930
|
+
assert len(parents) == 2
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
async def test_list_with_optional_where_case3_or_both_applied(session: AsyncSession):
|
|
934
|
+
"""Case 3: compound | expression, both values not None — both filters applied as OR."""
|
|
935
|
+
title_a = "optional-where-or-title-a"
|
|
936
|
+
title_b = "optional-where-or-title-b"
|
|
937
|
+
await session.execute(
|
|
938
|
+
insert(Parent),
|
|
939
|
+
[
|
|
940
|
+
{
|
|
941
|
+
"title": title_a,
|
|
942
|
+
"slug": "optional-where-or-slug1",
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
"title": title_b,
|
|
946
|
+
"slug": "optional-where-or-slug2",
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
"title": "optional-where-or-title-other",
|
|
950
|
+
"slug": "optional-where-or-slug3",
|
|
951
|
+
},
|
|
952
|
+
],
|
|
953
|
+
)
|
|
954
|
+
await session.commit()
|
|
955
|
+
|
|
956
|
+
parents = await parent_manager.list(
|
|
957
|
+
session=session,
|
|
958
|
+
optional_where=(Parent.title == title_a) | (Parent.title == title_b),
|
|
959
|
+
)
|
|
960
|
+
assert len(parents) == 2
|
|
961
|
+
titles = {p.title for p in parents}
|
|
962
|
+
assert title_a in titles
|
|
963
|
+
assert title_b in titles
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
async def test_list_with_optional_where_case3_or_one_none(session: AsyncSession):
|
|
967
|
+
"""Case 3: compound | expression, one value is None — only non-None filter applied."""
|
|
968
|
+
target_title = "optional-where-or-none-title"
|
|
969
|
+
await session.execute(
|
|
970
|
+
insert(Parent),
|
|
971
|
+
[
|
|
972
|
+
{
|
|
973
|
+
"title": target_title,
|
|
974
|
+
"slug": "optional-where-or-none-slug1",
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
"title": "optional-where-or-none-title-other",
|
|
978
|
+
"slug": "optional-where-or-none-slug2",
|
|
979
|
+
},
|
|
980
|
+
],
|
|
981
|
+
)
|
|
982
|
+
await session.commit()
|
|
983
|
+
|
|
984
|
+
none_value = None
|
|
985
|
+
parents = await parent_manager.list(
|
|
986
|
+
session=session,
|
|
987
|
+
optional_where=(Parent.title == target_title) | (Parent.slug == none_value),
|
|
988
|
+
)
|
|
989
|
+
assert len(parents) == 1
|
|
990
|
+
assert parents[0].title == target_title
|
|
991
|
+
|
|
992
|
+
|
|
732
993
|
async def test_create_unique_constraint_validation(session: AsyncSession):
|
|
733
994
|
await parent_manager.create(
|
|
734
995
|
session=session,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/db_validation.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/transactions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/__init__.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/__init__.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/api.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/deps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/managers.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/models.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/schemas.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/docker-compose.yml
RENAMED
|
File without changes
|
|
File without changes
|