fastapi-sqlalchemy-toolkit 0.8.2.3__tar.gz → 0.8.3.1__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.3.1/.github/workflows/python-publish.yml +33 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/PKG-INFO +3 -2
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/db_validation.md +14 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/benefits.md +7 -7
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/db_validation.md +17 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/filtering.md +105 -106
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/fastapi_sqlalchemy_toolkit/model_manager.py +105 -4
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/pyproject.toml +2 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/requirements/base.txt +1 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/requirements/test.txt +1 -1
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/conftest.py +22 -12
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/models.py +63 -1
- fastapi_sqlalchemy_toolkit-0.8.3.1/tests/test_partial_where.py +171 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/test_public_methods.py +68 -0
- fastapi_sqlalchemy_toolkit-0.8.2.3/.github/workflows/python-publish.yml +0 -34
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/.flake8 +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/.github/workflows/docker-test.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/.gitignore +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/.pylintrc +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/AUTHORS +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/LICENSE +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/Makefile +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/README.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/benefits.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/extension.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/filtering.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/index.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/extension.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/index.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/sorting.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/transactions.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/usage.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/utils.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/sorting.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/transactions.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/usage.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/utils.md +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/.env +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/api.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/deps.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/endpoints/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/endpoints/child.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/config.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/db.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/main.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/managers.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/models.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/schemas.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/mkdocs.yml +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/poetry.lock +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/requirements/docs.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/requirements/lint.txt +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/Dockerfile +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/__init__.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/db.py +0 -0
- {fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/docker-compose.yml +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# This workflow will upload a Python Package using Twine when a release is created
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
|
3
|
+
|
|
4
|
+
name: Upload Python Package
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
deploy:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v3
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v3
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.x"
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip
|
|
26
|
+
pip install build
|
|
27
|
+
- name: Build package
|
|
28
|
+
run: python -m build
|
|
29
|
+
- name: Publish package
|
|
30
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
31
|
+
with:
|
|
32
|
+
user: __token__
|
|
33
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi_sqlalchemy_toolkit
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3.1
|
|
4
4
|
Summary: FastAPI SQLAlchemy Toolkit
|
|
5
5
|
Project-URL: Homepage, https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit
|
|
6
6
|
Author-email: Egor Kondrashov <e.kondr01@gmail.com>
|
|
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Requires-Python: >=3.11
|
|
12
12
|
Requires-Dist: fastapi-pagination>=0.12.12
|
|
13
13
|
Requires-Dist: fastapi>=0.100.0
|
|
14
|
+
Requires-Dist: greenlet>=3.0.0
|
|
14
15
|
Requires-Dist: pydantic>=2.0.0
|
|
15
16
|
Requires-Dist: sqlalchemy>=2.0.0
|
|
16
17
|
Description-Content-Type: text/markdown
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/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.
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/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.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/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.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/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,108 +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
|
-
Также если `value` является пустым списком (`[]`) или пустой строкой (`""`), фильтр пропускается.
|
|
222
|
-
Это распространяется на `in_([])`, `endswith("")`, `startswith("")` и аналогичные операторы, которые
|
|
223
|
-
не допускают передачи `None`.
|
|
224
|
-
|
|
225
|
-
```python
|
|
226
|
-
@router.get("/parents")
|
|
227
|
-
async def get_parents(
|
|
228
|
-
session: Session,
|
|
229
|
-
created_at_date: date | None = None,
|
|
230
|
-
) -> list[ParentListSchema]:
|
|
231
|
-
return await parent_manager.list(
|
|
232
|
-
session,
|
|
233
|
-
optional_where=(func.date(Parent.created_at) == created_at_date),
|
|
234
|
-
)
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
**Кейс 3: Составное выражение через `&` или `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
|
|
238
|
-
|
|
239
|
-
Части выражения, значения которых равны `None`, исключаются. Оставшиеся части объединяются
|
|
240
|
-
с использованием исходного оператора (`&` или `|`). Если все значения `None`,
|
|
241
|
-
фильтр пропускается полностью.
|
|
242
|
-
|
|
243
|
-
```python
|
|
244
|
-
@router.get("/parents")
|
|
245
|
-
async def get_parents(
|
|
246
|
-
session: Session,
|
|
247
|
-
title: str | None = None,
|
|
248
|
-
slug: str | None = None,
|
|
249
|
-
) -> list[ParentListSchema]:
|
|
250
|
-
return await parent_manager.list(
|
|
251
|
-
session,
|
|
252
|
-
optional_where=(Parent.title == title) & (Parent.slug == slug),
|
|
253
|
-
)
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
257
|
-
|
|
258
|
-
Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
|
|
259
|
-
|
|
260
|
-
Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
|
|
261
|
-
|
|
262
|
-
> **Примечание**: вложенные составные выражения (например, `(a & b) | c`) не поддерживаются.
|
|
263
|
-
|
|
264
|
-
**Несколько выражений как отдельные аргументы**
|
|
265
|
-
|
|
266
|
-
Вместо использования `&` для объединения выражений можно передать их как отдельные
|
|
267
|
-
аргументы в виде кортежа. Каждый аргумент поддерживает все три вида выражений выше.
|
|
268
|
-
Оставшиеся (не-`None`) выражения объединяются через `AND`.
|
|
269
|
-
|
|
270
|
-
```python
|
|
271
|
-
@router.get("/parents")
|
|
272
|
-
async def get_parents(
|
|
273
|
-
session: Session,
|
|
274
|
-
title: str | None = None,
|
|
275
|
-
slug: str | None = None,
|
|
276
|
-
) -> list[ParentListSchema]:
|
|
277
|
-
return await parent_manager.list(
|
|
278
|
-
session,
|
|
279
|
-
optional_where=(Parent.title == title, Parent.slug == slug),
|
|
280
|
-
)
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
|
|
284
|
-
|
|
285
|
-
Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
|
|
286
|
-
|
|
287
|
-
Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
|
|
288
|
-
|
|
289
288
|
## Фильтрация без дополнительной обработки
|
|
290
289
|
|
|
291
290
|
Для фильтрации без дополнительной обработки в методах `list` и `paginated_list` можно
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# ruff: noqa: UP006
|
|
2
|
+
import re
|
|
2
3
|
from collections.abc import Callable, Iterable
|
|
4
|
+
from enum import Enum
|
|
3
5
|
from typing import Any, Generic, List, TypeVar, overload # noqa: UP035
|
|
4
6
|
|
|
5
7
|
from fastapi import HTTPException, status
|
|
@@ -25,9 +27,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
25
27
|
from sqlalchemy.orm import DeclarativeBase, contains_eager, load_only
|
|
26
28
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
27
29
|
from sqlalchemy.orm.relationships import Relationship
|
|
28
|
-
from sqlalchemy.sql import Select
|
|
29
|
-
from sqlalchemy.sql.elements import
|
|
30
|
-
|
|
30
|
+
from sqlalchemy.sql import Select, operators
|
|
31
|
+
from sqlalchemy.sql.elements import (
|
|
32
|
+
BinaryExpression,
|
|
33
|
+
BindParameter,
|
|
34
|
+
BooleanClauseList,
|
|
35
|
+
Null,
|
|
36
|
+
TextClause,
|
|
37
|
+
UnaryExpression,
|
|
38
|
+
)
|
|
39
|
+
from sqlalchemy.sql.expression import ColumnElement
|
|
31
40
|
from sqlalchemy.sql.functions import Function
|
|
32
41
|
from sqlalchemy.sql.schema import ScalarElementColumnDefault
|
|
33
42
|
from sqlalchemy.sql.selectable import Exists
|
|
@@ -1483,11 +1492,100 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1483
1492
|
),
|
|
1484
1493
|
)
|
|
1485
1494
|
|
|
1495
|
+
@staticmethod
|
|
1496
|
+
def _partial_index_values_equal(actual: Any, expected: Any) -> bool:
|
|
1497
|
+
"""Сравнить обычные и Enum-значения из объекта и условия индекса."""
|
|
1498
|
+
actual_normalized = actual.name if isinstance(actual, Enum) else actual
|
|
1499
|
+
expected_normalized = expected.name if isinstance(expected, Enum) else expected
|
|
1500
|
+
if actual_normalized == expected_normalized:
|
|
1501
|
+
return True
|
|
1502
|
+
if isinstance(actual, Enum) and actual.value == expected_normalized:
|
|
1503
|
+
return True
|
|
1504
|
+
if isinstance(expected, Enum) and expected.value == actual_normalized:
|
|
1505
|
+
return True
|
|
1506
|
+
return str(actual_normalized) == str(expected_normalized)
|
|
1507
|
+
|
|
1508
|
+
_NULL_CHECK_RE = re.compile(
|
|
1509
|
+
r"^\s*(?P<column>[A-Za-z_][A-Za-z0-9_]*)\s+IS\s+(?P<not>NOT\s+)?NULL\s*$",
|
|
1510
|
+
re.IGNORECASE,
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
@classmethod
|
|
1514
|
+
def _matches_partial_index_where(cls, condition: Any, in_obj: ModelDict) -> bool:
|
|
1515
|
+
"""
|
|
1516
|
+
Проверить, покрывается ли объект условием partial index.
|
|
1517
|
+
|
|
1518
|
+
Неподдерживаемое условие считается совпавшим, чтобы не пропустить
|
|
1519
|
+
потенциальное нарушение уникальности.
|
|
1520
|
+
"""
|
|
1521
|
+
if isinstance(condition, BooleanClauseList):
|
|
1522
|
+
return all(
|
|
1523
|
+
cls._matches_partial_index_where(clause, in_obj) for clause in condition
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
column_name: str | None = None
|
|
1527
|
+
expected: Any = None
|
|
1528
|
+
if isinstance(condition, TextClause):
|
|
1529
|
+
null_match = cls._NULL_CHECK_RE.match(condition.text)
|
|
1530
|
+
if null_match:
|
|
1531
|
+
column_name = null_match.group("column")
|
|
1532
|
+
if column_name not in in_obj:
|
|
1533
|
+
return True
|
|
1534
|
+
is_null = in_obj[column_name] is None
|
|
1535
|
+
return not is_null if null_match.group("not") else is_null
|
|
1536
|
+
|
|
1537
|
+
raw_column, separator, raw_expected = condition.text.partition("=")
|
|
1538
|
+
column_name = raw_column.strip()
|
|
1539
|
+
raw_expected = raw_expected.strip()
|
|
1540
|
+
valid_column = column_name.isascii() and column_name.isidentifier()
|
|
1541
|
+
|
|
1542
|
+
if not separator or not valid_column or not raw_expected:
|
|
1543
|
+
column_name = None
|
|
1544
|
+
elif raw_expected[0] in {'"', "'"}:
|
|
1545
|
+
quote = raw_expected[0]
|
|
1546
|
+
if raw_expected[-1] == quote and quote not in raw_expected[1:-1]:
|
|
1547
|
+
expected = raw_expected[1:-1]
|
|
1548
|
+
else:
|
|
1549
|
+
column_name = None
|
|
1550
|
+
elif all(char.isalnum() or char in "._" for char in raw_expected):
|
|
1551
|
+
expected = raw_expected
|
|
1552
|
+
else:
|
|
1553
|
+
column_name = None
|
|
1554
|
+
elif isinstance(condition, BinaryExpression) and condition.operator in (
|
|
1555
|
+
operators.eq,
|
|
1556
|
+
operators.is_,
|
|
1557
|
+
operators.isnot,
|
|
1558
|
+
):
|
|
1559
|
+
column_name = getattr(condition.left, "name", None) or getattr(
|
|
1560
|
+
condition.left, "key", None
|
|
1561
|
+
)
|
|
1562
|
+
if condition.operator in (operators.is_, operators.isnot):
|
|
1563
|
+
if not isinstance(column_name, str) or not isinstance(
|
|
1564
|
+
condition.right, Null
|
|
1565
|
+
):
|
|
1566
|
+
column_name = None
|
|
1567
|
+
elif column_name in in_obj:
|
|
1568
|
+
is_null = in_obj[column_name] is None
|
|
1569
|
+
return not is_null if condition.operator is operators.isnot else is_null
|
|
1570
|
+
else:
|
|
1571
|
+
expected = getattr(
|
|
1572
|
+
condition.right,
|
|
1573
|
+
"value",
|
|
1574
|
+
getattr(condition.right, "effective_value", condition.right),
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
if not isinstance(column_name, str) or column_name not in in_obj:
|
|
1578
|
+
return True
|
|
1579
|
+
return cls._partial_index_values_equal(in_obj[column_name], expected)
|
|
1580
|
+
|
|
1486
1581
|
async def validate_unique_indexes(
|
|
1487
1582
|
self, session: AsyncSession, in_obj: ModelDict
|
|
1488
1583
|
) -> None:
|
|
1489
1584
|
"""
|
|
1490
|
-
Валидирует соблюдение уникальности
|
|
1585
|
+
Валидирует соблюдение уникальности индексов.
|
|
1586
|
+
|
|
1587
|
+
Для partial unique index (`postgresql_where`) проверка выполняется только
|
|
1588
|
+
если создаваемая/обновляемая запись сама попадает под WHERE.
|
|
1491
1589
|
"""
|
|
1492
1590
|
for index in self.unique_indexes:
|
|
1493
1591
|
condition = (
|
|
@@ -1495,6 +1593,9 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
|
|
|
1495
1593
|
if index.dialect_options.get("postgresql")
|
|
1496
1594
|
else None
|
|
1497
1595
|
)
|
|
1596
|
+
if not self._matches_partial_index_where(condition, in_obj):
|
|
1597
|
+
continue
|
|
1598
|
+
|
|
1498
1599
|
filters = []
|
|
1499
1600
|
|
|
1500
1601
|
for column in index.columns:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi_sqlalchemy_toolkit"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.3.1"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Egor Kondrashov", email="e.kondr01@gmail.com" },
|
|
10
10
|
]
|
|
@@ -18,6 +18,7 @@ classifiers = [
|
|
|
18
18
|
dependencies = [
|
|
19
19
|
"fastapi>=0.100.0",
|
|
20
20
|
"sqlalchemy>=2.0.0",
|
|
21
|
+
"greenlet>=3.0.0",
|
|
21
22
|
"fastapi_pagination>=0.12.12",
|
|
22
23
|
"pydantic>=2.0.0",
|
|
23
24
|
]
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/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,171 @@
|
|
|
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
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_text_clause_is_null_matches() -> None:
|
|
119
|
+
condition = text("group_id IS NULL")
|
|
120
|
+
assert (
|
|
121
|
+
ModelManager._matches_partial_index_where(condition, {"group_id": None})
|
|
122
|
+
is True
|
|
123
|
+
)
|
|
124
|
+
assert (
|
|
125
|
+
ModelManager._matches_partial_index_where(condition, {"group_id": 1}) is False
|
|
126
|
+
)
|
|
127
|
+
# column missing from the object being validated: fail safe to "matches"
|
|
128
|
+
assert ModelManager._matches_partial_index_where(condition, {}) is True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_text_clause_is_not_null_matches() -> None:
|
|
132
|
+
condition = text(" group_id is not null ")
|
|
133
|
+
assert (
|
|
134
|
+
ModelManager._matches_partial_index_where(condition, {"group_id": None})
|
|
135
|
+
is False
|
|
136
|
+
)
|
|
137
|
+
assert (
|
|
138
|
+
ModelManager._matches_partial_index_where(condition, {"group_id": 1}) is True
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_binary_expression_is_null_matches() -> None:
|
|
143
|
+
index = Index(
|
|
144
|
+
"ix_is_null",
|
|
145
|
+
_Model.group_id,
|
|
146
|
+
unique=True,
|
|
147
|
+
postgresql_where=(_Model.kind.is_(None)),
|
|
148
|
+
)
|
|
149
|
+
condition = index.dialect_options["postgresql"]["where"]
|
|
150
|
+
assert (
|
|
151
|
+
ModelManager._matches_partial_index_where(condition, {"kind": None}) is True
|
|
152
|
+
)
|
|
153
|
+
assert (
|
|
154
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "main"}) is False
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_binary_expression_is_not_null_matches() -> None:
|
|
159
|
+
index = Index(
|
|
160
|
+
"ix_is_not_null",
|
|
161
|
+
_Model.group_id,
|
|
162
|
+
unique=True,
|
|
163
|
+
postgresql_where=(_Model.kind.isnot(None)),
|
|
164
|
+
)
|
|
165
|
+
condition = index.dialect_options["postgresql"]["where"]
|
|
166
|
+
assert (
|
|
167
|
+
ModelManager._matches_partial_index_where(condition, {"kind": None}) is False
|
|
168
|
+
)
|
|
169
|
+
assert (
|
|
170
|
+
ModelManager._matches_partial_index_where(condition, {"kind": "main"}) is True
|
|
171
|
+
)
|
|
@@ -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
|
|
|
@@ -1422,3 +1426,67 @@ async def test_custom_pk_unique_validation_on_update(session: AsyncSession):
|
|
|
1422
1426
|
with pytest.raises(HTTPException) as exc_info:
|
|
1423
1427
|
await item_manager.update(session, item1, ItemSchema(name="upd-unique-2"))
|
|
1424
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
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# This workflow will upload a Python Package using Twine when a release is created
|
|
2
|
-
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
|
3
|
-
|
|
4
|
-
name: Upload Python Package
|
|
5
|
-
|
|
6
|
-
on:
|
|
7
|
-
release:
|
|
8
|
-
types: [published]
|
|
9
|
-
|
|
10
|
-
permissions:
|
|
11
|
-
contents: read
|
|
12
|
-
|
|
13
|
-
jobs:
|
|
14
|
-
deploy:
|
|
15
|
-
|
|
16
|
-
runs-on: ubuntu-latest
|
|
17
|
-
|
|
18
|
-
steps:
|
|
19
|
-
- uses: actions/checkout@v3
|
|
20
|
-
- name: Set up Python
|
|
21
|
-
uses: actions/setup-python@v3
|
|
22
|
-
with:
|
|
23
|
-
python-version: '3.x'
|
|
24
|
-
- name: Install dependencies
|
|
25
|
-
run: |
|
|
26
|
-
python -m pip install --upgrade pip
|
|
27
|
-
pip install build
|
|
28
|
-
- name: Build package
|
|
29
|
-
run: python -m build
|
|
30
|
-
- name: Publish package
|
|
31
|
-
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
|
|
32
|
-
with:
|
|
33
|
-
user: __token__
|
|
34
|
-
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
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.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/extension.md
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/sorting.md
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/ru/transactions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/docs/transactions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/api.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/api/deps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/config.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/db.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/main.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/managers.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/examples/app/models.py
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/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.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/requirements/docs.txt
RENAMED
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/requirements/lint.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_sqlalchemy_toolkit-0.8.2.3 → fastapi_sqlalchemy_toolkit-0.8.3.1}/tests/docker-compose.yml
RENAMED
|
File without changes
|