fastapi-toolsets 5.0.0b2__tar.gz → 5.1.0__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 (51) hide show
  1. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/PKG-INFO +2 -7
  2. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/README.md +0 -2
  3. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/pyproject.toml +14 -6
  4. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/__init__.py +1 -1
  5. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/commands/fixtures.py +1 -1
  6. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/crud/factory.py +109 -46
  7. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/crud/search.py +87 -60
  8. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/db/core.py +2 -3
  9. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/db/m2m.py +1 -3
  10. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/dependencies.py +25 -33
  11. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/exceptions/handler.py +25 -26
  12. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/fixtures/__init__.py +6 -0
  13. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/fixtures/utils.py +9 -7
  14. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/models/watched.py +46 -37
  15. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/schemas.py +4 -4
  16. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/__init__.py +0 -26
  17. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/abc.py +0 -55
  18. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/oauth.py +0 -197
  19. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/sources/__init__.py +0 -8
  20. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/sources/bearer.py +0 -120
  21. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/sources/cookie.py +0 -148
  22. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/sources/header.py +0 -67
  23. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/security/sources/multi.py +0 -71
  24. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/LICENSE +0 -0
  25. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/_imports.py +0 -0
  26. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/__init__.py +0 -0
  27. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/app.py +0 -0
  28. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  29. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/config.py +0 -0
  30. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  31. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/cli/utils.py +0 -0
  32. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/crud/__init__.py +1 -1
  33. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/db/__init__.py +0 -0
  34. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/db/locks.py +0 -0
  35. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/db/testing.py +0 -0
  36. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/db/watch.py +0 -0
  37. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/exceptions/__init__.py +2 -2
  38. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  39. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  40. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  41. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/logger.py +0 -0
  42. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  43. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/metrics/handler.py +0 -0
  44. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/metrics/registry.py +0 -0
  45. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/models/__init__.py +3 -3
  46. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/models/columns.py +0 -0
  47. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/py.typed +0 -0
  48. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  49. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  50. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/pytest/utils.py +0 -0
  51. {fastapi_toolsets-5.0.0b2 → fastapi_toolsets-5.1.0}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 5.0.0b2
3
+ Version: 5.1.0
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -29,14 +29,12 @@ Requires-Dist: asyncpg>=0.29.0
29
29
  Requires-Dist: fastapi>=0.100.0
30
30
  Requires-Dist: pydantic>=2.0
31
31
  Requires-Dist: sqlalchemy[asyncio]>=2.0
32
- Requires-Dist: fastapi-toolsets[cli,metrics,pytest,security] ; extra == 'all'
32
+ Requires-Dist: fastapi-toolsets[cli,metrics,pytest] ; extra == 'all'
33
33
  Requires-Dist: typer>=0.9.0 ; extra == 'cli'
34
34
  Requires-Dist: prometheus-client>=0.20.0 ; extra == 'metrics'
35
35
  Requires-Dist: httpx>=0.25.0 ; extra == 'pytest'
36
36
  Requires-Dist: pytest-xdist>=3.0.0 ; extra == 'pytest'
37
37
  Requires-Dist: pytest>=8.0.0 ; extra == 'pytest'
38
- Requires-Dist: async-lru>=1.0 ; extra == 'security'
39
- Requires-Dist: httpx>=0.25.0 ; extra == 'security'
40
38
  Requires-Python: >=3.11
41
39
  Project-URL: Homepage, https://github.com/d3vyce/fastapi-toolsets
42
40
  Project-URL: Documentation, https://fastapi-toolsets.d3vyce.fr/
@@ -46,7 +44,6 @@ Provides-Extra: all
46
44
  Provides-Extra: cli
47
45
  Provides-Extra: metrics
48
46
  Provides-Extra: pytest
49
- Provides-Extra: security
50
47
  Description-Content-Type: text/markdown
51
48
 
52
49
  # FastAPI Toolsets
@@ -82,7 +79,6 @@ Install only the extras you need:
82
79
  ```bash
83
80
  uv add "fastapi-toolsets[cli]"
84
81
  uv add "fastapi-toolsets[metrics]"
85
- uv add "fastapi-toolsets[security]"
86
82
  uv add "fastapi-toolsets[pytest]"
87
83
  ```
88
84
 
@@ -108,7 +104,6 @@ uv add "fastapi-toolsets[all]"
108
104
 
109
105
  ### Optional
110
106
 
111
- - **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
112
107
  - **CLI**: Django-like command-line interface with fixture management and custom commands support
113
108
  - **Metrics**: Prometheus metrics endpoint with provider/collector registry
114
109
  - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
@@ -31,7 +31,6 @@ Install only the extras you need:
31
31
  ```bash
32
32
  uv add "fastapi-toolsets[cli]"
33
33
  uv add "fastapi-toolsets[metrics]"
34
- uv add "fastapi-toolsets[security]"
35
34
  uv add "fastapi-toolsets[pytest]"
36
35
  ```
37
36
 
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
57
56
 
58
57
  ### Optional
59
58
 
60
- - **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
61
59
  - **CLI**: Django-like command-line interface with fixture management and custom commands support
62
60
  - **Metrics**: Prometheus metrics endpoint with provider/collector registry
63
61
  - **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "5.0.0b2"
3
+ version = "5.1.0"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -50,17 +50,13 @@ cli = [
50
50
  metrics = [
51
51
  "prometheus_client>=0.20.0",
52
52
  ]
53
- security = [
54
- "async-lru>=1.0",
55
- "httpx>=0.25.0",
56
- ]
57
53
  pytest = [
58
54
  "httpx>=0.25.0",
59
55
  "pytest-xdist>=3.0.0",
60
56
  "pytest>=8.0.0",
61
57
  ]
62
58
  all = [
63
- "fastapi-toolsets[cli,metrics,pytest,security]",
59
+ "fastapi-toolsets[cli,metrics,pytest]",
64
60
  ]
65
61
 
66
62
  [project.scripts]
@@ -98,6 +94,18 @@ docs-src = [
98
94
  requires = ["uv_build>=0.10,<0.12.0"]
99
95
  build-backend = "uv_build"
100
96
 
97
+ [tool.ruff.format]
98
+ exclude = ["*.md"]
99
+
100
+ [tool.ruff.lint]
101
+ extend-select = ["E712"]
102
+
103
+ [tool.ruff.lint.flake8-bugbear]
104
+ extend-immutable-calls = ["fastapi.Depends"]
105
+
106
+ [tool.ruff.lint.per-file-ignores]
107
+ "tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
108
+
101
109
  [tool.pytest.ini_options]
102
110
  testpaths = ["tests"]
103
111
  filterwarnings = [
@@ -24,4 +24,4 @@ Example usage:
24
24
  return Response(data={"user": user.username}, message="Success")
25
25
  """
26
26
 
27
- __version__ = "5.0.0b2"
27
+ __version__ = "5.1.0"
@@ -57,7 +57,7 @@ async def load(
57
57
  ctx: typer.Context,
58
58
  contexts: Annotated[
59
59
  list[str] | None,
60
- typer.Argument(help="Contexts to load."),
60
+ typer.Argument(help="Contexts to load.", envvar="FIXTURES_CONTEXT"),
61
61
  ] = None,
62
62
  strategy: Annotated[
63
63
  LoadStrategy,
@@ -44,6 +44,7 @@ from ..types import (
44
44
  )
45
45
  from .search import (
46
46
  SearchConfig,
47
+ apply_search_joins,
47
48
  build_facets,
48
49
  build_filter_by,
49
50
  build_search_filters,
@@ -51,7 +52,6 @@ from .search import (
51
52
  search_field_keys,
52
53
  )
53
54
 
54
-
55
55
  _ForUpdateMode: TypeAlias = bool | Literal["nowait", "skip_locked"]
56
56
 
57
57
 
@@ -128,17 +128,6 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
128
128
  return q
129
129
 
130
130
 
131
- def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
132
- """Apply relationship-based outer joins (from search/filter_by) to a query."""
133
- seen: set[str] = set()
134
- for join_rel in search_joins:
135
- key = str(join_rel)
136
- if key not in seen:
137
- seen.add(key)
138
- q = q.outerjoin(join_rel)
139
- return q
140
-
141
-
142
131
  class AsyncCrud(Generic[ModelType]):
143
132
  """Generic async CRUD operations for SQLAlchemy models.
144
133
 
@@ -184,16 +173,30 @@ class AsyncCrud(Generic[ModelType]):
184
173
  return cls.default_load_options
185
174
 
186
175
  @classmethod
187
- async def _reload_with_options(
188
- cls: type[Self], session: AsyncSession, instance: ModelType
176
+ def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]:
177
+ """Capture PK values off instance — call before commit expires attributes."""
178
+ return {
179
+ cast(str, col.key): getattr(instance, cast(str, col.key))
180
+ for col in cls.model.__mapper__.primary_key
181
+ }
182
+
183
+ @classmethod
184
+ async def _reload_with_options_by_pk(
185
+ cls: type[Self], session: AsyncSession, pk_values: dict[str, Any]
189
186
  ) -> ModelType:
190
- """Re-query instance by PK with default_load_options applied."""
191
- mapper = cls.model.__mapper__
187
+ """Re-query by previously captured PK values, with default_load_options applied."""
188
+ # Only called when cls.default_load_options is set (see call sites).
192
189
  pk_filters = [
193
- getattr(cls.model, col.key) == getattr(instance, col.key)
194
- for col in mapper.primary_key
190
+ getattr(cls.model, key) == value for key, value in pk_values.items()
195
191
  ]
196
- return await cls.get(session, filters=pk_filters)
192
+ q = select(cls.model).where(and_(*pk_filters))
193
+ q = q.execution_options(populate_existing=True)
194
+ q = q.options(*cast(Sequence[ExecutableOption], cls.default_load_options))
195
+ result = await session.execute(q)
196
+ item = result.unique().scalar_one_or_none()
197
+ if item is None: # pragma: no cover — row was just flushed in this transaction
198
+ raise NotFoundError()
199
+ return cast(ModelType, item)
197
200
 
198
201
  @classmethod
199
202
  async def _resolve_m2m(
@@ -264,12 +267,12 @@ class AsyncCrud(Generic[ModelType]):
264
267
  cls: type[Self],
265
268
  filter_by: dict[str, Any] | BaseModel | None,
266
269
  facet_fields: Sequence[FacetFieldType] | None,
267
- ) -> tuple[list[Any], list[Any]]:
268
- """Normalize filter_by and return (filters, joins) to apply to the query."""
270
+ ) -> tuple[dict[str, Any], list[Any]]:
271
+ """Normalize filter_by and return ({facet_key: filter}, joins) to apply to the query."""
269
272
  if isinstance(filter_by, BaseModel):
270
273
  filter_by = filter_by.model_dump(exclude_none=True)
271
274
  if not filter_by:
272
- return [], []
275
+ return {}, []
273
276
  resolved = cls._resolve_facet_fields(facet_fields)
274
277
  return build_filter_by(filter_by, resolved or [])
275
278
 
@@ -280,8 +283,13 @@ class AsyncCrud(Generic[ModelType]):
280
283
  facet_fields: Sequence[FacetFieldType] | None,
281
284
  filters: list[Any],
282
285
  search_joins: list[Any],
286
+ *,
287
+ include_facets: bool = True,
288
+ own_filters: dict[str, Any] | None = None,
283
289
  ) -> dict[str, list[Any]] | None:
284
- """Build facet filter_attributes, or return None if no facet fields configured."""
290
+ """Build facet filter_attributes, or None if disabled/no facet fields configured."""
291
+ if not include_facets:
292
+ return None
285
293
  resolved = cls._resolve_facet_fields(facet_fields)
286
294
  if not resolved:
287
295
  return None
@@ -291,6 +299,7 @@ class AsyncCrud(Generic[ModelType]):
291
299
  resolved,
292
300
  base_filters=filters,
293
301
  base_joins=search_joins,
302
+ own_filters=own_filters,
294
303
  )
295
304
 
296
305
  @classmethod
@@ -474,6 +483,7 @@ class AsyncCrud(Generic[ModelType]):
474
483
  default_page_size: int = 20,
475
484
  max_page_size: int = 100,
476
485
  include_total: bool = True,
486
+ include_facets: bool = True,
477
487
  search: bool = True,
478
488
  filter: bool = True,
479
489
  order: bool = True,
@@ -489,6 +499,7 @@ class AsyncCrud(Generic[ModelType]):
489
499
  default_page_size: Default ``items_per_page`` value.
490
500
  max_page_size: Maximum ``items_per_page`` value.
491
501
  include_total: Whether to include total count (not a query param).
502
+ include_facets: Whether to run facet queries (not a query param).
492
503
  search: Enable search query parameters.
493
504
  filter: Enable facet filter query parameters.
494
505
  order: Enable order query parameters.
@@ -518,7 +529,10 @@ class AsyncCrud(Generic[ModelType]):
518
529
  ]
519
530
  return cls._build_paginate_params(
520
531
  pagination_params=pagination_params,
521
- pagination_fixed={"include_total": include_total},
532
+ pagination_fixed={
533
+ "include_total": include_total,
534
+ "include_facets": include_facets,
535
+ },
522
536
  dep_name=f"{cls.model.__name__}OffsetPaginateParams",
523
537
  search=search,
524
538
  filter=filter,
@@ -536,6 +550,7 @@ class AsyncCrud(Generic[ModelType]):
536
550
  *,
537
551
  default_page_size: int = 20,
538
552
  max_page_size: int = 100,
553
+ include_facets: bool = True,
539
554
  search: bool = True,
540
555
  filter: bool = True,
541
556
  order: bool = True,
@@ -550,6 +565,7 @@ class AsyncCrud(Generic[ModelType]):
550
565
  Args:
551
566
  default_page_size: Default ``items_per_page`` value.
552
567
  max_page_size: Maximum ``items_per_page`` value.
568
+ include_facets: Whether to run facet queries (not a query param).
553
569
  search: Enable search query parameters.
554
570
  filter: Enable facet filter query parameters.
555
571
  order: Enable order query parameters.
@@ -581,7 +597,7 @@ class AsyncCrud(Generic[ModelType]):
581
597
  ]
582
598
  return cls._build_paginate_params(
583
599
  pagination_params=pagination_params,
584
- pagination_fixed={},
600
+ pagination_fixed={"include_facets": include_facets},
585
601
  dep_name=f"{cls.model.__name__}CursorPaginateParams",
586
602
  search=search,
587
603
  filter=filter,
@@ -601,6 +617,7 @@ class AsyncCrud(Generic[ModelType]):
601
617
  max_page_size: int = 100,
602
618
  default_pagination_type: PaginationType = PaginationType.OFFSET,
603
619
  include_total: bool = True,
620
+ include_facets: bool = True,
604
621
  search: bool = True,
605
622
  filter: bool = True,
606
623
  order: bool = True,
@@ -617,6 +634,7 @@ class AsyncCrud(Generic[ModelType]):
617
634
  max_page_size: Maximum ``items_per_page`` value.
618
635
  default_pagination_type: Default pagination strategy.
619
636
  include_total: Whether to include total count (not a query param).
637
+ include_facets: Whether to run facet queries (not a query param).
620
638
  search: Enable search query parameters.
621
639
  filter: Enable facet filter query parameters.
622
640
  order: Enable order query parameters.
@@ -665,7 +683,10 @@ class AsyncCrud(Generic[ModelType]):
665
683
  ]
666
684
  return cls._build_paginate_params(
667
685
  pagination_params=pagination_params,
668
- pagination_fixed={"include_total": include_total},
686
+ pagination_fixed={
687
+ "include_total": include_total,
688
+ "include_facets": include_facets,
689
+ },
669
690
  dep_name=f"{cls.model.__name__}PaginateParams",
670
691
  search=search,
671
692
  filter=filter,
@@ -729,9 +750,14 @@ class AsyncCrud(Generic[ModelType]):
729
750
  setattr(db_model, rel_attr, related_instances)
730
751
 
731
752
  session.add(db_model)
732
- await session.refresh(db_model)
733
- if cls.default_load_options:
734
- db_model = await cls._reload_with_options(session, db_model)
753
+ pk_values: dict[str, Any] | None = None
754
+ if cls.default_load_options:
755
+ await session.flush()
756
+ pk_values = cls._capture_pk_values(db_model)
757
+ if pk_values is not None:
758
+ db_model = await cls._reload_with_options_by_pk(session, pk_values)
759
+ else:
760
+ await session.refresh(db_model)
735
761
  result = cast(ModelType, db_model)
736
762
  if schema:
737
763
  return Response(data=schema.model_validate(result))
@@ -1096,9 +1122,15 @@ class AsyncCrud(Generic[ModelType]):
1096
1122
  m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
1097
1123
  for rel_attr, related_instances in m2m_resolved.items():
1098
1124
  setattr(db_model, rel_attr, related_instances)
1099
- await session.refresh(db_model)
1100
- if cls.default_load_options:
1101
- db_model = await cls._reload_with_options(session, db_model)
1125
+
1126
+ pk_values: dict[str, Any] | None = None
1127
+ if cls.default_load_options:
1128
+ await session.flush()
1129
+ pk_values = cls._capture_pk_values(db_model)
1130
+ if pk_values is not None:
1131
+ db_model = await cls._reload_with_options_by_pk(session, pk_values)
1132
+ else:
1133
+ await session.refresh(db_model)
1102
1134
  if schema:
1103
1135
  return Response(data=schema.model_validate(db_model))
1104
1136
  return db_model
@@ -1270,6 +1302,7 @@ class AsyncCrud(Generic[ModelType]):
1270
1302
  search_column: str | None = None,
1271
1303
  order_fields: Sequence[OrderFieldType] | None = None,
1272
1304
  facet_fields: Sequence[FacetFieldType] | None = None,
1305
+ include_facets: bool = True,
1273
1306
  filter_by: dict[str, Any] | BaseModel | None = None,
1274
1307
  schema: type[BaseModel],
1275
1308
  ) -> OffsetPaginatedResponse[Any]:
@@ -1291,6 +1324,9 @@ class AsyncCrud(Generic[ModelType]):
1291
1324
  search_column: Restrict search to a single column key.
1292
1325
  order_fields: Fields allowed for sorting (overrides class default).
1293
1326
  facet_fields: Columns to compute distinct values for (overrides class default)
1327
+ include_facets: When ``False``, skip facet queries entirely;
1328
+ ``filter_attributes`` will be ``None``. Useful on pages 2..N
1329
+ where the facet counts were already fetched on page 1.
1294
1330
  filter_by: Dict of {column_key: value} to filter by declared facet fields.
1295
1331
  Keys must match the column.key of a facet field. Scalar → equality,
1296
1332
  list → IN clause. Raises InvalidFacetFilterError for unknown keys.
@@ -1303,7 +1339,6 @@ class AsyncCrud(Generic[ModelType]):
1303
1339
  offset = (page - 1) * items_per_page
1304
1340
 
1305
1341
  fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
1306
- filters.extend(fb_filters)
1307
1342
 
1308
1343
  # Build search filters
1309
1344
  if search:
@@ -1317,6 +1352,11 @@ class AsyncCrud(Generic[ModelType]):
1317
1352
  filters.extend(search_filters)
1318
1353
  search_joins.extend(new_search_joins)
1319
1354
 
1355
+ # Facets combine these with each facet's own filter individually, so
1356
+ # fb_filters is applied to the query below but excluded here.
1357
+ facet_base_filters = list(filters)
1358
+ filters.extend(fb_filters.values())
1359
+
1320
1360
  # Build query with joins
1321
1361
  q = select(cls.model)
1322
1362
 
@@ -1324,11 +1364,11 @@ class AsyncCrud(Generic[ModelType]):
1324
1364
  q = _apply_joins(q, joins, outer_join)
1325
1365
 
1326
1366
  # Apply search joins (always outer joins for search)
1327
- q = _apply_search_joins(q, search_joins)
1367
+ q = apply_search_joins(q, search_joins)
1328
1368
 
1329
1369
  # Apply order joins (relation joins required for order_by field)
1330
1370
  if order_joins:
1331
- q = _apply_search_joins(q, order_joins)
1371
+ q = apply_search_joins(q, order_joins)
1332
1372
 
1333
1373
  if filters:
1334
1374
  q = q.where(and_(*filters))
@@ -1351,7 +1391,7 @@ class AsyncCrud(Generic[ModelType]):
1351
1391
  count_q = _apply_joins(count_q, joins, outer_join)
1352
1392
 
1353
1393
  # Apply search joins to count query
1354
- count_q = _apply_search_joins(count_q, search_joins)
1394
+ count_q = apply_search_joins(count_q, search_joins)
1355
1395
 
1356
1396
  if filters:
1357
1397
  count_q = count_q.where(and_(*filters))
@@ -1371,7 +1411,12 @@ class AsyncCrud(Generic[ModelType]):
1371
1411
  items: list[Any] = [schema.model_validate(item) for item in raw_items]
1372
1412
 
1373
1413
  filter_attributes = await cls._build_filter_attributes(
1374
- session, facet_fields, filters, search_joins
1414
+ session,
1415
+ facet_fields,
1416
+ facet_base_filters,
1417
+ search_joins,
1418
+ include_facets=include_facets,
1419
+ own_filters=fb_filters,
1375
1420
  )
1376
1421
  search_columns = cls._resolve_search_columns(search_fields)
1377
1422
  order_columns = cls._resolve_order_columns(order_fields)
@@ -1407,6 +1452,7 @@ class AsyncCrud(Generic[ModelType]):
1407
1452
  search_column: str | None = None,
1408
1453
  order_fields: Sequence[OrderFieldType] | None = None,
1409
1454
  facet_fields: Sequence[FacetFieldType] | None = None,
1455
+ include_facets: bool = True,
1410
1456
  filter_by: dict[str, Any] | BaseModel | None = None,
1411
1457
  schema: type[BaseModel],
1412
1458
  ) -> CursorPaginatedResponse[Any]:
@@ -1429,6 +1475,8 @@ class AsyncCrud(Generic[ModelType]):
1429
1475
  search_column: Restrict search to a single column key.
1430
1476
  order_fields: Fields allowed for sorting (overrides class default).
1431
1477
  facet_fields: Columns to compute distinct values for (overrides class default).
1478
+ include_facets: When ``False``, skip facet queries entirely;
1479
+ ``filter_attributes`` will be ``None``.
1432
1480
  filter_by: Dict of {column_key: value} to filter by declared facet fields.
1433
1481
  Keys must match the column.key of a facet field. Scalar → equality,
1434
1482
  list → IN clause. Raises InvalidFacetFilterError for unknown keys.
@@ -1440,7 +1488,6 @@ class AsyncCrud(Generic[ModelType]):
1440
1488
  filters = list(filters) if filters else []
1441
1489
 
1442
1490
  fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
1443
- filters.extend(fb_filters)
1444
1491
 
1445
1492
  if cls.cursor_column is None:
1446
1493
  raise ValueError(
@@ -1472,6 +1519,11 @@ class AsyncCrud(Generic[ModelType]):
1472
1519
  filters.extend(search_filters)
1473
1520
  search_joins.extend(new_search_joins)
1474
1521
 
1522
+ # Facets combine these with each facet's own filter individually, so
1523
+ # fb_filters is applied to the query below but excluded here.
1524
+ facet_base_filters = list(filters)
1525
+ filters.extend(fb_filters.values())
1526
+
1475
1527
  # Build query
1476
1528
  q = select(cls.model)
1477
1529
 
@@ -1479,11 +1531,11 @@ class AsyncCrud(Generic[ModelType]):
1479
1531
  q = _apply_joins(q, joins, outer_join)
1480
1532
 
1481
1533
  # Apply search joins (always outer joins)
1482
- q = _apply_search_joins(q, search_joins)
1534
+ q = apply_search_joins(q, search_joins)
1483
1535
 
1484
1536
  # Apply order joins (relation joins required for order_by field)
1485
1537
  if order_joins:
1486
- q = _apply_search_joins(q, order_joins)
1538
+ q = apply_search_joins(q, order_joins)
1487
1539
 
1488
1540
  if filters:
1489
1541
  q = q.where(and_(*filters))
@@ -1528,11 +1580,10 @@ class AsyncCrud(Generic[ModelType]):
1528
1580
 
1529
1581
  # prev_cursor: points before the first item in ascending order
1530
1582
  prev_cursor: str | None = None
1531
- if direction is _CursorDirection.NEXT and cursor is not None and items_page:
1532
- prev_cursor = _encode_cursor(
1533
- getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
1534
- )
1535
- elif direction is _CursorDirection.PREV and has_more and items_page:
1583
+ if items_page and (
1584
+ (direction is _CursorDirection.NEXT and cursor is not None)
1585
+ or (direction is _CursorDirection.PREV and has_more)
1586
+ ):
1536
1587
  prev_cursor = _encode_cursor(
1537
1588
  getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
1538
1589
  )
@@ -1540,7 +1591,12 @@ class AsyncCrud(Generic[ModelType]):
1540
1591
  items: list[Any] = [schema.model_validate(item) for item in items_page]
1541
1592
 
1542
1593
  filter_attributes = await cls._build_filter_attributes(
1543
- session, facet_fields, filters, search_joins
1594
+ session,
1595
+ facet_fields,
1596
+ facet_base_filters,
1597
+ search_joins,
1598
+ include_facets=include_facets,
1599
+ own_filters=fb_filters,
1544
1600
  )
1545
1601
  search_columns = cls._resolve_search_columns(search_fields)
1546
1602
  order_columns = cls._resolve_order_columns(order_fields)
@@ -1580,6 +1636,7 @@ class AsyncCrud(Generic[ModelType]):
1580
1636
  search_column: str | None = ...,
1581
1637
  order_fields: Sequence[OrderFieldType] | None = ...,
1582
1638
  facet_fields: Sequence[FacetFieldType] | None = ...,
1639
+ include_facets: bool = ...,
1583
1640
  filter_by: dict[str, Any] | BaseModel | None = ...,
1584
1641
  schema: type[BaseModel],
1585
1642
  ) -> OffsetPaginatedResponse[Any]: ...
@@ -1606,6 +1663,7 @@ class AsyncCrud(Generic[ModelType]):
1606
1663
  search_column: str | None = ...,
1607
1664
  order_fields: Sequence[OrderFieldType] | None = ...,
1608
1665
  facet_fields: Sequence[FacetFieldType] | None = ...,
1666
+ include_facets: bool = ...,
1609
1667
  filter_by: dict[str, Any] | BaseModel | None = ...,
1610
1668
  schema: type[BaseModel],
1611
1669
  ) -> CursorPaginatedResponse[Any]: ...
@@ -1631,6 +1689,7 @@ class AsyncCrud(Generic[ModelType]):
1631
1689
  search_column: str | None = None,
1632
1690
  order_fields: Sequence[OrderFieldType] | None = None,
1633
1691
  facet_fields: Sequence[FacetFieldType] | None = None,
1692
+ include_facets: bool = True,
1634
1693
  filter_by: dict[str, Any] | BaseModel | None = None,
1635
1694
  schema: type[BaseModel],
1636
1695
  ) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
@@ -1661,6 +1720,8 @@ class AsyncCrud(Generic[ModelType]):
1661
1720
  order_fields: Fields allowed for sorting (overrides class default).
1662
1721
  facet_fields: Columns to compute distinct values for (overrides
1663
1722
  class default).
1723
+ include_facets: When ``False``, skip facet queries entirely;
1724
+ ``filter_attributes`` will be ``None``.
1664
1725
  filter_by: Dict of ``{column_key: value}`` to filter by declared
1665
1726
  facet fields. Keys must match the ``column.key`` of a facet
1666
1727
  field. Scalar → equality, list → IN clause. Raises
@@ -1691,6 +1752,7 @@ class AsyncCrud(Generic[ModelType]):
1691
1752
  search_column=search_column,
1692
1753
  order_fields=order_fields,
1693
1754
  facet_fields=facet_fields,
1755
+ include_facets=include_facets,
1694
1756
  filter_by=filter_by,
1695
1757
  schema=schema,
1696
1758
  )
@@ -1713,6 +1775,7 @@ class AsyncCrud(Generic[ModelType]):
1713
1775
  search_column=search_column,
1714
1776
  order_fields=order_fields,
1715
1777
  facet_fields=facet_fields,
1778
+ include_facets=include_facets,
1716
1779
  filter_by=filter_by,
1717
1780
  schema=schema,
1718
1781
  )