sqlmodel-ext 0.3.4__tar.gz → 0.4.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.
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/PKG-INFO +80 -24
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/README.md +79 -23
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/README_zh.md +18 -5
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/index.md +1 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/release-connection-during-long-io.md +21 -21
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/decorators.md +8 -6
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/field-types.md +275 -220
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/index.md +1 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/01-getting-started.md +174 -174
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/index.md +32 -32
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/index.md +1 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/release-connection-during-long-io.md +21 -21
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/decorators.md +8 -6
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/field-types.md +275 -220
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/index.md +1 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/01-getting-started.md +174 -174
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/index.md +32 -32
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/pyproject.toml +1 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/__init__.py +42 -3
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/base.py +257 -9
- sqlmodel_ext-0.4.0/src/sqlmodel_ext/field_types/__init__.py +508 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/__init__.py +1 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/cached_table.py +276 -239
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/polymorphic.py +118 -2
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/table.py +35 -13
- sqlmodel_ext-0.4.0/src/sqlmodel_ext/session.py +105 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/_models.py +22 -0
- sqlmodel_ext-0.4.0/tests/test_all_fields_optional_constraints.py +76 -0
- sqlmodel_ext-0.4.0/tests/test_array_explicit_field_sa_type.py +131 -0
- sqlmodel_ext-0.4.0/tests/test_decimal_types.py +256 -0
- sqlmodel_ext-0.4.0/tests/test_deferred_index.py +50 -0
- sqlmodel_ext-0.4.0/tests/test_enhanced_session.py +87 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_smoke_imports.py +41 -1
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/uv.lock +1 -1
- sqlmodel_ext-0.3.4/src/sqlmodel_ext/field_types/__init__.py +0 -195
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/.github/workflows/ci.yml +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/.gitignore +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/LICENSE +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/config.mts +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/en.mts +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/theme/index.ts +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/theme/style/custom.css +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/zh.mts +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/cached-table.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/cascade-delete-semantics.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/crud-pipeline.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/metaclass.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/polymorphic-internals.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/prerequisites.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/relation-preload.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/cached-table.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/crud.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/field-types.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/pagination.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/polymorphic.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/relation-preload.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/cache-queries.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/configure-cascade-delete.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/define-jti-models.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/define-sti-models.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/handle-concurrent-updates.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/integrate-with-fastapi.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/paginate-a-list-endpoint.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/prevent-missing-greenlet.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/cached-table.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/crud.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/metaclass.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/polymorphic.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/prerequisites.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/relation-preload.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/base-classes.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/crud-methods.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/mixins.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/pagination-types.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/02-building-a-blog-api.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/03-adding-redis-cache.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/cached-table.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/cascade-delete-semantics.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/crud-pipeline.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/metaclass.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/polymorphic-internals.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/prerequisites.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/relation-preload.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/cached-table.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/crud.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/field-types.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/pagination.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/polymorphic.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/relation-preload.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/cache-queries.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/configure-cascade-delete.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/define-jti-models.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/define-sti-models.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/handle-concurrent-updates.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/integrate-with-fastapi.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/paginate-a-list-endpoint.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/prevent-missing-greenlet.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/cached-table.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/crud.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/index.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/metaclass.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/optimistic-lock.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/polymorphic.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/prerequisites.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/relation-load-checker.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/relation-preload.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/base-classes.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/crud-methods.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/mixins.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/pagination-types.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/02-building-a-blog-api.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/03-adding-redis-cache.md +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/examples/consumer_integration/run.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/package.json +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/pnpm-lock.yaml +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_compat.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_exceptions.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_sa_type.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_utils.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/_internal/__init__.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/_internal/path.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/_ssrf.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/__init__.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/__init__.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/array.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/exceptions.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/ip_address.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/mixins/__init__.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/mixins/module_name_mixin.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/url.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/info_response.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/optimistic_lock.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/relation_preload.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/pagination.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/relation_load_checker.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/__init__.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/conftest.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_annotated_default_factory_inheritance.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_basic_crud.py +0 -0
- {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_sti_default_isolation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlmodel-ext
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphic inheritance, optimistic locking, relation preloading, and reusable field types.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Foxerine/sqlmodel-ext
|
|
6
6
|
Project-URL: Repository, https://github.com/Foxerine/sqlmodel-ext
|
|
@@ -170,10 +170,12 @@ async def demo(session: AsyncSession):
|
|
|
170
170
|
A complete REST API -- models, DTOs, and five endpoints:
|
|
171
171
|
|
|
172
172
|
```python
|
|
173
|
+
from collections.abc import AsyncGenerator
|
|
173
174
|
from typing import Annotated
|
|
174
175
|
from uuid import UUID
|
|
175
176
|
|
|
176
177
|
from fastapi import APIRouter, Depends
|
|
178
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
177
179
|
from sqlmodel import Field
|
|
178
180
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
179
181
|
from sqlmodel_ext import (
|
|
@@ -181,17 +183,37 @@ from sqlmodel_ext import (
|
|
|
181
183
|
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin,
|
|
182
184
|
)
|
|
183
185
|
|
|
184
|
-
# ──
|
|
186
|
+
# ── Dependency-injection layer: declare once (e.g. in a shared
|
|
187
|
+
# deps module), every router imports the type aliases ────────────
|
|
188
|
+
|
|
189
|
+
_engine = create_async_engine("postgresql+asyncpg://localhost/app")
|
|
190
|
+
_Session = async_sessionmaker(_engine, expire_on_commit=True)
|
|
191
|
+
|
|
192
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
193
|
+
async with _Session() as session:
|
|
194
|
+
yield session
|
|
185
195
|
|
|
186
196
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
187
|
-
|
|
197
|
+
"""Request-scoped AsyncSession. The single way endpoints touch the DB."""
|
|
188
198
|
|
|
189
|
-
#
|
|
199
|
+
# TableViewRequest is a Pydantic model → FastAPI binds its fields as
|
|
200
|
+
# query params automatically. No hand-written offset/limit/order plumbing.
|
|
201
|
+
TableViewRequestDep = Annotated[TableViewRequest, Depends()]
|
|
202
|
+
|
|
203
|
+
async def get_current_user(session: SessionDep) -> "User":
|
|
204
|
+
... # decode the bearer token, load the user — your auth, unchanged
|
|
205
|
+
|
|
206
|
+
CurrentUserDep = Annotated["User", Depends(get_current_user)]
|
|
207
|
+
|
|
208
|
+
# ── Models: the DTO ladder (Base → Create → Update → Response) ─────
|
|
190
209
|
|
|
191
210
|
class ArticleBase(SQLModelBase):
|
|
192
211
|
title: Str64
|
|
212
|
+
"""Article title"""
|
|
193
213
|
body: Text10K
|
|
214
|
+
"""Article body (max 10k chars)"""
|
|
194
215
|
is_published: bool = False
|
|
216
|
+
"""Whether the article is publicly visible"""
|
|
195
217
|
|
|
196
218
|
class Article(ArticleBase, UUIDTableBaseMixin, table=True):
|
|
197
219
|
author_id: UUID = Field(foreign_key='user.id')
|
|
@@ -199,14 +221,24 @@ class Article(ArticleBase, UUIDTableBaseMixin, table=True):
|
|
|
199
221
|
class ArticleCreate(ArticleBase):
|
|
200
222
|
pass
|
|
201
223
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
224
|
+
# all_fields_optional flips every inherited field to ``T | None = None``
|
|
225
|
+
# while preserving its Annotated constraints AND attribute docstrings —
|
|
226
|
+
# no hand-maintained per-field overrides.
|
|
227
|
+
class ArticleUpdate(ArticleBase, all_fields_optional=True):
|
|
228
|
+
pass
|
|
206
229
|
|
|
207
230
|
class ArticleResponse(ArticleBase, UUIDIdDatetimeInfoMixin):
|
|
208
231
|
author_id: UUID
|
|
209
232
|
|
|
233
|
+
# ── Resource-as-dependency: wrap "load by id or 404" once, then the
|
|
234
|
+
# fetched ORM instance is just another injected parameter ────────
|
|
235
|
+
|
|
236
|
+
async def get_article(session: SessionDep, article_id: UUID) -> Article:
|
|
237
|
+
return await Article.get_exist_one(session, article_id)
|
|
238
|
+
|
|
239
|
+
ArticleDep = Annotated[Article, Depends(get_article)]
|
|
240
|
+
"""The Article for {article_id}, or 404 — fetched before the handler runs."""
|
|
241
|
+
|
|
210
242
|
# ── Endpoints ─────────────────────────────────────────────────────
|
|
211
243
|
|
|
212
244
|
router = APIRouter(prefix="/articles", tags=["articles"])
|
|
@@ -220,7 +252,7 @@ async def create_article(
|
|
|
220
252
|
|
|
221
253
|
@router.get("", response_model=ListResponse[ArticleResponse])
|
|
222
254
|
async def list_articles(
|
|
223
|
-
session: SessionDep, table_view:
|
|
255
|
+
session: SessionDep, table_view: TableViewRequestDep,
|
|
224
256
|
) -> ListResponse[Article]:
|
|
225
257
|
return await Article.get_with_count(
|
|
226
258
|
session,
|
|
@@ -228,24 +260,24 @@ async def list_articles(
|
|
|
228
260
|
table_view=table_view,
|
|
229
261
|
)
|
|
230
262
|
|
|
263
|
+
# article: ArticleDep — the 404 + fetch is the dependency's job, so the
|
|
264
|
+
# single-resource handlers carry no lookup boilerplate at all.
|
|
231
265
|
@router.get("/{article_id}", response_model=ArticleResponse)
|
|
232
|
-
async def
|
|
233
|
-
return
|
|
266
|
+
async def get_article_detail(article: ArticleDep) -> Article:
|
|
267
|
+
return article
|
|
234
268
|
|
|
235
269
|
@router.patch("/{article_id}", response_model=ArticleResponse)
|
|
236
270
|
async def update_article(
|
|
237
|
-
session: SessionDep,
|
|
271
|
+
session: SessionDep, article: ArticleDep, data: ArticleUpdate,
|
|
238
272
|
) -> Article:
|
|
239
|
-
article = await Article.get_exist_one(session, article_id)
|
|
240
273
|
return await article.update(session, data)
|
|
241
274
|
|
|
242
275
|
@router.delete("/{article_id}")
|
|
243
|
-
async def delete_article(session: SessionDep,
|
|
244
|
-
article = await Article.get_exist_one(session, article_id)
|
|
276
|
+
async def delete_article(session: SessionDep, article: ArticleDep) -> None:
|
|
245
277
|
await Article.delete(session, article)
|
|
246
278
|
```
|
|
247
279
|
|
|
248
|
-
No manual SQL, no hand-written pagination logic, no boilerplate session management. The `
|
|
280
|
+
No manual SQL, no hand-written pagination logic, no boilerplate session management. The `TableViewRequestDep` gives clients `offset`, `limit`, `desc`, `order`, and four time filters out of the box.
|
|
249
281
|
|
|
250
282
|
**What the client gets from `GET /articles?offset=0&limit=10&desc=true`:**
|
|
251
283
|
|
|
@@ -440,7 +472,7 @@ class PushNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMix
|
|
|
440
472
|
|
|
441
473
|
@router.get("/notifications", response_model=ListResponse[NotificationBase])
|
|
442
474
|
async def list_notifications(
|
|
443
|
-
session: SessionDep, user: CurrentUserDep, table_view:
|
|
475
|
+
session: SessionDep, user: CurrentUserDep, table_view: TableViewRequestDep,
|
|
444
476
|
) -> ListResponse[Notification]:
|
|
445
477
|
return await Notification.get_with_count(
|
|
446
478
|
session,
|
|
@@ -1370,18 +1402,42 @@ class UserUpdateRequest(UserBase, all_fields_optional=True):
|
|
|
1370
1402
|
|
|
1371
1403
|
---
|
|
1372
1404
|
|
|
1373
|
-
###
|
|
1405
|
+
### Enhanced AsyncSession (cache-aware commit/reset/refresh)
|
|
1374
1406
|
|
|
1375
|
-
|
|
1407
|
+
`sqlmodel_ext.AsyncSession` subclasses sqlmodel's `AsyncSession` and makes cache
|
|
1408
|
+
correctness automatic. Point your session factory at it:
|
|
1376
1409
|
|
|
1377
1410
|
```python
|
|
1378
|
-
from
|
|
1411
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
1412
|
+
from sqlmodel_ext import AsyncSession
|
|
1379
1413
|
|
|
1380
|
-
|
|
1381
|
-
await safe_reset(session)
|
|
1414
|
+
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=True)
|
|
1382
1415
|
```
|
|
1383
1416
|
|
|
1384
|
-
|
|
1417
|
+
What it does:
|
|
1418
|
+
|
|
1419
|
+
- **`commit()`** auto-registers every `CachedTableBaseMixin` mutation in the
|
|
1420
|
+
session (including bare `session.add()` / attribute mutation /
|
|
1421
|
+
`session.delete()` paths) and synchronously invalidates the Redis cache after
|
|
1422
|
+
commit — no more "forgot the cache-aware commit" stale windows.
|
|
1423
|
+
- **`reset()`** releases the connection and clears FOR UPDATE lock tracking +
|
|
1424
|
+
cache-invalidation tracking from `session.info` (replaces the removed
|
|
1425
|
+
`safe_reset()` helper — just call `await session.reset()`).
|
|
1426
|
+
- **`refresh()`** routes whole-object refreshes of cached models through
|
|
1427
|
+
`Model.get()` (Redis hit + STI polymorphic columns) instead of a raw DB query.
|
|
1428
|
+
- **`execute()`** warns when a bare `UPDATE`/`DELETE` hits a cached table
|
|
1429
|
+
without registered invalidation.
|
|
1430
|
+
|
|
1431
|
+
Models that don't use `CachedTableBaseMixin` are unaffected — every hook
|
|
1432
|
+
degrades to upstream behavior.
|
|
1433
|
+
|
|
1434
|
+
> **Breaking change in 0.4.0**: `safe_reset()` and
|
|
1435
|
+
> `CachedTableBaseMixin.cache_aware_commit()` were removed. CRUD methods no
|
|
1436
|
+
> longer invalidate the cache themselves; invalidation now happens inside
|
|
1437
|
+
> `AsyncSession.commit()`. If you use `CachedTableBaseMixin`, you **must**
|
|
1438
|
+
> construct sessions with `class_=sqlmodel_ext.AsyncSession` (plain sessions
|
|
1439
|
+
> fall back to the fire-and-forget `after_commit` compensation hook, which
|
|
1440
|
+
> reintroduces a brief stale-cache window).
|
|
1385
1441
|
|
|
1386
1442
|
---
|
|
1387
1443
|
|
|
@@ -1398,7 +1454,7 @@ sqlmodel_ext/
|
|
|
1398
1454
|
pagination.py # ListResponse, TimeFilterRequest, PaginationRequest, TableViewRequest
|
|
1399
1455
|
mixins/
|
|
1400
1456
|
__init__.py # Mixin re-exports
|
|
1401
|
-
table.py # TableBaseMixin, UUIDTableBaseMixin
|
|
1457
|
+
table.py # TableBaseMixin, UUIDTableBaseMixin (async CRUD)
|
|
1402
1458
|
cached_table.py # CachedTableBaseMixin (two-tier Redis cache with version invalidation)
|
|
1403
1459
|
polymorphic.py # PolymorphicBaseMixin, AutoPolymorphicIdentityMixin, create_subclass_id_mixin
|
|
1404
1460
|
optimistic_lock.py # OptimisticLockMixin, OptimisticLockError
|
|
@@ -125,10 +125,12 @@ async def demo(session: AsyncSession):
|
|
|
125
125
|
A complete REST API -- models, DTOs, and five endpoints:
|
|
126
126
|
|
|
127
127
|
```python
|
|
128
|
+
from collections.abc import AsyncGenerator
|
|
128
129
|
from typing import Annotated
|
|
129
130
|
from uuid import UUID
|
|
130
131
|
|
|
131
132
|
from fastapi import APIRouter, Depends
|
|
133
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
132
134
|
from sqlmodel import Field
|
|
133
135
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
134
136
|
from sqlmodel_ext import (
|
|
@@ -136,17 +138,37 @@ from sqlmodel_ext import (
|
|
|
136
138
|
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin,
|
|
137
139
|
)
|
|
138
140
|
|
|
139
|
-
# ──
|
|
141
|
+
# ── Dependency-injection layer: declare once (e.g. in a shared
|
|
142
|
+
# deps module), every router imports the type aliases ────────────
|
|
143
|
+
|
|
144
|
+
_engine = create_async_engine("postgresql+asyncpg://localhost/app")
|
|
145
|
+
_Session = async_sessionmaker(_engine, expire_on_commit=True)
|
|
146
|
+
|
|
147
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
148
|
+
async with _Session() as session:
|
|
149
|
+
yield session
|
|
140
150
|
|
|
141
151
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
142
|
-
|
|
152
|
+
"""Request-scoped AsyncSession. The single way endpoints touch the DB."""
|
|
143
153
|
|
|
144
|
-
#
|
|
154
|
+
# TableViewRequest is a Pydantic model → FastAPI binds its fields as
|
|
155
|
+
# query params automatically. No hand-written offset/limit/order plumbing.
|
|
156
|
+
TableViewRequestDep = Annotated[TableViewRequest, Depends()]
|
|
157
|
+
|
|
158
|
+
async def get_current_user(session: SessionDep) -> "User":
|
|
159
|
+
... # decode the bearer token, load the user — your auth, unchanged
|
|
160
|
+
|
|
161
|
+
CurrentUserDep = Annotated["User", Depends(get_current_user)]
|
|
162
|
+
|
|
163
|
+
# ── Models: the DTO ladder (Base → Create → Update → Response) ─────
|
|
145
164
|
|
|
146
165
|
class ArticleBase(SQLModelBase):
|
|
147
166
|
title: Str64
|
|
167
|
+
"""Article title"""
|
|
148
168
|
body: Text10K
|
|
169
|
+
"""Article body (max 10k chars)"""
|
|
149
170
|
is_published: bool = False
|
|
171
|
+
"""Whether the article is publicly visible"""
|
|
150
172
|
|
|
151
173
|
class Article(ArticleBase, UUIDTableBaseMixin, table=True):
|
|
152
174
|
author_id: UUID = Field(foreign_key='user.id')
|
|
@@ -154,14 +176,24 @@ class Article(ArticleBase, UUIDTableBaseMixin, table=True):
|
|
|
154
176
|
class ArticleCreate(ArticleBase):
|
|
155
177
|
pass
|
|
156
178
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
179
|
+
# all_fields_optional flips every inherited field to ``T | None = None``
|
|
180
|
+
# while preserving its Annotated constraints AND attribute docstrings —
|
|
181
|
+
# no hand-maintained per-field overrides.
|
|
182
|
+
class ArticleUpdate(ArticleBase, all_fields_optional=True):
|
|
183
|
+
pass
|
|
161
184
|
|
|
162
185
|
class ArticleResponse(ArticleBase, UUIDIdDatetimeInfoMixin):
|
|
163
186
|
author_id: UUID
|
|
164
187
|
|
|
188
|
+
# ── Resource-as-dependency: wrap "load by id or 404" once, then the
|
|
189
|
+
# fetched ORM instance is just another injected parameter ────────
|
|
190
|
+
|
|
191
|
+
async def get_article(session: SessionDep, article_id: UUID) -> Article:
|
|
192
|
+
return await Article.get_exist_one(session, article_id)
|
|
193
|
+
|
|
194
|
+
ArticleDep = Annotated[Article, Depends(get_article)]
|
|
195
|
+
"""The Article for {article_id}, or 404 — fetched before the handler runs."""
|
|
196
|
+
|
|
165
197
|
# ── Endpoints ─────────────────────────────────────────────────────
|
|
166
198
|
|
|
167
199
|
router = APIRouter(prefix="/articles", tags=["articles"])
|
|
@@ -175,7 +207,7 @@ async def create_article(
|
|
|
175
207
|
|
|
176
208
|
@router.get("", response_model=ListResponse[ArticleResponse])
|
|
177
209
|
async def list_articles(
|
|
178
|
-
session: SessionDep, table_view:
|
|
210
|
+
session: SessionDep, table_view: TableViewRequestDep,
|
|
179
211
|
) -> ListResponse[Article]:
|
|
180
212
|
return await Article.get_with_count(
|
|
181
213
|
session,
|
|
@@ -183,24 +215,24 @@ async def list_articles(
|
|
|
183
215
|
table_view=table_view,
|
|
184
216
|
)
|
|
185
217
|
|
|
218
|
+
# article: ArticleDep — the 404 + fetch is the dependency's job, so the
|
|
219
|
+
# single-resource handlers carry no lookup boilerplate at all.
|
|
186
220
|
@router.get("/{article_id}", response_model=ArticleResponse)
|
|
187
|
-
async def
|
|
188
|
-
return
|
|
221
|
+
async def get_article_detail(article: ArticleDep) -> Article:
|
|
222
|
+
return article
|
|
189
223
|
|
|
190
224
|
@router.patch("/{article_id}", response_model=ArticleResponse)
|
|
191
225
|
async def update_article(
|
|
192
|
-
session: SessionDep,
|
|
226
|
+
session: SessionDep, article: ArticleDep, data: ArticleUpdate,
|
|
193
227
|
) -> Article:
|
|
194
|
-
article = await Article.get_exist_one(session, article_id)
|
|
195
228
|
return await article.update(session, data)
|
|
196
229
|
|
|
197
230
|
@router.delete("/{article_id}")
|
|
198
|
-
async def delete_article(session: SessionDep,
|
|
199
|
-
article = await Article.get_exist_one(session, article_id)
|
|
231
|
+
async def delete_article(session: SessionDep, article: ArticleDep) -> None:
|
|
200
232
|
await Article.delete(session, article)
|
|
201
233
|
```
|
|
202
234
|
|
|
203
|
-
No manual SQL, no hand-written pagination logic, no boilerplate session management. The `
|
|
235
|
+
No manual SQL, no hand-written pagination logic, no boilerplate session management. The `TableViewRequestDep` gives clients `offset`, `limit`, `desc`, `order`, and four time filters out of the box.
|
|
204
236
|
|
|
205
237
|
**What the client gets from `GET /articles?offset=0&limit=10&desc=true`:**
|
|
206
238
|
|
|
@@ -395,7 +427,7 @@ class PushNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMix
|
|
|
395
427
|
|
|
396
428
|
@router.get("/notifications", response_model=ListResponse[NotificationBase])
|
|
397
429
|
async def list_notifications(
|
|
398
|
-
session: SessionDep, user: CurrentUserDep, table_view:
|
|
430
|
+
session: SessionDep, user: CurrentUserDep, table_view: TableViewRequestDep,
|
|
399
431
|
) -> ListResponse[Notification]:
|
|
400
432
|
return await Notification.get_with_count(
|
|
401
433
|
session,
|
|
@@ -1325,18 +1357,42 @@ class UserUpdateRequest(UserBase, all_fields_optional=True):
|
|
|
1325
1357
|
|
|
1326
1358
|
---
|
|
1327
1359
|
|
|
1328
|
-
###
|
|
1360
|
+
### Enhanced AsyncSession (cache-aware commit/reset/refresh)
|
|
1329
1361
|
|
|
1330
|
-
|
|
1362
|
+
`sqlmodel_ext.AsyncSession` subclasses sqlmodel's `AsyncSession` and makes cache
|
|
1363
|
+
correctness automatic. Point your session factory at it:
|
|
1331
1364
|
|
|
1332
1365
|
```python
|
|
1333
|
-
from
|
|
1366
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
1367
|
+
from sqlmodel_ext import AsyncSession
|
|
1334
1368
|
|
|
1335
|
-
|
|
1336
|
-
await safe_reset(session)
|
|
1369
|
+
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=True)
|
|
1337
1370
|
```
|
|
1338
1371
|
|
|
1339
|
-
|
|
1372
|
+
What it does:
|
|
1373
|
+
|
|
1374
|
+
- **`commit()`** auto-registers every `CachedTableBaseMixin` mutation in the
|
|
1375
|
+
session (including bare `session.add()` / attribute mutation /
|
|
1376
|
+
`session.delete()` paths) and synchronously invalidates the Redis cache after
|
|
1377
|
+
commit — no more "forgot the cache-aware commit" stale windows.
|
|
1378
|
+
- **`reset()`** releases the connection and clears FOR UPDATE lock tracking +
|
|
1379
|
+
cache-invalidation tracking from `session.info` (replaces the removed
|
|
1380
|
+
`safe_reset()` helper — just call `await session.reset()`).
|
|
1381
|
+
- **`refresh()`** routes whole-object refreshes of cached models through
|
|
1382
|
+
`Model.get()` (Redis hit + STI polymorphic columns) instead of a raw DB query.
|
|
1383
|
+
- **`execute()`** warns when a bare `UPDATE`/`DELETE` hits a cached table
|
|
1384
|
+
without registered invalidation.
|
|
1385
|
+
|
|
1386
|
+
Models that don't use `CachedTableBaseMixin` are unaffected — every hook
|
|
1387
|
+
degrades to upstream behavior.
|
|
1388
|
+
|
|
1389
|
+
> **Breaking change in 0.4.0**: `safe_reset()` and
|
|
1390
|
+
> `CachedTableBaseMixin.cache_aware_commit()` were removed. CRUD methods no
|
|
1391
|
+
> longer invalidate the cache themselves; invalidation now happens inside
|
|
1392
|
+
> `AsyncSession.commit()`. If you use `CachedTableBaseMixin`, you **must**
|
|
1393
|
+
> construct sessions with `class_=sqlmodel_ext.AsyncSession` (plain sessions
|
|
1394
|
+
> fall back to the fire-and-forget `after_commit` compensation hook, which
|
|
1395
|
+
> reintroduces a brief stale-cache window).
|
|
1340
1396
|
|
|
1341
1397
|
---
|
|
1342
1398
|
|
|
@@ -1353,7 +1409,7 @@ sqlmodel_ext/
|
|
|
1353
1409
|
pagination.py # ListResponse, TimeFilterRequest, PaginationRequest, TableViewRequest
|
|
1354
1410
|
mixins/
|
|
1355
1411
|
__init__.py # Mixin re-exports
|
|
1356
|
-
table.py # TableBaseMixin, UUIDTableBaseMixin
|
|
1412
|
+
table.py # TableBaseMixin, UUIDTableBaseMixin (async CRUD)
|
|
1357
1413
|
cached_table.py # CachedTableBaseMixin (two-tier Redis cache with version invalidation)
|
|
1358
1414
|
polymorphic.py # PolymorphicBaseMixin, AutoPolymorphicIdentityMixin, create_subclass_id_mixin
|
|
1359
1415
|
optimistic_lock.py # OptimisticLockMixin, OptimisticLockError
|
|
@@ -1288,15 +1288,28 @@ class ArticleUpdateRequest(ArticleBase, all_fields_optional=True):
|
|
|
1288
1288
|
|
|
1289
1289
|
---
|
|
1290
1290
|
|
|
1291
|
-
###
|
|
1291
|
+
### 增强 AsyncSession(缓存感知 commit/reset/refresh)
|
|
1292
1292
|
|
|
1293
|
-
|
|
1293
|
+
`sqlmodel_ext.AsyncSession` 是 sqlmodel `AsyncSession` 的增强子类,把缓存正确性变为自动行为。把 session 工厂指向它:
|
|
1294
1294
|
|
|
1295
1295
|
```python
|
|
1296
|
-
from
|
|
1297
|
-
|
|
1296
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
1297
|
+
from sqlmodel_ext import AsyncSession
|
|
1298
|
+
|
|
1299
|
+
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=True)
|
|
1298
1300
|
```
|
|
1299
1301
|
|
|
1302
|
+
能力:
|
|
1303
|
+
|
|
1304
|
+
- **`commit()`** 自动登记 session 中所有 `CachedTableBaseMixin` 变更(含裸 `session.add()` / 改属性 / `session.delete()` 路径),commit 后同步失效 Redis 缓存——消除"忘记 cache-aware commit"的 stale 窗口。
|
|
1305
|
+
- **`reset()`** 释放连接并清理 `session.info` 中的 FOR UPDATE 锁跟踪 + 缓存失效跟踪(替代已删除的 `safe_reset()`——直接 `await session.reset()` 即可)。
|
|
1306
|
+
- **`refresh()`** 对缓存模型的整体刷新走 `Model.get()`(Redis 命中 + STI 多态列),而非裸查 DB。
|
|
1307
|
+
- **`execute()`** 对绕过缓存失效的裸 `UPDATE`/`DELETE` 打 WARNING。
|
|
1308
|
+
|
|
1309
|
+
不使用 `CachedTableBaseMixin` 的模型完全不受影响——所有钩子退化为上游行为。
|
|
1310
|
+
|
|
1311
|
+
> **0.4.0 Breaking change**:`safe_reset()` 与 `CachedTableBaseMixin.cache_aware_commit()` 已删除。CRUD 方法不再自行失效缓存,失效统一发生在 `AsyncSession.commit()` 内。使用 `CachedTableBaseMixin` 时**必须**以 `class_=sqlmodel_ext.AsyncSession` 构造 session(普通 session 会退化为 fire-and-forget 的 `after_commit` 补偿钩子,重新引入短暂的 stale 窗口)。
|
|
1312
|
+
|
|
1300
1313
|
---
|
|
1301
1314
|
|
|
1302
1315
|
## 架构
|
|
@@ -1312,7 +1325,7 @@ sqlmodel_ext/
|
|
|
1312
1325
|
pagination.py # ListResponse、TimeFilterRequest、PaginationRequest、TableViewRequest
|
|
1313
1326
|
mixins/
|
|
1314
1327
|
__init__.py # Mixin 重导出
|
|
1315
|
-
table.py # TableBaseMixin、UUIDTableBaseMixin
|
|
1328
|
+
table.py # TableBaseMixin、UUIDTableBaseMixin(异步 CRUD)
|
|
1316
1329
|
cached_table.py # CachedTableBaseMixin(双层 Redis 缓存 + 版本号失效)
|
|
1317
1330
|
polymorphic.py # PolymorphicBaseMixin、AutoPolymorphicIdentityMixin、create_subclass_id_mixin
|
|
1318
1331
|
optimistic_lock.py # OptimisticLockMixin、OptimisticLockError
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
- [Handle concurrent updates](./handle-concurrent-updates) — Use `OptimisticLockMixin` to prevent lost updates
|
|
22
22
|
- [Prevent MissingGreenlet errors](./prevent-missing-greenlet) — `@requires_relations` + `lazy='raise_on_sql'` + static analysis: three layers of defense
|
|
23
|
-
- [Release the DB connection during long I/O](./release-connection-during-long-io) — Use `
|
|
23
|
+
- [Release the DB connection during long I/O](./release-connection-during-long-io) — Use `session.reset()` to keep external I/O from exhausting the pool
|
|
24
24
|
|
|
25
25
|
### Performance
|
|
26
26
|
|
{sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/release-connection-during-long-io.md
RENAMED
|
@@ -28,11 +28,11 @@ async def get_file(session: SessionDep, s3: S3APIClientDep, file_id: UUID):
|
|
|
28
28
|
return file
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
## 2. Use `
|
|
31
|
+
## 2. Use `session.reset()` around the I/O block
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
from sqlmodel_ext import safe_reset
|
|
33
|
+
> Since 0.4.0 `safe_reset()` has been removed -- its responsibility moved into the enhanced `sqlmodel_ext.AsyncSession.reset()` (construct sessions with `async_sessionmaker(class_=AsyncSession)`). `reset()` releases the connection and also clears FOR UPDATE lock tracking and cache-invalidation tracking.
|
|
35
34
|
|
|
35
|
+
```python
|
|
36
36
|
@router.get("/files/{file_id}")
|
|
37
37
|
async def get_file(session: SessionDep, s3: S3APIClientDep, file_id: UUID):
|
|
38
38
|
file = await UserFile.get(session, UserFile.id == file_id)
|
|
@@ -42,7 +42,7 @@ async def get_file(session: SessionDep, s3: S3APIClientDep, file_id: UUID):
|
|
|
42
42
|
key = file.key
|
|
43
43
|
file_id = file.id
|
|
44
44
|
|
|
45
|
-
await
|
|
45
|
+
await session.reset() # ← release the DB connection // [!code ++]
|
|
46
46
|
|
|
47
47
|
# ↓ This block holds NO DB connection — concurrent requests can use the pool
|
|
48
48
|
data = await s3.download_file(bucket_id, key)
|
|
@@ -56,26 +56,26 @@ async def get_file(session: SessionDep, s3: S3APIClientDep, file_id: UUID):
|
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
**Key points**:
|
|
59
|
-
- `
|
|
59
|
+
- `session.reset()` releases the current transaction and connection, but the session object itself stays alive (it's not closed)
|
|
60
60
|
- Any subsequent `await Model.get/save` that issues SQL transparently checks out a new connection from the pool
|
|
61
61
|
- During the released window all 30 connections are idle (unless other requests use them) — concurrent requests **are not stalled**
|
|
62
62
|
|
|
63
|
-
## 3. Object state after `
|
|
63
|
+
## 3. Object state after `session.reset()`: detached but not expired
|
|
64
64
|
|
|
65
|
-
After `
|
|
65
|
+
After `session.reset()`, every ORM object in the session enters the **detached** state (no longer in `session.identity_map`), but **already-loaded scalar fields remain in `obj.__dict__`** — accessing them does not trigger a SQL query, so it does **not** raise `MissingGreenlet`.
|
|
66
66
|
|
|
67
67
|
```python
|
|
68
68
|
from sqlalchemy import inspect as sa_inspect
|
|
69
69
|
|
|
70
|
-
# Before
|
|
70
|
+
# Before reset:
|
|
71
71
|
file = await UserFile.get(session, UserFile.id == fid)
|
|
72
72
|
print(sa_inspect(file).detached) # False
|
|
73
73
|
print(sa_inspect(file).expired) # False
|
|
74
74
|
print(file.bucket_id) # OK, loaded
|
|
75
75
|
|
|
76
|
-
await
|
|
76
|
+
await session.reset()
|
|
77
77
|
|
|
78
|
-
# After
|
|
78
|
+
# After reset:
|
|
79
79
|
print(sa_inspect(file).detached) # True ← note // [!code highlight]
|
|
80
80
|
print(sa_inspect(file).expired) # False ← loaded fields are NOT expired // [!code highlight]
|
|
81
81
|
print(file.bucket_id) # OK! scalar field access is safe // [!code ++]
|
|
@@ -84,7 +84,7 @@ print(file.parent) # InvalidRequestError or MissingGreenlet // [!
|
|
|
84
84
|
|
|
85
85
|
### Safe-access rules
|
|
86
86
|
|
|
87
|
-
| Access | After
|
|
87
|
+
| Access | After session.reset() | Why |
|
|
88
88
|
|--------|-----------------|-----|
|
|
89
89
|
| Already-loaded scalar field | ✓ Safe | In `__dict__`, no DB query |
|
|
90
90
|
| Already-loaded relation (preloaded via `load=`) | ✓ Safe | Same |
|
|
@@ -93,7 +93,7 @@ print(file.parent) # InvalidRequestError or MissingGreenlet // [!
|
|
|
93
93
|
|
|
94
94
|
### Recommended style
|
|
95
95
|
|
|
96
|
-
When writing the code that runs **before** `
|
|
96
|
+
When writing the code that runs **before** `session.reset()`, extract any field you'll need later into local variables:
|
|
97
97
|
|
|
98
98
|
```python
|
|
99
99
|
# Recommended:
|
|
@@ -102,7 +102,7 @@ bucket_id = file.bucket_id
|
|
|
102
102
|
key = file.key
|
|
103
103
|
user_id = file.user_id
|
|
104
104
|
|
|
105
|
-
await
|
|
105
|
+
await session.reset()
|
|
106
106
|
|
|
107
107
|
# The code below uses local variables (file_id / bucket_id / ...),
|
|
108
108
|
# never touches file.X again.
|
|
@@ -112,10 +112,10 @@ This way, even if some field on the model becomes lazy-loaded in the future, the
|
|
|
112
112
|
|
|
113
113
|
## 4. You must re-query before writing
|
|
114
114
|
|
|
115
|
-
After `
|
|
115
|
+
After `session.reset()` the object is detached and **cannot be saved directly**. To write, re-query a fresh attached instance:
|
|
116
116
|
|
|
117
117
|
```python
|
|
118
|
-
await
|
|
118
|
+
await session.reset()
|
|
119
119
|
|
|
120
120
|
# Long I/O...
|
|
121
121
|
metadata = await fetch_external_metadata(...)
|
|
@@ -130,15 +130,15 @@ file.metadata = metadata
|
|
|
130
130
|
await file.save(session)
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
## 5. When to use
|
|
133
|
+
## 5. When to use session.reset() vs Taskiq dispatch
|
|
134
134
|
|
|
135
|
-
`
|
|
135
|
+
`session.reset()` fits when you **must** synchronously return a result to the client. If your business can tolerate async — dispatch a job, return immediately, let the client poll — use Taskiq instead and **don't do long I/O in the HTTP endpoint at all**.
|
|
136
136
|
|
|
137
137
|
```mermaid
|
|
138
138
|
flowchart TD
|
|
139
139
|
A["Need long I/O in the endpoint?"] --> B{Can the response wait until the next poll?}
|
|
140
140
|
B -- Yes --> C["Dispatch a Taskiq task<br/>return status=pending immediately"]
|
|
141
|
-
B -- No --> D["
|
|
141
|
+
B -- No --> D["session.reset() to release connection<br/>execute the long I/O synchronously"]
|
|
142
142
|
C --> E["Cleanest, holds zero connection time"]
|
|
143
143
|
D --> F["Pool is usable during the released window<br/>but still occupies a worker coroutine"]
|
|
144
144
|
```
|
|
@@ -147,7 +147,7 @@ flowchart TD
|
|
|
147
147
|
|----------|-------------|
|
|
148
148
|
| Polling for upload status | Taskiq (the polling client picks up the state change) |
|
|
149
149
|
| AI generation tasks (image/video) | Taskiq (the project's mainstream pattern) |
|
|
150
|
-
| Synchronous response that must contain the full result (e.g. PDF export) |
|
|
150
|
+
| Synchronous response that must contain the full result (e.g. PDF export) | session.reset() |
|
|
151
151
|
| WebSocket long connections | `session.close()` + per-message short-lived sessions |
|
|
152
152
|
|
|
153
153
|
## 6. Verification: did your endpoint actually release the connection?
|
|
@@ -169,13 +169,13 @@ def _on_checkin(conn, rec):
|
|
|
169
169
|
|
|
170
170
|
Trigger a request that hits the long-I/O path. You should see:
|
|
171
171
|
1. `checkout 1/N` — endpoint starts
|
|
172
|
-
2. `checkin 0/N` —
|
|
172
|
+
2. `checkin 0/N` — session.reset() fires
|
|
173
173
|
3. 30 seconds of external I/O with no checkout log in between
|
|
174
174
|
4. `checkout 1/N` — subsequent DB op
|
|
175
175
|
5. `checkin 0/N` — endpoint cleanup
|
|
176
176
|
|
|
177
177
|
## 7. Related
|
|
178
178
|
|
|
179
|
-
- [`
|
|
179
|
+
- [`session.reset()` API reference](/en/reference/decorators#session-reset)
|
|
180
180
|
- [Integrate with FastAPI](./integrate-with-fastapi)
|
|
181
181
|
- [Prevent MissingGreenlet](./prevent-missing-greenlet)
|
|
@@ -113,21 +113,23 @@ scope = cond(UserFile.user_id == current_user.id)
|
|
|
113
113
|
condition = scope & cond(UserFile.status == FileStatusEnum.uploaded)
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
## `
|
|
116
|
+
## `session.reset()` (enhanced AsyncSession) {#session-reset}
|
|
117
117
|
|
|
118
118
|
```python
|
|
119
|
-
from sqlmodel_ext import
|
|
119
|
+
from sqlmodel_ext import AsyncSession
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
**Signature
|
|
122
|
+
**Signature** (enhanced method on `sqlmodel_ext.session.AsyncSession`):
|
|
123
123
|
|
|
124
124
|
```python
|
|
125
|
-
async def
|
|
125
|
+
async def reset(self) -> None
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
> Replaces the removed `safe_reset()` helper since 0.4.0. Construct sessions with `async_sessionmaker(class_=AsyncSession)` and simply `await session.reset()`.
|
|
129
|
+
|
|
130
|
+
**Purpose**: after the upstream `reset()` (releasing the transaction and connection), automatically clears the FOR UPDATE lock-tracking set in `session.info[SESSION_FOR_UPDATE_KEY]` and the cache-invalidation tracking state — preventing tracking state from leaking into the next session reuse cycle.
|
|
129
131
|
|
|
130
|
-
**Typical use case**: an HTTP endpoint / Taskiq task needs to do long external I/O mid-flight (S3, ffprobe, third-party HTTP polling, etc.). Call `
|
|
132
|
+
**Typical use case**: an HTTP endpoint / Taskiq task needs to do long external I/O mid-flight (S3, ffprobe, third-party HTTP polling, etc.). Call `session.reset()` first to release the DB connection, so the connection isn't blocked on network I/O and the pool isn't exhausted. See [Release the DB connection during long I/O](/en/how-to/release-connection-during-long-io).
|
|
131
133
|
|
|
132
134
|
**Object state after the call**:
|
|
133
135
|
|