Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
5471ece
Support hybrid_property
50Bytes-dev Feb 14, 2024
c363ce6
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Feb 14, 2024
5bf07f8
fix
50Bytes-dev Feb 14, 2024
224c74b
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Feb 14, 2024
3f82be3
fix
50Bytes-dev Feb 14, 2024
953c01d
Merge remote-tracking branch 'origin/main'
50Bytes-dev Feb 14, 2024
e5dad94
add declared_attr, column_property support
50Bytes-dev Mar 1, 2024
6bfff90
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Mar 1, 2024
e5bb32a
Merge branch 'tiangolo:main' into main
50Bytes-dev Mar 1, 2024
5ade49a
fix tests
50Bytes-dev Mar 1, 2024
b6e2caf
fix tests
50Bytes-dev Mar 1, 2024
e0b201d
Update sqlmodel/main.py
50Bytes-dev Mar 7, 2024
7ac7889
Update sqlmodel/main.py
50Bytes-dev Mar 7, 2024
bb25d90
Update sqlmodel/main.py
50Bytes-dev Mar 7, 2024
8da2993
Merge branch 'tiangolo:main' into main
50Bytes-dev May 10, 2024
7a53368
fix
50Bytes-dev May 16, 2024
87d9c02
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] May 16, 2024
0fa2d40
fix
50Bytes-dev May 16, 2024
7f0aa44
Merge branch 'main' of github.com:50Bytes-dev/sqlmodel
50Bytes-dev May 16, 2024
2e76370
fix
50Bytes-dev May 28, 2024
a6b81de
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] May 28, 2024
23929c3
Merge branch 'tiangolo:main' into main
50Bytes-dev May 28, 2024
d5aa7dd
Merge branch 'tiangolo:main' into main
50Bytes-dev Jun 19, 2024
f4dadc5
fix
50Bytes-dev Jun 19, 2024
fba4308
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Jun 19, 2024
4afc41b
fix list type
50Bytes-dev Jun 20, 2024
7799fe9
Merge branch 'main' of github.com:50Bytes-dev/sqlmodel
50Bytes-dev Jun 20, 2024
d06c75a
add validation_alias
50Bytes-dev Jun 20, 2024
c980f31
fix
50Bytes-dev Jun 20, 2024
74d3129
Merge remote-tracking branch 'upstream/main'
50Bytes-dev Nov 5, 2024
1fae234
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Nov 5, 2024
88acd2a
fix
50Bytes-dev Nov 5, 2024
47b88bf
Merge branch 'main' of github.com:50Bytes-dev/sqlmodel
50Bytes-dev Nov 5, 2024
b08c757
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Nov 5, 2024
9db90a5
fix
50Bytes-dev Nov 5, 2024
ecd3997
Merge branch 'main' of github.com:50Bytes-dev/sqlmodel
50Bytes-dev Nov 5, 2024
14c5aba
add association proxy support
50Bytes-dev Nov 5, 2024
44e0cbe
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Nov 5, 2024
b1ae757
fix: update return type of col function to Column
50Bytes-dev Mar 4, 2025
0c96495
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] Mar 4, 2025
fdcd531
fix: extend sa_column type to include MappedSQLExpression
50Bytes-dev Mar 21, 2025
cf6b48b
Merge branch 'main' of github.com:50Bytes-dev/sqlmodel
50Bytes-dev Mar 21, 2025
b991725
fix
50Bytes-dev Mar 22, 2025
683b0c7
fix
50Bytes-dev Mar 22, 2025
e141966
Add comprehensive tests for Pydantic to SQLModel conversion and relat…
50Bytes-dev Jun 4, 2025
1229a44
refactor: streamline relationship update tests for Pydantic to SQLMod…
50Bytes-dev Jun 4, 2025
244069f
Fix: Correct relationship updates with forward references and test logic
google-labs-jules[bot] Jun 4, 2025
b5d7b2d
Merge pull request #1 from 50Bytes-dev/fix/relationship-update-forwar…
50Bytes-dev Jun 4, 2025
c875330
feat: enhance relationship handling for Pydantic to SQLModel conversi…
50Bytes-dev Jun 5, 2025
170b343
feat: enhance handling of association proxies in SQLModel initialization
50Bytes-dev Jul 3, 2025
c6f39f0
chore: add pdm.lock and .pdm-python to .gitignore
50Bytes-dev Jul 3, 2025
ddd0474
feat: add support for hybrid properties with setters in SQLModel and …
50Bytes-dev Aug 5, 2025
0566470
feat: implement __sqlalchemy_hybrid_property_setters__ for hybrid pro…
50Bytes-dev Aug 5, 2025
e4a89e8
feat: add deferred_column_property for safe deferred loading in SQLModel
50Bytes-dev Aug 7, 2025
5d255c5
feat: enhance async handling in SafeDeferredColumnLoader and add test…
50Bytes-dev Aug 7, 2025
ad7e957
feat: implement SafeAttributeWrapper for improved async handling and …
50Bytes-dev Aug 7, 2025
4ea632a
feat: implement event-based fallback for deferred_column_property; ad…
50Bytes-dev Aug 7, 2025
08e57d9
feat: refactor deferred_column_property to use standard ColumnPropert…
50Bytes-dev Aug 7, 2025
8e9fe31
feat: enhance deferred_column_property with additional parameters and…
50Bytes-dev Aug 7, 2025
5ff1e71
feat: update col function to return QueryableAttribute for improved t…
50Bytes-dev Aug 25, 2025
e9c74ee
feat: refactor compatibility handling for Pydantic v2; introduce new …
50Bytes-dev Oct 22, 2025
a6db8af
feat: add new utility functions for SQLModel to handle relations and …
50Bytes-dev Nov 20, 2025
fef15c5
feat: import new utility functions for handling columns by schema and…
50Bytes-dev Nov 20, 2025
833b870
feat: update validation_alias type in Field function to support Alias…
50Bytes-dev Nov 20, 2025
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
25 changes: 25 additions & 0 deletions sqlmodel/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@
inspect,
)
from sqlalchemy import Enum as sa_Enum
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import (
ColumnProperty,
Mapped,
RelationshipProperty,
declared_attr,
Expand Down Expand Up @@ -83,6 +85,7 @@
_T = TypeVar("_T")
NoArgAnyCallable = Callable[[], Any]
IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any], None]
SQLAlchemyConstruct = Union[hybrid_property, ColumnProperty, declared_attr]


def __dataclass_transform__(
Expand Down Expand Up @@ -388,6 +391,7 @@ def Relationship(
@__dataclass_transform__(kw_only_default=True, field_descriptors=(Field, FieldInfo))
class SQLModelMetaclass(ModelMetaclass, DeclarativeMeta):
__sqlmodel_relationships__: Dict[str, RelationshipInfo]
__sqlalchemy_constructs__: Dict[str, Any]
model_config: SQLModelConfig
model_fields: Dict[str, FieldInfo]
__config__: Type[SQLModelConfig]
Expand Down Expand Up @@ -415,13 +419,20 @@ def __new__(
**kwargs: Any,
) -> Any:
relationships: Dict[str, RelationshipInfo] = {}
sqlalchemy_constructs: Dict[str, SQLAlchemyConstruct] = {}
dict_for_pydantic = {}
original_annotations = get_annotations(class_dict)
pydantic_annotations = {}
relationship_annotations = {}
for k, v in class_dict.items():
if isinstance(v, RelationshipInfo):
relationships[k] = v
elif isinstance(v, hybrid_property):
sqlalchemy_constructs[k] = v
elif isinstance(v, ColumnProperty):
sqlalchemy_constructs[k] = v
elif isinstance(v, declared_attr):
sqlalchemy_constructs[k] = v
else:
dict_for_pydantic[k] = v
for k, v in original_annotations.items():
Expand All @@ -434,6 +445,7 @@ def __new__(
"__weakref__": None,
"__sqlmodel_relationships__": relationships,
"__annotations__": pydantic_annotations,
"__sqlalchemy_constructs__": sqlalchemy_constructs,
}
# Duplicate logic from Pydantic to filter config kwargs because if they are
# passed directly including the registry Pydantic will pass them over to the
Expand All @@ -455,6 +467,11 @@ def __new__(
**new_cls.__annotations__,
}

# We did not provide the sqlalchemy constructs to Pydantic's new function above
# so that they wouldn't be modified. Instead we set them directly to the class below:
for k, v in sqlalchemy_constructs.items():
setattr(new_cls, k, v)

def get_config(name: str) -> Any:
config_class_value = get_config_value(
model=new_cls, parameter=name, default=Undefined
Expand All @@ -472,6 +489,8 @@ def get_config(name: str) -> Any:
set_config_value(model=new_cls, parameter="table", value=config_table)
for k, v in get_model_fields(new_cls).items():
col = get_column_from_field(v)
if k in sqlalchemy_constructs:
continue
setattr(new_cls, k, col)
# Set a config flag to tell FastAPI that this should be read with a field
# in orm_mode instead of preemptively converting it to a dict.
Expand All @@ -493,6 +512,9 @@ def get_config(name: str) -> Any:
setattr(new_cls, "_sa_registry", config_registry) # noqa: B010
setattr(new_cls, "metadata", config_registry.metadata) # noqa: B010
setattr(new_cls, "__abstract__", True) # noqa: B010
setattr(new_cls, "__pydantic_private__", {}) # noqa: B010
setattr(new_cls, "__pydantic_extra__", {}) # noqa: B010

return new_cls

# Override SQLAlchemy, allow both SQLAlchemy and plain Pydantic models
Expand All @@ -506,6 +528,9 @@ def __init__(
base_is_table = any(is_table_model_class(base) for base in bases)
if is_table_model_class(cls) and not base_is_table:
for rel_name, rel_info in cls.__sqlmodel_relationships__.items():
if rel_name in cls.__sqlalchemy_constructs__:
# Skip hybrid properties
continue
if rel_info.sa_relationship:
# There's a SQLAlchemy relationship declared, that takes precedence
# over anything else, use that and continue with the next attribute
Expand Down
80 changes: 80 additions & 0 deletions tests/test_column_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from typing import List, Optional

from sqlalchemy import case, create_engine, func
from sqlalchemy.orm import column_property, declared_attr
from sqlmodel import Field, Relationship, Session, SQLModel, select


def test_query(clear_sqlmodel):
class Item(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
value: float
hero_id: int = Field(foreign_key="hero.id")
hero: "Hero" = Relationship(back_populates="items")

class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
items: List[Item] = Relationship(back_populates="hero")

@declared_attr
def total_items(cls):
return column_property(cls._total_items_expression())

@classmethod
def _total_items_expression(cls):
return (
select(func.coalesce(func.sum(Item.value), 0))
.where(Item.hero_id == cls.id)
.correlate_except(Item)
.label("total_items")
)

@declared_attr
def status(cls):
return column_property(
select(
case(
(cls._total_items_expression() > 0, "active"), else_="inactive"
)
).scalar_subquery()
)

hero_1 = Hero(name="Deadpond")
hero_2 = Hero(name="Spiderman")

engine = create_engine("sqlite://")

SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.commit()
session.refresh(hero_1)
session.refresh(hero_2)

item_1 = Item(value=1.0, hero_id=hero_1.id)
item_2 = Item(value=2.0, hero_id=hero_1.id)

with Session(engine) as session:
session.add(item_1)
session.add(item_2)
session.commit()
session.refresh(item_1)
session.refresh(item_2)

with Session(engine) as session:
hero_statement = select(Hero).where(Hero.total_items > 0.0)
hero = session.exec(hero_statement).first()
assert hero.name == "Deadpond"
assert hero.total_items == 3.0
assert hero.status == "active"

with Session(engine) as session:
hero_statement = select(Hero).where(
Hero.status == "inactive",
)
hero = session.exec(hero_statement).first()
assert hero.name == "Spiderman"
assert hero.total_items == 0.0
assert hero.status == "inactive"
13 changes: 13 additions & 0 deletions tests/test_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,19 @@ def sqlite_dump(sql: TypeEngine, *args, **kwargs):
sqlite_engine = create_mock_engine("sqlite://", sqlite_dump)


def _reset_metadata():
SQLModel.metadata.clear()

class FlatModel(SQLModel, table=True):
id: uuid.UUID = Field(primary_key=True)
enum_field: MyEnum1

class InheritModel(BaseModel, table=True):
pass


def test_postgres_ddl_sql(capsys):
_reset_metadata()
SQLModel.metadata.create_all(bind=postgres_engine, checkfirst=False)

captured = capsys.readouterr()
Expand All @@ -67,6 +79,7 @@ def test_postgres_ddl_sql(capsys):


def test_sqlite_ddl_sql(capsys):
_reset_metadata()
SQLModel.metadata.create_all(bind=sqlite_engine, checkfirst=False)

captured = capsys.readouterr()
Expand Down
80 changes: 80 additions & 0 deletions tests/test_hybrid_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from typing import List, Optional

from sqlalchemy import case, create_engine, func
from sqlalchemy.ext.hybrid import hybrid_property
from sqlmodel import Field, Relationship, Session, SQLModel, select


def test_query(clear_sqlmodel):
class Item(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
value: float
hero_id: int = Field(foreign_key="hero.id")
hero: "Hero" = Relationship(back_populates="items")

class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
items: List[Item] = Relationship(back_populates="hero")

@hybrid_property
def total_items(self):
return sum([item.value for item in self.items], 0)

@total_items.inplace.expression
@classmethod
def _total_items_expression(cls):
return (
select(func.coalesce(func.sum(Item.value), 0))
.where(Item.hero_id == cls.id)
.correlate(cls)
.label("total_items")
)

@hybrid_property
def status(self):
return "active" if self.total_items > 0 else "inactive"

@status.inplace.expression
@classmethod
def _status_expression(cls):
return select(
case((cls.total_items > 0, "active"), else_="inactive")
).label("status")

hero_1 = Hero(name="Deadpond")
hero_2 = Hero(name="Spiderman")

engine = create_engine("sqlite://")

SQLModel.metadata.create_all(engine)
with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.commit()
session.refresh(hero_1)
session.refresh(hero_2)

item_1 = Item(value=1.0, hero_id=hero_1.id)
item_2 = Item(value=2.0, hero_id=hero_1.id)

with Session(engine) as session:
session.add(item_1)
session.add(item_2)
session.commit()
session.refresh(item_1)
session.refresh(item_2)

with Session(engine) as session:
hero_statement = select(Hero).where(Hero.total_items > 0.0)
hero = session.exec(hero_statement).first()
assert hero.total_items == 3.0
assert hero.status == "active"

with Session(engine) as session:
hero_statement = select(Hero).where(
Hero.status == "inactive",
)
hero = session.exec(hero_statement).first()
assert hero.total_items == 0.0
assert hero.status == "inactive"