fastapi-sqlalchemy-toolkit 0.8.2.2__tar.gz → 0.8.2.3__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.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/PKG-INFO +1 -1
  2. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/filtering.md +21 -0
  3. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/filtering.md +4 -0
  4. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/fastapi_sqlalchemy_toolkit/model_manager.py +22 -9
  5. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/pyproject.toml +1 -1
  6. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/tests/test_public_methods.py +116 -0
  7. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/.flake8 +0 -0
  8. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/.github/workflows/docker-test.yml +0 -0
  9. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/.github/workflows/python-publish.yml +0 -0
  10. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/.gitignore +0 -0
  11. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/.pylintrc +0 -0
  12. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/AUTHORS +0 -0
  13. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/LICENSE +0 -0
  14. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/Makefile +0 -0
  15. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/README.md +0 -0
  16. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/benefits.md +0 -0
  17. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/db_validation.md +0 -0
  18. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/extension.md +0 -0
  19. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/index.md +0 -0
  20. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/benefits.md +0 -0
  21. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/db_validation.md +0 -0
  22. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/extension.md +0 -0
  23. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/index.md +0 -0
  24. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/sorting.md +0 -0
  25. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/transactions.md +0 -0
  26. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/usage.md +0 -0
  27. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/ru/utils.md +0 -0
  28. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/sorting.md +0 -0
  29. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/transactions.md +0 -0
  30. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/usage.md +0 -0
  31. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/docs/utils.md +0 -0
  32. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/.env +0 -0
  33. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/__init__.py +0 -0
  34. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/api/__init__.py +0 -0
  35. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/api/api.py +0 -0
  36. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/api/deps.py +0 -0
  37. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/api/endpoints/__init__.py +0 -0
  38. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/api/endpoints/child.py +0 -0
  39. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/config.py +0 -0
  40. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/db.py +0 -0
  41. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/main.py +0 -0
  42. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/managers.py +0 -0
  43. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/models.py +0 -0
  44. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/examples/app/schemas.py +0 -0
  45. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/fastapi_sqlalchemy_toolkit/__init__.py +0 -0
  46. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/fastapi_sqlalchemy_toolkit/filters.py +0 -0
  47. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
  48. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/fastapi_sqlalchemy_toolkit/utils.py +0 -0
  49. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/mkdocs.yml +0 -0
  50. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/poetry.lock +0 -0
  51. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/requirements/base.txt +0 -0
  52. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/requirements/docs.txt +0 -0
  53. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/requirements/lint.txt +0 -0
  54. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/requirements/test.txt +0 -0
  55. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/tests/Dockerfile +0 -0
  56. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/tests/__init__.py +0 -0
  57. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/tests/conftest.py +0 -0
  58. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/tests/db.py +0 -0
  59. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/tests/docker-compose.yml +0 -0
  60. {fastapi_sqlalchemy_toolkit-0.8.2.2 → fastapi_sqlalchemy_toolkit-0.8.2.3}/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.2.2
3
+ Version: 0.8.2.3
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>
@@ -278,6 +278,27 @@ async def get_parents(
278
278
 
279
279
  `GET /parents?title=foo&slug=bar` — both filters are applied with `AND`.
280
280
 
281
+ **Case 4: Expression with a falsy value** (`MyModel.column.in_(value)`, `MyModel.column.endswith(value)`, `MyModel.column.startswith(value)`)
282
+
283
+ If `value` is an empty list (`[]`) or an empty string (`""`), the filter is skipped.
284
+ This covers `in_([])`, `endswith("")`, `startswith("")` and similar operators.
285
+
286
+ ```python
287
+ @router.get("/parents")
288
+ async def get_parents(
289
+ session: Session,
290
+ slugs: list[str] = Query(default=[]),
291
+ ) -> list[ParentListSchema]:
292
+ return await parent_manager.list(
293
+ session,
294
+ optional_where=Parent.slug.in_(slugs),
295
+ )
296
+ ```
297
+
298
+ `GET /parents` — no filter applied, all `Parent` objects are returned.
299
+
300
+ `GET /parents?slugs=foo&slugs=bar` — only `Parent` objects whose `slug` is in `['foo', 'bar']` are returned.
301
+
281
302
  > **Note**: nesting of compound expressions (e.g. `(a & b) | c`) is not supported.
282
303
 
283
304
  **Multiple expressions as separate arguments**
@@ -218,6 +218,10 @@ async def get_parents(
218
218
 
219
219
  Аналогично кейсу 1 — если `value` равно `None`, фильтр пропускается.
220
220
 
221
+ Также если `value` является пустым списком (`[]`) или пустой строкой (`""`), фильтр пропускается.
222
+ Это распространяется на `in_([])`, `endswith("")`, `startswith("")` и аналогичные операторы, которые
223
+ не допускают передачи `None`.
224
+
221
225
  ```python
222
226
  @router.get("/parents")
223
227
  async def get_parents(
@@ -26,7 +26,7 @@ from sqlalchemy.orm import DeclarativeBase, contains_eager, load_only
26
26
  from sqlalchemy.orm.attributes import InstrumentedAttribute
27
27
  from sqlalchemy.orm.relationships import Relationship
28
28
  from sqlalchemy.sql import Select
29
- from sqlalchemy.sql.elements import Null, UnaryExpression
29
+ from sqlalchemy.sql.elements import BindParameter, Null, UnaryExpression
30
30
  from sqlalchemy.sql.expression import BinaryExpression, ColumnElement
31
31
  from sqlalchemy.sql.functions import Function
32
32
  from sqlalchemy.sql.schema import ScalarElementColumnDefault
@@ -46,6 +46,15 @@ def sqlalchemy_model_to_dict(model: DeclarativeBase) -> dict:
46
46
  return db_obj_dict
47
47
 
48
48
 
49
+ def _is_falsy_bind_param(right: Any) -> bool:
50
+ """Return True if *right* is a BindParameter with a falsy str or list value."""
51
+ return (
52
+ isinstance(right, BindParameter)
53
+ and isinstance(right.value, str | list)
54
+ and not right.value
55
+ )
56
+
57
+
49
58
  def _get_model_pk(model: type[DeclarativeBase]) -> InstrumentedAttribute:
50
59
  """Получить атрибут первичного ключа для произвольной модели."""
51
60
  pk_columns = list(model.__table__.primary_key.columns)
@@ -1236,16 +1245,20 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1236
1245
  @classmethod
1237
1246
  def handle_optional_where_expression(cls, optional_where: Any) -> Any:
1238
1247
  """
1239
- Обрабатывает одно выражение optional_where, пропуская фильтр, если его значение None.
1248
+ Обрабатывает одно выражение optional_where, пропуская фильтр,
1249
+ если его значение None.
1240
1250
 
1241
1251
  Поддерживает:
1242
1252
  1. Простые выражения вида MyModel.field == value
1243
1253
  2. Выражения с функциями/операторами вида func.date(MyModel.field) == value
1244
1254
  3. Составные выражения вида (expr1) & (expr2) или (expr1) | (expr2)
1245
1255
  (без вложенности)
1256
+ 4. Выражения с "пустым" значением вида MyModel.column.in_([]),
1257
+ MyModel.column.endswith(""), MyModel.column.startswith("")
1246
1258
 
1247
- Если value равно None, фильтр не применяется.
1248
- В составных выражениях исключаются части с value == None,
1259
+ Если value равно None или является пустой строкой/списком,
1260
+ фильтр не применяется.
1261
+ В составных выражениях исключаются части с такими значениями,
1249
1262
  при этом оператор & или | сохраняется.
1250
1263
  """
1251
1264
  if optional_where is None:
@@ -1257,17 +1270,17 @@ class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]):
1257
1270
  clause
1258
1271
  for clause in optional_where.clauses
1259
1272
  if not isinstance(clause.right, Null)
1273
+ and not _is_falsy_bind_param(getattr(clause, "right", None))
1260
1274
  ]
1261
1275
  if not remaining:
1262
1276
  return None
1263
1277
  if len(remaining) == 1:
1264
1278
  return remaining[0]
1265
- if optional_where.operator.__name__ == "and_":
1266
- return and_(*remaining)
1267
- return or_(*remaining)
1279
+ combiner = and_ if optional_where.operator.__name__ == "and_" else or_
1280
+ return combiner(*remaining)
1268
1281
 
1269
- # simple binary expression
1270
- if isinstance(getattr(optional_where, "right", None), Null):
1282
+ right = getattr(optional_where, "right", None)
1283
+ if isinstance(right, Null) or _is_falsy_bind_param(right):
1271
1284
  return None
1272
1285
  return optional_where
1273
1286
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi_sqlalchemy_toolkit"
7
- version = "0.8.2.2"
7
+ version = "0.8.2.3"
8
8
  authors = [
9
9
  { name="Egor Kondrashov", email="e.kondr01@gmail.com" },
10
10
  ]
@@ -1079,6 +1079,122 @@ async def test_list_with_optional_where_multi_all_none(session: AsyncSession):
1079
1079
  assert len(parents) == 2
1080
1080
 
1081
1081
 
1082
+ async def test_list_with_optional_where_in_applied(session: AsyncSession):
1083
+ """Case 4: in_ expression, non-empty list — filter is applied."""
1084
+ target_slug = "optional-where-in-slug1"
1085
+ await session.execute(
1086
+ insert(Parent),
1087
+ [
1088
+ {"title": "optional-where-in-title-1", "slug": target_slug},
1089
+ {"title": "optional-where-in-title-2", "slug": "optional-where-in-slug2"},
1090
+ ],
1091
+ )
1092
+ await session.commit()
1093
+
1094
+ parents = await parent_manager.list(
1095
+ session=session,
1096
+ optional_where=Parent.slug.in_([target_slug]),
1097
+ )
1098
+ assert len(parents) == 1
1099
+ assert parents[0].slug == target_slug
1100
+
1101
+
1102
+ async def test_list_with_optional_where_in_skipped(session: AsyncSession):
1103
+ """Case 4: in_ expression, empty list — filter is skipped (all returned)."""
1104
+ await session.execute(
1105
+ insert(Parent),
1106
+ [
1107
+ {
1108
+ "title": "optional-where-in-skip-title-1",
1109
+ "slug": "optional-where-in-skip-slug1",
1110
+ },
1111
+ {
1112
+ "title": "optional-where-in-skip-title-2",
1113
+ "slug": "optional-where-in-skip-slug2",
1114
+ },
1115
+ ],
1116
+ )
1117
+ await session.commit()
1118
+
1119
+ parents = await parent_manager.list(
1120
+ session=session,
1121
+ optional_where=Parent.slug.in_([]),
1122
+ )
1123
+ assert len(parents) == 2
1124
+
1125
+
1126
+ async def test_list_with_optional_where_endswith_applied(session: AsyncSession):
1127
+ """Case 4: endswith expression, non-empty string — filter is applied."""
1128
+ target_slug = "optional-where-ends-slug-xyz"
1129
+ await session.execute(
1130
+ insert(Parent),
1131
+ [
1132
+ {"title": "optional-where-ends-title-1", "slug": target_slug},
1133
+ {
1134
+ "title": "optional-where-ends-title-2",
1135
+ "slug": "optional-where-ends-slug-abc",
1136
+ },
1137
+ ],
1138
+ )
1139
+ await session.commit()
1140
+
1141
+ parents = await parent_manager.list(
1142
+ session=session,
1143
+ optional_where=Parent.slug.endswith("xyz"),
1144
+ )
1145
+ assert len(parents) == 1
1146
+ assert parents[0].slug == target_slug
1147
+
1148
+
1149
+ async def test_list_with_optional_where_endswith_skipped(session: AsyncSession):
1150
+ """Case 4: endswith expression, empty string — filter is skipped (all returned)."""
1151
+ await session.execute(
1152
+ insert(Parent),
1153
+ [
1154
+ {
1155
+ "title": "optional-where-ends-skip-title-1",
1156
+ "slug": "optional-where-ends-skip-slug1",
1157
+ },
1158
+ {
1159
+ "title": "optional-where-ends-skip-title-2",
1160
+ "slug": "optional-where-ends-skip-slug2",
1161
+ },
1162
+ ],
1163
+ )
1164
+ await session.commit()
1165
+
1166
+ parents = await parent_manager.list(
1167
+ session=session,
1168
+ optional_where=Parent.slug.endswith(""),
1169
+ )
1170
+ assert len(parents) == 2
1171
+
1172
+
1173
+ async def test_list_with_optional_where_in_compound_one_empty(session: AsyncSession):
1174
+ """Case 4 in compound: in_([]) in & expression — only non-empty filter applied."""
1175
+ target_title = "optional-where-compound-in-title"
1176
+ await session.execute(
1177
+ insert(Parent),
1178
+ [
1179
+ {"title": target_title, "slug": "optional-where-compound-in-slug1"},
1180
+ {"title": target_title, "slug": "optional-where-compound-in-slug2"},
1181
+ {
1182
+ "title": "optional-where-compound-in-other",
1183
+ "slug": "optional-where-compound-in-slug3",
1184
+ },
1185
+ ],
1186
+ )
1187
+ await session.commit()
1188
+
1189
+ parents = await parent_manager.list(
1190
+ session=session,
1191
+ optional_where=(Parent.title == target_title) & Parent.slug.in_([]),
1192
+ )
1193
+ assert len(parents) == 2
1194
+ for parent in parents:
1195
+ assert parent.title == target_title
1196
+
1197
+
1082
1198
  async def test_create_unique_constraint_validation(session: AsyncSession):
1083
1199
  await parent_manager.create(
1084
1200
  session=session,