Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion sqlmodel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
inspect,
)
from sqlalchemy import Enum as sa_Enum
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property
from sqlalchemy.orm import (
Mapped,
RelationshipProperty,
Expand Down Expand Up @@ -809,7 +811,14 @@ class SQLModel(BaseModel, metaclass=SQLModelMetaclass, registry=default_registry
__name__: ClassVar[str]
metadata: ClassVar[MetaData]
__allow_unmapped__ = True # https://docs.sqlalchemy.org/en/20/changelog/migration_20.html#migration-20-step-six
model_config = SQLModelConfig(from_attributes=True)
# SQLAlchemy descriptors (hybrid_property, hybrid_method, association_proxy)
# are not Pydantic fields. Pydantic v2 otherwise raises ``PydanticUserError``
# ("A non-annotated attribute was detected") when they appear in a class body
# without a type annotation -- see https://github.com/fastapi/sqlmodel/issues/299
model_config = SQLModelConfig(
from_attributes=True,
ignored_types=(hybrid_property, hybrid_method, AssociationProxy),
)

# Typing spec says `__new__` returning `Any` overrides normal constructor
# behavior, but a missing annotation does not:
Expand Down
110 changes: 110 additions & 0 deletions tests/test_hybrid_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Tests for SQLAlchemy descriptor compatibility with SQLModel metaclass.

Regression tests for https://github.com/fastapi/sqlmodel/issues/299:

Declaring a ``sqlalchemy.ext.hybrid.hybrid_property`` (or ``hybrid_method``)
directly on a ``SQLModel`` class with ``table=True`` raises
``pydantic.errors.PydanticUserError: A non-annotated attribute was detected``
because Pydantic v2 inspects every non-dunder attribute on the class body and
expects an annotation. ``hybrid_property`` is a SQLAlchemy descriptor, not a
Pydantic field, so the SQLModel metaclass must tell Pydantic to skip it via
``model_config["ignored_types"]``.
"""

from datetime import datetime

from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property
from sqlmodel import Field, Session, SQLModel, create_engine


def _make_engine():
return create_engine("sqlite:///:memory:")


def test_table_model_allows_hybrid_property(clear_sqlmodel):
"""A ``hybrid_property`` defined on a ``table=True`` model must not crash
class construction and must be callable at the instance level."""

class Span(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
start: datetime
end: datetime

@hybrid_property
def duration_seconds(self) -> float:
return (self.end - self.start).total_seconds()

engine = _make_engine()
SQLModel.metadata.create_all(engine)
# The hybrid attribute must not be turned into a SQL column.
assert "duration_seconds" not in Span.__table__.columns

with Session(engine) as session:
span = Span(start=datetime(2024, 1, 1), end=datetime(2024, 1, 2))
session.add(span)
session.commit()
session.refresh(span)
assert span.duration_seconds == 86400.0


def test_table_model_allows_hybrid_method(clear_sqlmodel):
"""A ``hybrid_method`` must not raise during class construction."""

class Box(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
width: int
height: int

@hybrid_method
def area_at_least(self, threshold: int) -> bool:
return (self.width * self.height) >= threshold

engine = _make_engine()
SQLModel.metadata.create_all(engine)
assert "area_at_least" not in Box.__table__.columns

with Session(engine) as session:
box = Box(width=4, height=5)
session.add(box)
session.commit()
session.refresh(box)
assert box.area_at_least(10) is True
assert box.area_at_least(100) is False


def test_table_model_allows_association_proxy(clear_sqlmodel):
"""An ``association_proxy`` declared without an annotation must not raise.

The proxy itself does not need to be functional for this regression test;
its presence used to crash the metaclass in Pydantic v2 because
``AssociationProxy`` has no type annotation.
"""

class Item(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
label: str
# ``association_proxy`` is also a non-annotated SQLAlchemy descriptor.
# We do not need a working relationship to assert the metaclass does
# not blow up at class-body time -- that is the regression.
legacy_alias = association_proxy("label", "label")

engine = _make_engine()
SQLModel.metadata.create_all(engine)
assert "legacy_alias" not in Item.__table__.columns


def test_non_table_model_allows_hybrid_property(clear_sqlmodel):
"""The fix must also work for ``table=False`` (plain Pydantic) models so
that mix-ins shared between table and non-table classes do not break."""

class HasArea(SQLModel):
width: int = 0
height: int = 0

@hybrid_property
def area(self) -> int:
return self.width * self.height

instance = HasArea(width=3, height=4)
assert instance.area == 12
Loading