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
22 changes: 18 additions & 4 deletions sqlmodel/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ def get_model_fields(model: InstanceOrType[BaseModel]) -> dict[str, "FieldInfo"]
def init_pydantic_private_attrs(new_object: InstanceOrType["SQLModel"]) -> None:
object.__setattr__(new_object, "__pydantic_fields_set__", set())
object.__setattr__(new_object, "__pydantic_extra__", None)
object.__setattr__(new_object, "__pydantic_private__", None)
pydantic_private: dict[str, Any] = {}
for name, private_attr in new_object.__private_attributes__.items():
default = private_attr.get_default()
if default is not Undefined:
pydantic_private[name] = default
object.__setattr__(new_object, "__pydantic_private__", pydantic_private)


def get_annotations(class_dict: dict[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -259,9 +264,18 @@ def sqlmodel_table_construct(
if cls.__pydantic_post_init__:
self_instance.model_post_init(None)
elif not cls.__pydantic_root_model__:
# Note: if there are any private attributes, cls.__pydantic_post_init__ would exist
# Since it doesn't, that means that `__pydantic_private__` should be set to None
object.__setattr__(self_instance, "__pydantic_private__", None)
# Initialize __pydantic_private__ with defaults from private attributes
# rather than setting to None, so that PrivateAttr defaults are accessible
pydantic_private: dict[str, Any] = {}
for name, private_attr in cls.__private_attributes__.items():
default = private_attr.get_default()
if default is not Undefined:
pydantic_private[name] = default
object.__setattr__(
self_instance,
"__pydantic_private__",
pydantic_private if pydantic_private else None,
)
# SQLModel override, set relationships
# Get and set any relationship objects
for key in self_instance.__sqlmodel_relationships__:
Expand Down
152 changes: 152 additions & 0 deletions tests/test_private_attr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Tests for Pydantic PrivateAttr support in SQLModel.

Ensures that PrivateAttr defaults and default_factory values are properly
initialized both when constructing instances directly and when loading
them from the database via SQLAlchemy.

Ref: https://github.com/fastapi/sqlmodel/issues/149
"""

from typing import Optional

from pydantic import BaseModel, PrivateAttr
from sqlmodel import Field, Session, SQLModel, create_engine, select


def test_private_attr_default_on_construction():
"""PrivateAttr with a default should be accessible after construction."""

class HeroConstruct(SQLModel, table=True):
__tablename__ = "hero_construct"
id: Optional[int] = Field(default=None, primary_key=True)
secret_name: str
_name: str = PrivateAttr(default="default_hero")

engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)

hero = HeroConstruct(secret_name="Dive Wilson")
assert hero._name == "default_hero"


def test_private_attr_default_factory_on_construction():
"""PrivateAttr with default_factory should be accessible after construction."""

class InnerConfig(BaseModel):
setting: int = 42

class HeroFactory(SQLModel, table=True):
__tablename__ = "hero_factory"
id: Optional[int] = Field(default=None, primary_key=True)
secret_name: str
_config: InnerConfig = PrivateAttr(default_factory=InnerConfig)

engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)

hero = HeroFactory(secret_name="Dive Wilson")
assert hero._config.setting == 42


def test_private_attr_default_after_db_load():
"""PrivateAttr with a default should be accessible after loading from DB."""

class HeroDBDefault(SQLModel, table=True):
__tablename__ = "hero_db_default"
id: Optional[int] = Field(default=None, primary_key=True)
secret_name: str
_name: str = PrivateAttr(default="default_hero")

engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
hero = HeroDBDefault(secret_name="Dive Wilson")
session.add(hero)
session.commit()

with Session(engine) as session:
result = session.exec(select(HeroDBDefault)).first()
assert result is not None
assert result.__pydantic_private__ is not None
assert result._name == "default_hero"


def test_private_attr_default_factory_after_db_load():
"""PrivateAttr with default_factory should be accessible after loading from DB."""

class InnerConfig2(BaseModel):
setting: int = 99

class HeroDBFactory(SQLModel, table=True):
__tablename__ = "hero_db_factory"
id: Optional[int] = Field(default=None, primary_key=True)
secret_name: str
_config: InnerConfig2 = PrivateAttr(default_factory=InnerConfig2)

engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
hero = HeroDBFactory(secret_name="Dive Wilson")
session.add(hero)
session.commit()

with Session(engine) as session:
result = session.exec(select(HeroDBFactory)).first()
assert result is not None
assert result.__pydantic_private__ is not None
assert result._config.setting == 99


def test_private_attr_property_access_after_db_load():
"""Properties that access PrivateAttr should work after loading from DB."""

class UserWithProperty(SQLModel, table=True):
__tablename__ = "user_with_property"
id: Optional[int] = Field(default=None, primary_key=True)
username: str
_anonymous: bool = PrivateAttr(default=False)

@property
def is_anonymous(self) -> bool:
return self._anonymous

engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
user = UserWithProperty(username="testuser")
session.add(user)
session.commit()

with Session(engine) as session:
result = session.exec(select(UserWithProperty)).first()
assert result is not None
assert result.is_anonymous is False


def test_private_attr_without_default_not_in_private_dict():
"""PrivateAttr without a default should not appear in __pydantic_private__."""

class HeroNoDefault(SQLModel, table=True):
__tablename__ = "hero_no_default"
id: Optional[int] = Field(default=None, primary_key=True)
secret_name: str
_no_default: str = PrivateAttr()

engine = create_engine("sqlite://", echo=False)
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
hero = HeroNoDefault(secret_name="Test")
hero._no_default = "set_manually"
session.add(hero)
session.commit()

with Session(engine) as session:
result = session.exec(select(HeroNoDefault)).first()
assert result is not None
assert result.__pydantic_private__ is not None
# _no_default has no default, so it should NOT be pre-populated
assert "_no_default" not in result.__pydantic_private__