Skip to content

Commit c2bd7de

Browse files
🐛 Ignore SQLAlchemy descriptors in SQLModel metaclass to fix Pydantic v2 crash
Defining a `sqlalchemy.ext.hybrid.hybrid_property` (or `hybrid_method`, `association_proxy`) directly on a `SQLModel` class body raises `pydantic.errors.PydanticUserError: A non-annotated attribute was detected` under Pydantic v2 because those SQLAlchemy descriptors carry no Pydantic annotation. Add them to `SQLModel.model_config["ignored_types"]` so Pydantic skips them during model construction, while SQLAlchemy continues to expose them as descriptors at runtime. Python-side hybrid evaluation now works on table models; emitting them as SQL expressions/columns is intentionally still left to user code (the proposed broader feature in PR #801). Refs #299 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56f1dc3 commit c2bd7de

2 files changed

Lines changed: 120 additions & 1 deletion

File tree

sqlmodel/main.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
inspect,
3838
)
3939
from sqlalchemy import Enum as sa_Enum
40+
from sqlalchemy.ext.associationproxy import AssociationProxy
41+
from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property
4042
from sqlalchemy.orm import (
4143
Mapped,
4244
RelationshipProperty,
@@ -809,7 +811,14 @@ class SQLModel(BaseModel, metaclass=SQLModelMetaclass, registry=default_registry
809811
__name__: ClassVar[str]
810812
metadata: ClassVar[MetaData]
811813
__allow_unmapped__ = True # https://docs.sqlalchemy.org/en/20/changelog/migration_20.html#migration-20-step-six
812-
model_config = SQLModelConfig(from_attributes=True)
814+
# SQLAlchemy descriptors (hybrid_property, hybrid_method, association_proxy)
815+
# are not Pydantic fields. Pydantic v2 otherwise raises ``PydanticUserError``
816+
# ("A non-annotated attribute was detected") when they appear in a class body
817+
# without a type annotation -- see https://github.com/fastapi/sqlmodel/issues/299
818+
model_config = SQLModelConfig(
819+
from_attributes=True,
820+
ignored_types=(hybrid_property, hybrid_method, AssociationProxy),
821+
)
813822

814823
# Typing spec says `__new__` returning `Any` overrides normal constructor
815824
# behavior, but a missing annotation does not:

tests/test_hybrid_property.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for SQLAlchemy descriptor compatibility with SQLModel metaclass.
2+
3+
Regression tests for https://github.com/fastapi/sqlmodel/issues/299:
4+
5+
Declaring a ``sqlalchemy.ext.hybrid.hybrid_property`` (or ``hybrid_method``)
6+
directly on a ``SQLModel`` class with ``table=True`` raises
7+
``pydantic.errors.PydanticUserError: A non-annotated attribute was detected``
8+
because Pydantic v2 inspects every non-dunder attribute on the class body and
9+
expects an annotation. ``hybrid_property`` is a SQLAlchemy descriptor, not a
10+
Pydantic field, so the SQLModel metaclass must tell Pydantic to skip it via
11+
``model_config["ignored_types"]``.
12+
"""
13+
14+
from datetime import datetime
15+
16+
from sqlalchemy.ext.associationproxy import association_proxy
17+
from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property
18+
from sqlmodel import Field, Session, SQLModel, create_engine
19+
20+
21+
def _make_engine():
22+
return create_engine("sqlite:///:memory:")
23+
24+
25+
def test_table_model_allows_hybrid_property(clear_sqlmodel):
26+
"""A ``hybrid_property`` defined on a ``table=True`` model must not crash
27+
class construction and must be callable at the instance level."""
28+
29+
class Span(SQLModel, table=True):
30+
id: int | None = Field(default=None, primary_key=True)
31+
start: datetime
32+
end: datetime
33+
34+
@hybrid_property
35+
def duration_seconds(self) -> float:
36+
return (self.end - self.start).total_seconds()
37+
38+
engine = _make_engine()
39+
SQLModel.metadata.create_all(engine)
40+
# The hybrid attribute must not be turned into a SQL column.
41+
assert "duration_seconds" not in Span.__table__.columns
42+
43+
with Session(engine) as session:
44+
span = Span(start=datetime(2024, 1, 1), end=datetime(2024, 1, 2))
45+
session.add(span)
46+
session.commit()
47+
session.refresh(span)
48+
assert span.duration_seconds == 86400.0
49+
50+
51+
def test_table_model_allows_hybrid_method(clear_sqlmodel):
52+
"""A ``hybrid_method`` must not raise during class construction."""
53+
54+
class Box(SQLModel, table=True):
55+
id: int | None = Field(default=None, primary_key=True)
56+
width: int
57+
height: int
58+
59+
@hybrid_method
60+
def area_at_least(self, threshold: int) -> bool:
61+
return (self.width * self.height) >= threshold
62+
63+
engine = _make_engine()
64+
SQLModel.metadata.create_all(engine)
65+
assert "area_at_least" not in Box.__table__.columns
66+
67+
with Session(engine) as session:
68+
box = Box(width=4, height=5)
69+
session.add(box)
70+
session.commit()
71+
session.refresh(box)
72+
assert box.area_at_least(10) is True
73+
assert box.area_at_least(100) is False
74+
75+
76+
def test_table_model_allows_association_proxy(clear_sqlmodel):
77+
"""An ``association_proxy`` declared without an annotation must not raise.
78+
79+
The proxy itself does not need to be functional for this regression test;
80+
its presence used to crash the metaclass in Pydantic v2 because
81+
``AssociationProxy`` has no type annotation.
82+
"""
83+
84+
class Item(SQLModel, table=True):
85+
id: int | None = Field(default=None, primary_key=True)
86+
label: str
87+
# ``association_proxy`` is also a non-annotated SQLAlchemy descriptor.
88+
# We do not need a working relationship to assert the metaclass does
89+
# not blow up at class-body time -- that is the regression.
90+
legacy_alias = association_proxy("label", "label")
91+
92+
engine = _make_engine()
93+
SQLModel.metadata.create_all(engine)
94+
assert "legacy_alias" not in Item.__table__.columns
95+
96+
97+
def test_non_table_model_allows_hybrid_property(clear_sqlmodel):
98+
"""The fix must also work for ``table=False`` (plain Pydantic) models so
99+
that mix-ins shared between table and non-table classes do not break."""
100+
101+
class HasArea(SQLModel):
102+
width: int = 0
103+
height: int = 0
104+
105+
@hybrid_property
106+
def area(self) -> int:
107+
return self.width * self.height
108+
109+
instance = HasArea(width=3, height=4)
110+
assert instance.area == 12

0 commit comments

Comments
 (0)