fastapi-sqlalchemy-toolkit 0.8.1__tar.gz → 0.8.2.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.
Files changed (60) hide show
  1. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/PKG-INFO +1 -1
  2. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/filtering.md +99 -0
  3. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/filtering.md +101 -1
  4. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/fastapi_sqlalchemy_toolkit/model_manager.py +91 -5
  5. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/pyproject.toml +1 -1
  6. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/test_public_methods.py +359 -9
  7. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/.flake8 +0 -0
  8. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/.github/workflows/docker-test.yml +0 -0
  9. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/.github/workflows/python-publish.yml +0 -0
  10. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/.gitignore +0 -0
  11. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/.pylintrc +0 -0
  12. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/AUTHORS +0 -0
  13. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/LICENSE +0 -0
  14. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/Makefile +0 -0
  15. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/README.md +0 -0
  16. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/benefits.md +0 -0
  17. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/db_validation.md +0 -0
  18. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/extension.md +0 -0
  19. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/index.md +0 -0
  20. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/benefits.md +0 -0
  21. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/db_validation.md +0 -0
  22. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/extension.md +0 -0
  23. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/index.md +0 -0
  24. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/sorting.md +0 -0
  25. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/transactions.md +0 -0
  26. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/usage.md +0 -0
  27. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/ru/utils.md +0 -0
  28. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/sorting.md +0 -0
  29. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/transactions.md +0 -0
  30. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/usage.md +0 -0
  31. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/docs/utils.md +0 -0
  32. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/.env +0 -0
  33. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/__init__.py +0 -0
  34. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/api/__init__.py +0 -0
  35. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/api/api.py +0 -0
  36. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/api/deps.py +0 -0
  37. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/api/endpoints/__init__.py +0 -0
  38. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/api/endpoints/child.py +0 -0
  39. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/config.py +0 -0
  40. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/db.py +0 -0
  41. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/main.py +0 -0
  42. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/managers.py +0 -0
  43. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/models.py +0 -0
  44. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/examples/app/schemas.py +0 -0
  45. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
  46. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
  47. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
  48. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
  49. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/mkdocs.yml +0 -0
  50. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/poetry.lock +0 -0
  51. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/requirements/base.txt +0 -0
  52. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/requirements/docs.txt +0 -0
  53. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/requirements/lint.txt +0 -0
  54. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/requirements/test.txt +0 -0
  55. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/Dockerfile +0 -0
  56. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/__init__.py +0 -0
  57. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/conftest.py +0 -0
  58. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/db.py +0 -0
  59. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/docker-compose.yml +0 -0
  60. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2.1}/tests/models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi_sqlalchemy_toolkit
3
- Version: 0.8.1
3
+ Version: 0.8.2.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>
@@ -206,6 +206,105 @@ Unlike the `list` method, the `filter` method:
206
206
  2. Does not have the `filter_expressions` parameter, i.e., it will not perform `join`,
207
207
  necessary for filtering by fields of related models.
208
208
 
209
+ ### Filtering with optional expressions
210
+
211
+ The `optional_where` parameter of the `list` and `paginated_list` methods accepts
212
+ SQLAlchemy expressions that may contain `None` values. Filters with a `None` value
213
+ are automatically skipped.
214
+
215
+ This is useful in list API endpoints where filtering is optional — if the query parameter
216
+ is not provided (i.e., its value is `None`), the filter is not applied.
217
+
218
+ The `optional_where` parameter supports three kinds of expressions:
219
+
220
+ **Case 1: Simple expression** (`MyModel.field == value`)
221
+
222
+ If `value` is `None`, the filter is skipped entirely. Otherwise it is applied as-is.
223
+
224
+ ```python
225
+ @router.get("/parents")
226
+ async def get_parents(
227
+ session: Session,
228
+ title: str | None = None,
229
+ ) -> list[ParentListSchema]:
230
+ return await parent_manager.list(
231
+ session,
232
+ optional_where=(Parent.title == title),
233
+ )
234
+ ```
235
+
236
+ `GET /parents` — no filter applied, all `Parent` objects are returned.
237
+
238
+ `GET /parents?title=foo` — only `Parent` objects with `title = 'foo'` are returned.
239
+
240
+ **Case 2: Function or operator expression** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
241
+
242
+ Same behaviour as Case 1 — if `value` is `None`, the filter is skipped.
243
+
244
+ ```python
245
+ @router.get("/parents")
246
+ async def get_parents(
247
+ session: Session,
248
+ created_at_date: date | None = None,
249
+ ) -> list[ParentListSchema]:
250
+ return await parent_manager.list(
251
+ session,
252
+ optional_where=(func.date(Parent.created_at) == created_at_date),
253
+ )
254
+ ```
255
+
256
+ **Case 3: Compound expression with `&` or `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
257
+
258
+ Sub-expressions whose values are `None` are excluded. The remaining sub-expressions
259
+ are combined using the original operator (`&` or `|`). If all values are `None`,
260
+ the filter is skipped entirely.
261
+
262
+ ```python
263
+ @router.get("/parents")
264
+ async def get_parents(
265
+ session: Session,
266
+ title: str | None = None,
267
+ slug: str | None = None,
268
+ ) -> list[ParentListSchema]:
269
+ return await parent_manager.list(
270
+ session,
271
+ optional_where=(Parent.title == title) & (Parent.slug == slug),
272
+ )
273
+ ```
274
+
275
+ `GET /parents` — no filter applied, all `Parent` objects are returned.
276
+
277
+ `GET /parents?title=foo` — only the `title` filter is applied.
278
+
279
+ `GET /parents?title=foo&slug=bar` — both filters are applied with `AND`.
280
+
281
+ > **Note**: nesting of compound expressions (e.g. `(a & b) | c`) is not supported.
282
+
283
+ **Multiple expressions as separate arguments**
284
+
285
+ Instead of using `&` to combine expressions, you can pass them as separate arguments
286
+ in a tuple. Each argument supports all three expression kinds above. Non-`None`
287
+ expressions are combined with `AND`.
288
+
289
+ ```python
290
+ @router.get("/parents")
291
+ async def get_parents(
292
+ session: Session,
293
+ title: str | None = None,
294
+ slug: str | None = None,
295
+ ) -> list[ParentListSchema]:
296
+ return await parent_manager.list(
297
+ session,
298
+ optional_where=(Parent.title == title, Parent.slug == slug),
299
+ )
300
+ ```
301
+
302
+ `GET /parents` — no filter applied, all `Parent` objects are returned.
303
+
304
+ `GET /parents?title=foo` — only the `title` filter is applied.
305
+
306
+ `GET /parents?title=foo&slug=bar` — both filters are applied with `AND`.
307
+
209
308
  ### Filtering by `null` via API
210
309
 
211
310
  If in a list API endpoint, you need to be able to filter the field value
@@ -77,7 +77,9 @@ WHERE child.slug = :slug_1
77
77
  а не только те, у которых `slug is null`. Поэтому метод `list` (`paginated_list`) отбрасывает фильтрацию
78
78
  по этому параметру, если его значение не передано.
79
79
 
80
- ## Более сложная фильтрация
80
+ ## Фильтрация с выражениями
81
+
82
+ ### filter_expressions
81
83
 
82
84
  Чтобы использовать фильтрацию не только по точному соответствию атрибуту модели,
83
85
  в методах `list` и `paginated_list` можно передать параметр `filter_expressions`.
@@ -182,6 +184,104 @@ WHERE lower(parent.title) LIKE lower(:title_1)
182
184
  **Важно**: работает только для моделей, напрямую связанных с основной, и только тогда, когда
183
185
  эти модели связывает единственный внешний ключ.
184
186
 
187
+ ### optional_where
188
+
189
+ Параметр `optional_where` методов `list` и `paginated_list` принимает выражения SQLAlchemy,
190
+ в которых значения фильтров могут быть `None`. Фильтры со значением `None` автоматически пропускаются.
191
+
192
+ Это удобно использовать в списочных API эндпоинтах, где фильтрация необязательна —
193
+ если параметр запроса не передан (т. е. его значение `None`), фильтр не применяется.
194
+
195
+ Параметр `optional_where` поддерживает три вида выражений:
196
+
197
+ **Кейс 1: Простое выражение** (`MyModel.field == value`)
198
+
199
+ Если `value` равно `None`, фильтр пропускается. Иначе применяется как есть.
200
+
201
+ ```python
202
+ @router.get("/parents")
203
+ async def get_parents(
204
+ session: Session,
205
+ title: str | None = None,
206
+ ) -> list[ParentListSchema]:
207
+ return await parent_manager.list(
208
+ session,
209
+ optional_where=(Parent.title == title),
210
+ )
211
+ ```
212
+
213
+ Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
214
+
215
+ Запрос `GET /parents?title=foo` — возвращаются только объекты `Parent` с `title = 'foo'`.
216
+
217
+ **Кейс 2: Выражение с функцией или оператором** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
218
+
219
+ Аналогично кейсу 1 — если `value` равно `None`, фильтр пропускается.
220
+
221
+ ```python
222
+ @router.get("/parents")
223
+ async def get_parents(
224
+ session: Session,
225
+ created_at_date: date | None = None,
226
+ ) -> list[ParentListSchema]:
227
+ return await parent_manager.list(
228
+ session,
229
+ optional_where=(func.date(Parent.created_at) == created_at_date),
230
+ )
231
+ ```
232
+
233
+ **Кейс 3: Составное выражение через `&` или `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
234
+
235
+ Части выражения, значения которых равны `None`, исключаются. Оставшиеся части объединяются
236
+ с использованием исходного оператора (`&` или `|`). Если все значения `None`,
237
+ фильтр пропускается полностью.
238
+
239
+ ```python
240
+ @router.get("/parents")
241
+ async def get_parents(
242
+ session: Session,
243
+ title: str | None = None,
244
+ slug: str | None = None,
245
+ ) -> list[ParentListSchema]:
246
+ return await parent_manager.list(
247
+ session,
248
+ optional_where=(Parent.title == title) & (Parent.slug == slug),
249
+ )
250
+ ```
251
+
252
+ Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
253
+
254
+ Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
255
+
256
+ Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
257
+
258
+ > **Примечание**: вложенные составные выражения (например, `(a & b) | c`) не поддерживаются.
259
+
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
+
185
285
  ## Фильтрация без дополнительной обработки
186
286
 
187
287
  Для фильтрации без дополнительной обработки в методах `list` и `paginated_list` можно
@@ -16,6 +16,7 @@ from sqlalchemy import (
16
16
  delete,
17
17
  func,
18
18
  insert,
19
+ or_,
19
20
  select,
20
21
  update,
21
22
  )
@@ -25,7 +26,7 @@ from sqlalchemy.orm import DeclarativeBase, contains_eager, load_only
25
26
  from sqlalchemy.orm.attributes import InstrumentedAttribute
26
27
  from sqlalchemy.orm.relationships import Relationship
27
28
  from sqlalchemy.sql import Select
28
- from sqlalchemy.sql.elements import UnaryExpression
29
+ from sqlalchemy.sql.elements import Null, UnaryExpression
29
30
  from sqlalchemy.sql.expression import BinaryExpression, ColumnElement
30
31
  from sqlalchemy.sql.functions import Function
31
32
  from sqlalchemy.sql.schema import ScalarElementColumnDefault
@@ -532,6 +533,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
532
533
  ) = ...,
533
534
  options: List[Any] | Any | None = ...,
534
535
  where: Any | None = ...,
536
+ optional_where: Any | None = ...,
535
537
  base_stmt: None = ...,
536
538
  transformer: Callable | None = ...,
537
539
  **simple_filters: Any,
@@ -549,6 +551,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
549
551
  ) = ...,
550
552
  options: List[Any] | Any | None = ...,
551
553
  where: Any | None = ...,
554
+ optional_where: Any | None = ...,
552
555
  base_stmt: Select = ...,
553
556
  transformer: Callable | None = ...,
554
557
  **simple_filters: Any,
@@ -565,6 +568,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
565
568
  ) = None,
566
569
  options: List[Any] | Any | None = None,
567
570
  where: Any | None = None,
571
+ optional_where: Any | None = None,
568
572
  base_stmt: Select | None = None,
569
573
  transformer: Callable | None = None,
570
574
  **simple_filters: Any,
@@ -590,6 +594,12 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
590
594
 
591
595
  :param where: выражение, которое будет передано в метод .where() SQLAlchemy
592
596
 
597
+ :param optional_where: выражение SQLAlchemy, в котором фильтры со значением None
598
+ пропускаются. Поддерживает простые выражения (MyModel.field == value),
599
+ выражения с функциями (func.date(MyModel.field) == value) и составные
600
+ выражения через & или | ((expr1) & (expr2)). См. раздел "фильтрация"
601
+ в документации.
602
+
593
603
  :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт
594
604
  страницу Row, а не ModelT.
595
605
  Примечание: фильтрация и сортировка по связанным моделям скорее всего
@@ -630,6 +640,10 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
630
640
  else:
631
641
  stmt = stmt.filter(filter_expression(value))
632
642
 
643
+ processed_optional_where = self.handle_optional_where(optional_where)
644
+ if processed_optional_where is not None:
645
+ stmt = stmt.where(processed_optional_where)
646
+
633
647
  return await paginate(session, stmt, transformer=transformer)
634
648
 
635
649
  @overload
@@ -730,6 +744,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
730
744
  ) = ...,
731
745
  options: List[Any] | Any | None = ...,
732
746
  where: Any | None = ...,
747
+ optional_where: Any | None = ...,
733
748
  base_stmt: None = ...,
734
749
  limit: int | None = ...,
735
750
  offset: int | None = ...,
@@ -750,6 +765,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
750
765
  ) = ...,
751
766
  options: List[Any] | Any | None = ...,
752
767
  where: Any | None = ...,
768
+ optional_where: Any | None = ...,
753
769
  base_stmt: Select = ...,
754
770
  limit: int | None = ...,
755
771
  offset: int | None = ...,
@@ -769,6 +785,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
769
785
  ) = None,
770
786
  options: List[Any] | Any | None = None,
771
787
  where: Any | None = None,
788
+ optional_where: Any | None = None,
772
789
  base_stmt: Select | None = None,
773
790
  limit: int | None = None,
774
791
  offset: int | None = None,
@@ -797,6 +814,12 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
797
814
 
798
815
  :param where: выражение, которое будет передано в метод .where() SQLAlchemy
799
816
 
817
+ :param optional_where: выражение SQLAlchemy, в котором фильтры со значением None
818
+ пропускаются. Поддерживает простые выражения (MyModel.field == value),
819
+ выражения с функциями (func.date(MyModel.field) == value) и составные
820
+ выражения через & или | ((expr1) & (expr2)). См. раздел "фильтрация"
821
+ в документации.
822
+
800
823
  :param unique: определяет необходимость вызова метода .unique()
801
824
  у результата SQLAlchemy
802
825
 
@@ -848,6 +871,10 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
848
871
  else:
849
872
  stmt = stmt.filter(filter_expression(value))
850
873
 
874
+ processed_optional_where = self.handle_optional_where(optional_where)
875
+ if processed_optional_where is not None:
876
+ stmt = stmt.where(processed_optional_where)
877
+
851
878
  result = await session.execute(stmt)
852
879
 
853
880
  if base_stmt is None:
@@ -1208,6 +1235,68 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1208
1235
  related_pk = _get_model_pk(related_model)
1209
1236
  return relationship.any(related_pk.in_(value))
1210
1237
 
1238
+ @classmethod
1239
+ def handle_optional_where_expression(cls, optional_where: Any) -> Any:
1240
+ """
1241
+ Обрабатывает одно выражение optional_where, пропуская фильтр, если его значение None.
1242
+
1243
+ Поддерживает:
1244
+ 1. Простые выражения вида MyModel.field == value
1245
+ 2. Выражения с функциями/операторами вида func.date(MyModel.field) == value
1246
+ 3. Составные выражения вида (expr1) & (expr2) или (expr1) | (expr2)
1247
+ (без вложенности)
1248
+
1249
+ Если value равно None, фильтр не применяется.
1250
+ В составных выражениях исключаются части с value == None,
1251
+ при этом оператор & или | сохраняется.
1252
+ """
1253
+ if optional_where is None:
1254
+ return None
1255
+
1256
+ # compound expression (& or |) — has .clauses attribute
1257
+ if hasattr(optional_where, "clauses"):
1258
+ remaining = [
1259
+ clause
1260
+ for clause in optional_where.clauses
1261
+ if not isinstance(clause.right, Null)
1262
+ ]
1263
+ if not remaining:
1264
+ return None
1265
+ if len(remaining) == 1:
1266
+ return remaining[0]
1267
+ if optional_where.operator.__name__ == "and_":
1268
+ return and_(*remaining)
1269
+ return or_(*remaining)
1270
+
1271
+ # simple binary expression
1272
+ if isinstance(getattr(optional_where, "right", None), Null):
1273
+ return None
1274
+ return optional_where
1275
+
1276
+ @classmethod
1277
+ def handle_optional_where(cls, optional_where: Any) -> Any:
1278
+ """
1279
+ Обрабатывает выражения optional_where, пропуская фильтры, значения которых None.
1280
+
1281
+ Принимает одно выражение SQLAlchemy или кортеж выражений.
1282
+ Каждое выражение обрабатывается через handle_optional_where_expression
1283
+ и поддерживает все три вида выражений.
1284
+ При передаче кортежа оставшиеся (не-None) выражения
1285
+ объединяются через оператор &.
1286
+ """
1287
+ if isinstance(optional_where, tuple):
1288
+ remaining = []
1289
+ for arg in optional_where:
1290
+ processed = cls.handle_optional_where_expression(arg)
1291
+ if processed is not None:
1292
+ remaining.append(processed)
1293
+ if not remaining:
1294
+ return None
1295
+ if len(remaining) == 1:
1296
+ return remaining[0]
1297
+ return and_(*remaining)
1298
+ return cls.handle_optional_where_expression(optional_where)
1299
+
1211
1300
  def assemble_stmt(
1212
1301
  self,
1213
1302
  base_stmt: Select | None = None,
@@ -1252,10 +1341,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1252
1341
  stmt = stmt.options(option)
1253
1342
 
1254
1343
  if where is not None:
1255
- if isinstance(where, tuple):
1256
- stmt = stmt.where(*where)
1257
- else:
1258
- stmt = stmt.where(where)
1344
+ stmt = stmt.where(*where) if isinstance(where, tuple) else stmt.where(where)
1259
1345
 
1260
1346
  if limit is not None:
1261
1347
  stmt = stmt.limit(limit)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi_sqlalchemy_toolkit"
7
- version = "0.8.1"
7
+ version = "0.8.2.1"
8
8
  authors = [
9
9
  { name="Egor Kondrashov", email="e.kondr01@gmail.com" },
10
10
  ]
@@ -31,9 +31,9 @@ async def test_get(session: AsyncSession):
31
31
  category_to_check = await session.execute(
32
32
  select(Category).where(Category.title == "test-get-category-title")
33
33
  )
34
- assert category == category_to_check.scalars().first(), (
35
- "Got not equal to object in database"
36
- )
34
+ assert (
35
+ category == category_to_check.scalars().first()
36
+ ), "Got not equal to object in database"
37
37
 
38
38
  nonexistent = await session.execute(
39
39
  select(Category).where(Category.title == "nonexistent-test-get-category-title")
@@ -279,9 +279,9 @@ async def test_create(session: AsyncSession):
279
279
  category_to_check = await session.execute(
280
280
  select(Category).where(Category.title == "test-create-category-title")
281
281
  )
282
- assert created == category_to_check.scalars().first(), (
283
- "Created not equal to object in database"
284
- )
282
+ assert (
283
+ created == category_to_check.scalars().first()
284
+ ), "Created not equal to object in database"
285
285
 
286
286
 
287
287
  async def test_create_unique_filed_validation(session: AsyncSession):
@@ -339,9 +339,9 @@ async def test_update(session: AsyncSession):
339
339
  category_to_check = await session.execute(
340
340
  select(Category).where(Category.title == "UPDATED-test-update-category-title")
341
341
  )
342
- assert updated == category_to_check.scalars().first(), (
343
- "Updated not equal to object in database"
344
- )
342
+ assert (
343
+ updated == category_to_check.scalars().first()
344
+ ), "Updated not equal to object in database"
345
345
 
346
346
 
347
347
  async def test_update_unique_filed_validation(session: AsyncSession):
@@ -729,6 +729,356 @@ async def test_list_with_where(session: AsyncSession):
729
729
  assert parent.title == same_title
730
730
 
731
731
 
732
+ # ########################################################################
733
+ # Tests for optional_where parameter
734
+ # ########################################################################
735
+
736
+
737
+ async def test_list_with_optional_where_case1_applied(session: AsyncSession):
738
+ """Case 1: simple expression, value is not None — filter is applied."""
739
+ target_title = "optional-where-title-target"
740
+ await session.execute(
741
+ insert(Parent),
742
+ [
743
+ {
744
+ "title": target_title,
745
+ "slug": "optional-where-slug1",
746
+ },
747
+ {
748
+ "title": "optional-where-title-other",
749
+ "slug": "optional-where-slug2",
750
+ },
751
+ ],
752
+ )
753
+ await session.commit()
754
+
755
+ parents = await parent_manager.list(
756
+ session=session,
757
+ optional_where=(Parent.title == target_title),
758
+ )
759
+ assert len(parents) == 1
760
+ assert parents[0].title == target_title
761
+
762
+
763
+ async def test_list_with_optional_where_case1_skipped(session: AsyncSession):
764
+ """Case 1: simple expression, value is None — filter is skipped (all returned)."""
765
+ await session.execute(
766
+ insert(Parent),
767
+ [
768
+ {
769
+ "title": "optional-where-title-1",
770
+ "slug": "optional-where-slug1",
771
+ },
772
+ {
773
+ "title": "optional-where-title-2",
774
+ "slug": "optional-where-slug2",
775
+ },
776
+ ],
777
+ )
778
+ await session.commit()
779
+
780
+ none_value = None
781
+ parents = await parent_manager.list(
782
+ session=session,
783
+ optional_where=(Parent.title == none_value),
784
+ )
785
+ assert len(parents) == 2
786
+
787
+
788
+ async def test_list_with_optional_where_case2_applied(session: AsyncSession):
789
+ """Case 2: func expression, value is not None — filter is applied."""
790
+ from sqlalchemy import func
791
+
792
+ target_slug = "optional-where-func-slug1"
793
+ await session.execute(
794
+ insert(Parent),
795
+ [
796
+ {
797
+ "title": "optional-where-func-title-1",
798
+ "slug": target_slug,
799
+ },
800
+ {
801
+ "title": "optional-where-func-title-2",
802
+ "slug": "optional-where-func-slug2",
803
+ },
804
+ ],
805
+ )
806
+ await session.commit()
807
+
808
+ # Use func.lower() on the slug to simulate a function expression
809
+ parents = await parent_manager.list(
810
+ session=session,
811
+ optional_where=(func.lower(Parent.slug) == target_slug),
812
+ )
813
+ assert len(parents) == 1
814
+ assert parents[0].slug == target_slug
815
+
816
+
817
+ async def test_list_with_optional_where_case2_skipped(session: AsyncSession):
818
+ """Case 2: func expression, value is None — filter is skipped (all returned)."""
819
+ from sqlalchemy import func
820
+
821
+ await session.execute(
822
+ insert(Parent),
823
+ [
824
+ {
825
+ "title": "optional-where-func-title-1",
826
+ "slug": "optional-where-func-slug1",
827
+ },
828
+ {
829
+ "title": "optional-where-func-title-2",
830
+ "slug": "optional-where-func-slug2",
831
+ },
832
+ ],
833
+ )
834
+ await session.commit()
835
+
836
+ none_value = None
837
+ parents = await parent_manager.list(
838
+ session=session,
839
+ optional_where=(func.lower(Parent.slug) == none_value),
840
+ )
841
+ assert len(parents) == 2
842
+
843
+
844
+ async def test_list_with_optional_where_case3_and_both_applied(session: AsyncSession):
845
+ """Case 3: compound & expression, both values not None — both filters applied."""
846
+ target_title = "optional-where-and-title"
847
+ target_slug = "optional-where-and-slug1"
848
+ await session.execute(
849
+ insert(Parent),
850
+ [
851
+ {
852
+ "title": target_title,
853
+ "slug": target_slug,
854
+ },
855
+ {
856
+ "title": target_title,
857
+ "slug": "optional-where-and-slug2",
858
+ },
859
+ {
860
+ "title": "optional-where-and-title-other",
861
+ "slug": "optional-where-and-slug3",
862
+ },
863
+ ],
864
+ )
865
+ await session.commit()
866
+
867
+ parents = await parent_manager.list(
868
+ session=session,
869
+ optional_where=(Parent.title == target_title) & (Parent.slug == target_slug),
870
+ )
871
+ assert len(parents) == 1
872
+ assert parents[0].title == target_title
873
+ assert parents[0].slug == target_slug
874
+
875
+
876
+ async def test_list_with_optional_where_case3_and_one_none(session: AsyncSession):
877
+ """Case 3: compound & expression, one value is None — only non-None filter applied."""
878
+ target_title = "optional-where-one-none-title"
879
+ await session.execute(
880
+ insert(Parent),
881
+ [
882
+ {
883
+ "title": target_title,
884
+ "slug": "optional-where-one-none-slug1",
885
+ },
886
+ {
887
+ "title": target_title,
888
+ "slug": "optional-where-one-none-slug2",
889
+ },
890
+ {
891
+ "title": "optional-where-one-none-title-other",
892
+ "slug": "optional-where-one-none-slug3",
893
+ },
894
+ ],
895
+ )
896
+ await session.commit()
897
+
898
+ none_value = None
899
+ parents = await parent_manager.list(
900
+ session=session,
901
+ optional_where=(Parent.title == target_title) & (Parent.slug == none_value),
902
+ )
903
+ assert len(parents) == 2
904
+ for parent in parents:
905
+ assert parent.title == target_title
906
+
907
+
908
+ async def test_list_with_optional_where_case3_all_none(session: AsyncSession):
909
+ """Case 3: compound expression, all values are None — filter skipped (all returned)."""
910
+ await session.execute(
911
+ insert(Parent),
912
+ [
913
+ {
914
+ "title": "optional-where-all-none-title-1",
915
+ "slug": "optional-where-all-none-slug1",
916
+ },
917
+ {
918
+ "title": "optional-where-all-none-title-2",
919
+ "slug": "optional-where-all-none-slug2",
920
+ },
921
+ ],
922
+ )
923
+ await session.commit()
924
+
925
+ none_value = None
926
+ parents = await parent_manager.list(
927
+ session=session,
928
+ optional_where=(Parent.title == none_value) & (Parent.slug == none_value),
929
+ )
930
+ assert len(parents) == 2
931
+
932
+
933
+ async def test_list_with_optional_where_case3_or_both_applied(session: AsyncSession):
934
+ """Case 3: compound | expression, both values not None — both filters applied as OR."""
935
+ title_a = "optional-where-or-title-a"
936
+ title_b = "optional-where-or-title-b"
937
+ await session.execute(
938
+ insert(Parent),
939
+ [
940
+ {
941
+ "title": title_a,
942
+ "slug": "optional-where-or-slug1",
943
+ },
944
+ {
945
+ "title": title_b,
946
+ "slug": "optional-where-or-slug2",
947
+ },
948
+ {
949
+ "title": "optional-where-or-title-other",
950
+ "slug": "optional-where-or-slug3",
951
+ },
952
+ ],
953
+ )
954
+ await session.commit()
955
+
956
+ parents = await parent_manager.list(
957
+ session=session,
958
+ optional_where=(Parent.title == title_a) | (Parent.title == title_b),
959
+ )
960
+ assert len(parents) == 2
961
+ titles = {p.title for p in parents}
962
+ assert title_a in titles
963
+ assert title_b in titles
964
+
965
+
966
+ async def test_list_with_optional_where_case3_or_one_none(session: AsyncSession):
967
+ """Case 3: compound | expression, one value is None — only non-None filter applied."""
968
+ target_title = "optional-where-or-none-title"
969
+ await session.execute(
970
+ insert(Parent),
971
+ [
972
+ {
973
+ "title": target_title,
974
+ "slug": "optional-where-or-none-slug1",
975
+ },
976
+ {
977
+ "title": "optional-where-or-none-title-other",
978
+ "slug": "optional-where-or-none-slug2",
979
+ },
980
+ ],
981
+ )
982
+ await session.commit()
983
+
984
+ none_value = None
985
+ parents = await parent_manager.list(
986
+ session=session,
987
+ optional_where=(Parent.title == target_title) | (Parent.slug == none_value),
988
+ )
989
+ assert len(parents) == 1
990
+ assert parents[0].title == target_title
991
+
992
+
993
+ async def test_list_with_optional_where_multi_all_applied(session: AsyncSession):
994
+ """Multi-arg: both values not None — both filters applied with AND."""
995
+ target_title = "optional-where-multi-title"
996
+ target_slug = "optional-where-multi-slug1"
997
+ await session.execute(
998
+ insert(Parent),
999
+ [
1000
+ {
1001
+ "title": target_title,
1002
+ "slug": target_slug,
1003
+ },
1004
+ {
1005
+ "title": target_title,
1006
+ "slug": "optional-where-multi-slug2",
1007
+ },
1008
+ {
1009
+ "title": "optional-where-multi-title-other",
1010
+ "slug": "optional-where-multi-slug3",
1011
+ },
1012
+ ],
1013
+ )
1014
+ await session.commit()
1015
+
1016
+ parents = await parent_manager.list(
1017
+ session=session,
1018
+ optional_where=(Parent.title == target_title, Parent.slug == target_slug),
1019
+ )
1020
+ assert len(parents) == 1
1021
+ assert parents[0].title == target_title
1022
+ assert parents[0].slug == target_slug
1023
+
1024
+
1025
+ async def test_list_with_optional_where_multi_one_none(session: AsyncSession):
1026
+ """Multi-arg: one value is None — only non-None filter applied."""
1027
+ target_title = "optional-where-multi-none-title"
1028
+ await session.execute(
1029
+ insert(Parent),
1030
+ [
1031
+ {
1032
+ "title": target_title,
1033
+ "slug": "optional-where-multi-none-slug1",
1034
+ },
1035
+ {
1036
+ "title": target_title,
1037
+ "slug": "optional-where-multi-none-slug2",
1038
+ },
1039
+ {
1040
+ "title": "optional-where-multi-none-title-other",
1041
+ "slug": "optional-where-multi-none-slug3",
1042
+ },
1043
+ ],
1044
+ )
1045
+ await session.commit()
1046
+
1047
+ none_value = None
1048
+ parents = await parent_manager.list(
1049
+ session=session,
1050
+ optional_where=(Parent.title == target_title, Parent.slug == none_value),
1051
+ )
1052
+ assert len(parents) == 2
1053
+ for parent in parents:
1054
+ assert parent.title == target_title
1055
+
1056
+
1057
+ async def test_list_with_optional_where_multi_all_none(session: AsyncSession):
1058
+ """Multi-arg: all values are None — filter skipped (all returned)."""
1059
+ await session.execute(
1060
+ insert(Parent),
1061
+ [
1062
+ {
1063
+ "title": "optional-where-multi-all-none-title-1",
1064
+ "slug": "optional-where-multi-all-none-slug1",
1065
+ },
1066
+ {
1067
+ "title": "optional-where-multi-all-none-title-2",
1068
+ "slug": "optional-where-multi-all-none-slug2",
1069
+ },
1070
+ ],
1071
+ )
1072
+ await session.commit()
1073
+
1074
+ none_value = None
1075
+ parents = await parent_manager.list(
1076
+ session=session,
1077
+ optional_where=(Parent.title == none_value, Parent.slug == none_value),
1078
+ )
1079
+ assert len(parents) == 2
1080
+
1081
+
732
1082
  async def test_create_unique_constraint_validation(session: AsyncSession):
733
1083
  await parent_manager.create(
734
1084
  session=session,