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.
Files changed (61) hide show
  1. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/PKG-INFO +1 -1
  2. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/db_validation.md +14 -1
  3. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/filtering.md +21 -0
  4. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/benefits.md +7 -7
  5. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/db_validation.md +17 -1
  6. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/filtering.md +105 -102
  7. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/model_manager.py +102 -12
  8. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/pyproject.toml +1 -1
  9. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/test.txt +1 -1
  10. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/conftest.py +22 -12
  11. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/models.py +63 -1
  12. fastapi_sqlalchemy_toolkit-0.8.2.4/tests/test_partial_where.py +115 -0
  13. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/test_public_methods.py +184 -0
  14. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.flake8 +0 -0
  15. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.github/workflows/docker-test.yml +0 -0
  16. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.github/workflows/python-publish.yml +0 -0
  17. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.gitignore +0 -0
  18. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/.pylintrc +0 -0
  19. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/AUTHORS +0 -0
  20. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/LICENSE +0 -0
  21. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/Makefile +0 -0
  22. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/README.md +0 -0
  23. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/benefits.md +0 -0
  24. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/extension.md +0 -0
  25. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/index.md +0 -0
  26. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/extension.md +0 -0
  27. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/index.md +0 -0
  28. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/sorting.md +0 -0
  29. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/transactions.md +0 -0
  30. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/usage.md +0 -0
  31. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/ru/utils.md +0 -0
  32. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/sorting.md +0 -0
  33. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/transactions.md +0 -0
  34. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/usage.md +0 -0
  35. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/docs/utils.md +0 -0
  36. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/.env +0 -0
  37. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/__init__.py +0 -0
  38. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/__init__.py +0 -0
  39. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/api.py +0 -0
  40. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/deps.py +0 -0
  41. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/endpoints/__init__.py +0 -0
  42. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/api/endpoints/child.py +0 -0
  43. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/config.py +0 -0
  44. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/db.py +0 -0
  45. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/main.py +0 -0
  46. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/managers.py +0 -0
  47. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/models.py +0 -0
  48. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/examples/app/schemas.py +0 -0
  49. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
  50. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
  51. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
  52. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
  53. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/mkdocs.yml +0 -0
  54. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/poetry.lock +0 -0
  55. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/base.txt +0 -0
  56. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/docs.txt +0 -0
  57. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/requirements/lint.txt +0 -0
  58. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/Dockerfile +0 -0
  59. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/__init__.py +0 -0
  60. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/db.py +0 -0
  61. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.4}/tests/docker-compose.yml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi_sqlalchemy_toolkit
3
- Version: 0.8.2.2
3
+ Version: 0.8.2.4
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>
@@ -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**
@@ -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.ilike == f"%{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 == f"%{parent_name}%")
34
+ stmt = stmt.filter(ParentModel.name.ilike == "parent_name")
35
35
  return await paginate(session, stmt)
36
36
  ```
37
37
 
38
- В `fastapi-sqlalchemy-toolkit` этот эндпоинт выглядит так:
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
- filter_expressions={
54
- MyObject.name: name,
55
- MyObjectParent.name: parent_name
56
- }
53
+ optional_where=(
54
+ MyObject.name == name,
55
+ MyObjectParent.name == parent_name
56
+ )
57
57
  )
58
58
  ```
59
59
 
@@ -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): валидация всё равно выполняется.
@@ -72,13 +72,114 @@ FROM child
72
72
  WHERE child.slug = :slug_1
73
73
  ```
74
74
 
75
- По конвенции `FastAPI`, необязательные параметры запроса типизируются как `slug: str | None = None`.
76
- При этом клиенты API обычно ожидают, что при запросе `GET /children` будут возвращены все объекты `Child`,
77
- а не только те, у которых `slug is null`. Поэтому метод `list` (`paginated_list`) отбрасывает фильтрацию
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 Null, UnaryExpression
30
- from sqlalchemy.sql.expression import BinaryExpression, ColumnElement
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, пропуская фильтр, если его значение None.
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
- В составных выражениях исключаются части с value == None,
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
- return and_(*remaining)
1267
- return or_(*remaining)
1287
+ combiner = and_ if optional_where.operator.__name__ == "and_" else or_
1288
+ return combiner(*remaining)
1268
1289
 
1269
- # simple binary expression
1270
- if isinstance(getattr(optional_where, "right", None), Null):
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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi_sqlalchemy_toolkit"
7
- version = "0.8.2.2"
7
+ version = "0.8.2.4"
8
8
  authors = [
9
9
  { name="Egor Kondrashov", email="e.kondr01@gmail.com" },
10
10
  ]
@@ -1,2 +1,2 @@
1
- pytest>=8.0.0
1
+ pytest>=9.0.0
2
2
  asyncpg>=0.29.0
@@ -1,4 +1,4 @@
1
- from typing import AsyncGenerator
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(anyio_backend) -> AsyncGenerator[AsyncConnection, None]:
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 Column, DateTime, ForeignKey, Table, UniqueConstraint, func
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