fastapi-sqlalchemy-toolkit 0.8.2.2__tar.gz → 0.8.2.4__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.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/PKG-INFO +1 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/db_validation.md +14 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/filtering.md +21 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/benefits.md +7 -7
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/db_validation.md +17 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/filtering.md +105 -102
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/model_manager.py +102 -12
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/pyproject.toml +1 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/test.txt +1 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/conftest.py +22 -12
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/models.py +63 -1
- fastapi_sqlalchemy_toolkit-0.8.2.4/tests/test_partial_where.py +115 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/test_public_methods.py +184 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.flake8 +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.github/workflows/docker-test.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.github/workflows/python-publish.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.gitignore +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.pylintrc +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/AUTHORS +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/LICENSE +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/Makefile +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/README.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/benefits.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/extension.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/index.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/extension.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/index.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/sorting.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/transactions.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/usage.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/utils.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/sorting.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/transactions.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/usage.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/utils.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/.env +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/api.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/deps.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/endpoints/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/endpoints/child.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/config.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/db.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/main.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/managers.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/models.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/schemas.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/mkdocs.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/poetry.lock +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/base.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/docs.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/lint.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/Dockerfile +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/db.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/docker-compose.yml +0 -0
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/db_validation.md
RENAMED
|
@@ -24,4 +24,17 @@ If the uniqueness constraint is violated, an `fastapi.HTTPException` will be rai
|
|
|
24
24
|
|
|
25
25
|
If the model defines unique constraints using `sqlalchemy.UniqueConstraint`, then when using the `create` or `update` methods, an SQL query will be executed to verify that no other objects with the same combination of field values in the unique constraint exist.
|
|
26
26
|
|
|
27
|
-
If the unique constraint is violated, an `fastapi.HTTPException` will be raised.
|
|
27
|
+
If the unique constraint is violated, an `fastapi.HTTPException` will be raised.
|
|
28
|
+
|
|
29
|
+
## Unique Indexes Validation
|
|
30
|
+
|
|
31
|
+
If the model defines a unique `sqlalchemy.Index`, then when using the `create` or `update` methods, an SQL query will be executed to verify that no other object violates that index.
|
|
32
|
+
|
|
33
|
+
For **partial** unique indexes (`postgresql_where=...`), validation runs only when the created/updated row itself matches the `WHERE` predicate. Rows outside the partial index are not checked against it.
|
|
34
|
+
|
|
35
|
+
Supported `postgresql_where` forms for deciding whether the row is covered:
|
|
36
|
+
|
|
37
|
+
- Column expressions such as `Model.kind == "main"` (including `and_(...)` of equalities)
|
|
38
|
+
- Simple `text()` equalities such as `text("kind = 'main'")`
|
|
39
|
+
|
|
40
|
+
Unsupported / complex predicates are treated as matching (fail-safe): validation still runs.
|
|
@@ -278,6 +278,27 @@ async def get_parents(
|
|
|
278
278
|
|
|
279
279
|
`GET /parents?title=foo&slug=bar` — both filters are applied with `AND`.
|
|
280
280
|
|
|
281
|
+
**Case 4: Expression with a falsy value** (`MyModel.column.in_(value)`, `MyModel.column.endswith(value)`, `MyModel.column.startswith(value)`)
|
|
282
|
+
|
|
283
|
+
If `value` is an empty list (`[]`) or an empty string (`""`), the filter is skipped.
|
|
284
|
+
This covers `in_([])`, `endswith("")`, `startswith("")` and similar operators.
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
@router.get("/parents")
|
|
288
|
+
async def get_parents(
|
|
289
|
+
session: Session,
|
|
290
|
+
slugs: list[str] = Query(default=[]),
|
|
291
|
+
) -> list[ParentListSchema]:
|
|
292
|
+
return await parent_manager.list(
|
|
293
|
+
session,
|
|
294
|
+
optional_where=Parent.slug.in_(slugs),
|
|
295
|
+
)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`GET /parents` — no filter applied, all `Parent` objects are returned.
|
|
299
|
+
|
|
300
|
+
`GET /parents?slugs=foo&slugs=bar` — only `Parent` objects whose `slug` is in `['foo', 'bar']` are returned.
|
|
301
|
+
|
|
281
302
|
> **Note**: nesting of compound expressions (e.g. `(a & b) | c`) is not supported.
|
|
282
303
|
|
|
283
304
|
**Multiple expressions as separate arguments**
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/benefits.md
RENAMED
|
@@ -28,14 +28,14 @@ async def get_my_objects(
|
|
|
28
28
|
if user_id is not None:
|
|
29
29
|
stmt = stmt.filter_by(user_id=user_id)
|
|
30
30
|
if name is not None:
|
|
31
|
-
stmt = stmt.filter(MyModel.name
|
|
31
|
+
stmt = stmt.filter(MyModel.name == "name")
|
|
32
32
|
if parent_name is not None:
|
|
33
33
|
stmt = stmt.join(MyModel.parent)
|
|
34
|
-
stmt = stmt.filter(ParentModel.name.ilike ==
|
|
34
|
+
stmt = stmt.filter(ParentModel.name.ilike == "parent_name")
|
|
35
35
|
return await paginate(session, stmt)
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
С `fastapi-sqlalchemy-toolkit` этот эндпоинт выглядит так:
|
|
39
39
|
|
|
40
40
|
```python
|
|
41
41
|
from app.managers import my_object_manager
|
|
@@ -50,10 +50,10 @@ async def get_my_objects(
|
|
|
50
50
|
return await my_object_manager.paginated_list(
|
|
51
51
|
session,
|
|
52
52
|
user_id=user_id,
|
|
53
|
-
|
|
54
|
-
MyObject.name
|
|
55
|
-
MyObjectParent.name
|
|
56
|
-
|
|
53
|
+
optional_where=(
|
|
54
|
+
MyObject.name == name,
|
|
55
|
+
MyObjectParent.name == parent_name
|
|
56
|
+
)
|
|
57
57
|
)
|
|
58
58
|
```
|
|
59
59
|
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/db_validation.md
RENAMED
|
@@ -32,4 +32,20 @@ Ecли `Post.slug` -- поле, определённое с `unique=True`, и з
|
|
|
32
32
|
при использовании методов `create` или `update` будет выполнен SQL запрос для проверки
|
|
33
33
|
существования других объектов с таким же набором значений полей, входящих в уникальное огранчение.
|
|
34
34
|
|
|
35
|
-
Если уникальное ограничение будет нарушено, вызывается `fastapi.HTTPException`.
|
|
35
|
+
Если уникальное ограничение будет нарушено, вызывается `fastapi.HTTPException`.
|
|
36
|
+
|
|
37
|
+
## Валидация уникальных индексов
|
|
38
|
+
|
|
39
|
+
Если в модели определён уникальный `sqlalchemy.Index`, то при использовании методов
|
|
40
|
+
`create` или `update` будет выполнен SQL запрос для проверки, что индекс не нарушается.
|
|
41
|
+
|
|
42
|
+
Для **частичных** уникальных индексов (`postgresql_where=...`) валидация выполняется
|
|
43
|
+
только если создаваемая/обновляемая запись сама попадает под условие `WHERE`.
|
|
44
|
+
Записи вне partial index по этому индексу не проверяются.
|
|
45
|
+
|
|
46
|
+
Поддерживаемые формы `postgresql_where` для определения покрытия записи:
|
|
47
|
+
|
|
48
|
+
- выражения по колонкам, например `Model.kind == "main"` (включая `and_(...)` из равенств)
|
|
49
|
+
- простые равенства через `text()`, например `text("kind = 'main'")`
|
|
50
|
+
|
|
51
|
+
Неподдерживаемые / сложные предикаты считаются подходящими (fail-safe): валидация всё равно выполняется.
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/filtering.md
RENAMED
|
@@ -72,13 +72,114 @@ FROM child
|
|
|
72
72
|
WHERE child.slug = :slug_1
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
по этому параметру, если его значение не передано.
|
|
75
|
+
Клиенты API ожидают, что при запросе `GET /children` будут возвращены все объекты `Child`,
|
|
76
|
+
поэтому метод `list` (`paginated_list`) отбрасывает фильтрацию по параметрам, значения которых
|
|
77
|
+
не переданы.
|
|
79
78
|
|
|
80
79
|
## Фильтрация с выражениями
|
|
81
80
|
|
|
81
|
+
### optional_where
|
|
82
|
+
|
|
83
|
+
Параметр `optional_where` методов `list` и `paginated_list` принимает выражения SQLAlchemy,
|
|
84
|
+
аналогично методу `select().where()`, причём фильтры со значением `None` (`[]`, `""`) автоматически пропускаются.
|
|
85
|
+
|
|
86
|
+
Это удобно использовать в списочных API эндпоинтах, где фильтрация необязательна:
|
|
87
|
+
если параметр запроса не передан, фильтр не применяется.
|
|
88
|
+
|
|
89
|
+
Примеры использования `optional_where`:
|
|
90
|
+
|
|
91
|
+
**Бинарное выражение** (`MyModel.field == value`)
|
|
92
|
+
|
|
93
|
+
Если `value` равно `None`, фильтр пропускается. Иначе применяется как есть.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@router.get("/parents")
|
|
97
|
+
async def get_parents(
|
|
98
|
+
session: Session,
|
|
99
|
+
created_at_gte: datetime | None = None,
|
|
100
|
+
) -> list[ParentListSchema]:
|
|
101
|
+
return await parent_manager.list(
|
|
102
|
+
session,
|
|
103
|
+
optional_where=(Parent.created_at >= created_at_gte),
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
108
|
+
|
|
109
|
+
Запрос `GET /parents?created_at_gte=2026-01-01T00:00:00Z` — возвращаются только объекты `Parent` с `created_at >= '2026-01-01T00:00:00Z'`.
|
|
110
|
+
|
|
111
|
+
**Выражение с функцией или оператором** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
|
|
112
|
+
|
|
113
|
+
Если `value` равно `None`, фильтр пропускается.
|
|
114
|
+
|
|
115
|
+
Также если `value` является пустым списком (`[]`) или пустой строкой (`""`), фильтр пропускается.
|
|
116
|
+
Это распространяется на `in_([])`, `endswith("")`, `startswith("")` и аналогичные операторы, которые
|
|
117
|
+
не допускают передачи `None`.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
@router.get("/parents")
|
|
121
|
+
async def get_parents(
|
|
122
|
+
session: Session,
|
|
123
|
+
created_at_date: date | None = None,
|
|
124
|
+
) -> list[ParentListSchema]:
|
|
125
|
+
return await parent_manager.list(
|
|
126
|
+
session,
|
|
127
|
+
optional_where=(func.date(Parent.created_at) == created_at_date),
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Составное выражение через `&` или `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
|
|
132
|
+
|
|
133
|
+
Части выражения, значения которых равны `None`, исключаются. Оставшиеся части объединяются
|
|
134
|
+
с использованием исходного оператора (`&` или `|`). Если все значения `None`,
|
|
135
|
+
фильтр пропускается полностью.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
@router.get("/parents")
|
|
139
|
+
async def get_parents(
|
|
140
|
+
session: Session,
|
|
141
|
+
title: str | None = None,
|
|
142
|
+
slug: str | None = None,
|
|
143
|
+
) -> list[ParentListSchema]:
|
|
144
|
+
return await parent_manager.list(
|
|
145
|
+
session,
|
|
146
|
+
optional_where=(Parent.title == title) & (Parent.slug == slug),
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
151
|
+
|
|
152
|
+
Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
|
|
153
|
+
|
|
154
|
+
Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
|
|
155
|
+
|
|
156
|
+
> **Примечание**: вложенные составные выражения (например, `(a & b) | c`) не поддерживаются.
|
|
157
|
+
|
|
158
|
+
**Несколько выражений как отдельные аргументы**
|
|
159
|
+
|
|
160
|
+
Вместо использования `&` для объединения выражений можно передать их как отдельные
|
|
161
|
+
аргументы в виде кортежа. Каждый аргумент поддерживает все три вида выражений выше.
|
|
162
|
+
Оставшиеся (не-`None`) выражения объединяются через `AND`.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
@router.get("/parents")
|
|
166
|
+
async def get_parents(
|
|
167
|
+
session: Session,
|
|
168
|
+
title: str | None = None,
|
|
169
|
+
slug: str | None = None,
|
|
170
|
+
) -> list[ParentListSchema]:
|
|
171
|
+
return await parent_manager.list(
|
|
172
|
+
session,
|
|
173
|
+
optional_where=(Parent.title == title, Parent.slug == slug),
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
178
|
+
|
|
179
|
+
Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
|
|
180
|
+
|
|
181
|
+
Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
|
|
182
|
+
|
|
82
183
|
### filter_expressions
|
|
83
184
|
|
|
84
185
|
Чтобы использовать фильтрацию не только по точному соответствию атрибуту модели,
|
|
@@ -184,104 +285,6 @@ WHERE lower(parent.title) LIKE lower(:title_1)
|
|
|
184
285
|
**Важно**: работает только для моделей, напрямую связанных с основной, и только тогда, когда
|
|
185
286
|
эти модели связывает единственный внешний ключ.
|
|
186
287
|
|
|
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
|
-
|
|
260
|
-
**Несколько выражений как отдельные аргументы**
|
|
261
|
-
|
|
262
|
-
Вместо использования `&` для объединения выражений можно передать их как отдельные
|
|
263
|
-
аргументы в виде кортежа. Каждый аргумент поддерживает все три вида выражений выше.
|
|
264
|
-
Оставшиеся (не-`None`) выражения объединяются через `AND`.
|
|
265
|
-
|
|
266
|
-
```python
|
|
267
|
-
@router.get("/parents")
|
|
268
|
-
async def get_parents(
|
|
269
|
-
session: Session,
|
|
270
|
-
title: str | None = None,
|
|
271
|
-
slug: str | None = None,
|
|
272
|
-
) -> list[ParentListSchema]:
|
|
273
|
-
return await parent_manager.list(
|
|
274
|
-
session,
|
|
275
|
-
optional_where=(Parent.title == title, Parent.slug == slug),
|
|
276
|
-
)
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
280
|
-
|
|
281
|
-
Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
|
|
282
|
-
|
|
283
|
-
Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
|
|
284
|
-
|
|
285
288
|
## Фильтрация без дополнительной обработки
|
|
286
289
|
|
|
287
290
|
Для фильтрации без дополнительной обработки в методах `list` и `paginated_list` можно
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ruff: noqa: UP006
|
|
2
2
|
from collections.abc import Callable, Iterable
|
|
3
|
+
from enum import Enum
|
|
3
4
|
from typing import Any, Generic, List, TypeVar, overload # noqa: UP035
|
|
4
5
|
|
|
5
6
|
from fastapi import HTTPException, status
|
|
@@ -25,9 +26,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
25
26
|
from sqlalchemy.orm import DeclarativeBase, contains_eager, load_only
|
|
26
27
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
27
28
|
from sqlalchemy.orm.relationships import Relationship
|
|
28
|
-
from sqlalchemy.sql import Select
|
|
29
|
-
from sqlalchemy.sql.elements import
|
|
30
|
-
|
|
29
|
+
from sqlalchemy.sql import Select, operators
|
|
30
|
+
from sqlalchemy.sql.elements import (
|
|
31
|
+
BinaryExpression,
|
|
32
|
+
BindParameter,
|
|
33
|
+
BooleanClauseList,
|
|
34
|
+
Null,
|
|
35
|
+
TextClause,
|
|
36
|
+
UnaryExpression,
|
|
37
|
+
)
|
|
38
|
+
from sqlalchemy.sql.expression import ColumnElement
|
|
31
39
|
from sqlalchemy.sql.functions import Function
|
|
32
40
|
from sqlalchemy.sql.schema import ScalarElementColumnDefault
|
|
33
41
|
from sqlalchemy.sql.selectable import Exists
|
|
@@ -46,6 +54,15 @@ def sqlalchemy_model_to_dict(model: DeclarativeBase) -> dict:
|
|
|
46
54
|
return db_obj_dict
|
|
47
55
|
|
|
48
56
|
|
|
57
|
+
def _is_falsy_bind_param(right: Any) -> bool:
|
|
58
|
+
"""Return True if *right* is a BindParameter with a falsy str or list value."""
|
|
59
|
+
return (
|
|
60
|
+
isinstance(right, BindParameter)
|
|
61
|
+
and isinstance(right.value, str | list)
|
|
62
|
+
and not right.value
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
49
66
|
def _get_model_pk(model: type[DeclarativeBase]) -> InstrumentedAttribute:
|
|
50
67
|
"""Получить атрибут первичного ключа для произвольной модели."""
|
|
51
68
|
pk_columns = list(model.__table__.primary_key.columns)
|
|
@@ -1236,16 +1253,20 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1236
1253
|
@classmethod
|
|
1237
1254
|
def handle_optional_where_expression(cls, optional_where: Any) -> Any:
|
|
1238
1255
|
"""
|
|
1239
|
-
Обрабатывает одно выражение optional_where, пропуская фильтр,
|
|
1256
|
+
Обрабатывает одно выражение optional_where, пропуская фильтр,
|
|
1257
|
+
если его значение None.
|
|
1240
1258
|
|
|
1241
1259
|
Поддерживает:
|
|
1242
1260
|
1. Простые выражения вида MyModel.field == value
|
|
1243
1261
|
2. Выражения с функциями/операторами вида func.date(MyModel.field) == value
|
|
1244
1262
|
3. Составные выражения вида (expr1) & (expr2) или (expr1) | (expr2)
|
|
1245
1263
|
(без вложенности)
|
|
1264
|
+
4. Выражения с "пустым" значением вида MyModel.column.in_([]),
|
|
1265
|
+
MyModel.column.endswith(""), MyModel.column.startswith("")
|
|
1246
1266
|
|
|
1247
|
-
Если value равно None
|
|
1248
|
-
|
|
1267
|
+
Если value равно None или является пустой строкой/списком,
|
|
1268
|
+
фильтр не применяется.
|
|
1269
|
+
В составных выражениях исключаются части с такими значениями,
|
|
1249
1270
|
при этом оператор & или | сохраняется.
|
|
1250
1271
|
"""
|
|
1251
1272
|
if optional_where is None:
|
|
@@ -1257,17 +1278,17 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1257
1278
|
clause
|
|
1258
1279
|
for clause in optional_where.clauses
|
|
1259
1280
|
if not isinstance(clause.right, Null)
|
|
1281
|
+
and not _is_falsy_bind_param(getattr(clause, "right", None))
|
|
1260
1282
|
]
|
|
1261
1283
|
if not remaining:
|
|
1262
1284
|
return None
|
|
1263
1285
|
if len(remaining) == 1:
|
|
1264
1286
|
return remaining[0]
|
|
1265
|
-
if optional_where.operator.__name__ == "and_"
|
|
1266
|
-
|
|
1267
|
-
return or_(*remaining)
|
|
1287
|
+
combiner = and_ if optional_where.operator.__name__ == "and_" else or_
|
|
1288
|
+
return combiner(*remaining)
|
|
1268
1289
|
|
|
1269
|
-
|
|
1270
|
-
if isinstance(
|
|
1290
|
+
right = getattr(optional_where, "right", None)
|
|
1291
|
+
if isinstance(right, Null) or _is_falsy_bind_param(right):
|
|
1271
1292
|
return None
|
|
1272
1293
|
return optional_where
|
|
1273
1294
|
|
|
@@ -1470,11 +1491,77 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1470
1491
|
),
|
|
1471
1492
|
)
|
|
1472
1493
|
|
|
1494
|
+
@staticmethod
|
|
1495
|
+
def _partial_index_values_equal(actual: Any, expected: Any) -> bool:
|
|
1496
|
+
"""Сравнить обычные и Enum-значения из объекта и условия индекса."""
|
|
1497
|
+
actual_normalized = actual.name if isinstance(actual, Enum) else actual
|
|
1498
|
+
expected_normalized = expected.name if isinstance(expected, Enum) else expected
|
|
1499
|
+
if actual_normalized == expected_normalized:
|
|
1500
|
+
return True
|
|
1501
|
+
if isinstance(actual, Enum) and actual.value == expected_normalized:
|
|
1502
|
+
return True
|
|
1503
|
+
if isinstance(expected, Enum) and expected.value == actual_normalized:
|
|
1504
|
+
return True
|
|
1505
|
+
return str(actual_normalized) == str(expected_normalized)
|
|
1506
|
+
|
|
1507
|
+
@classmethod
|
|
1508
|
+
def _matches_partial_index_where(cls, condition: Any, in_obj: ModelDict) -> bool:
|
|
1509
|
+
"""
|
|
1510
|
+
Проверить, покрывается ли объект условием partial index.
|
|
1511
|
+
|
|
1512
|
+
Неподдерживаемое условие считается совпавшим, чтобы не пропустить
|
|
1513
|
+
потенциальное нарушение уникальности.
|
|
1514
|
+
"""
|
|
1515
|
+
if isinstance(condition, BooleanClauseList):
|
|
1516
|
+
return all(
|
|
1517
|
+
cls._matches_partial_index_where(clause, in_obj) for clause in condition
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
column_name: str | None = None
|
|
1521
|
+
expected: Any = None
|
|
1522
|
+
if isinstance(condition, TextClause):
|
|
1523
|
+
raw_column, separator, raw_expected = condition.text.partition("=")
|
|
1524
|
+
column_name = raw_column.strip()
|
|
1525
|
+
raw_expected = raw_expected.strip()
|
|
1526
|
+
valid_column = column_name.isascii() and column_name.isidentifier()
|
|
1527
|
+
|
|
1528
|
+
if not separator or not valid_column or not raw_expected:
|
|
1529
|
+
column_name = None
|
|
1530
|
+
elif raw_expected[0] in {'"', "'"}:
|
|
1531
|
+
quote = raw_expected[0]
|
|
1532
|
+
if raw_expected[-1] == quote and quote not in raw_expected[1:-1]:
|
|
1533
|
+
expected = raw_expected[1:-1]
|
|
1534
|
+
else:
|
|
1535
|
+
column_name = None
|
|
1536
|
+
elif all(char.isalnum() or char in "._" for char in raw_expected):
|
|
1537
|
+
expected = raw_expected
|
|
1538
|
+
else:
|
|
1539
|
+
column_name = None
|
|
1540
|
+
elif (
|
|
1541
|
+
isinstance(condition, BinaryExpression)
|
|
1542
|
+
and condition.operator is operators.eq
|
|
1543
|
+
):
|
|
1544
|
+
column_name = getattr(condition.left, "name", None) or getattr(
|
|
1545
|
+
condition.left, "key", None
|
|
1546
|
+
)
|
|
1547
|
+
expected = getattr(
|
|
1548
|
+
condition.right,
|
|
1549
|
+
"value",
|
|
1550
|
+
getattr(condition.right, "effective_value", condition.right),
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
if not isinstance(column_name, str) or column_name not in in_obj:
|
|
1554
|
+
return True
|
|
1555
|
+
return cls._partial_index_values_equal(in_obj[column_name], expected)
|
|
1556
|
+
|
|
1473
1557
|
async def validate_unique_indexes(
|
|
1474
1558
|
self, session: AsyncSession, in_obj: ModelDict
|
|
1475
1559
|
) -> None:
|
|
1476
1560
|
"""
|
|
1477
|
-
Валидирует соблюдение уникальности
|
|
1561
|
+
Валидирует соблюдение уникальности индексов.
|
|
1562
|
+
|
|
1563
|
+
Для partial unique index (`postgresql_where`) проверка выполняется только
|
|
1564
|
+
если создаваемая/обновляемая запись сама попадает под WHERE.
|
|
1478
1565
|
"""
|
|
1479
1566
|
for index in self.unique_indexes:
|
|
1480
1567
|
condition = (
|
|
@@ -1482,6 +1569,9 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1482
1569
|
if index.dialect_options.get("postgresql")
|
|
1483
1570
|
else None
|
|
1484
1571
|
)
|
|
1572
|
+
if not self._matches_partial_index_where(condition, in_obj):
|
|
1573
|
+
continue
|
|
1574
|
+
|
|
1485
1575
|
filters = []
|
|
1486
1576
|
|
|
1487
1577
|
for column in index.columns:
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/test.txt
RENAMED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
pytest>=
|
|
1
|
+
pytest>=9.0.0
|
|
2
2
|
asyncpg>=0.29.0
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, AsyncTransaction
|
|
@@ -7,25 +7,35 @@ from tests.db import async_session_factory, engine
|
|
|
7
7
|
from tests.models import Base, CustomPKBase
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
@pytest.fixture(autouse=True)
|
|
11
|
-
async def create_metadata():
|
|
12
|
-
async with engine.begin() as conn:
|
|
13
|
-
await conn.run_sync(Base.metadata.create_all)
|
|
14
|
-
await conn.run_sync(CustomPKBase.metadata.create_all)
|
|
15
|
-
|
|
16
|
-
|
|
17
10
|
@pytest.fixture(scope="session")
|
|
18
|
-
def anyio_backend():
|
|
11
|
+
def anyio_backend() -> str:
|
|
19
12
|
return "asyncio"
|
|
20
13
|
|
|
21
14
|
|
|
22
15
|
@pytest.fixture(scope="session")
|
|
23
|
-
async def connection(
|
|
16
|
+
async def connection(
|
|
17
|
+
anyio_backend: str, # noqa: ARG001 - activates AnyIO's async-fixture runner
|
|
18
|
+
) -> AsyncGenerator[AsyncConnection, None]:
|
|
24
19
|
async with engine.connect() as connection:
|
|
25
20
|
yield connection
|
|
26
21
|
|
|
27
22
|
|
|
28
|
-
@pytest.fixture()
|
|
23
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
24
|
+
async def create_metadata(
|
|
25
|
+
connection: AsyncConnection,
|
|
26
|
+
) -> AsyncGenerator[None, None]:
|
|
27
|
+
await connection.run_sync(Base.metadata.create_all)
|
|
28
|
+
await connection.run_sync(CustomPKBase.metadata.create_all)
|
|
29
|
+
await connection.commit()
|
|
30
|
+
|
|
31
|
+
yield
|
|
32
|
+
|
|
33
|
+
await connection.run_sync(CustomPKBase.metadata.drop_all)
|
|
34
|
+
await connection.run_sync(Base.metadata.drop_all)
|
|
35
|
+
await connection.commit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
29
39
|
async def transaction(
|
|
30
40
|
connection: AsyncConnection,
|
|
31
41
|
) -> AsyncGenerator[AsyncTransaction, None]:
|
|
@@ -39,7 +49,7 @@ async def persistent_session() -> AsyncGenerator[AsyncSession, None]:
|
|
|
39
49
|
yield session
|
|
40
50
|
|
|
41
51
|
|
|
42
|
-
@pytest.fixture
|
|
52
|
+
@pytest.fixture
|
|
43
53
|
async def session(
|
|
44
54
|
connection: AsyncConnection, transaction: AsyncTransaction
|
|
45
55
|
) -> AsyncGenerator[AsyncSession, None]:
|
|
@@ -2,7 +2,17 @@ from datetime import datetime
|
|
|
2
2
|
from uuid import UUID, uuid4
|
|
3
3
|
|
|
4
4
|
from pydantic import BaseModel
|
|
5
|
-
from sqlalchemy import
|
|
5
|
+
from sqlalchemy import (
|
|
6
|
+
Column,
|
|
7
|
+
DateTime,
|
|
8
|
+
ForeignKey,
|
|
9
|
+
Index,
|
|
10
|
+
Table,
|
|
11
|
+
UniqueConstraint,
|
|
12
|
+
column,
|
|
13
|
+
func,
|
|
14
|
+
text,
|
|
15
|
+
)
|
|
6
16
|
from sqlalchemy.orm import (
|
|
7
17
|
DeclarativeBase,
|
|
8
18
|
Mapped,
|
|
@@ -112,3 +122,55 @@ class ItemSchema(BaseModel):
|
|
|
112
122
|
|
|
113
123
|
|
|
114
124
|
item_manager = ModelManager[Item, ItemSchema, ItemSchema](Item)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PartialUniqueText(Base):
|
|
128
|
+
"""Partial unique index via text() postgresql_where."""
|
|
129
|
+
|
|
130
|
+
group_id: Mapped[UUID]
|
|
131
|
+
kind: Mapped[str]
|
|
132
|
+
|
|
133
|
+
__table_args__ = (
|
|
134
|
+
Index(
|
|
135
|
+
"uix_partial_unique_text_one_main_per_group",
|
|
136
|
+
"group_id",
|
|
137
|
+
unique=True,
|
|
138
|
+
postgresql_where=text("kind = 'main'"),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PartialUniqueTextSchema(BaseModel):
|
|
144
|
+
group_id: UUID
|
|
145
|
+
kind: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
partial_unique_text_manager = ModelManager[
|
|
149
|
+
PartialUniqueText, PartialUniqueTextSchema, PartialUniqueTextSchema
|
|
150
|
+
](PartialUniqueText)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PartialUniqueExpr(Base):
|
|
154
|
+
"""Partial unique index via Column expression postgresql_where."""
|
|
155
|
+
|
|
156
|
+
group_id: Mapped[UUID]
|
|
157
|
+
kind: Mapped[str]
|
|
158
|
+
|
|
159
|
+
__table_args__ = (
|
|
160
|
+
Index(
|
|
161
|
+
"uix_partial_unique_expr_one_main_per_group",
|
|
162
|
+
"group_id",
|
|
163
|
+
unique=True,
|
|
164
|
+
postgresql_where=(column("kind") == "main"),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PartialUniqueExprSchema(BaseModel):
|
|
170
|
+
group_id: UUID
|
|
171
|
+
kind: str
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
partial_unique_expr_manager = ModelManager[
|
|
175
|
+
PartialUniqueExpr, PartialUniqueExprSchema, PartialUniqueExprSchema
|
|
176
|
+
](PartialUniqueExpr)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# ruff: noqa: S101
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Index, and_, text
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from fastapi_sqlalchemy_toolkit.model_manager import ModelManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Base(DeclarativeBase):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Kind(Enum):
|
|
15
|
+
MAIN = "main"
|
|
16
|
+
EXTRA = "extra"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Model(_Base):
|
|
20
|
+
__tablename__ = "partial_where_model"
|
|
21
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
22
|
+
kind: Mapped[str]
|
|
23
|
+
group_id: Mapped[int]
|
|
24
|
+
active: Mapped[bool]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_none_condition_matches() -> None:
|
|
28
|
+
assert ModelManager._matches_partial_index_where(None, {"kind": "extra"}) is True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_text_clause_main_matches() -> None:
|
|
32
|
+
for condition in (
|
|
33
|
+
text("kind = 'main'"),
|
|
34
|
+
text('kind = "main"'),
|
|
35
|
+
text("kind = main"),
|
|
36
|
+
):
|
|
37
|
+
assert (
|
|
38
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "main"})
|
|
39
|
+
is True
|
|
40
|
+
)
|
|
41
|
+
assert (
|
|
42
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "extra"})
|
|
43
|
+
is False
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_binary_expression_matches() -> None:
|
|
48
|
+
index = Index(
|
|
49
|
+
"ix",
|
|
50
|
+
_Model.group_id,
|
|
51
|
+
unique=True,
|
|
52
|
+
postgresql_where=(_Model.kind == "main"),
|
|
53
|
+
)
|
|
54
|
+
condition = index.dialect_options["postgresql"]["where"]
|
|
55
|
+
assert (
|
|
56
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "main"}) is True
|
|
57
|
+
)
|
|
58
|
+
assert (
|
|
59
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "extra"}) is False
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_binary_expression_with_enum_matches() -> None:
|
|
64
|
+
index = Index(
|
|
65
|
+
"ix_enum",
|
|
66
|
+
_Model.group_id,
|
|
67
|
+
unique=True,
|
|
68
|
+
postgresql_where=(_Model.kind == _Kind.MAIN),
|
|
69
|
+
)
|
|
70
|
+
condition = index.dialect_options["postgresql"]["where"]
|
|
71
|
+
assert (
|
|
72
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "main"}) is True
|
|
73
|
+
)
|
|
74
|
+
assert (
|
|
75
|
+
ModelManager._matches_partial_index_where(condition, {"kind": _Kind.MAIN})
|
|
76
|
+
is True
|
|
77
|
+
)
|
|
78
|
+
assert (
|
|
79
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "extra"}) is False
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_boolean_clause_list_matches() -> None:
|
|
84
|
+
index = Index(
|
|
85
|
+
"ix_and",
|
|
86
|
+
_Model.group_id,
|
|
87
|
+
unique=True,
|
|
88
|
+
postgresql_where=and_(_Model.kind == "main", _Model.active == True), # noqa: E712
|
|
89
|
+
)
|
|
90
|
+
condition = index.dialect_options["postgresql"]["where"]
|
|
91
|
+
assert (
|
|
92
|
+
ModelManager._matches_partial_index_where(
|
|
93
|
+
condition, {"kind": "main", "active": True}
|
|
94
|
+
)
|
|
95
|
+
is True
|
|
96
|
+
)
|
|
97
|
+
assert (
|
|
98
|
+
ModelManager._matches_partial_index_where(
|
|
99
|
+
condition, {"kind": "main", "active": False}
|
|
100
|
+
)
|
|
101
|
+
is False
|
|
102
|
+
)
|
|
103
|
+
assert (
|
|
104
|
+
ModelManager._matches_partial_index_where(
|
|
105
|
+
condition, {"kind": "extra", "active": True}
|
|
106
|
+
)
|
|
107
|
+
is False
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unsupported_text_fail_safe() -> None:
|
|
112
|
+
condition = text("kind IN ('main', 'special')")
|
|
113
|
+
assert (
|
|
114
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "extra"}) is True
|
|
115
|
+
)
|
|
@@ -14,10 +14,14 @@ from tests.models import (
|
|
|
14
14
|
ItemSchema,
|
|
15
15
|
Parent,
|
|
16
16
|
ParentSchema,
|
|
17
|
+
PartialUniqueExprSchema,
|
|
18
|
+
PartialUniqueTextSchema,
|
|
17
19
|
category_manager,
|
|
18
20
|
child_manager,
|
|
19
21
|
item_manager,
|
|
20
22
|
parent_manager,
|
|
23
|
+
partial_unique_expr_manager,
|
|
24
|
+
partial_unique_text_manager,
|
|
21
25
|
)
|
|
22
26
|
|
|
23
27
|
|
|
@@ -1079,6 +1083,122 @@ async def test_list_with_optional_where_multi_all_none(session: AsyncSession):
|
|
|
1079
1083
|
assert len(parents) == 2
|
|
1080
1084
|
|
|
1081
1085
|
|
|
1086
|
+
async def test_list_with_optional_where_in_applied(session: AsyncSession):
|
|
1087
|
+
"""Case 4: in_ expression, non-empty list — filter is applied."""
|
|
1088
|
+
target_slug = "optional-where-in-slug1"
|
|
1089
|
+
await session.execute(
|
|
1090
|
+
insert(Parent),
|
|
1091
|
+
[
|
|
1092
|
+
{"title": "optional-where-in-title-1", "slug": target_slug},
|
|
1093
|
+
{"title": "optional-where-in-title-2", "slug": "optional-where-in-slug2"},
|
|
1094
|
+
],
|
|
1095
|
+
)
|
|
1096
|
+
await session.commit()
|
|
1097
|
+
|
|
1098
|
+
parents = await parent_manager.list(
|
|
1099
|
+
session=session,
|
|
1100
|
+
optional_where=Parent.slug.in_([target_slug]),
|
|
1101
|
+
)
|
|
1102
|
+
assert len(parents) == 1
|
|
1103
|
+
assert parents[0].slug == target_slug
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
async def test_list_with_optional_where_in_skipped(session: AsyncSession):
|
|
1107
|
+
"""Case 4: in_ expression, empty list — filter is skipped (all returned)."""
|
|
1108
|
+
await session.execute(
|
|
1109
|
+
insert(Parent),
|
|
1110
|
+
[
|
|
1111
|
+
{
|
|
1112
|
+
"title": "optional-where-in-skip-title-1",
|
|
1113
|
+
"slug": "optional-where-in-skip-slug1",
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
"title": "optional-where-in-skip-title-2",
|
|
1117
|
+
"slug": "optional-where-in-skip-slug2",
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
1120
|
+
)
|
|
1121
|
+
await session.commit()
|
|
1122
|
+
|
|
1123
|
+
parents = await parent_manager.list(
|
|
1124
|
+
session=session,
|
|
1125
|
+
optional_where=Parent.slug.in_([]),
|
|
1126
|
+
)
|
|
1127
|
+
assert len(parents) == 2
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
async def test_list_with_optional_where_endswith_applied(session: AsyncSession):
|
|
1131
|
+
"""Case 4: endswith expression, non-empty string — filter is applied."""
|
|
1132
|
+
target_slug = "optional-where-ends-slug-xyz"
|
|
1133
|
+
await session.execute(
|
|
1134
|
+
insert(Parent),
|
|
1135
|
+
[
|
|
1136
|
+
{"title": "optional-where-ends-title-1", "slug": target_slug},
|
|
1137
|
+
{
|
|
1138
|
+
"title": "optional-where-ends-title-2",
|
|
1139
|
+
"slug": "optional-where-ends-slug-abc",
|
|
1140
|
+
},
|
|
1141
|
+
],
|
|
1142
|
+
)
|
|
1143
|
+
await session.commit()
|
|
1144
|
+
|
|
1145
|
+
parents = await parent_manager.list(
|
|
1146
|
+
session=session,
|
|
1147
|
+
optional_where=Parent.slug.endswith("xyz"),
|
|
1148
|
+
)
|
|
1149
|
+
assert len(parents) == 1
|
|
1150
|
+
assert parents[0].slug == target_slug
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
async def test_list_with_optional_where_endswith_skipped(session: AsyncSession):
|
|
1154
|
+
"""Case 4: endswith expression, empty string — filter is skipped (all returned)."""
|
|
1155
|
+
await session.execute(
|
|
1156
|
+
insert(Parent),
|
|
1157
|
+
[
|
|
1158
|
+
{
|
|
1159
|
+
"title": "optional-where-ends-skip-title-1",
|
|
1160
|
+
"slug": "optional-where-ends-skip-slug1",
|
|
1161
|
+
},
|
|
1162
|
+
{
|
|
1163
|
+
"title": "optional-where-ends-skip-title-2",
|
|
1164
|
+
"slug": "optional-where-ends-skip-slug2",
|
|
1165
|
+
},
|
|
1166
|
+
],
|
|
1167
|
+
)
|
|
1168
|
+
await session.commit()
|
|
1169
|
+
|
|
1170
|
+
parents = await parent_manager.list(
|
|
1171
|
+
session=session,
|
|
1172
|
+
optional_where=Parent.slug.endswith(""),
|
|
1173
|
+
)
|
|
1174
|
+
assert len(parents) == 2
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
async def test_list_with_optional_where_in_compound_one_empty(session: AsyncSession):
|
|
1178
|
+
"""Case 4 in compound: in_([]) in & expression — only non-empty filter applied."""
|
|
1179
|
+
target_title = "optional-where-compound-in-title"
|
|
1180
|
+
await session.execute(
|
|
1181
|
+
insert(Parent),
|
|
1182
|
+
[
|
|
1183
|
+
{"title": target_title, "slug": "optional-where-compound-in-slug1"},
|
|
1184
|
+
{"title": target_title, "slug": "optional-where-compound-in-slug2"},
|
|
1185
|
+
{
|
|
1186
|
+
"title": "optional-where-compound-in-other",
|
|
1187
|
+
"slug": "optional-where-compound-in-slug3",
|
|
1188
|
+
},
|
|
1189
|
+
],
|
|
1190
|
+
)
|
|
1191
|
+
await session.commit()
|
|
1192
|
+
|
|
1193
|
+
parents = await parent_manager.list(
|
|
1194
|
+
session=session,
|
|
1195
|
+
optional_where=(Parent.title == target_title) & Parent.slug.in_([]),
|
|
1196
|
+
)
|
|
1197
|
+
assert len(parents) == 2
|
|
1198
|
+
for parent in parents:
|
|
1199
|
+
assert parent.title == target_title
|
|
1200
|
+
|
|
1201
|
+
|
|
1082
1202
|
async def test_create_unique_constraint_validation(session: AsyncSession):
|
|
1083
1203
|
await parent_manager.create(
|
|
1084
1204
|
session=session,
|
|
@@ -1306,3 +1426,67 @@ async def test_custom_pk_unique_validation_on_update(session: AsyncSession):
|
|
|
1306
1426
|
with pytest.raises(HTTPException) as exc_info:
|
|
1307
1427
|
await item_manager.update(session, item1, ItemSchema(name="upd-unique-2"))
|
|
1308
1428
|
assert exc_info.value.status_code == 422
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
async def test_partial_unique_index_text_allows_non_matching_kind(
|
|
1432
|
+
session: AsyncSession,
|
|
1433
|
+
):
|
|
1434
|
+
group_id = uuid4()
|
|
1435
|
+
await partial_unique_text_manager.create(
|
|
1436
|
+
session, PartialUniqueTextSchema(group_id=group_id, kind="main")
|
|
1437
|
+
)
|
|
1438
|
+
extra = await partial_unique_text_manager.create(
|
|
1439
|
+
session, PartialUniqueTextSchema(group_id=group_id, kind="extra")
|
|
1440
|
+
)
|
|
1441
|
+
assert extra.kind == "extra"
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
async def test_partial_unique_index_text_rejects_second_main(session: AsyncSession):
|
|
1445
|
+
group_id = uuid4()
|
|
1446
|
+
await partial_unique_text_manager.create(
|
|
1447
|
+
session, PartialUniqueTextSchema(group_id=group_id, kind="main")
|
|
1448
|
+
)
|
|
1449
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
1450
|
+
await partial_unique_text_manager.create(
|
|
1451
|
+
session, PartialUniqueTextSchema(group_id=group_id, kind="main")
|
|
1452
|
+
)
|
|
1453
|
+
assert exc_info.value.status_code == 400
|
|
1454
|
+
assert "group_id" in exc_info.value.detail
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
async def test_partial_unique_index_text_allows_main_for_other_group(
|
|
1458
|
+
session: AsyncSession,
|
|
1459
|
+
):
|
|
1460
|
+
await partial_unique_text_manager.create(
|
|
1461
|
+
session, PartialUniqueTextSchema(group_id=uuid4(), kind="main")
|
|
1462
|
+
)
|
|
1463
|
+
other = await partial_unique_text_manager.create(
|
|
1464
|
+
session, PartialUniqueTextSchema(group_id=uuid4(), kind="main")
|
|
1465
|
+
)
|
|
1466
|
+
assert other.kind == "main"
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
async def test_partial_unique_index_expr_allows_non_matching_kind(
|
|
1470
|
+
session: AsyncSession,
|
|
1471
|
+
):
|
|
1472
|
+
group_id = uuid4()
|
|
1473
|
+
await partial_unique_expr_manager.create(
|
|
1474
|
+
session, PartialUniqueExprSchema(group_id=group_id, kind="main")
|
|
1475
|
+
)
|
|
1476
|
+
extra = await partial_unique_expr_manager.create(
|
|
1477
|
+
session, PartialUniqueExprSchema(group_id=group_id, kind="extra")
|
|
1478
|
+
)
|
|
1479
|
+
assert extra.kind == "extra"
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
async def test_partial_unique_index_expr_rejects_second_main(session: AsyncSession):
|
|
1483
|
+
group_id = uuid4()
|
|
1484
|
+
await partial_unique_expr_manager.create(
|
|
1485
|
+
session, PartialUniqueExprSchema(group_id=group_id, kind="main")
|
|
1486
|
+
)
|
|
1487
|
+
with pytest.raises(HTTPException) as exc_info:
|
|
1488
|
+
await partial_unique_expr_manager.create(
|
|
1489
|
+
session, PartialUniqueExprSchema(group_id=group_id, kind="main")
|
|
1490
|
+
)
|
|
1491
|
+
assert exc_info.value.status_code == 400
|
|
1492
|
+
assert "group_id" in exc_info.value.detail
|
|
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.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/extension.md
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/sorting.md
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/transactions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/transactions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/api.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/deps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/config.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/db.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/main.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/managers.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/models.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/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
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/base.txt
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/docs.txt
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/lint.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/docker-compose.yml
RENAMED
|
File without changes
|