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.
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/PKG-INFO +3 -3
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/README.md +2 -2
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/pyproject.toml +1 -1
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/crud/__init__.py +4 -1
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/crud/factory.py +267 -55
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/models.py +15 -4
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/schemas.py +63 -3
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/LICENSE +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/db.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {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.
|
|
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 `
|
|
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 `
|
|
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,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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
) ->
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
1083
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
(
|
|
1442
|
+
(base_class,),
|
|
1231
1443
|
{
|
|
1232
1444
|
"model": model,
|
|
1233
|
-
"searchable_fields":
|
|
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,
|
|
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=
|
|
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=
|
|
42
|
-
onupdate=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-2.2.1 → fastapi_toolsets-2.3.0}/src/fastapi_toolsets/exceptions/handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|