From d818e00f4a9877a2d53b550c730e8b896e118500 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 8 Feb 2026 15:02:50 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20PrivateAttr=20defaults=20n?= =?UTF-8?q?ot=20initialized=20when=20loading=20from=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SQLAlchemy loads objects from the database, it bypasses __init__ and only calls __new__. The init_pydantic_private_attrs function was setting __pydantic_private__ to None, which caused accessing any PrivateAttr to raise "TypeError: 'NoneType' object is not subscriptable". Now properly initializes __pydantic_private__ with default values from __private_attributes__, matching Pydantic's own init_private_attributes behavior. Also fixes the same issue in sqlmodel_table_construct. Fixes #149 Co-Authored-By: Claude Opus 4.6 --- sqlmodel/_compat.py | 22 +++++- tests/test_private_attr.py | 152 +++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 tests/test_private_attr.py diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index 5907d279c8..033801665f 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -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]: @@ -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__: diff --git a/tests/test_private_attr.py b/tests/test_private_attr.py new file mode 100644 index 0000000000..e0a2615cac --- /dev/null +++ b/tests/test_private_attr.py @@ -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__