sqlmodel-ext 0.3.3__py3-none-any.whl → 0.3.5__py3-none-any.whl
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/__init__.py +3 -1
- sqlmodel_ext/base.py +55 -4
- {sqlmodel_ext-0.3.3.dist-info → sqlmodel_ext-0.3.5.dist-info}/METADATA +49 -17
- {sqlmodel_ext-0.3.3.dist-info → sqlmodel_ext-0.3.5.dist-info}/RECORD +6 -6
- {sqlmodel_ext-0.3.3.dist-info → sqlmodel_ext-0.3.5.dist-info}/WHEEL +0 -0
- {sqlmodel_ext-0.3.3.dist-info → sqlmodel_ext-0.3.5.dist-info}/licenses/LICENSE +0 -0
sqlmodel_ext/__init__.py
CHANGED
|
@@ -20,7 +20,7 @@ Quick start::
|
|
|
20
20
|
user = await user.save(session)
|
|
21
21
|
users = await User.get(session, fetch_mode="all")
|
|
22
22
|
"""
|
|
23
|
-
__version__ = "0.3.
|
|
23
|
+
__version__ = "0.3.5"
|
|
24
24
|
|
|
25
25
|
# Base
|
|
26
26
|
from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
|
|
@@ -94,10 +94,12 @@ from sqlmodel_ext.field_types import (
|
|
|
94
94
|
Text3K,
|
|
95
95
|
Text5K,
|
|
96
96
|
Text10K,
|
|
97
|
+
Text16K,
|
|
97
98
|
Text32K,
|
|
98
99
|
Text60K,
|
|
99
100
|
Text64K,
|
|
100
101
|
Text100K,
|
|
102
|
+
Text128K,
|
|
101
103
|
Text1M,
|
|
102
104
|
# Numeric constraints
|
|
103
105
|
INT32_MAX,
|
sqlmodel_ext/base.py
CHANGED
|
@@ -28,9 +28,15 @@ from sqlmodel.main import (
|
|
|
28
28
|
is_table_model_class,
|
|
29
29
|
get_relationship_to,
|
|
30
30
|
FieldInfo as SQLModelFieldInfo, # Internal API: stable since sqlmodel 0.0.22
|
|
31
|
+
FieldInfoMetadata, # Internal API: pydantic-rebuild-safe sa_type carrier
|
|
31
32
|
get_column_from_field, # Internal API: stable since sqlmodel 0.0.22
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
# sqlmodel's FieldInfoMetadata fields default to sqlmodel's own Undefined
|
|
36
|
+
# sentinel (distinct from pydantic_core PydanticUndefined imported above);
|
|
37
|
+
# capture it to detect "sa_type not yet set" without importing sqlmodel internals.
|
|
38
|
+
_FIM_UNSET_SA_TYPE = FieldInfoMetadata().sa_type
|
|
39
|
+
|
|
34
40
|
# Import _compat for side effects (Python 3.14 monkey-patches)
|
|
35
41
|
import sqlmodel_ext._compat # noqa: F401
|
|
36
42
|
|
|
@@ -143,6 +149,49 @@ def _find_field_info_in_annotated(annotation: Any) -> SQLModelFieldInfo | None:
|
|
|
143
149
|
return merged
|
|
144
150
|
|
|
145
151
|
|
|
152
|
+
def _durably_set_sa_type(field_info: Any, sa_type: Any) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Inject ``sa_type`` so it survives Pydantic's model_fields rebuild.
|
|
155
|
+
|
|
156
|
+
Root cause this fixes: the metaclass extracts ``sa_type`` from
|
|
157
|
+
``Array[T]`` / custom Annotated handlers and must hand it to SQLModel's
|
|
158
|
+
column builder. A plain ``setattr(field_info, 'sa_type', sa_type)`` is
|
|
159
|
+
LOST: ``SQLModelMetaclass.__new__`` (invoked from our ``super().__new__``)
|
|
160
|
+
runs ``get_column_from_field`` *before* step-7's SQLModelFieldInfo
|
|
161
|
+
restore, and Pydantic has by then rebuilt ``model_fields`` into fresh
|
|
162
|
+
FieldInfo objects that never saw the post-hoc attribute. The previous
|
|
163
|
+
code only fixed the no-``= Field(...)`` branch (c00696c); the explicit
|
|
164
|
+
``Array[T] = Field(default_factory=list)`` form still raised
|
|
165
|
+
``<class 'list'> has no matching SQLAlchemy type``.
|
|
166
|
+
|
|
167
|
+
Fix: write ``sa_type`` into a ``FieldInfoMetadata`` entry inside the
|
|
168
|
+
FieldInfo's pydantic ``metadata`` list — the exact channel SQLModel's
|
|
169
|
+
own ``Field(sa_type=...)`` uses and which ``get_sqlalchemy_type``
|
|
170
|
+
(via ``_get_sqlmodel_field_value``) reads *first*. Pydantic preserves
|
|
171
|
+
the ``metadata`` list across rebuilds, so the type survives into the
|
|
172
|
+
column build. The instance attribute is also set as a belt-and-braces
|
|
173
|
+
fallback for any direct ``getattr(field_info, 'sa_type')`` reader.
|
|
174
|
+
|
|
175
|
+
:param field_info: target FieldInfo (user's Field or recovered Annotated FI)
|
|
176
|
+
:param sa_type: SQLAlchemy type extracted from the annotation handler
|
|
177
|
+
"""
|
|
178
|
+
md = list(getattr(field_info, 'metadata', None) or [])
|
|
179
|
+
existing = next(
|
|
180
|
+
(m for m in md if isinstance(m, FieldInfoMetadata)), None
|
|
181
|
+
)
|
|
182
|
+
if existing is not None:
|
|
183
|
+
if existing.sa_type is _FIM_UNSET_SA_TYPE:
|
|
184
|
+
existing.sa_type = sa_type
|
|
185
|
+
else:
|
|
186
|
+
md.append(FieldInfoMetadata(sa_type=sa_type))
|
|
187
|
+
field_info.metadata = md
|
|
188
|
+
if getattr(field_info, 'sa_type', Undefined) is Undefined:
|
|
189
|
+
try:
|
|
190
|
+
field_info.sa_type = sa_type
|
|
191
|
+
except (AttributeError, TypeError):
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
146
195
|
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
147
196
|
"""
|
|
148
197
|
Convert type annotation to optional: ``T → T | None``
|
|
@@ -602,14 +651,16 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
602
651
|
# inheritance.
|
|
603
652
|
annotated_fi = _find_field_info_in_annotated(field_type)
|
|
604
653
|
if annotated_fi is not None:
|
|
605
|
-
|
|
606
|
-
setattr(annotated_fi, 'sa_type', sa_type)
|
|
654
|
+
_durably_set_sa_type(annotated_fi, sa_type)
|
|
607
655
|
attrs[field_name] = annotated_fi
|
|
608
656
|
else:
|
|
609
657
|
attrs[field_name] = Field(sa_type=sa_type)
|
|
610
658
|
elif isinstance(field_value, FieldInfo):
|
|
611
|
-
|
|
612
|
-
|
|
659
|
+
# Explicit ``Array[T] = Field(default_factory=list)`` form.
|
|
660
|
+
# Must inject sa_type via the pydantic-rebuild-safe
|
|
661
|
+
# FieldInfoMetadata channel — plain setattr is dropped
|
|
662
|
+
# before SQLModel's column build (see _durably_set_sa_type).
|
|
663
|
+
_durably_set_sa_type(field_value, sa_type)
|
|
613
664
|
|
|
614
665
|
# 5. Save SQLModel FieldInfo from Annotated fields before super().__new__(),
|
|
615
666
|
# because Pydantic rebuilds model_fields with plain FieldInfo that lacks
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlmodel-ext
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
sqlmodel_ext/__init__.py,sha256=
|
|
1
|
+
sqlmodel_ext/__init__.py,sha256=2X2Dpu8wC5e5w8uygvc0ZNAC-P_t9OnWQd4mfM9mYKY,2862
|
|
2
2
|
sqlmodel_ext/_compat.py,sha256=zia-2vSQqmzk6_4Y404IKzcip5n4fe7PrRAYntlY-Zg,12186
|
|
3
3
|
sqlmodel_ext/_exceptions.py,sha256=2mC_BVvcreaFutR4UxIra238o1sr4zWv8vctBI6CL2o,413
|
|
4
4
|
sqlmodel_ext/_sa_type.py,sha256=zDwXBR0O7dne3Y6j8V8CaMtfqZqcpvsPucZUxGh8dAk,4866
|
|
5
5
|
sqlmodel_ext/_utils.py,sha256=cFrlQp1Uq-4ZAdvi_u79UbEI0IHZaAm5VkXadCXrBmI,174
|
|
6
|
-
sqlmodel_ext/base.py,sha256=
|
|
6
|
+
sqlmodel_ext/base.py,sha256=J--AdnXPxHoZ_uOoxuIQIhAoHwkrfnBIObTSQT977d0,55481
|
|
7
7
|
sqlmodel_ext/pagination.py,sha256=1JOzt51KvIZTyjgYsQTP0v7KDytIkQKlYN4vGkq9RH8,4611
|
|
8
8
|
sqlmodel_ext/relation_load_checker.py,sha256=1W2vSdk_PVTqBRRcSyIffw-ECysJRfg1bJYVTmx9Wxs,172457
|
|
9
9
|
sqlmodel_ext/field_types/__init__.py,sha256=dFnaZxQb6ItO3ZrQ1RyqAceykF_JyFrYnJvdjq7mFPE,7916
|
|
@@ -27,7 +27,7 @@ sqlmodel_ext/mixins/optimistic_lock.py,sha256=XVjO6NgKYfpcsGlH1Yj8Ou9NkpCsqVN9AZ
|
|
|
27
27
|
sqlmodel_ext/mixins/polymorphic.py,sha256=rX2XER0s0kRhHnpmwWUUj6wEaImCM2KBzgkh2M1wRIk,27836
|
|
28
28
|
sqlmodel_ext/mixins/relation_preload.py,sha256=SD9Nnc8zIbUqWqI85gr4XRpbPsmJwXQd7JPHj_EVrLs,15967
|
|
29
29
|
sqlmodel_ext/mixins/table.py,sha256=BsOoimqZwD64EjZpVHffXU_in7G2utS2CprdPXMSQQo,56444
|
|
30
|
-
sqlmodel_ext-0.3.
|
|
31
|
-
sqlmodel_ext-0.3.
|
|
32
|
-
sqlmodel_ext-0.3.
|
|
33
|
-
sqlmodel_ext-0.3.
|
|
30
|
+
sqlmodel_ext-0.3.5.dist-info/METADATA,sha256=RrQXH6fYG872w349elyjSErryB3u_R7xuH7dF1Pv0Ow,50709
|
|
31
|
+
sqlmodel_ext-0.3.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
32
|
+
sqlmodel_ext-0.3.5.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
|
|
33
|
+
sqlmodel_ext-0.3.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|