fastapi-toolsets 2.2.1__tar.gz → 2.3.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 (36) hide show
  1. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/PKG-INFO +3 -3
  2. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/README.md +2 -2
  3. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/pyproject.toml +1 -1
  4. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/__init__.py +1 -1
  5. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/crud/__init__.py +4 -1
  6. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/crud/factory.py +267 -55
  7. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/models.py +15 -4
  8. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/schemas.py +63 -3
  9. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/LICENSE +0 -0
  10. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/_imports.py +0 -0
  11. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/__init__.py +0 -0
  12. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/app.py +0 -0
  13. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  14. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  15. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/config.py +0 -0
  16. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  17. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/utils.py +0 -0
  18. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/crud/search.py +0 -0
  19. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/db.py +0 -0
  20. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/dependencies.py +0 -0
  21. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  22. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  23. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  24. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  25. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  26. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  27. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  28. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/logger.py +0 -0
  29. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  30. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/metrics/handler.py +0 -0
  31. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/metrics/registry.py +0 -0
  32. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/py.typed +0 -0
  33. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  34. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  35. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/pytest/utils.py +0 -0
  36. {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.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: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -96,8 +96,8 @@ uv add "fastapi-toolsets[all]"
96
96
  - **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
97
97
  - **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
98
98
  - **Fixtures**: Fixture system with dependency management, context support, and pytest integration
99
- - **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
100
- - **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
99
+ - **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
100
+ - **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
101
101
  - **Exception Handling**: Structured error responses with automatic OpenAPI documentation
102
102
  - **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
103
103
 
@@ -48,8 +48,8 @@ uv add "fastapi-toolsets[all]"
48
48
  - **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
49
49
  - **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
50
50
  - **Fixtures**: Fixture system with dependency management, context support, and pytest integration
51
- - **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
52
- - **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
51
+ - **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
52
+ - **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
53
53
  - **Exception Handling**: Structured error responses with automatic OpenAPI documentation
54
54
  - **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
55
55
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "2.2.1"
3
+ version = "2.3.0"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,4 +21,4 @@ Example usage:
21
21
  return Response(data={"user": user.username}, message="Success")
22
22
  """
23
23
 
24
- __version__ = "2.2.1"
24
+ __version__ = "2.3.0"
@@ -1,6 +1,7 @@
1
1
  """Generic async CRUD operations for SQLAlchemy models."""
2
2
 
3
3
  from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
4
+ from ..schemas import PaginationType
4
5
  from ..types import (
5
6
  FacetFieldType,
6
7
  JoinType,
@@ -8,10 +9,11 @@ from ..types import (
8
9
  OrderByClause,
9
10
  SearchFieldType,
10
11
  )
11
- from .factory import CrudFactory
12
+ from .factory import AsyncCrud, CrudFactory
12
13
  from .search import SearchConfig, get_searchable_fields
13
14
 
14
15
  __all__ = [
16
+ "AsyncCrud",
15
17
  "CrudFactory",
16
18
  "FacetFieldType",
17
19
  "get_searchable_fields",
@@ -20,6 +22,7 @@ __all__ = [
20
22
  "M2MFieldType",
21
23
  "NoSearchableFieldsError",
22
24
  "OrderByClause",
25
+ "PaginationType",
23
26
  "SearchConfig",
24
27
  "SearchFieldType",
25
28
  ]
@@ -9,6 +9,7 @@ import uuid as uuid_module
9
9
  from collections.abc import Awaitable, Callable, Sequence
10
10
  from datetime import date, datetime
11
11
  from decimal import Decimal
12
+ from enum import Enum
12
13
  from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
13
14
 
14
15
  from fastapi import Query
@@ -23,7 +24,14 @@ from sqlalchemy.sql.roles import WhereHavingRole
23
24
 
24
25
  from ..db import get_transaction
25
26
  from ..exceptions import InvalidOrderFieldError, NotFoundError
26
- from ..schemas import CursorPagination, OffsetPagination, PaginatedResponse, Response
27
+ from ..schemas import (
28
+ CursorPaginatedResponse,
29
+ CursorPagination,
30
+ OffsetPaginatedResponse,
31
+ OffsetPagination,
32
+ PaginationType,
33
+ Response,
34
+ )
27
35
  from ..types import (
28
36
  FacetFieldType,
29
37
  JoinType,
@@ -42,14 +50,43 @@ from .search import (
42
50
  )
43
51
 
44
52
 
45
- def _encode_cursor(value: Any) -> str:
46
- """Encode cursor column value as an base64 string."""
47
- return base64.b64encode(json.dumps(str(value)).encode()).decode()
48
-
49
-
50
- def _decode_cursor(cursor: str) -> str:
51
- """Decode cursor base64 string."""
52
- return json.loads(base64.b64decode(cursor.encode()).decode())
53
+ class _CursorDirection(str, Enum):
54
+ NEXT = "next"
55
+ PREV = "prev"
56
+
57
+
58
+ def _encode_cursor(
59
+ value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
60
+ ) -> str:
61
+ """Encode a cursor column value and navigation direction as a base64 string."""
62
+ return base64.b64encode(
63
+ json.dumps({"val": str(value), "dir": direction}).encode()
64
+ ).decode()
65
+
66
+
67
+ def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
68
+ """Decode a cursor base64 string into ``(raw_value, direction)``."""
69
+ payload = json.loads(base64.b64decode(cursor.encode()).decode())
70
+ return payload["val"], _CursorDirection(payload["dir"])
71
+
72
+
73
+ def _parse_cursor_value(raw_val: str, col_type: Any) -> Any:
74
+ """Parse a raw cursor string value back into the appropriate Python type."""
75
+ if isinstance(col_type, Integer):
76
+ return int(raw_val)
77
+ if isinstance(col_type, Uuid):
78
+ return uuid_module.UUID(raw_val)
79
+ if isinstance(col_type, DateTime):
80
+ return datetime.fromisoformat(raw_val)
81
+ if isinstance(col_type, Date):
82
+ return date.fromisoformat(raw_val)
83
+ if isinstance(col_type, (Float, Numeric)):
84
+ return Decimal(raw_val)
85
+ raise ValueError(
86
+ f"Unsupported cursor column type: {type(col_type).__name__!r}. "
87
+ "Supported types: Integer, BigInteger, SmallInteger, Uuid, "
88
+ "DateTime, Date, Float, Numeric."
89
+ )
53
90
 
54
91
 
55
92
  def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
@@ -82,6 +119,27 @@ class AsyncCrud(Generic[ModelType]):
82
119
  default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
83
120
  cursor_column: ClassVar[Any | None] = None
84
121
 
122
+ @classmethod
123
+ def __init_subclass__(cls, **kwargs: Any) -> None:
124
+ super().__init_subclass__(**kwargs)
125
+ if "model" not in cls.__dict__:
126
+ return
127
+ model: type[DeclarativeBase] = cls.__dict__["model"]
128
+ pk_key = model.__mapper__.primary_key[0].key
129
+ assert pk_key is not None
130
+ pk_col = getattr(model, pk_key)
131
+
132
+ raw_fields: Sequence[SearchFieldType] | None = cls.__dict__.get(
133
+ "searchable_fields", None
134
+ )
135
+ if raw_fields is None:
136
+ cls.searchable_fields = [pk_col]
137
+ else:
138
+ if not any(
139
+ not isinstance(f, tuple) and f.key == pk_key for f in raw_fields
140
+ ):
141
+ cls.searchable_fields = [pk_col, *raw_fields]
142
+
85
143
  @classmethod
86
144
  def _resolve_load_options(
87
145
  cls, load_options: Sequence[ExecutableOption] | None
@@ -869,7 +927,7 @@ class AsyncCrud(Generic[ModelType]):
869
927
  facet_fields: Sequence[FacetFieldType] | None = None,
870
928
  filter_by: dict[str, Any] | BaseModel | None = None,
871
929
  schema: type[BaseModel],
872
- ) -> PaginatedResponse[Any]:
930
+ ) -> OffsetPaginatedResponse[Any]:
873
931
  """Get paginated results using offset-based pagination.
874
932
 
875
933
  Args:
@@ -951,7 +1009,7 @@ class AsyncCrud(Generic[ModelType]):
951
1009
  session, facet_fields, filters, search_joins
952
1010
  )
953
1011
 
954
- return PaginatedResponse(
1012
+ return OffsetPaginatedResponse(
955
1013
  data=items,
956
1014
  pagination=OffsetPagination(
957
1015
  total_count=total_count,
@@ -979,7 +1037,7 @@ class AsyncCrud(Generic[ModelType]):
979
1037
  facet_fields: Sequence[FacetFieldType] | None = None,
980
1038
  filter_by: dict[str, Any] | BaseModel | None = None,
981
1039
  schema: type[BaseModel],
982
- ) -> PaginatedResponse[Any]:
1040
+ ) -> CursorPaginatedResponse[Any]:
983
1041
  """Get paginated results using cursor-based pagination.
984
1042
 
985
1043
  Args:
@@ -1018,26 +1076,15 @@ class AsyncCrud(Generic[ModelType]):
1018
1076
  cursor_column: Any = cls.cursor_column
1019
1077
  cursor_col_name: str = cursor_column.key
1020
1078
 
1079
+ direction = _CursorDirection.NEXT
1021
1080
  if cursor is not None:
1022
- raw_val = _decode_cursor(cursor)
1081
+ raw_val, direction = _decode_cursor(cursor)
1023
1082
  col_type = cursor_column.property.columns[0].type
1024
- if isinstance(col_type, Integer):
1025
- cursor_val: Any = int(raw_val)
1026
- elif isinstance(col_type, Uuid):
1027
- cursor_val = uuid_module.UUID(raw_val)
1028
- elif isinstance(col_type, DateTime):
1029
- cursor_val = datetime.fromisoformat(raw_val)
1030
- elif isinstance(col_type, Date):
1031
- cursor_val = date.fromisoformat(raw_val)
1032
- elif isinstance(col_type, (Float, Numeric)):
1033
- cursor_val = Decimal(raw_val)
1083
+ cursor_val: Any = _parse_cursor_value(raw_val, col_type)
1084
+ if direction is _CursorDirection.PREV:
1085
+ filters.append(cursor_column < cursor_val)
1034
1086
  else:
1035
- raise ValueError(
1036
- f"Unsupported cursor column type: {type(col_type).__name__!r}. "
1037
- "Supported types: Integer, BigInteger, SmallInteger, Uuid, "
1038
- "DateTime, Date, Float, Numeric."
1039
- )
1040
- filters.append(cursor_column > cursor_val)
1087
+ filters.append(cursor_column > cursor_val)
1041
1088
 
1042
1089
  # Build search filters
1043
1090
  if search:
@@ -1064,12 +1111,15 @@ class AsyncCrud(Generic[ModelType]):
1064
1111
  if resolved := cls._resolve_load_options(load_options):
1065
1112
  q = q.options(*resolved)
1066
1113
 
1067
- # Cursor column is always the primary sort
1068
- q = q.order_by(cursor_column)
1114
+ # Cursor column is always the primary sort; reverse direction for prev traversal
1115
+ if direction is _CursorDirection.PREV:
1116
+ q = q.order_by(cursor_column.desc())
1117
+ else:
1118
+ q = q.order_by(cursor_column)
1069
1119
  if order_by is not None:
1070
1120
  q = q.order_by(order_by)
1071
1121
 
1072
- # Fetch one extra to detect whether a next page exists
1122
+ # Fetch one extra to detect whether another page exists in this direction
1073
1123
  q = q.limit(items_per_page + 1)
1074
1124
  result = await session.execute(q)
1075
1125
  raw_items = cast(list[ModelType], result.unique().scalars().all())
@@ -1077,15 +1127,36 @@ class AsyncCrud(Generic[ModelType]):
1077
1127
  has_more = len(raw_items) > items_per_page
1078
1128
  items_page = raw_items[:items_per_page]
1079
1129
 
1080
- # next_cursor points past the last item on this page
1130
+ # Restore ascending order when traversing backward
1131
+ if direction is _CursorDirection.PREV:
1132
+ items_page = list(reversed(items_page))
1133
+
1134
+ # next_cursor: points past the last item in ascending order
1081
1135
  next_cursor: str | None = None
1082
- if has_more and items_page:
1083
- next_cursor = _encode_cursor(getattr(items_page[-1], cursor_col_name))
1136
+ if direction is _CursorDirection.NEXT:
1137
+ if has_more and items_page:
1138
+ next_cursor = _encode_cursor(
1139
+ getattr(items_page[-1], cursor_col_name),
1140
+ direction=_CursorDirection.NEXT,
1141
+ )
1142
+ else:
1143
+ # Going backward: always provide a next_cursor to allow returning forward
1144
+ if items_page:
1145
+ next_cursor = _encode_cursor(
1146
+ getattr(items_page[-1], cursor_col_name),
1147
+ direction=_CursorDirection.NEXT,
1148
+ )
1084
1149
 
1085
- # prev_cursor points to the first item on this page or None when on the first page
1150
+ # prev_cursor: points before the first item in ascending order
1086
1151
  prev_cursor: str | None = None
1087
- if cursor is not None and items_page:
1088
- prev_cursor = _encode_cursor(getattr(items_page[0], cursor_col_name))
1152
+ if direction is _CursorDirection.NEXT and cursor is not None and items_page:
1153
+ prev_cursor = _encode_cursor(
1154
+ getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
1155
+ )
1156
+ elif direction is _CursorDirection.PREV and has_more and items_page:
1157
+ prev_cursor = _encode_cursor(
1158
+ getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
1159
+ )
1089
1160
 
1090
1161
  items: list[Any] = [schema.model_validate(item) for item in items_page]
1091
1162
 
@@ -1093,7 +1164,7 @@ class AsyncCrud(Generic[ModelType]):
1093
1164
  session, facet_fields, filters, search_joins
1094
1165
  )
1095
1166
 
1096
- return PaginatedResponse(
1167
+ return CursorPaginatedResponse(
1097
1168
  data=items,
1098
1169
  pagination=CursorPagination(
1099
1170
  next_cursor=next_cursor,
@@ -1104,10 +1175,149 @@ class AsyncCrud(Generic[ModelType]):
1104
1175
  filter_attributes=filter_attributes,
1105
1176
  )
1106
1177
 
1178
+ @overload
1179
+ @classmethod
1180
+ async def paginate( # pragma: no cover
1181
+ cls: type[Self],
1182
+ session: AsyncSession,
1183
+ *,
1184
+ pagination_type: Literal[PaginationType.OFFSET],
1185
+ filters: list[Any] | None = ...,
1186
+ joins: JoinType | None = ...,
1187
+ outer_join: bool = ...,
1188
+ load_options: Sequence[ExecutableOption] | None = ...,
1189
+ order_by: OrderByClause | None = ...,
1190
+ page: int = ...,
1191
+ cursor: str | None = ...,
1192
+ items_per_page: int = ...,
1193
+ search: str | SearchConfig | None = ...,
1194
+ search_fields: Sequence[SearchFieldType] | None = ...,
1195
+ facet_fields: Sequence[FacetFieldType] | None = ...,
1196
+ filter_by: dict[str, Any] | BaseModel | None = ...,
1197
+ schema: type[BaseModel],
1198
+ ) -> OffsetPaginatedResponse[Any]: ...
1199
+
1200
+ @overload
1201
+ @classmethod
1202
+ async def paginate( # pragma: no cover
1203
+ cls: type[Self],
1204
+ session: AsyncSession,
1205
+ *,
1206
+ pagination_type: Literal[PaginationType.CURSOR],
1207
+ filters: list[Any] | None = ...,
1208
+ joins: JoinType | None = ...,
1209
+ outer_join: bool = ...,
1210
+ load_options: Sequence[ExecutableOption] | None = ...,
1211
+ order_by: OrderByClause | None = ...,
1212
+ page: int = ...,
1213
+ cursor: str | None = ...,
1214
+ items_per_page: int = ...,
1215
+ search: str | SearchConfig | None = ...,
1216
+ search_fields: Sequence[SearchFieldType] | None = ...,
1217
+ facet_fields: Sequence[FacetFieldType] | None = ...,
1218
+ filter_by: dict[str, Any] | BaseModel | None = ...,
1219
+ schema: type[BaseModel],
1220
+ ) -> CursorPaginatedResponse[Any]: ...
1221
+
1222
+ @classmethod
1223
+ async def paginate(
1224
+ cls: type[Self],
1225
+ session: AsyncSession,
1226
+ *,
1227
+ pagination_type: PaginationType = PaginationType.OFFSET,
1228
+ filters: list[Any] | None = None,
1229
+ joins: JoinType | None = None,
1230
+ outer_join: bool = False,
1231
+ load_options: Sequence[ExecutableOption] | None = None,
1232
+ order_by: OrderByClause | None = None,
1233
+ page: int = 1,
1234
+ cursor: str | None = None,
1235
+ items_per_page: int = 20,
1236
+ search: str | SearchConfig | None = None,
1237
+ search_fields: Sequence[SearchFieldType] | None = None,
1238
+ facet_fields: Sequence[FacetFieldType] | None = None,
1239
+ filter_by: dict[str, Any] | BaseModel | None = None,
1240
+ schema: type[BaseModel],
1241
+ ) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
1242
+ """Get paginated results using either offset or cursor pagination.
1243
+
1244
+ Args:
1245
+ session: DB async session.
1246
+ pagination_type: Pagination strategy. Defaults to
1247
+ ``PaginationType.OFFSET``.
1248
+ filters: List of SQLAlchemy filter conditions.
1249
+ joins: List of ``(model, condition)`` tuples for joining related
1250
+ tables.
1251
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
1252
+ load_options: SQLAlchemy loader options. Falls back to
1253
+ ``default_load_options`` when not provided.
1254
+ order_by: Column or expression to order results by.
1255
+ page: Page number (1-indexed). Only used when
1256
+ ``pagination_type`` is ``OFFSET``.
1257
+ cursor: Cursor token from a previous
1258
+ :class:`.CursorPaginatedResponse`. Only used when
1259
+ ``pagination_type`` is ``CURSOR``.
1260
+ items_per_page: Number of items per page (default 20).
1261
+ search: Search query string or :class:`.SearchConfig` object.
1262
+ search_fields: Fields to search in (overrides class default).
1263
+ facet_fields: Columns to compute distinct values for (overrides
1264
+ class default).
1265
+ filter_by: Dict of ``{column_key: value}`` to filter by declared
1266
+ facet fields. Keys must match the ``column.key`` of a facet
1267
+ field. Scalar → equality, list → IN clause. Raises
1268
+ :exc:`.InvalidFacetFilterError` for unknown keys.
1269
+ schema: Pydantic schema to serialize each item into.
1270
+
1271
+ Returns:
1272
+ :class:`.OffsetPaginatedResponse` when ``pagination_type`` is
1273
+ ``OFFSET``, :class:`.CursorPaginatedResponse` when it is
1274
+ ``CURSOR``.
1275
+ """
1276
+ if items_per_page < 1:
1277
+ raise ValueError(f"items_per_page must be >= 1, got {items_per_page}")
1278
+ match pagination_type:
1279
+ case PaginationType.CURSOR:
1280
+ return await cls.cursor_paginate(
1281
+ session,
1282
+ cursor=cursor,
1283
+ filters=filters,
1284
+ joins=joins,
1285
+ outer_join=outer_join,
1286
+ load_options=load_options,
1287
+ order_by=order_by,
1288
+ items_per_page=items_per_page,
1289
+ search=search,
1290
+ search_fields=search_fields,
1291
+ facet_fields=facet_fields,
1292
+ filter_by=filter_by,
1293
+ schema=schema,
1294
+ )
1295
+ case PaginationType.OFFSET:
1296
+ if page < 1:
1297
+ raise ValueError(f"page must be >= 1, got {page}")
1298
+ return await cls.offset_paginate(
1299
+ session,
1300
+ filters=filters,
1301
+ joins=joins,
1302
+ outer_join=outer_join,
1303
+ load_options=load_options,
1304
+ order_by=order_by,
1305
+ page=page,
1306
+ items_per_page=items_per_page,
1307
+ search=search,
1308
+ search_fields=search_fields,
1309
+ facet_fields=facet_fields,
1310
+ filter_by=filter_by,
1311
+ schema=schema,
1312
+ )
1313
+ case _:
1314
+ raise ValueError(f"Unknown pagination_type: {pagination_type!r}")
1315
+
1107
1316
 
1108
1317
  def CrudFactory(
1109
1318
  model: type[ModelType],
1110
1319
  *,
1320
+ base_class: type[AsyncCrud[Any]] = AsyncCrud,
1111
1321
  searchable_fields: Sequence[SearchFieldType] | None = None,
1112
1322
  facet_fields: Sequence[FacetFieldType] | None = None,
1113
1323
  order_fields: Sequence[QueryableAttribute[Any]] | None = None,
@@ -1119,6 +1329,9 @@ def CrudFactory(
1119
1329
 
1120
1330
  Args:
1121
1331
  model: SQLAlchemy model class
1332
+ base_class: Optional base class to inherit from instead of ``AsyncCrud``.
1333
+ Use this to share custom methods across multiple CRUD classes while
1334
+ still using the factory shorthand.
1122
1335
  searchable_fields: Optional list of searchable fields
1123
1336
  facet_fields: Optional list of columns to compute distinct values for in paginated
1124
1337
  responses. Supports direct columns (``User.status``) and relationship tuples
@@ -1209,28 +1422,27 @@ def CrudFactory(
1209
1422
  joins=[(Post, Post.user_id == User.id)],
1210
1423
  outer_join=True,
1211
1424
  )
1425
+
1426
+ # With a shared custom base class:
1427
+ from typing import Generic, TypeVar
1428
+ from sqlalchemy.orm import DeclarativeBase
1429
+
1430
+ T = TypeVar("T", bound=DeclarativeBase)
1431
+
1432
+ class AuditedCrud(AsyncCrud[T], Generic[T]):
1433
+ @classmethod
1434
+ async def get_active(cls, session):
1435
+ return await cls.get_multi(session, filters=[cls.model.is_active == True])
1436
+
1437
+ UserCrud = CrudFactory(User, base_class=AuditedCrud)
1212
1438
  ```
1213
1439
  """
1214
- pk_key = model.__mapper__.primary_key[0].key
1215
- assert pk_key is not None
1216
- pk_col = getattr(model, pk_key)
1217
-
1218
- if searchable_fields is None:
1219
- effective_searchable = [pk_col]
1220
- else:
1221
- existing_keys = {f.key for f in searchable_fields if not isinstance(f, tuple)}
1222
- effective_searchable = (
1223
- [pk_col, *searchable_fields]
1224
- if pk_key not in existing_keys
1225
- else list(searchable_fields)
1226
- )
1227
-
1228
1440
  cls = type(
1229
1441
  f"Async{model.__name__}Crud",
1230
- (AsyncCrud,),
1442
+ (base_class,),
1231
1443
  {
1232
1444
  "model": model,
1233
- "searchable_fields": effective_searchable,
1445
+ "searchable_fields": searchable_fields,
1234
1446
  "facet_fields": facet_fields,
1235
1447
  "order_fields": order_fields,
1236
1448
  "m2m_fields": m2m_fields,
@@ -3,11 +3,12 @@
3
3
  import uuid
4
4
  from datetime import datetime
5
5
 
6
- from sqlalchemy import DateTime, Uuid, func, text
6
+ from sqlalchemy import DateTime, Uuid, text
7
7
  from sqlalchemy.orm import Mapped, mapped_column
8
8
 
9
9
  __all__ = [
10
10
  "UUIDMixin",
11
+ "UUIDv7Mixin",
11
12
  "CreatedAtMixin",
12
13
  "UpdatedAtMixin",
13
14
  "TimestampMixin",
@@ -24,12 +25,22 @@ class UUIDMixin:
24
25
  )
25
26
 
26
27
 
28
+ class UUIDv7Mixin:
29
+ """Mixin that adds a UUIDv7 primary key auto-generated by the database."""
30
+
31
+ id: Mapped[uuid.UUID] = mapped_column(
32
+ Uuid,
33
+ primary_key=True,
34
+ server_default=text("uuidv7()"),
35
+ )
36
+
37
+
27
38
  class CreatedAtMixin:
28
39
  """Mixin that adds a ``created_at`` timestamp column."""
29
40
 
30
41
  created_at: Mapped[datetime] = mapped_column(
31
42
  DateTime(timezone=True),
32
- server_default=func.now(),
43
+ server_default=text("clock_timestamp()"),
33
44
  )
34
45
 
35
46
 
@@ -38,8 +49,8 @@ class UpdatedAtMixin:
38
49
 
39
50
  updated_at: Mapped[datetime] = mapped_column(
40
51
  DateTime(timezone=True),
41
- server_default=func.now(),
42
- onupdate=func.now(),
52
+ server_default=text("clock_timestamp()"),
53
+ onupdate=text("clock_timestamp()"),
43
54
  )
44
55
 
45
56
 
@@ -1,18 +1,21 @@
1
1
  """Base Pydantic schemas for API responses."""
2
2
 
3
3
  from enum import Enum
4
- from typing import Any, ClassVar, Generic
4
+ from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
5
5
 
6
- from pydantic import BaseModel, ConfigDict
6
+ from pydantic import BaseModel, ConfigDict, Field
7
7
 
8
8
  from .types import DataT
9
9
 
10
10
  __all__ = [
11
11
  "ApiError",
12
12
  "CursorPagination",
13
+ "CursorPaginatedResponse",
13
14
  "ErrorResponse",
14
15
  "OffsetPagination",
16
+ "OffsetPaginatedResponse",
15
17
  "PaginatedResponse",
18
+ "PaginationType",
16
19
  "PydanticBase",
17
20
  "Response",
18
21
  "ResponseStatus",
@@ -123,9 +126,66 @@ class CursorPagination(PydanticBase):
123
126
  has_more: bool
124
127
 
125
128
 
129
+ class PaginationType(str, Enum):
130
+ """Pagination strategy selector for :meth:`.AsyncCrud.paginate`."""
131
+
132
+ OFFSET = "offset"
133
+ CURSOR = "cursor"
134
+
135
+
126
136
  class PaginatedResponse(BaseResponse, Generic[DataT]):
127
- """Paginated API response for list endpoints."""
137
+ """Paginated API response for list endpoints.
138
+
139
+ Base class and return type for endpoints that support both pagination
140
+ strategies. Use :class:`OffsetPaginatedResponse` or
141
+ :class:`CursorPaginatedResponse` when the strategy is fixed.
142
+
143
+ When used as ``PaginatedResponse[T]`` in a return annotation, subscripting
144
+ returns ``Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]``
145
+ so FastAPI emits a proper ``oneOf`` + discriminator in the OpenAPI schema.
146
+ """
128
147
 
129
148
  data: list[DataT]
130
149
  pagination: OffsetPagination | CursorPagination
150
+ pagination_type: PaginationType | None = None
131
151
  filter_attributes: dict[str, list[Any]] | None = None
152
+
153
+ _discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
154
+
155
+ def __class_getitem__( # type: ignore[invalid-method-override]
156
+ cls, item: type[Any] | tuple[type[Any], ...]
157
+ ) -> type[Any]:
158
+ if cls is PaginatedResponse and not isinstance(item, TypeVar):
159
+ cached = cls._discriminated_union_cache.get(item)
160
+ if cached is None:
161
+ cached = Annotated[
162
+ Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # type: ignore[invalid-type-form]
163
+ Field(discriminator="pagination_type"),
164
+ ]
165
+ cls._discriminated_union_cache[item] = cached
166
+ return cached # type: ignore[invalid-return-type]
167
+ return super().__class_getitem__(item)
168
+
169
+
170
+ class OffsetPaginatedResponse(PaginatedResponse[DataT]):
171
+ """Paginated response with typed offset-based pagination metadata.
172
+
173
+ The ``pagination_type`` field is always ``"offset"`` and acts as a
174
+ discriminator, allowing frontend clients to narrow the union type returned
175
+ by a unified ``paginate()`` endpoint.
176
+ """
177
+
178
+ pagination: OffsetPagination
179
+ pagination_type: Literal[PaginationType.OFFSET] = PaginationType.OFFSET
180
+
181
+
182
+ class CursorPaginatedResponse(PaginatedResponse[DataT]):
183
+ """Paginated response with typed cursor-based pagination metadata.
184
+
185
+ The ``pagination_type`` field is always ``"cursor"`` and acts as a
186
+ discriminator, allowing frontend clients to narrow the union type returned
187
+ by a unified ``paginate()`` endpoint.
188
+ """
189
+
190
+ pagination: CursorPagination
191
+ pagination_type: Literal[PaginationType.CURSOR] = PaginationType.CURSOR