fastapi-sqlalchemy-toolkit 0.8.1__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.1 → fastapi_sqlalchemy_toolkit-0.8.2}/PKG-INFO +1 -1
  2. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/filtering.md +74 -0
  3. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/filtering.md +76 -1
  4. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/model_manager.py +66 -1
  5. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/pyproject.toml +1 -1
  6. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/test_public_methods.py +270 -9
  7. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.flake8 +0 -0
  8. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.github/workflows/docker-test.yml +0 -0
  9. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.github/workflows/python-publish.yml +0 -0
  10. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.gitignore +0 -0
  11. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/.pylintrc +0 -0
  12. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/AUTHORS +0 -0
  13. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/LICENSE +0 -0
  14. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/Makefile +0 -0
  15. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/README.md +0 -0
  16. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/benefits.md +0 -0
  17. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/db_validation.md +0 -0
  18. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/extension.md +0 -0
  19. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/index.md +0 -0
  20. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/benefits.md +0 -0
  21. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/db_validation.md +0 -0
  22. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/extension.md +0 -0
  23. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/index.md +0 -0
  24. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/sorting.md +0 -0
  25. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/transactions.md +0 -0
  26. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/usage.md +0 -0
  27. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/ru/utils.md +0 -0
  28. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/sorting.md +0 -0
  29. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/transactions.md +0 -0
  30. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/usage.md +0 -0
  31. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/docs/utils.md +0 -0
  32. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/.env +0 -0
  33. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/__init__.py +0 -0
  34. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/__init__.py +0 -0
  35. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/api.py +0 -0
  36. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/deps.py +0 -0
  37. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/endpoints/__init__.py +0 -0
  38. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/api/endpoints/child.py +0 -0
  39. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/config.py +0 -0
  40. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/db.py +0 -0
  41. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/main.py +0 -0
  42. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/managers.py +0 -0
  43. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/models.py +0 -0
  44. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/examples/app/schemas.py +0 -0
  45. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
  46. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
  47. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
  48. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
  49. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/mkdocs.yml +0 -0
  50. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/poetry.lock +0 -0
  51. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/base.txt +0 -0
  52. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/docs.txt +0 -0
  53. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/lint.txt +0 -0
  54. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/requirements/test.txt +0 -0
  55. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/Dockerfile +0 -0
  56. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/__init__.py +0 -0
  57. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/conftest.py +0 -0
  58. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/db.py +0 -0
  59. {fastapi_sqlalchemy_toolkit-0.8.1 → fastapi_sqlalchemy_toolkit-0.8.2}/tests/docker-compose.yml +0 -0
  60. {fastapi_sqlalchemy_toolkit-0.8.1 → 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.1
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,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,44 @@ 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
+ @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
+
1211
1276
  def assemble_stmt(
1212
1277
  self,
1213
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.1"
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,