sqlmodel-ext 0.3.4__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 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.4"
23
+ __version__ = "0.3.5"
24
24
 
25
25
  # Base
26
26
  from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
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
- if getattr(annotated_fi, 'sa_type', Undefined) is Undefined:
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
- if getattr(field_value, 'sa_type', Undefined) is Undefined:
612
- setattr(field_value, 'sa_type', sa_type)
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.4
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
- # ── 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,
@@ -1,9 +1,9 @@
1
- sqlmodel_ext/__init__.py,sha256=PbFopnDRXl6y3Raas4VuePuQPpY5TQMGAyqe4P7AZiM,2862
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=ADFwrEW5UJBSAVEEqLrWz_tIHpaj5iBCiBFRyd9jjc4,52848
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.4.dist-info/METADATA,sha256=D13ATIqAPkZ3qbzI4nBTtuCPx9oPdMjIH_D_OeP1gm4,49387
31
- sqlmodel_ext-0.3.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
32
- sqlmodel_ext-0.3.4.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
33
- sqlmodel_ext-0.3.4.dist-info/RECORD,,
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,,