fastapi-toolsets 5.1.0__tar.gz → 5.1.1__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-5.1.0 → fastapi_toolsets-5.1.1}/PKG-INFO +1 -1
- fastapi_toolsets-5.1.1/pyproject.toml +133 -0
- fastapi_toolsets-5.1.0/pyproject.toml → fastapi_toolsets-5.1.1/pyproject.toml.orig +3 -3
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/crud/factory.py +162 -30
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/crud/search.py +4 -2
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/db/core.py +9 -6
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/dependencies.py +74 -55
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/models/watched.py +36 -7
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/LICENSE +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/README.md +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/db/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/db/locks.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/db/m2m.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/db/testing.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/db/watch.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/types.py +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-toolsets"
|
|
3
|
+
version = "5.1.1"
|
|
4
|
+
description = "Production-ready utilities for FastAPI applications"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = [
|
|
10
|
+
"fastapi",
|
|
11
|
+
"sqlalchemy",
|
|
12
|
+
"postgresql",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Framework :: FastAPI",
|
|
18
|
+
"Framework :: Pydantic",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Information Technology",
|
|
21
|
+
"Intended Audience :: System Administrators",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"Topic :: Software Development :: Libraries",
|
|
31
|
+
"Topic :: Software Development",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"asyncpg>=0.29.0",
|
|
36
|
+
"fastapi>=0.100.0",
|
|
37
|
+
"pydantic>=2.0",
|
|
38
|
+
"sqlalchemy[asyncio]>=2.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[[project.authors]]
|
|
42
|
+
name = "d3vyce"
|
|
43
|
+
email = "contact@d3vyce.fr"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/d3vyce/fastapi-toolsets"
|
|
47
|
+
Documentation = "https://fastapi-toolsets.d3vyce.fr/"
|
|
48
|
+
Repository = "https://github.com/d3vyce/fastapi-toolsets"
|
|
49
|
+
Issues = "https://github.com/d3vyce/fastapi-toolsets/issues"
|
|
50
|
+
|
|
51
|
+
[project.optional-dependencies]
|
|
52
|
+
cli = ["typer>=0.9.0"]
|
|
53
|
+
metrics = ["prometheus_client>=0.20.0"]
|
|
54
|
+
pytest = [
|
|
55
|
+
"httpx>=0.25.0",
|
|
56
|
+
"pytest-xdist>=3.0.0",
|
|
57
|
+
"pytest>=8.0.0",
|
|
58
|
+
]
|
|
59
|
+
all = ["fastapi-toolsets[cli,metrics,pytest]"]
|
|
60
|
+
|
|
61
|
+
[project.scripts]
|
|
62
|
+
manager = "fastapi_toolsets.cli.app:cli"
|
|
63
|
+
|
|
64
|
+
[dependency-groups]
|
|
65
|
+
dev = [
|
|
66
|
+
{ include-group = "tests" },
|
|
67
|
+
{ include-group = "docs" },
|
|
68
|
+
{ include-group = "docs-src" },
|
|
69
|
+
"fastapi-toolsets[all]",
|
|
70
|
+
"prek>=0.3.8",
|
|
71
|
+
"ruff>=0.1.0",
|
|
72
|
+
"ty>=0.0.1a0",
|
|
73
|
+
]
|
|
74
|
+
tests = [
|
|
75
|
+
"async-lru>=1.0",
|
|
76
|
+
"coverage>=7.0.0",
|
|
77
|
+
"httpx>=0.25.0",
|
|
78
|
+
"pytest-anyio>=0.0.0",
|
|
79
|
+
"pytest-cov>=4.0.0",
|
|
80
|
+
"pytest-xdist>=3.0.0",
|
|
81
|
+
"pytest>=8.0.0",
|
|
82
|
+
]
|
|
83
|
+
docs = [
|
|
84
|
+
"mike",
|
|
85
|
+
"mkdocstrings-python>=2.0.2",
|
|
86
|
+
"zensical>=0.0.30",
|
|
87
|
+
]
|
|
88
|
+
docs-src = ["bcrypt>=4.0.0"]
|
|
89
|
+
|
|
90
|
+
[build-system]
|
|
91
|
+
requires = ["uv_build>=0.10,<0.13.0"]
|
|
92
|
+
build-backend = "uv_build"
|
|
93
|
+
|
|
94
|
+
[tool.ruff.format]
|
|
95
|
+
exclude = ["*.md"]
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint]
|
|
98
|
+
extend-select = ["E712"]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint.flake8-bugbear]
|
|
101
|
+
extend-immutable-calls = [
|
|
102
|
+
"fastapi.Depends",
|
|
103
|
+
"fastapi.Security",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.per-file-ignores]
|
|
107
|
+
"tests/**" = [
|
|
108
|
+
"RUF012",
|
|
109
|
+
"RUF059",
|
|
110
|
+
"SIM117",
|
|
111
|
+
"DTZ001",
|
|
112
|
+
"S110",
|
|
113
|
+
"BLE001",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[tool.pytest.ini_options]
|
|
117
|
+
testpaths = ["tests"]
|
|
118
|
+
filterwarnings = ["ignore::DeprecationWarning"]
|
|
119
|
+
|
|
120
|
+
[tool.coverage.run]
|
|
121
|
+
source = ["src/fastapi_toolsets"]
|
|
122
|
+
branch = true
|
|
123
|
+
|
|
124
|
+
[tool.coverage.report]
|
|
125
|
+
exclude_lines = [
|
|
126
|
+
"pragma: no cover",
|
|
127
|
+
"if TYPE_CHECKING:",
|
|
128
|
+
"raise NotImplementedError",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
[tool.uv.sources.mike]
|
|
132
|
+
git = "https://github.com/squidfunk/mike.git"
|
|
133
|
+
tag = "2.2.0+zensical-0.1.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-toolsets"
|
|
3
|
-
version = "5.1.
|
|
3
|
+
version = "5.1.1"
|
|
4
4
|
description = "Production-ready utilities for FastAPI applications"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -91,7 +91,7 @@ docs-src = [
|
|
|
91
91
|
]
|
|
92
92
|
|
|
93
93
|
[build-system]
|
|
94
|
-
requires = ["uv_build>=0.10,<0.
|
|
94
|
+
requires = ["uv_build>=0.10,<0.13.0"]
|
|
95
95
|
build-backend = "uv_build"
|
|
96
96
|
|
|
97
97
|
[tool.ruff.format]
|
|
@@ -101,7 +101,7 @@ exclude = ["*.md"]
|
|
|
101
101
|
extend-select = ["E712"]
|
|
102
102
|
|
|
103
103
|
[tool.ruff.lint.flake8-bugbear]
|
|
104
|
-
extend-immutable-calls = ["fastapi.Depends"]
|
|
104
|
+
extend-immutable-calls = ["fastapi.Depends", "fastapi.Security"]
|
|
105
105
|
|
|
106
106
|
[tool.ruff.lint.per-file-ignores]
|
|
107
107
|
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
|
|
@@ -14,12 +14,25 @@ from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overl
|
|
|
14
14
|
|
|
15
15
|
from fastapi import Query
|
|
16
16
|
from pydantic import BaseModel
|
|
17
|
-
from sqlalchemy import
|
|
17
|
+
from sqlalchemy import (
|
|
18
|
+
Date,
|
|
19
|
+
DateTime,
|
|
20
|
+
Float,
|
|
21
|
+
Integer,
|
|
22
|
+
Numeric,
|
|
23
|
+
Uuid,
|
|
24
|
+
and_,
|
|
25
|
+
func,
|
|
26
|
+
select,
|
|
27
|
+
tuple_,
|
|
28
|
+
)
|
|
18
29
|
from sqlalchemy.dialects.postgresql import insert
|
|
19
30
|
from sqlalchemy.exc import NoResultFound
|
|
20
31
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
32
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
|
33
|
+
from sqlalchemy.sql import operators
|
|
22
34
|
from sqlalchemy.sql.base import ExecutableOption
|
|
35
|
+
from sqlalchemy.sql.elements import UnaryExpression
|
|
23
36
|
from sqlalchemy.sql.roles import WhereHavingRole
|
|
24
37
|
|
|
25
38
|
from ..db import transaction
|
|
@@ -128,6 +141,32 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
|
|
128
141
|
return q
|
|
129
142
|
|
|
130
143
|
|
|
144
|
+
def _fans_out(
|
|
145
|
+
search_joins: Sequence[Any] | None, order_joins: Sequence[Any] | None
|
|
146
|
+
) -> bool:
|
|
147
|
+
"""True if any relationship join yields a collection."""
|
|
148
|
+
return any(
|
|
149
|
+
rel.property.uselist for rel in (*(search_joins or ()), *(order_joins or ()))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _grouped_order(clause: Any, table: Any) -> Any:
|
|
154
|
+
"""Recast an order clause for a query grouped by the entity's key."""
|
|
155
|
+
inner = clause.element if isinstance(clause, UnaryExpression) else clause
|
|
156
|
+
expr = inner.__clause_element__() if hasattr(inner, "__clause_element__") else inner
|
|
157
|
+
tables = {
|
|
158
|
+
t
|
|
159
|
+
for c in getattr(expr, "base_columns", ())
|
|
160
|
+
if (t := getattr(c, "table", None)) is not None
|
|
161
|
+
}
|
|
162
|
+
if tables and tables <= {table}:
|
|
163
|
+
return clause
|
|
164
|
+
agg = func.min(inner)
|
|
165
|
+
if isinstance(clause, UnaryExpression) and clause.modifier is operators.desc_op:
|
|
166
|
+
return agg.desc()
|
|
167
|
+
return agg.asc()
|
|
168
|
+
|
|
169
|
+
|
|
131
170
|
class AsyncCrud(Generic[ModelType]):
|
|
132
171
|
"""Generic async CRUD operations for SQLAlchemy models.
|
|
133
172
|
|
|
@@ -163,6 +202,64 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
163
202
|
):
|
|
164
203
|
cls.searchable_fields = [pk_col, *raw_fields]
|
|
165
204
|
|
|
205
|
+
@classmethod
|
|
206
|
+
def _pk_attrs(cls: type[Self]) -> list[QueryableAttribute[Any]]:
|
|
207
|
+
"""The model's primary key columns as instrumented attributes."""
|
|
208
|
+
return [
|
|
209
|
+
getattr(cls.model, cast(str, col.key))
|
|
210
|
+
for col in cls.model.__mapper__.primary_key
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
async def _page_entities(
|
|
215
|
+
cls: type[Self],
|
|
216
|
+
session: AsyncSession,
|
|
217
|
+
q: Any,
|
|
218
|
+
*,
|
|
219
|
+
order_clauses: Sequence[Any],
|
|
220
|
+
limit: int,
|
|
221
|
+
offset: int | None = None,
|
|
222
|
+
load_options: Sequence[ExecutableOption] | None = None,
|
|
223
|
+
with_for_update: _ForUpdateMode = False,
|
|
224
|
+
) -> list[ModelType]:
|
|
225
|
+
"""Return up to *limit* entities, paging over distinct primary keys."""
|
|
226
|
+
pk_attrs = cls._pk_attrs()
|
|
227
|
+
table = cls.model.__table__
|
|
228
|
+
# Fall back to the primary key so the page boundary is deterministic.
|
|
229
|
+
grouped = [_grouped_order(c, table) for c in order_clauses] or [pk_attrs[0]]
|
|
230
|
+
id_q = (
|
|
231
|
+
q.order_by(None)
|
|
232
|
+
.with_only_columns(*pk_attrs)
|
|
233
|
+
.group_by(*pk_attrs)
|
|
234
|
+
.order_by(*grouped)
|
|
235
|
+
.limit(limit)
|
|
236
|
+
)
|
|
237
|
+
if offset:
|
|
238
|
+
id_q = id_q.offset(offset)
|
|
239
|
+
rows = (await session.execute(id_q)).all()
|
|
240
|
+
ids = [row[0] if len(pk_attrs) == 1 else tuple(row) for row in rows]
|
|
241
|
+
if not ids:
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
where = (
|
|
245
|
+
pk_attrs[0].in_(ids) if len(pk_attrs) == 1 else tuple_(*pk_attrs).in_(ids)
|
|
246
|
+
)
|
|
247
|
+
item_q = select(cls.model).where(where)
|
|
248
|
+
if resolved := cls._resolve_load_options(load_options):
|
|
249
|
+
item_q = item_q.options(*resolved)
|
|
250
|
+
item_q = _apply_for_update(item_q, with_for_update)
|
|
251
|
+
found = (await session.execute(item_q)).unique().scalars().all()
|
|
252
|
+
|
|
253
|
+
rank = {pk: n for n, pk in enumerate(ids)}
|
|
254
|
+
|
|
255
|
+
def _key(obj: Any) -> Any:
|
|
256
|
+
values = tuple(getattr(obj, a.key) for a in pk_attrs)
|
|
257
|
+
return values[0] if len(pk_attrs) == 1 else values
|
|
258
|
+
|
|
259
|
+
return cast(
|
|
260
|
+
list[ModelType], sorted(found, key=lambda o: rank.get(_key(o), len(ids)))
|
|
261
|
+
)
|
|
262
|
+
|
|
166
263
|
@classmethod
|
|
167
264
|
def _resolve_load_options(
|
|
168
265
|
cls, load_options: Sequence[ExecutableOption] | None
|
|
@@ -173,7 +270,9 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
173
270
|
return cls.default_load_options
|
|
174
271
|
|
|
175
272
|
@classmethod
|
|
176
|
-
def _capture_pk_values(
|
|
273
|
+
def _capture_pk_values(
|
|
274
|
+
cls: type[Self], instance: DeclarativeBase
|
|
275
|
+
) -> dict[str, Any]:
|
|
177
276
|
"""Capture PK values off instance — call before commit expires attributes."""
|
|
178
277
|
return {
|
|
179
278
|
cast(str, col.key): getattr(instance, cast(str, col.key))
|
|
@@ -1023,9 +1122,21 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1023
1122
|
q = q.where(and_(*filters))
|
|
1024
1123
|
if resolved := cls._resolve_load_options(load_options):
|
|
1025
1124
|
q = q.options(*resolved)
|
|
1026
|
-
q = _apply_for_update(q, with_for_update)
|
|
1027
1125
|
if order_by is not None:
|
|
1028
1126
|
q = q.order_by(order_by)
|
|
1127
|
+
|
|
1128
|
+
if limit is not None and joins:
|
|
1129
|
+
return await cls._page_entities(
|
|
1130
|
+
session,
|
|
1131
|
+
q,
|
|
1132
|
+
order_clauses=[] if order_by is None else [order_by],
|
|
1133
|
+
limit=limit,
|
|
1134
|
+
offset=offset,
|
|
1135
|
+
load_options=load_options,
|
|
1136
|
+
with_for_update=with_for_update,
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
q = _apply_for_update(q, with_for_update)
|
|
1029
1140
|
if offset is not None:
|
|
1030
1141
|
q = q.offset(offset)
|
|
1031
1142
|
if limit is not None:
|
|
@@ -1374,17 +1485,31 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1374
1485
|
q = q.where(and_(*filters))
|
|
1375
1486
|
if resolved := cls._resolve_load_options(load_options):
|
|
1376
1487
|
q = q.options(*resolved)
|
|
1377
|
-
if order_by is
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
if include_total
|
|
1381
|
-
|
|
1382
|
-
|
|
1488
|
+
order_clauses: list[Any] = [] if order_by is None else [order_by]
|
|
1489
|
+
q = q.order_by(*order_clauses)
|
|
1490
|
+
|
|
1491
|
+
fetch_limit = items_per_page if include_total else items_per_page + 1
|
|
1492
|
+
total_count: int | None = None
|
|
1493
|
+
# A to-many join repeats each entity, so LIMIT would slice joined rows
|
|
1494
|
+
# and `.unique()` would shrink the page after the fact.
|
|
1495
|
+
if _fans_out(search_joins, order_joins):
|
|
1496
|
+
raw_items = await cls._page_entities(
|
|
1497
|
+
session,
|
|
1498
|
+
q,
|
|
1499
|
+
order_clauses=order_clauses,
|
|
1500
|
+
limit=fetch_limit,
|
|
1501
|
+
offset=offset,
|
|
1502
|
+
load_options=load_options,
|
|
1503
|
+
)
|
|
1504
|
+
else:
|
|
1505
|
+
result = await session.execute(q.offset(offset).limit(fetch_limit))
|
|
1383
1506
|
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
1507
|
+
fetched = len(raw_items)
|
|
1508
|
+
raw_items = raw_items[:items_per_page]
|
|
1384
1509
|
|
|
1510
|
+
if include_total:
|
|
1385
1511
|
# Count query (with same joins and filters)
|
|
1386
|
-
|
|
1387
|
-
count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
|
|
1512
|
+
count_q = select(func.count(func.distinct(cls._pk_attrs()[0])))
|
|
1388
1513
|
count_q = count_q.select_from(cls.model)
|
|
1389
1514
|
|
|
1390
1515
|
# Apply explicit joins to count query
|
|
@@ -1397,16 +1522,11 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1397
1522
|
count_q = count_q.where(and_(*filters))
|
|
1398
1523
|
|
|
1399
1524
|
count_result = await session.execute(count_q)
|
|
1400
|
-
total_count
|
|
1525
|
+
total_count = count_result.scalar_one()
|
|
1401
1526
|
has_more = page * items_per_page < total_count
|
|
1402
1527
|
else:
|
|
1403
|
-
#
|
|
1404
|
-
|
|
1405
|
-
result = await session.execute(q)
|
|
1406
|
-
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
1407
|
-
has_more = len(raw_items) > items_per_page
|
|
1408
|
-
raw_items = raw_items[:items_per_page]
|
|
1409
|
-
total_count = None
|
|
1528
|
+
# One extra row was fetched to detect a next page without COUNT
|
|
1529
|
+
has_more = fetched > items_per_page
|
|
1410
1530
|
|
|
1411
1531
|
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
|
1412
1532
|
|
|
@@ -1543,18 +1663,30 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1543
1663
|
q = q.options(*resolved)
|
|
1544
1664
|
|
|
1545
1665
|
# Cursor column is always the primary sort; reverse direction for prev traversal
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1666
|
+
cursor_clause = (
|
|
1667
|
+
cursor_column.desc()
|
|
1668
|
+
if direction is _CursorDirection.PREV
|
|
1669
|
+
else cursor_column
|
|
1670
|
+
)
|
|
1671
|
+
order_clauses: list[Any] = [cursor_clause]
|
|
1550
1672
|
if order_by is not None:
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1673
|
+
order_clauses.append(order_by)
|
|
1674
|
+
q = q.order_by(*order_clauses)
|
|
1675
|
+
|
|
1676
|
+
# One extra row detects whether another page exists in this direction.
|
|
1677
|
+
# Under a to-many join that extra row may be a duplicate of one already
|
|
1678
|
+
# on the page, which reads as "no next page" and ends traversal early.
|
|
1679
|
+
if _fans_out(search_joins, order_joins):
|
|
1680
|
+
raw_items = await cls._page_entities(
|
|
1681
|
+
session,
|
|
1682
|
+
q,
|
|
1683
|
+
order_clauses=order_clauses,
|
|
1684
|
+
limit=items_per_page + 1,
|
|
1685
|
+
load_options=load_options,
|
|
1686
|
+
)
|
|
1687
|
+
else:
|
|
1688
|
+
result = await session.execute(q.limit(items_per_page + 1))
|
|
1689
|
+
raw_items = cast(list[ModelType], result.unique().scalars().all())
|
|
1558
1690
|
has_more = len(raw_items) > items_per_page
|
|
1559
1691
|
items_page = raw_items[:items_per_page]
|
|
1560
1692
|
|
|
@@ -160,9 +160,11 @@ def build_search_filters(
|
|
|
160
160
|
column = field
|
|
161
161
|
|
|
162
162
|
# Build the filter (cast to String only when needed, to preserve
|
|
163
|
-
# pg_trgm GIN index usability on already-String columns)
|
|
163
|
+
# pg_trgm GIN index usability on already-String columns).
|
|
164
164
|
column_as_string = (
|
|
165
|
-
column
|
|
165
|
+
column
|
|
166
|
+
if isinstance(column.type, String) and not isinstance(column.type, Enum)
|
|
167
|
+
else column.cast(String)
|
|
166
168
|
)
|
|
167
169
|
if config.case_sensitive:
|
|
168
170
|
filters.append(column_as_string.like(f"%{query}%"))
|
|
@@ -66,10 +66,8 @@ class _CommitOnResponseMiddleware:
|
|
|
66
66
|
|
|
67
67
|
async def send_wrapper(message: Message) -> None:
|
|
68
68
|
if message["type"] == "http.response.start":
|
|
69
|
-
# ``scope["state"]`` is the same dict ``request.state`` writes
|
|
70
|
-
# to, so this is the session stashed by the dependency.
|
|
71
69
|
state = scope.get("state")
|
|
72
|
-
session = state.
|
|
70
|
+
session = state.pop(self.state_attr, None) if state else None
|
|
73
71
|
if session is not None and session.in_transaction():
|
|
74
72
|
await session.commit()
|
|
75
73
|
await send(message)
|
|
@@ -158,7 +156,6 @@ class Database:
|
|
|
158
156
|
# Private, per-instance state attribute; cannot collide with another
|
|
159
157
|
# Database or be mismatched against the middleware.
|
|
160
158
|
self._state_attr = f"_ft_db_session_{id(self):x}"
|
|
161
|
-
self._middleware_installed = False
|
|
162
159
|
self._disposed = False
|
|
163
160
|
|
|
164
161
|
async def _dispose(self) -> None:
|
|
@@ -206,7 +203,6 @@ class Database:
|
|
|
206
203
|
```
|
|
207
204
|
"""
|
|
208
205
|
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
|
209
|
-
self._middleware_installed = True
|
|
210
206
|
|
|
211
207
|
inner_lifespan = app.router.lifespan_context
|
|
212
208
|
|
|
@@ -243,10 +239,17 @@ class Database:
|
|
|
243
239
|
return await UserCrud.get(session, [User.id == user_id])
|
|
244
240
|
```
|
|
245
241
|
"""
|
|
242
|
+
borrowed = getattr(request.state, self._state_attr, None)
|
|
243
|
+
if borrowed is not None:
|
|
244
|
+
yield borrowed
|
|
245
|
+
return
|
|
246
246
|
async with self._open() as session:
|
|
247
247
|
setattr(request.state, self._state_attr, session)
|
|
248
248
|
yield session
|
|
249
|
-
if
|
|
249
|
+
if (
|
|
250
|
+
getattr(request.state, self._state_attr, None) is session
|
|
251
|
+
and session.in_transaction()
|
|
252
|
+
):
|
|
250
253
|
await session.commit()
|
|
251
254
|
|
|
252
255
|
@asynccontextmanager
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
4
|
import typing
|
|
5
|
-
from collections.abc import Callable
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
6
|
from typing import Any, cast
|
|
7
7
|
|
|
8
8
|
from fastapi import Depends
|
|
9
9
|
from fastapi.params import Depends as DependsClass
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.sql.base import ExecutableOption
|
|
11
12
|
|
|
12
|
-
from .crud import CrudFactory
|
|
13
|
+
from .crud import AsyncCrud, CrudFactory
|
|
13
14
|
from .types import ModelType, SessionDependency
|
|
14
15
|
|
|
15
16
|
__all__ = ["BodyDependency", "PathDependency"]
|
|
@@ -24,12 +25,59 @@ def _unwrap_session_dep(session_dep: SessionDependency) -> Callable[..., Any]:
|
|
|
24
25
|
return session_dep
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
def _fetch_dependency(
|
|
29
|
+
model: type[ModelType],
|
|
30
|
+
field: Any,
|
|
31
|
+
*,
|
|
32
|
+
session_dep: SessionDependency,
|
|
33
|
+
param_name: str,
|
|
34
|
+
crud: type[AsyncCrud[ModelType]] | None,
|
|
35
|
+
load_options: Sequence[ExecutableOption] | None,
|
|
36
|
+
) -> ModelType:
|
|
37
|
+
"""Build a Depends() that fetches one row by ``field == <param_name>``."""
|
|
38
|
+
session_callable = _unwrap_session_dep(session_dep)
|
|
39
|
+
if crud is not None and crud.model is not model:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"crud is bound to {crud.model.__name__}, not {model.__name__}"
|
|
42
|
+
)
|
|
43
|
+
crud = crud or CrudFactory(model)
|
|
44
|
+
|
|
45
|
+
# `session` has no default here: the __signature__ override below is what
|
|
46
|
+
# FastAPI reads, and it always passes `session` explicitly.
|
|
47
|
+
async def dependency(session: AsyncSession, **kwargs: Any) -> ModelType:
|
|
48
|
+
return await crud.get(
|
|
49
|
+
session,
|
|
50
|
+
filters=[field == kwargs[param_name]],
|
|
51
|
+
load_options=load_options,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
|
55
|
+
parameters=[
|
|
56
|
+
inspect.Parameter(
|
|
57
|
+
param_name,
|
|
58
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
59
|
+
annotation=field.type.python_type,
|
|
60
|
+
),
|
|
61
|
+
inspect.Parameter(
|
|
62
|
+
"session",
|
|
63
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
64
|
+
annotation=AsyncSession,
|
|
65
|
+
default=Depends(session_callable),
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
|
71
|
+
|
|
72
|
+
|
|
27
73
|
def PathDependency(
|
|
28
74
|
model: type[ModelType],
|
|
29
75
|
field: Any,
|
|
30
76
|
*,
|
|
31
77
|
session_dep: SessionDependency,
|
|
32
78
|
param_name: str | None = None,
|
|
79
|
+
crud: type[AsyncCrud[ModelType]] | None = None,
|
|
80
|
+
load_options: Sequence[ExecutableOption] | None = None,
|
|
33
81
|
) -> ModelType:
|
|
34
82
|
"""Create a dependency that fetches a DB object from a path parameter.
|
|
35
83
|
|
|
@@ -38,6 +86,10 @@ def PathDependency(
|
|
|
38
86
|
field: Model field to filter by (e.g., User.id)
|
|
39
87
|
session_dep: Session dependency function (e.g., get_db)
|
|
40
88
|
param_name: Path parameter name (defaults to model_field, e.g., user_id)
|
|
89
|
+
crud: Existing CRUD class to fetch with, so its ``default_load_options``
|
|
90
|
+
apply. Defaults to a bare ``CrudFactory(model)``.
|
|
91
|
+
load_options: SQLAlchemy loader options for the fetch. Overrides the CRUD's
|
|
92
|
+
``default_load_options`` entirely rather than merging with them.
|
|
41
93
|
|
|
42
94
|
Returns:
|
|
43
95
|
A Depends() instance that resolves to the model instance
|
|
@@ -55,36 +107,14 @@ def PathDependency(
|
|
|
55
107
|
): ...
|
|
56
108
|
```
|
|
57
109
|
"""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
110
|
+
return _fetch_dependency(
|
|
111
|
+
model,
|
|
112
|
+
field,
|
|
113
|
+
session_dep=session_dep,
|
|
114
|
+
param_name=param_name or f"{model.__name__.lower()}_{field.key}",
|
|
115
|
+
crud=crud,
|
|
116
|
+
load_options=load_options,
|
|
64
117
|
)
|
|
65
|
-
python_type = field.type.python_type
|
|
66
|
-
|
|
67
|
-
async def dependency(
|
|
68
|
-
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
|
69
|
-
) -> ModelType:
|
|
70
|
-
value = kwargs[name]
|
|
71
|
-
return await crud.get(session, filters=[field == value])
|
|
72
|
-
|
|
73
|
-
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
|
74
|
-
parameters=[
|
|
75
|
-
inspect.Parameter(
|
|
76
|
-
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
|
77
|
-
),
|
|
78
|
-
inspect.Parameter(
|
|
79
|
-
"session",
|
|
80
|
-
inspect.Parameter.KEYWORD_ONLY,
|
|
81
|
-
annotation=AsyncSession,
|
|
82
|
-
default=Depends(session_callable),
|
|
83
|
-
),
|
|
84
|
-
]
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
|
88
118
|
|
|
89
119
|
|
|
90
120
|
def BodyDependency(
|
|
@@ -93,6 +123,8 @@ def BodyDependency(
|
|
|
93
123
|
*,
|
|
94
124
|
session_dep: SessionDependency,
|
|
95
125
|
body_field: str,
|
|
126
|
+
crud: type[AsyncCrud[ModelType]] | None = None,
|
|
127
|
+
load_options: Sequence[ExecutableOption] | None = None,
|
|
96
128
|
) -> ModelType:
|
|
97
129
|
"""Create a dependency that fetches a DB object from a body field.
|
|
98
130
|
|
|
@@ -101,6 +133,10 @@ def BodyDependency(
|
|
|
101
133
|
field: Model field to filter by (e.g., User.id)
|
|
102
134
|
session_dep: Session dependency function (e.g., get_db)
|
|
103
135
|
body_field: Name of the field in the request body
|
|
136
|
+
crud: Existing CRUD class to fetch with, so its ``default_load_options``
|
|
137
|
+
apply. Defaults to a bare ``CrudFactory(model)``.
|
|
138
|
+
load_options: SQLAlchemy loader options for the fetch. Overrides the CRUD's
|
|
139
|
+
``default_load_options`` entirely rather than merging with them.
|
|
104
140
|
|
|
105
141
|
Returns:
|
|
106
142
|
A Depends() instance that resolves to the model instance
|
|
@@ -120,28 +156,11 @@ def BodyDependency(
|
|
|
120
156
|
): ...
|
|
121
157
|
```
|
|
122
158
|
"""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
value = kwargs[body_field]
|
|
131
|
-
return await crud.get(session, filters=[field == value])
|
|
132
|
-
|
|
133
|
-
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
|
134
|
-
parameters=[
|
|
135
|
-
inspect.Parameter(
|
|
136
|
-
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
|
137
|
-
),
|
|
138
|
-
inspect.Parameter(
|
|
139
|
-
"session",
|
|
140
|
-
inspect.Parameter.KEYWORD_ONLY,
|
|
141
|
-
annotation=AsyncSession,
|
|
142
|
-
default=Depends(session_callable),
|
|
143
|
-
),
|
|
144
|
-
]
|
|
159
|
+
return _fetch_dependency(
|
|
160
|
+
model,
|
|
161
|
+
field,
|
|
162
|
+
session_dep=session_dep,
|
|
163
|
+
param_name=body_field,
|
|
164
|
+
crud=crud,
|
|
165
|
+
load_options=load_options,
|
|
145
166
|
)
|
|
146
|
-
|
|
147
|
-
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
from sqlalchemy import event, select, tuple_
|
|
9
9
|
from sqlalchemy import inspect as sa_inspect
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
+
from sqlalchemy.orm import selectinload
|
|
11
12
|
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
|
12
13
|
|
|
13
14
|
from ..logger import get_logger
|
|
@@ -189,17 +190,44 @@ async def _invoke_callback(
|
|
|
189
190
|
await result
|
|
190
191
|
|
|
191
192
|
|
|
193
|
+
def _loaded_relationships(obj: Any) -> set[str]:
|
|
194
|
+
"""Relationship keys currently loaded on *obj*."""
|
|
195
|
+
state = sa_inspect(obj)
|
|
196
|
+
unloaded = state.unloaded
|
|
197
|
+
return {
|
|
198
|
+
rel.key
|
|
199
|
+
for rel in state.mapper.relationships
|
|
200
|
+
if rel.key not in unloaded and rel.lazy not in ("dynamic", "write_only")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _snapshot_loaded_relationships(session: Any) -> dict[int, set[str]]:
|
|
205
|
+
"""Record loaded relationships for the tracked objects, keyed by ``id``."""
|
|
206
|
+
objs = list(session.info.get(_SESSION_CREATES, []))
|
|
207
|
+
objs += [obj for obj, _ in session.info.get(_SESSION_UPDATES, {}).values()]
|
|
208
|
+
return {id(obj): _loaded_relationships(obj) for obj in objs}
|
|
209
|
+
|
|
210
|
+
|
|
192
211
|
async def _batch_reload(
|
|
193
|
-
session: AsyncSession,
|
|
212
|
+
session: AsyncSession,
|
|
213
|
+
model: type,
|
|
214
|
+
objs: list[Any],
|
|
215
|
+
preloaded: dict[int, set[str]],
|
|
194
216
|
) -> None:
|
|
195
|
-
"""Re-populate all rows of *model*
|
|
217
|
+
"""Re-populate all rows of *model* in one round trip."""
|
|
196
218
|
pk_cols = sa_inspect(model, raiseerr=True).primary_key
|
|
219
|
+
pk_tuples = [sa_inspect(obj).key[1] for obj in objs]
|
|
197
220
|
where = (
|
|
198
221
|
pk_cols[0].in_([pk[0] for pk in pk_tuples])
|
|
199
222
|
if len(pk_cols) == 1
|
|
200
223
|
else tuple_(*pk_cols).in_(pk_tuples)
|
|
201
224
|
)
|
|
202
225
|
q = select(model).where(where).execution_options(populate_existing=True)
|
|
226
|
+
loaded: set[str] = set()
|
|
227
|
+
for obj in objs:
|
|
228
|
+
loaded |= preloaded.get(id(obj), set())
|
|
229
|
+
if loaded:
|
|
230
|
+
q = q.options(*(selectinload(getattr(model, key)) for key in loaded))
|
|
203
231
|
await session.execute(q)
|
|
204
232
|
|
|
205
233
|
|
|
@@ -207,6 +235,7 @@ class EventSession(AsyncSession):
|
|
|
207
235
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
|
208
236
|
|
|
209
237
|
async def commit(self) -> None:
|
|
238
|
+
preloaded = _snapshot_loaded_relationships(self)
|
|
210
239
|
await super().commit()
|
|
211
240
|
|
|
212
241
|
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
|
@@ -249,25 +278,25 @@ class EventSession(AsyncSession):
|
|
|
249
278
|
# session.get() per object.
|
|
250
279
|
create_items: list[Any] = []
|
|
251
280
|
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
|
|
252
|
-
|
|
281
|
+
objs_by_type: dict[type, list[Any]] = {}
|
|
253
282
|
|
|
254
283
|
for obj in creates:
|
|
255
284
|
state = sa_inspect(obj, raiseerr=False)
|
|
256
285
|
if state is None or state.detached or state.transient: # pragma: no cover
|
|
257
286
|
continue
|
|
258
287
|
create_items.append(obj)
|
|
259
|
-
|
|
288
|
+
objs_by_type.setdefault(type(obj), []).append(obj)
|
|
260
289
|
|
|
261
290
|
for obj, changes in field_changes.values():
|
|
262
291
|
state = sa_inspect(obj, raiseerr=False)
|
|
263
292
|
if state is None or state.detached or state.transient: # pragma: no cover
|
|
264
293
|
continue
|
|
265
294
|
update_items.append((obj, changes))
|
|
266
|
-
|
|
295
|
+
objs_by_type.setdefault(type(obj), []).append(obj)
|
|
267
296
|
|
|
268
|
-
for model,
|
|
297
|
+
for model, objs in objs_by_type.items():
|
|
269
298
|
try:
|
|
270
|
-
await _batch_reload(self, model,
|
|
299
|
+
await _batch_reload(self, model, objs, preloaded)
|
|
271
300
|
except Exception as exc:
|
|
272
301
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
|
273
302
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.0 → fastapi_toolsets-5.1.1}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|