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.
Files changed (159) hide show
  1. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/PKG-INFO +80 -24
  2. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/README.md +79 -23
  3. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/README_zh.md +18 -5
  4. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/index.md +1 -1
  5. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/release-connection-during-long-io.md +21 -21
  6. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/decorators.md +8 -6
  7. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/field-types.md +275 -220
  8. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/index.md +1 -1
  9. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/01-getting-started.md +174 -174
  10. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/index.md +32 -32
  11. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/index.md +1 -1
  12. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/release-connection-during-long-io.md +21 -21
  13. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/decorators.md +8 -6
  14. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/field-types.md +275 -220
  15. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/index.md +1 -1
  16. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/01-getting-started.md +174 -174
  17. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/index.md +32 -32
  18. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/pyproject.toml +1 -1
  19. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/__init__.py +42 -3
  20. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/base.py +257 -9
  21. sqlmodel_ext-0.4.0/src/sqlmodel_ext/field_types/__init__.py +508 -0
  22. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/__init__.py +1 -1
  23. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/cached_table.py +276 -239
  24. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/polymorphic.py +118 -2
  25. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/table.py +35 -13
  26. sqlmodel_ext-0.4.0/src/sqlmodel_ext/session.py +105 -0
  27. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/_models.py +22 -0
  28. sqlmodel_ext-0.4.0/tests/test_all_fields_optional_constraints.py +76 -0
  29. sqlmodel_ext-0.4.0/tests/test_array_explicit_field_sa_type.py +131 -0
  30. sqlmodel_ext-0.4.0/tests/test_decimal_types.py +256 -0
  31. sqlmodel_ext-0.4.0/tests/test_deferred_index.py +50 -0
  32. sqlmodel_ext-0.4.0/tests/test_enhanced_session.py +87 -0
  33. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_smoke_imports.py +41 -1
  34. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/uv.lock +1 -1
  35. sqlmodel_ext-0.3.4/src/sqlmodel_ext/field_types/__init__.py +0 -195
  36. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/.github/workflows/ci.yml +0 -0
  37. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/.gitignore +0 -0
  38. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/LICENSE +0 -0
  39. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/config.mts +0 -0
  40. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/en.mts +0 -0
  41. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/theme/index.ts +0 -0
  42. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/theme/style/custom.css +0 -0
  43. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/.vitepress/zh.mts +0 -0
  44. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/cached-table.md +0 -0
  45. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/cascade-delete-semantics.md +0 -0
  46. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/crud-pipeline.md +0 -0
  47. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/index.md +0 -0
  48. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/metaclass.md +0 -0
  49. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/optimistic-lock.md +0 -0
  50. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/polymorphic-internals.md +0 -0
  51. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/prerequisites.md +0 -0
  52. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/relation-load-checker.md +0 -0
  53. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/explanation/relation-preload.md +0 -0
  54. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/cached-table.md +0 -0
  55. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/crud.md +0 -0
  56. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/field-types.md +0 -0
  57. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/index.md +0 -0
  58. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/optimistic-lock.md +0 -0
  59. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/pagination.md +0 -0
  60. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/polymorphic.md +0 -0
  61. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/relation-load-checker.md +0 -0
  62. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/guide/relation-preload.md +0 -0
  63. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/cache-queries.md +0 -0
  64. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/configure-cascade-delete.md +0 -0
  65. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/define-jti-models.md +0 -0
  66. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/define-sti-models.md +0 -0
  67. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/handle-concurrent-updates.md +0 -0
  68. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/integrate-with-fastapi.md +0 -0
  69. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/paginate-a-list-endpoint.md +0 -0
  70. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/how-to/prevent-missing-greenlet.md +0 -0
  71. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/index.md +0 -0
  72. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/cached-table.md +0 -0
  73. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/crud.md +0 -0
  74. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/index.md +0 -0
  75. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/metaclass.md +0 -0
  76. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/optimistic-lock.md +0 -0
  77. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/polymorphic.md +0 -0
  78. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/prerequisites.md +0 -0
  79. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/relation-load-checker.md +0 -0
  80. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/internals/relation-preload.md +0 -0
  81. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/base-classes.md +0 -0
  82. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/crud-methods.md +0 -0
  83. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/mixins.md +0 -0
  84. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/reference/pagination-types.md +0 -0
  85. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/02-building-a-blog-api.md +0 -0
  86. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/en/tutorials/03-adding-redis-cache.md +0 -0
  87. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/cached-table.md +0 -0
  88. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/cascade-delete-semantics.md +0 -0
  89. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/crud-pipeline.md +0 -0
  90. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/index.md +0 -0
  91. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/metaclass.md +0 -0
  92. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/optimistic-lock.md +0 -0
  93. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/polymorphic-internals.md +0 -0
  94. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/prerequisites.md +0 -0
  95. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/relation-load-checker.md +0 -0
  96. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/explanation/relation-preload.md +0 -0
  97. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/cached-table.md +0 -0
  98. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/crud.md +0 -0
  99. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/field-types.md +0 -0
  100. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/index.md +0 -0
  101. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/optimistic-lock.md +0 -0
  102. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/pagination.md +0 -0
  103. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/polymorphic.md +0 -0
  104. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/relation-load-checker.md +0 -0
  105. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/guide/relation-preload.md +0 -0
  106. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/cache-queries.md +0 -0
  107. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/configure-cascade-delete.md +0 -0
  108. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/define-jti-models.md +0 -0
  109. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/define-sti-models.md +0 -0
  110. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/handle-concurrent-updates.md +0 -0
  111. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/integrate-with-fastapi.md +0 -0
  112. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/paginate-a-list-endpoint.md +0 -0
  113. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/how-to/prevent-missing-greenlet.md +0 -0
  114. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/index.md +0 -0
  115. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/cached-table.md +0 -0
  116. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/crud.md +0 -0
  117. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/index.md +0 -0
  118. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/metaclass.md +0 -0
  119. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/optimistic-lock.md +0 -0
  120. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/polymorphic.md +0 -0
  121. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/prerequisites.md +0 -0
  122. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/relation-load-checker.md +0 -0
  123. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/internals/relation-preload.md +0 -0
  124. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/base-classes.md +0 -0
  125. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/crud-methods.md +0 -0
  126. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/mixins.md +0 -0
  127. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/reference/pagination-types.md +0 -0
  128. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/02-building-a-blog-api.md +0 -0
  129. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/docs/tutorials/03-adding-redis-cache.md +0 -0
  130. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/examples/consumer_integration/run.py +0 -0
  131. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/package.json +0 -0
  132. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/pnpm-lock.yaml +0 -0
  133. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_compat.py +0 -0
  134. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_exceptions.py +0 -0
  135. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_sa_type.py +0 -0
  136. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/_utils.py +0 -0
  137. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/_internal/__init__.py +0 -0
  138. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/_internal/path.py +0 -0
  139. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/_ssrf.py +0 -0
  140. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/__init__.py +0 -0
  141. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/__init__.py +0 -0
  142. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/array.py +0 -0
  143. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/exceptions.py +0 -0
  144. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +0 -0
  145. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +0 -0
  146. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/ip_address.py +0 -0
  147. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/mixins/__init__.py +0 -0
  148. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/mixins/module_name_mixin.py +0 -0
  149. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/field_types/url.py +0 -0
  150. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/info_response.py +0 -0
  151. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/optimistic_lock.py +0 -0
  152. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/mixins/relation_preload.py +0 -0
  153. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/pagination.py +0 -0
  154. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/src/sqlmodel_ext/relation_load_checker.py +0 -0
  155. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/__init__.py +0 -0
  156. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/conftest.py +0 -0
  157. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_annotated_default_factory_inheritance.py +0 -0
  158. {sqlmodel_ext-0.3.4 → sqlmodel_ext-0.4.0}/tests/test_basic_crud.py +0 -0
  159. {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.4
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
- # ── Dependencies (defined once, reused everywhere) ────────────────
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
- TableViewDep = Annotated[TableViewRequest, Depends()]
197
+ """Request-scoped AsyncSession. The single way endpoints touch the DB."""
188
198
 
189
- # ── Models ────────────────────────────────────────────────────────
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
- class ArticleUpdate(ArticleBase):
203
- title: Str64 | None = None # Override to optional,
204
- body: Text10K | None = None # preserving the original
205
- is_published: bool | None = None # type constraints from Base
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: TableViewDep,
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 get_article(session: SessionDep, article_id: UUID) -> Article:
233
- return await Article.get_exist_one(session, article_id)
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, article_id: UUID, data: ArticleUpdate,
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, article_id: UUID) -> None:
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 `TableViewDep` gives clients `offset`, `limit`, `desc`, `order`, and four time filters out of the box.
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: TableViewDep,
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
- ### safe_reset
1405
+ ### Enhanced AsyncSession (cache-aware commit/reset/refresh)
1374
1406
 
1375
- Safely reset an async session, clearing both the DB connection and FOR UPDATE lock tracking:
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 sqlmodel_ext import safe_reset
1411
+ from sqlalchemy.ext.asyncio import async_sessionmaker
1412
+ from sqlmodel_ext import AsyncSession
1379
1413
 
1380
- # In long-lived tasks (e.g. background workers) between operations:
1381
- await safe_reset(session)
1414
+ session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=True)
1382
1415
  ```
1383
1416
 
1384
- This prevents stale `FOR UPDATE` lock tracking in `session.info` when Python reuses object IDs across `reset()` cycles.
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, safe_reset (async CRUD)
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
- # ── Dependencies (defined once, reused everywhere) ────────────────
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
- TableViewDep = Annotated[TableViewRequest, Depends()]
152
+ """Request-scoped AsyncSession. The single way endpoints touch the DB."""
143
153
 
144
- # ── Models ────────────────────────────────────────────────────────
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
- class ArticleUpdate(ArticleBase):
158
- title: Str64 | None = None # Override to optional,
159
- body: Text10K | None = None # preserving the original
160
- is_published: bool | None = None # type constraints from Base
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: TableViewDep,
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 get_article(session: SessionDep, article_id: UUID) -> Article:
188
- return await Article.get_exist_one(session, article_id)
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, article_id: UUID, data: ArticleUpdate,
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, article_id: UUID) -> None:
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 `TableViewDep` gives clients `offset`, `limit`, `desc`, `order`, and four time filters out of the box.
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: TableViewDep,
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
- ### safe_reset
1360
+ ### Enhanced AsyncSession (cache-aware commit/reset/refresh)
1329
1361
 
1330
- Safely reset an async session, clearing both the DB connection and FOR UPDATE lock tracking:
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 sqlmodel_ext import safe_reset
1366
+ from sqlalchemy.ext.asyncio import async_sessionmaker
1367
+ from sqlmodel_ext import AsyncSession
1334
1368
 
1335
- # In long-lived tasks (e.g. background workers) between operations:
1336
- await safe_reset(session)
1369
+ session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=True)
1337
1370
  ```
1338
1371
 
1339
- This prevents stale `FOR UPDATE` lock tracking in `session.info` when Python reuses object IDs across `reset()` cycles.
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, safe_reset (async CRUD)
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
- ### safe_reset
1291
+ ### 增强 AsyncSession(缓存感知 commit/reset/refresh)
1292
1292
 
1293
- 安全重置异步会话,同时清理 FOR UPDATE 锁跟踪:
1293
+ `sqlmodel_ext.AsyncSession` 是 sqlmodel `AsyncSession` 的增强子类,把缓存正确性变为自动行为。把 session 工厂指向它:
1294
1294
 
1295
1295
  ```python
1296
- from sqlmodel_ext import safe_reset
1297
- await safe_reset(session)
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、safe_reset(异步 CRUD)
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 `safe_reset()` to keep external I/O from exhausting the pool
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
 
@@ -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 `safe_reset()` around the I/O block
31
+ ## 2. Use `session.reset()` around the I/O block
32
32
 
33
- ```python
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 safe_reset(session) # ← release the DB connection // [!code ++]
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
- - `safe_reset` releases the current transaction and connection, but the session object itself stays alive (it's not closed)
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 `safe_reset`: detached but not expired
63
+ ## 3. Object state after `session.reset()`: detached but not expired
64
64
 
65
- After `safe_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`.
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 safe_reset:
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 safe_reset(session)
76
+ await session.reset()
77
77
 
78
- # After safe_reset:
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 safe_reset | Why |
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** `safe_reset`, extract any field you'll need later into local variables:
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 safe_reset(session)
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 `safe_reset` the object is detached and **cannot be saved directly**. To write, re-query a fresh attached instance:
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 safe_reset(session)
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 safe_reset vs Taskiq dispatch
133
+ ## 5. When to use session.reset() vs Taskiq dispatch
134
134
 
135
- `safe_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**.
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["safe_reset to release connection<br/>execute the long I/O synchronously"]
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) | safe_reset |
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` — safe_reset fires
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
- - [`safe_reset()` API reference](/en/reference/decorators#safe-reset)
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
- ## `safe_reset()`
116
+ ## `session.reset()` (enhanced AsyncSession) {#session-reset}
117
117
 
118
118
  ```python
119
- from sqlmodel_ext import safe_reset
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 safe_reset(session: AsyncSession) -> None
125
+ async def reset(self) -> None
126
126
  ```
127
127
 
128
- **Purpose**: clears the FOR UPDATE lock tracking set in `session.info[SESSION_FOR_UPDATE_KEY]` before calling `session.reset()`. Safer than calling `session.reset()` directly — prevents the lock-tracking set from leaking into the next session reuse cycle.
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 `safe_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).
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