fastapi-sqlalchemy-toolkit 0.8.0__tar.gz → 0.8.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/PKG-INFO +1 -1
  2. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/filtering.md +74 -0
  3. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/filtering.md +76 -1
  4. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/model_manager.py +94 -12
  5. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/pyproject.toml +1 -1
  6. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/test_public_methods.py +270 -9
  7. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/.flake8 +0 -0
  8. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/.github/workflows/docker-test.yml +0 -0
  9. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/.github/workflows/python-publish.yml +0 -0
  10. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/.gitignore +0 -0
  11. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/.pylintrc +0 -0
  12. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/AUTHORS +0 -0
  13. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/LICENSE +0 -0
  14. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/Makefile +0 -0
  15. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/README.md +0 -0
  16. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/benefits.md +0 -0
  17. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/db_validation.md +0 -0
  18. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/extension.md +0 -0
  19. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/index.md +0 -0
  20. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/benefits.md +0 -0
  21. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/db_validation.md +0 -0
  22. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/extension.md +0 -0
  23. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/index.md +0 -0
  24. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/sorting.md +0 -0
  25. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/transactions.md +0 -0
  26. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/usage.md +0 -0
  27. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/utils.md +0 -0
  28. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/sorting.md +0 -0
  29. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/transactions.md +0 -0
  30. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/usage.md +0 -0
  31. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/utils.md +0 -0
  32. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/.env +0 -0
  33. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/__init__.py +0 -0
  34. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/__init__.py +0 -0
  35. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/api.py +0 -0
  36. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/deps.py +0 -0
  37. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/endpoints/__init__.py +0 -0
  38. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/endpoints/child.py +0 -0
  39. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/config.py +0 -0
  40. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/db.py +0 -0
  41. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/main.py +0 -0
  42. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/managers.py +0 -0
  43. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/models.py +0 -0
  44. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/schemas.py +0 -0
  45. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
  46. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
  47. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
  48. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
  49. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/mkdocs.yml +0 -0
  50. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/poetry.lock +0 -0
  51. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/base.txt +0 -0
  52. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/docs.txt +0 -0
  53. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/lint.txt +0 -0
  54. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/test.txt +0 -0
  55. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/Dockerfile +0 -0
  56. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/__init__.py +0 -0
  57. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/conftest.py +0 -0
  58. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/db.py +0 -0
  59. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/docker-compose.yml +0 -0
  60. {fastapi_sqlalchemy_toolkit-0.8.0 → fastapi_sqlalchemy_toolkit-0.8.2}/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.0
3
+ Version: 0.8.2
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,80 @@ Unlike the `list` method, the `filter` method:
206
206
  2. Does not have the `filter_expressions` parameter, i.e., it will not perform `join`,
207
207
  necessary for filtering by fields of related models.
208
208
 
209
+ ### Filtering with optional expressions
210
+
211
+ The `optional_where` parameter of the `list` and `paginated_list` methods accepts
212
+ SQLAlchemy expressions that may contain `None` values. Filters with a `None` value
213
+ are automatically skipped.
214
+
215
+ This is useful in list API endpoints where filtering is optional — if the query parameter
216
+ is not provided (i.e., its value is `None`), the filter is not applied.
217
+
218
+ The `optional_where` parameter supports three kinds of expressions:
219
+
220
+ **Case 1: Simple expression** (`MyModel.field == value`)
221
+
222
+ If `value` is `None`, the filter is skipped entirely. Otherwise it is applied as-is.
223
+
224
+ ```python
225
+ @router.get("/parents")
226
+ async def get_parents(
227
+ session: Session,
228
+ title: str | None = None,
229
+ ) -> list[ParentListSchema]:
230
+ return await parent_manager.list(
231
+ session,
232
+ optional_where=(Parent.title == title),
233
+ )
234
+ ```
235
+
236
+ `GET /parents` — no filter applied, all `Parent` objects are returned.
237
+
238
+ `GET /parents?title=foo` — only `Parent` objects with `title = 'foo'` are returned.
239
+
240
+ **Case 2: Function or operator expression** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
241
+
242
+ Same behaviour as Case 1 — if `value` is `None`, the filter is skipped.
243
+
244
+ ```python
245
+ @router.get("/parents")
246
+ async def get_parents(
247
+ session: Session,
248
+ created_at_date: date | None = None,
249
+ ) -> list[ParentListSchema]:
250
+ return await parent_manager.list(
251
+ session,
252
+ optional_where=(func.date(Parent.created_at) == created_at_date),
253
+ )
254
+ ```
255
+
256
+ **Case 3: Compound expression with `&` or `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
257
+
258
+ Sub-expressions whose values are `None` are excluded. The remaining sub-expressions
259
+ are combined using the original operator (`&` or `|`). If all values are `None`,
260
+ the filter is skipped entirely.
261
+
262
+ ```python
263
+ @router.get("/parents")
264
+ async def get_parents(
265
+ session: Session,
266
+ title: str | None = None,
267
+ slug: str | None = None,
268
+ ) -> list[ParentListSchema]:
269
+ return await parent_manager.list(
270
+ session,
271
+ optional_where=(Parent.title == title) & (Parent.slug == slug),
272
+ )
273
+ ```
274
+
275
+ `GET /parents` — no filter applied, all `Parent` objects are returned.
276
+
277
+ `GET /parents?title=foo` — only the `title` filter is applied.
278
+
279
+ `GET /parents?title=foo&slug=bar` — both filters are applied with `AND`.
280
+
281
+ > **Note**: nesting of compound expressions (e.g. `(a & b) | c`) is not supported.
282
+
209
283
  ### Filtering by `null` via API
210
284
 
211
285
  If in a list API endpoint, you need to be able to filter the field value
@@ -77,7 +77,9 @@ WHERE child.slug = :slug_1
77
77
  а не только те, у которых `slug is null`. Поэтому метод `list` (`paginated_list`) отбрасывает фильтрацию
78
78
  по этому параметру, если его значение не передано.
79
79
 
80
- ## Более сложная фильтрация
80
+ ## Фильтрация с выражениями
81
+
82
+ ### filter_expressions
81
83
 
82
84
  Чтобы использовать фильтрацию не только по точному соответствию атрибуту модели,
83
85
  в методах `list` и `paginated_list` можно передать параметр `filter_expressions`.
@@ -182,6 +184,79 @@ WHERE lower(parent.title) LIKE lower(:title_1)
182
184
  **Важно**: работает только для моделей, напрямую связанных с основной, и только тогда, когда
183
185
  эти модели связывает единственный внешний ключ.
184
186
 
187
+ ### optional_where
188
+
189
+ Параметр `optional_where` методов `list` и `paginated_list` принимает выражения SQLAlchemy,
190
+ в которых значения фильтров могут быть `None`. Фильтры со значением `None` автоматически пропускаются.
191
+
192
+ Это удобно использовать в списочных API эндпоинтах, где фильтрация необязательна —
193
+ если параметр запроса не передан (т. е. его значение `None`), фильтр не применяется.
194
+
195
+ Параметр `optional_where` поддерживает три вида выражений:
196
+
197
+ **Кейс 1: Простое выражение** (`MyModel.field == value`)
198
+
199
+ Если `value` равно `None`, фильтр пропускается. Иначе применяется как есть.
200
+
201
+ ```python
202
+ @router.get("/parents")
203
+ async def get_parents(
204
+ session: Session,
205
+ title: str | None = None,
206
+ ) -> list[ParentListSchema]:
207
+ return await parent_manager.list(
208
+ session,
209
+ optional_where=(Parent.title == title),
210
+ )
211
+ ```
212
+
213
+ Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
214
+
215
+ Запрос `GET /parents?title=foo` — возвращаются только объекты `Parent` с `title = 'foo'`.
216
+
217
+ **Кейс 2: Выражение с функцией или оператором** (`func.date(MyModel.field) == value`, `MyModel.field.ilike(value)`)
218
+
219
+ Аналогично кейсу 1 — если `value` равно `None`, фильтр пропускается.
220
+
221
+ ```python
222
+ @router.get("/parents")
223
+ async def get_parents(
224
+ session: Session,
225
+ created_at_date: date | None = None,
226
+ ) -> list[ParentListSchema]:
227
+ return await parent_manager.list(
228
+ session,
229
+ optional_where=(func.date(Parent.created_at) == created_at_date),
230
+ )
231
+ ```
232
+
233
+ **Кейс 3: Составное выражение через `&` или `|`** (`(expr1) & (expr2)`, `(expr1) | (expr2)`)
234
+
235
+ Части выражения, значения которых равны `None`, исключаются. Оставшиеся части объединяются
236
+ с использованием исходного оператора (`&` или `|`). Если все значения `None`,
237
+ фильтр пропускается полностью.
238
+
239
+ ```python
240
+ @router.get("/parents")
241
+ async def get_parents(
242
+ session: Session,
243
+ title: str | None = None,
244
+ slug: str | None = None,
245
+ ) -> list[ParentListSchema]:
246
+ return await parent_manager.list(
247
+ session,
248
+ optional_where=(Parent.title == title) & (Parent.slug == slug),
249
+ )
250
+ ```
251
+
252
+ Запрос `GET /parents` — фильтр не применяется, возвращаются все объекты `Parent`.
253
+
254
+ Запрос `GET /parents?title=foo` — применяется только фильтр по `title`.
255
+
256
+ Запрос `GET /parents?title=foo&slug=bar` — применяются оба фильтра через `AND`.
257
+
258
+ > **Примечание**: вложенные составные выражения (например, `(a & b) | c`) не поддерживаются.
259
+
185
260
  ## Фильтрация без дополнительной обработки
186
261
 
187
262
  Для фильтрации без дополнительной обработки в методах `list` и `paginated_list` можно
@@ -16,6 +16,7 @@ from sqlalchemy import (
16
16
  delete,
17
17
  func,
18
18
  insert,
19
+ or_,
19
20
  select,
20
21
  update,
21
22
  )
@@ -25,7 +26,8 @@ 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
30
+ from sqlalchemy.sql.expression import BinaryExpression, ColumnElement
29
31
  from sqlalchemy.sql.functions import Function
30
32
  from sqlalchemy.sql.schema import ScalarElementColumnDefault
31
33
  from sqlalchemy.sql.selectable import Exists
@@ -524,12 +526,14 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
524
526
  self,
525
527
  session: AsyncSession,
526
528
  order_by: InstrumentedAttribute | UnaryExpression | None = ...,
527
- filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = ...,
529
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any]
530
+ | None = ...,
528
531
  nullable_filter_expressions: (
529
532
  dict[InstrumentedAttribute | Callable, Any] | None
530
533
  ) = ...,
531
534
  options: List[Any] | Any | None = ...,
532
535
  where: Any | None = ...,
536
+ optional_where: Any | None = ...,
533
537
  base_stmt: None = ...,
534
538
  transformer: Callable | None = ...,
535
539
  **simple_filters: Any,
@@ -540,12 +544,14 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
540
544
  self,
541
545
  session: AsyncSession,
542
546
  order_by: InstrumentedAttribute | UnaryExpression | None = ...,
543
- filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = ...,
547
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any]
548
+ | None = ...,
544
549
  nullable_filter_expressions: (
545
550
  dict[InstrumentedAttribute | Callable, Any] | None
546
551
  ) = ...,
547
552
  options: List[Any] | Any | None = ...,
548
553
  where: Any | None = ...,
554
+ optional_where: Any | None = ...,
549
555
  base_stmt: Select = ...,
550
556
  transformer: Callable | None = ...,
551
557
  **simple_filters: Any,
@@ -555,12 +561,14 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
555
561
  self,
556
562
  session: AsyncSession,
557
563
  order_by: InstrumentedAttribute | UnaryExpression | None = None,
558
- filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = None,
564
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any]
565
+ | None = None,
559
566
  nullable_filter_expressions: (
560
567
  dict[InstrumentedAttribute | Callable, Any] | None
561
568
  ) = None,
562
569
  options: List[Any] | Any | None = None,
563
570
  where: Any | None = None,
571
+ optional_where: Any | None = None,
564
572
  base_stmt: Select | None = None,
565
573
  transformer: Callable | None = None,
566
574
  **simple_filters: Any,
@@ -586,6 +594,12 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
586
594
 
587
595
  :param where: выражение, которое будет передано в метод .where() SQLAlchemy
588
596
 
597
+ :param optional_where: выражение SQLAlchemy, в котором фильтры со значением None
598
+ пропускаются. Поддерживает простые выражения (MyModel.field == value),
599
+ выражения с функциями (func.date(MyModel.field) == value) и составные
600
+ выражения через & или | ((expr1) & (expr2)). См. раздел "фильтрация"
601
+ в документации.
602
+
589
603
  :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт
590
604
  страницу Row, а не ModelT.
591
605
  Примечание: фильтрация и сортировка по связанным моделям скорее всего
@@ -618,11 +632,18 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
618
632
  )
619
633
 
620
634
  for filter_expression, value in filter_expressions.items():
621
- if isinstance(filter_expression, InstrumentedAttribute | Function):
635
+ if isinstance(
636
+ filter_expression,
637
+ InstrumentedAttribute | Function | BinaryExpression | ColumnElement,
638
+ ):
622
639
  stmt = stmt.filter(filter_expression == value)
623
640
  else:
624
641
  stmt = stmt.filter(filter_expression(value))
625
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
+
626
647
  return await paginate(session, stmt, transformer=transformer)
627
648
 
628
649
  @overload
@@ -716,12 +737,14 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
716
737
  self,
717
738
  session: AsyncSession,
718
739
  order_by: InstrumentedAttribute | UnaryExpression | None = ...,
719
- filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = ...,
740
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any]
741
+ | None = ...,
720
742
  nullable_filter_expressions: (
721
743
  dict[InstrumentedAttribute | Callable, Any] | None
722
744
  ) = ...,
723
745
  options: List[Any] | Any | None = ...,
724
746
  where: Any | None = ...,
747
+ optional_where: Any | None = ...,
725
748
  base_stmt: None = ...,
726
749
  limit: int | None = ...,
727
750
  offset: int | None = ...,
@@ -735,12 +758,14 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
735
758
  self,
736
759
  session: AsyncSession,
737
760
  order_by: InstrumentedAttribute | UnaryExpression | None = ...,
738
- filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = ...,
761
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any]
762
+ | None = ...,
739
763
  nullable_filter_expressions: (
740
764
  dict[InstrumentedAttribute | Callable, Any] | None
741
765
  ) = ...,
742
766
  options: List[Any] | Any | None = ...,
743
767
  where: Any | None = ...,
768
+ optional_where: Any | None = ...,
744
769
  base_stmt: Select = ...,
745
770
  limit: int | None = ...,
746
771
  offset: int | None = ...,
@@ -753,12 +778,14 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
753
778
  self,
754
779
  session: AsyncSession,
755
780
  order_by: InstrumentedAttribute | UnaryExpression | None = None,
756
- filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = None,
781
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any]
782
+ | None = None,
757
783
  nullable_filter_expressions: (
758
784
  dict[InstrumentedAttribute | Callable, Any] | None
759
785
  ) = None,
760
786
  options: List[Any] | Any | None = None,
761
787
  where: Any | None = None,
788
+ optional_where: Any | None = None,
762
789
  base_stmt: Select | None = None,
763
790
  limit: int | None = None,
764
791
  offset: int | None = None,
@@ -787,6 +814,12 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
787
814
 
788
815
  :param where: выражение, которое будет передано в метод .where() SQLAlchemy
789
816
 
817
+ :param optional_where: выражение SQLAlchemy, в котором фильтры со значением None
818
+ пропускаются. Поддерживает простые выражения (MyModel.field == value),
819
+ выражения с функциями (func.date(MyModel.field) == value) и составные
820
+ выражения через & или | ((expr1) & (expr2)). См. раздел "фильтрация"
821
+ в документации.
822
+
790
823
  :param unique: определяет необходимость вызова метода .unique()
791
824
  у результата SQLAlchemy
792
825
 
@@ -830,11 +863,18 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
830
863
  )
831
864
 
832
865
  for filter_expression, value in filter_expressions.items():
833
- if isinstance(filter_expression, InstrumentedAttribute):
866
+ if isinstance(
867
+ filter_expression,
868
+ InstrumentedAttribute | BinaryExpression | ColumnElement,
869
+ ):
834
870
  stmt = stmt.filter(filter_expression == value)
835
871
  else:
836
872
  stmt = stmt.filter(filter_expression(value))
837
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
+
838
878
  result = await session.execute(stmt)
839
879
 
840
880
  if base_stmt is None:
@@ -1075,7 +1115,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1075
1115
  def get_joins(
1076
1116
  self,
1077
1117
  base_query: Select,
1078
- filter_expressions: dict[InstrumentedAttribute | Callable, Any],
1118
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any],
1079
1119
  options: List[Any] | None = None,
1080
1120
  order_by: InstrumentedAttribute | UnaryExpression | None = None,
1081
1121
  ) -> Select:
@@ -1108,6 +1148,8 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1108
1148
  model = filter_expression.parent._identity_class
1109
1149
  elif isinstance(filter_expression, Function):
1110
1150
  model = filter_expression.entity_namespace
1151
+ elif isinstance(filter_expression, ColumnElement):
1152
+ model = self.model # Not supported
1111
1153
  else:
1112
1154
  model = filter_expression.__self__.parent._identity_class
1113
1155
  if model != self.model:
@@ -1157,7 +1199,7 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1157
1199
 
1158
1200
  @staticmethod
1159
1201
  def handle_filter_expressions(
1160
- filter_expressions: dict[InstrumentedAttribute | Callable, Any],
1202
+ filter_expressions: dict[InstrumentedAttribute | Callable | ColumnElement, Any],
1161
1203
  ) -> None:
1162
1204
  for filter_expression, value in filter_expressions.copy().items():
1163
1205
  if value is None:
@@ -1169,7 +1211,9 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1169
1211
 
1170
1212
  @staticmethod
1171
1213
  def handle_nullable_filter_expressions(
1172
- nullable_filter_expressions: dict[InstrumentedAttribute | Callable, Any],
1214
+ nullable_filter_expressions: dict[
1215
+ InstrumentedAttribute | Callable | ColumnElement, Any
1216
+ ],
1173
1217
  ) -> None:
1174
1218
  for filter_expression, value in nullable_filter_expressions.copy().items():
1175
1219
  if value in null_query_values:
@@ -1191,6 +1235,44 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1191
1235
  related_pk = _get_model_pk(related_model)
1192
1236
  return relationship.any(related_pk.in_(value))
1193
1237
 
1238
+ @staticmethod
1239
+ def handle_optional_where(optional_where: Any) -> Any:
1240
+ """
1241
+ Обрабатывает выражение optional_where, пропуская фильтры, значения которых None.
1242
+
1243
+ Поддерживает:
1244
+ 1. Простые выражения вида MyModel.field == value
1245
+ 2. Выражения с функциями/операторами вида func.date(MyModel.field) == value
1246
+ 3. Составные выражения вида (expr1) & (expr2) или (expr1) | (expr2)
1247
+ (без вложенности)
1248
+
1249
+ Если value равно None, фильтр не применяется.
1250
+ В составных выражениях исключаются части с value == None,
1251
+ при этом оператор & или | сохраняется.
1252
+ """
1253
+ if optional_where is None:
1254
+ return None
1255
+
1256
+ # compound expression (& or |) — has .clauses attribute
1257
+ if hasattr(optional_where, "clauses"):
1258
+ remaining = [
1259
+ clause
1260
+ for clause in optional_where.clauses
1261
+ if not isinstance(clause.right, Null)
1262
+ ]
1263
+ if not remaining:
1264
+ return None
1265
+ if len(remaining) == 1:
1266
+ return remaining[0]
1267
+ if optional_where.operator.__name__ == "and_":
1268
+ return and_(*remaining)
1269
+ return or_(*remaining)
1270
+
1271
+ # simple binary expression
1272
+ if isinstance(getattr(optional_where, "right", None), Null):
1273
+ return None
1274
+ return optional_where
1275
+
1194
1276
  def assemble_stmt(
1195
1277
  self,
1196
1278
  base_stmt: Select | None = None,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi_sqlalchemy_toolkit"
7
- version = "0.8.0"
7
+ version = "0.8.2"
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,267 @@ async def test_list_with_where(session: AsyncSession):
729
729
  assert parent.title == same_title
730
730
 
731
731
 
732
+ # ########################################################################
733
+ # Tests for optional_where parameter
734
+ # ########################################################################
735
+
736
+
737
+ async def test_list_with_optional_where_case1_applied(session: AsyncSession):
738
+ """Case 1: simple expression, value is not None — filter is applied."""
739
+ target_title = "optional-where-title-target"
740
+ await session.execute(
741
+ insert(Parent),
742
+ [
743
+ {
744
+ "title": target_title,
745
+ "slug": "optional-where-slug1",
746
+ },
747
+ {
748
+ "title": "optional-where-title-other",
749
+ "slug": "optional-where-slug2",
750
+ },
751
+ ],
752
+ )
753
+ await session.commit()
754
+
755
+ parents = await parent_manager.list(
756
+ session=session,
757
+ optional_where=(Parent.title == target_title),
758
+ )
759
+ assert len(parents) == 1
760
+ assert parents[0].title == target_title
761
+
762
+
763
+ async def test_list_with_optional_where_case1_skipped(session: AsyncSession):
764
+ """Case 1: simple expression, value is None — filter is skipped (all returned)."""
765
+ await session.execute(
766
+ insert(Parent),
767
+ [
768
+ {
769
+ "title": "optional-where-title-1",
770
+ "slug": "optional-where-slug1",
771
+ },
772
+ {
773
+ "title": "optional-where-title-2",
774
+ "slug": "optional-where-slug2",
775
+ },
776
+ ],
777
+ )
778
+ await session.commit()
779
+
780
+ none_value = None
781
+ parents = await parent_manager.list(
782
+ session=session,
783
+ optional_where=(Parent.title == none_value),
784
+ )
785
+ assert len(parents) == 2
786
+
787
+
788
+ async def test_list_with_optional_where_case2_applied(session: AsyncSession):
789
+ """Case 2: func expression, value is not None — filter is applied."""
790
+ from sqlalchemy import func
791
+
792
+ target_slug = "optional-where-func-slug1"
793
+ await session.execute(
794
+ insert(Parent),
795
+ [
796
+ {
797
+ "title": "optional-where-func-title-1",
798
+ "slug": target_slug,
799
+ },
800
+ {
801
+ "title": "optional-where-func-title-2",
802
+ "slug": "optional-where-func-slug2",
803
+ },
804
+ ],
805
+ )
806
+ await session.commit()
807
+
808
+ # Use func.lower() on the slug to simulate a function expression
809
+ parents = await parent_manager.list(
810
+ session=session,
811
+ optional_where=(func.lower(Parent.slug) == target_slug),
812
+ )
813
+ assert len(parents) == 1
814
+ assert parents[0].slug == target_slug
815
+
816
+
817
+ async def test_list_with_optional_where_case2_skipped(session: AsyncSession):
818
+ """Case 2: func expression, value is None — filter is skipped (all returned)."""
819
+ from sqlalchemy import func
820
+
821
+ await session.execute(
822
+ insert(Parent),
823
+ [
824
+ {
825
+ "title": "optional-where-func-title-1",
826
+ "slug": "optional-where-func-slug1",
827
+ },
828
+ {
829
+ "title": "optional-where-func-title-2",
830
+ "slug": "optional-where-func-slug2",
831
+ },
832
+ ],
833
+ )
834
+ await session.commit()
835
+
836
+ none_value = None
837
+ parents = await parent_manager.list(
838
+ session=session,
839
+ optional_where=(func.lower(Parent.slug) == none_value),
840
+ )
841
+ assert len(parents) == 2
842
+
843
+
844
+ async def test_list_with_optional_where_case3_and_both_applied(session: AsyncSession):
845
+ """Case 3: compound & expression, both values not None — both filters applied."""
846
+ target_title = "optional-where-and-title"
847
+ target_slug = "optional-where-and-slug1"
848
+ await session.execute(
849
+ insert(Parent),
850
+ [
851
+ {
852
+ "title": target_title,
853
+ "slug": target_slug,
854
+ },
855
+ {
856
+ "title": target_title,
857
+ "slug": "optional-where-and-slug2",
858
+ },
859
+ {
860
+ "title": "optional-where-and-title-other",
861
+ "slug": "optional-where-and-slug3",
862
+ },
863
+ ],
864
+ )
865
+ await session.commit()
866
+
867
+ parents = await parent_manager.list(
868
+ session=session,
869
+ optional_where=(Parent.title == target_title) & (Parent.slug == target_slug),
870
+ )
871
+ assert len(parents) == 1
872
+ assert parents[0].title == target_title
873
+ assert parents[0].slug == target_slug
874
+
875
+
876
+ async def test_list_with_optional_where_case3_and_one_none(session: AsyncSession):
877
+ """Case 3: compound & expression, one value is None — only non-None filter applied."""
878
+ target_title = "optional-where-one-none-title"
879
+ await session.execute(
880
+ insert(Parent),
881
+ [
882
+ {
883
+ "title": target_title,
884
+ "slug": "optional-where-one-none-slug1",
885
+ },
886
+ {
887
+ "title": target_title,
888
+ "slug": "optional-where-one-none-slug2",
889
+ },
890
+ {
891
+ "title": "optional-where-one-none-title-other",
892
+ "slug": "optional-where-one-none-slug3",
893
+ },
894
+ ],
895
+ )
896
+ await session.commit()
897
+
898
+ none_value = None
899
+ parents = await parent_manager.list(
900
+ session=session,
901
+ optional_where=(Parent.title == target_title) & (Parent.slug == none_value),
902
+ )
903
+ assert len(parents) == 2
904
+ for parent in parents:
905
+ assert parent.title == target_title
906
+
907
+
908
+ async def test_list_with_optional_where_case3_all_none(session: AsyncSession):
909
+ """Case 3: compound expression, all values are None — filter skipped (all returned)."""
910
+ await session.execute(
911
+ insert(Parent),
912
+ [
913
+ {
914
+ "title": "optional-where-all-none-title-1",
915
+ "slug": "optional-where-all-none-slug1",
916
+ },
917
+ {
918
+ "title": "optional-where-all-none-title-2",
919
+ "slug": "optional-where-all-none-slug2",
920
+ },
921
+ ],
922
+ )
923
+ await session.commit()
924
+
925
+ none_value = None
926
+ parents = await parent_manager.list(
927
+ session=session,
928
+ optional_where=(Parent.title == none_value) & (Parent.slug == none_value),
929
+ )
930
+ assert len(parents) == 2
931
+
932
+
933
+ async def test_list_with_optional_where_case3_or_both_applied(session: AsyncSession):
934
+ """Case 3: compound | expression, both values not None — both filters applied as OR."""
935
+ title_a = "optional-where-or-title-a"
936
+ title_b = "optional-where-or-title-b"
937
+ await session.execute(
938
+ insert(Parent),
939
+ [
940
+ {
941
+ "title": title_a,
942
+ "slug": "optional-where-or-slug1",
943
+ },
944
+ {
945
+ "title": title_b,
946
+ "slug": "optional-where-or-slug2",
947
+ },
948
+ {
949
+ "title": "optional-where-or-title-other",
950
+ "slug": "optional-where-or-slug3",
951
+ },
952
+ ],
953
+ )
954
+ await session.commit()
955
+
956
+ parents = await parent_manager.list(
957
+ session=session,
958
+ optional_where=(Parent.title == title_a) | (Parent.title == title_b),
959
+ )
960
+ assert len(parents) == 2
961
+ titles = {p.title for p in parents}
962
+ assert title_a in titles
963
+ assert title_b in titles
964
+
965
+
966
+ async def test_list_with_optional_where_case3_or_one_none(session: AsyncSession):
967
+ """Case 3: compound | expression, one value is None — only non-None filter applied."""
968
+ target_title = "optional-where-or-none-title"
969
+ await session.execute(
970
+ insert(Parent),
971
+ [
972
+ {
973
+ "title": target_title,
974
+ "slug": "optional-where-or-none-slug1",
975
+ },
976
+ {
977
+ "title": "optional-where-or-none-title-other",
978
+ "slug": "optional-where-or-none-slug2",
979
+ },
980
+ ],
981
+ )
982
+ await session.commit()
983
+
984
+ none_value = None
985
+ parents = await parent_manager.list(
986
+ session=session,
987
+ optional_where=(Parent.title == target_title) | (Parent.slug == none_value),
988
+ )
989
+ assert len(parents) == 1
990
+ assert parents[0].title == target_title
991
+
992
+
732
993
  async def test_create_unique_constraint_validation(session: AsyncSession):
733
994
  await parent_manager.create(
734
995
  session=session,