Skip to content

Commit 3da06f9

Browse files
Foxerineclaude
andcommitted
merge: bring release/0.3.4 (0.3.5 hotfix) into master
PyPI 0.3.4 was published from master before the Array sa_type fix (9aababf) landed on the release/0.3.4 branch, so the published wheel silently missed the fix. The release/0.3.4 branch then shipped 0.3.5 to PyPI with the fix. Master and release/0.3.4 were left divergent — master had the doc-sync work (eee01d5), release had the Array fix plus README example (9aababf, eca1e5d, 9451fc1). This merge converges them so master is once again the single source of truth and matches the wheel on PyPI. Conflicts: pyproject.toml and src/sqlmodel_ext/__init__.py disagreed on version. Resolved to 0.3.5 in both — that's what is published. Net effect on master: - keeps the doc-sync (Text16K/Text128K rows, Python 3.12+ tutorial prereq, "Design orientation" home section). - adds the Array sa_type fix (_durably_set_sa_type / FieldInfoMetadata channel) so Array[StrEnum] = Field(default_factory=list) columns on table=True models stop raising "<class 'list'> has no matching SQLAlchemy type". - adds the new tests/test_array_explicit_field_sa_type.py regression (note: those tests register table=True models on the global SQLModel.metadata and currently pollute SQLite-backed tests in the same run — to be cleaned up in a follow-up). - adds the README DI / resource-as-dependency example. - bumps version to 0.3.5 to match PyPI. v0.3.5 tag still points at 9451fc1, which is now reachable from master via this merge commit. No tags rewritten. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents f2cb93e + 9451fc1 commit 3da06f9

5 files changed

Lines changed: 221 additions & 22 deletions

File tree

README.md

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,43 +125,75 @@ async def demo(session: AsyncSession):
125125
A complete REST API -- models, DTOs, and five endpoints:
126126

127127
```python
128+
from collections.abc import AsyncGenerator
128129
from typing import Annotated
129130
from uuid import UUID
130131

131132
from fastapi import APIRouter, Depends
133+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
132134
from sqlmodel import Field
133135
from sqlmodel.ext.asyncio.session import AsyncSession
134136
from sqlmodel_ext import (
135137
SQLModelBase, UUIDTableBaseMixin, Str64, Text10K,
136138
ListResponse, TableViewRequest, UUIDIdDatetimeInfoMixin,
137139
)
138140

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
140150

141151
SessionDep = Annotated[AsyncSession, Depends(get_session)]
142-
TableViewDep = Annotated[TableViewRequest, Depends()]
152+
"""Request-scoped AsyncSession. The single way endpoints touch the DB."""
143153

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) ─────
145164

146165
class ArticleBase(SQLModelBase):
147166
title: Str64
167+
"""Article title"""
148168
body: Text10K
169+
"""Article body (max 10k chars)"""
149170
is_published: bool = False
171+
"""Whether the article is publicly visible"""
150172

151173
class Article(ArticleBase, UUIDTableBaseMixin, table=True):
152174
author_id: UUID = Field(foreign_key='user.id')
153175

154176
class ArticleCreate(ArticleBase):
155177
pass
156178

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
161184

162185
class ArticleResponse(ArticleBase, UUIDIdDatetimeInfoMixin):
163186
author_id: UUID
164187

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+
165197
# ── Endpoints ─────────────────────────────────────────────────────
166198

167199
router = APIRouter(prefix="/articles", tags=["articles"])
@@ -175,32 +207,32 @@ async def create_article(
175207

176208
@router.get("", response_model=ListResponse[ArticleResponse])
177209
async def list_articles(
178-
session: SessionDep, table_view: TableViewDep,
210+
session: SessionDep, table_view: TableViewRequestDep,
179211
) -> ListResponse[Article]:
180212
return await Article.get_with_count(
181213
session,
182214
Article.is_published == True,
183215
table_view=table_view,
184216
)
185217

218+
# article: ArticleDep — the 404 + fetch is the dependency's job, so the
219+
# single-resource handlers carry no lookup boilerplate at all.
186220
@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
189223

190224
@router.patch("/{article_id}", response_model=ArticleResponse)
191225
async def update_article(
192-
session: SessionDep, article_id: UUID, data: ArticleUpdate,
226+
session: SessionDep, article: ArticleDep, data: ArticleUpdate,
193227
) -> Article:
194-
article = await Article.get_exist_one(session, article_id)
195228
return await article.update(session, data)
196229

197230
@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:
200232
await Article.delete(session, article)
201233
```
202234

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.
204236

205237
**What the client gets from `GET /articles?offset=0&limit=10&desc=true`:**
206238

@@ -395,7 +427,7 @@ class PushNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMix
395427

396428
@router.get("/notifications", response_model=ListResponse[NotificationBase])
397429
async def list_notifications(
398-
session: SessionDep, user: CurrentUserDep, table_view: TableViewDep,
430+
session: SessionDep, user: CurrentUserDep, table_view: TableViewRequestDep,
399431
) -> ListResponse[Notification]:
400432
return await Notification.get_with_count(
401433
session,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sqlmodel-ext"
7-
version = "0.3.4"
7+
version = "0.3.5"
88
description = "Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphic inheritance, optimistic locking, relation preloading, and reusable field types."
99
readme = "README.md"
1010
license = "MIT"

src/sqlmodel_ext/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class User(UserBase, UUIDTableBaseMixin, table=True):
2020
user = await user.save(session)
2121
users = await User.get(session, fetch_mode="all")
2222
"""
23-
__version__ = "0.3.4"
23+
__version__ = "0.3.5"
2424

2525
# Base
2626
from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase

src/sqlmodel_ext/base.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,15 @@
2828
is_table_model_class,
2929
get_relationship_to,
3030
FieldInfo as SQLModelFieldInfo, # Internal API: stable since sqlmodel 0.0.22
31+
FieldInfoMetadata, # Internal API: pydantic-rebuild-safe sa_type carrier
3132
get_column_from_field, # Internal API: stable since sqlmodel 0.0.22
3233
)
3334

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+
3440
# Import _compat for side effects (Python 3.14 monkey-patches)
3541
import sqlmodel_ext._compat # noqa: F401
3642

@@ -143,6 +149,49 @@ def _find_field_info_in_annotated(annotation: Any) -> SQLModelFieldInfo | None:
143149
return merged
144150

145151

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+
146195
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
147196
"""
148197
Convert type annotation to optional: ``T → T | None``
@@ -602,14 +651,16 @@ def __new__(cls, name, bases, attrs, **kwargs):
602651
# inheritance.
603652
annotated_fi = _find_field_info_in_annotated(field_type)
604653
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)
607655
attrs[field_name] = annotated_fi
608656
else:
609657
attrs[field_name] = Field(sa_type=sa_type)
610658
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)
613664

614665
# 5. Save SQLModel FieldInfo from Annotated fields before super().__new__(),
615666
# because Pydantic rebuilds model_fields with plain FieldInfo that lacks
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Regression: explicit ``Array[T] = Field(default_factory=list)`` must build a
3+
PostgreSQL ``ARRAY`` column (not raise ``<class 'list'> has no matching
4+
SQLAlchemy type``).
5+
6+
Root cause (sqlmodel_ext.base metaclass sa_type injection loop, the
7+
``elif isinstance(field_value, FieldInfo)`` branch — i.e. the user wrote an
8+
explicit ``= Field(...)``):
9+
10+
c00696c fixed only the ``field_value is Undefined`` (no ``= Field(...)``)
11+
branch. The explicit-Field branch still did a plain
12+
``setattr(field_value, 'sa_type', sa_type)``. That attribute is LOST:
13+
``SQLModelMetaclass.__new__`` (reached via the metaclass ``super().__new__``)
14+
runs ``get_column_from_field`` *before* step-7's SQLModelFieldInfo restore,
15+
and Pydantic has by then rebuilt ``model_fields`` into fresh FieldInfo
16+
objects that never saw the post-hoc attribute. ``get_sqlalchemy_type``
17+
reads ``sa_type`` via ``_get_sqlmodel_field_value``, which prefers a
18+
``FieldInfoMetadata`` entry in the FieldInfo's pydantic ``metadata`` list
19+
(the rebuild-safe channel SQLModel's own ``Field(sa_type=...)`` uses).
20+
21+
Fix: ``_durably_set_sa_type()`` writes ``sa_type`` into a
22+
``FieldInfoMetadata`` on the FieldInfo's ``metadata`` list (plus the
23+
instance attribute as a fallback), so it survives Pydantic's rebuild into
24+
the column build. Applied to both the explicit-Field branch and the
25+
recovered-Annotated-FieldInfo path.
26+
27+
This locks in the real-world trigger: an ``Array[StrEnum] =
28+
Field(default_factory=list)`` column on a ``table=True`` model that also
29+
inherits a non-table base declaring the same field as a plain ``list``
30+
(the contract-base / persistence-table split). Such fields were silently
31+
mistyped (or raised ``<class 'list'> has no matching SQLAlchemy type``),
32+
500-ing every INSERT into that table.
33+
"""
34+
from __future__ import annotations
35+
36+
from enum import StrEnum
37+
38+
import sqlalchemy as sa
39+
from sqlmodel import Field
40+
41+
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin
42+
from sqlmodel_ext.field_types.dialects.postgresql import Array
43+
44+
45+
# Module-level enums: with ``from __future__ import annotations`` all
46+
# annotations are lazy strings; the metaclass resolves them via
47+
# ``get_type_hints`` against module globals, so referenced names MUST be
48+
# module-level (function-local names are unresolvable).
49+
class ScopeLikeEnum(StrEnum):
50+
a = "a:b"
51+
c = "c:d"
52+
53+
54+
DynScopeEnum = StrEnum(
55+
"DynScopeEnum",
56+
{f"r{i}_{a}": f"r{i}:{a}" for i in range(3) for a in ("read", "write")},
57+
)
58+
59+
60+
class TestExplicitFieldArraySaType:
61+
def test_lib_documented_array_str_pattern(self) -> None:
62+
"""The README/array.py documented form on a table model."""
63+
64+
class DocTags(SQLModelBase, TableBaseMixin, table=True):
65+
tags: Array[str] = Field(default_factory=list)
66+
67+
col_type = DocTags.__table__.c.tags.type
68+
assert isinstance(col_type, sa.ARRAY)
69+
assert isinstance(col_type.item_type, sa.String)
70+
71+
def test_array_enum_overrides_inherited_plain_list(self) -> None:
72+
"""
73+
Production shape: non-table contract base declares ``scopes`` as a
74+
plain ``list[Enum]``; the table subclass overrides it with
75+
``Array[Enum]`` to get a real PG ``ARRAY(Enum)`` column.
76+
"""
77+
class ContractBase(SQLModelBase):
78+
is_admin: bool = False
79+
scopes: list[ScopeLikeEnum] = Field(default_factory=list)
80+
81+
class ScopeTable(ContractBase, TableBaseMixin, table=True):
82+
scopes: Array[ScopeLikeEnum] = Field(default_factory=list)
83+
tags: Array[str] = Field(default_factory=list)
84+
85+
scopes_type = ScopeTable.__table__.c.scopes.type
86+
assert isinstance(scopes_type, sa.ARRAY)
87+
assert isinstance(scopes_type.item_type, sa.Enum)
88+
assert isinstance(ScopeTable.__table__.c.tags.type, sa.ARRAY)
89+
90+
# default_factory survives (not silently is_required=True)
91+
inst = ScopeTable(scopes=[ScopeLikeEnum.a], tags=["x"])
92+
assert inst.scopes == [ScopeLikeEnum.a]
93+
assert ScopeTable().scopes == []
94+
95+
def test_array_enum_on_uuid_mixin(self) -> None:
96+
"""Dynamically-built StrEnum (mirrors ScopeValueEnum) + UUID PK mixin."""
97+
class ScopeRow(SQLModelBase, UUIDTableBaseMixin, table=True):
98+
scopes: Array[DynScopeEnum] = Field(default_factory=list)
99+
100+
col_type = ScopeRow.__table__.c.scopes.type
101+
assert isinstance(col_type, sa.ARRAY)
102+
assert isinstance(col_type.item_type, sa.Enum)
103+
104+
def test_non_array_field_unaffected(self) -> None:
105+
"""Guard: ordinary scalar Field columns keep working unchanged."""
106+
107+
class Plain(SQLModelBase, TableBaseMixin, table=True):
108+
name: str = Field(max_length=32, index=True)
109+
qty: int = 0
110+
111+
assert Plain.__table__.c.name.type.__class__.__name__ in (
112+
"AutoString",
113+
"String",
114+
"VARCHAR",
115+
)
116+
assert isinstance(Plain.__table__.c.qty.type, sa.Integer)

0 commit comments

Comments
 (0)