diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index a220b193f1..818410f3bd 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -94,7 +94,20 @@ 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) + # Initialize __pydantic_private__ with defaults from __private_attributes__, + # mirroring what Pydantic's own BaseModel.__init__ does. Previously this was + # set to None, which caused AttributeError when accessing PrivateAttr fields + # on instances reconstructed from the database (via __new__, bypassing __init__). + cls = new_object if isinstance(new_object, type) else new_object.__class__ + private_attributes = getattr(cls, "__private_attributes__", {}) + pydantic_private = {} + for k, v in private_attributes.items(): + pydantic_private[k] = v.get_default() + object.__setattr__( + new_object, + "__pydantic_private__", + pydantic_private if pydantic_private else None, + ) def get_annotations(class_dict: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_private_attr.py b/tests/test_private_attr.py new file mode 100644 index 0000000000..682f19052a --- /dev/null +++ b/tests/test_private_attr.py @@ -0,0 +1,52 @@ +from pydantic import PrivateAttr +from sqlmodel import Field, Session, SQLModel, create_engine, select + + +def test_private_attr_default_preserved_after_db_load(clear_sqlmodel): + """PrivateAttr defaults should be available on instances loaded from DB (#149).""" + + class Item(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str + _secret: str = PrivateAttr(default="default_secret") + + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + + with Session(engine) as db: + item = Item(name="test") + assert item._secret == "default_secret" + db.add(item) + db.commit() + + with Session(engine) as db: + loaded_item = db.exec(select(Item)).one() + # Previously raised AttributeError because __pydantic_private__ was None. + assert loaded_item._secret == "default_secret" + + SQLModel.metadata.clear() + + +def test_private_attr_default_factory_preserved_after_db_load(clear_sqlmodel): + """PrivateAttr with default_factory should work on DB-loaded instances (#149).""" + + class Item(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str + _tags: list[str] = PrivateAttr(default_factory=list) + + engine = create_engine("sqlite:///:memory:") + SQLModel.metadata.create_all(engine) + + with Session(engine) as db: + item = Item(name="test") + item._tags.append("hello") + db.add(item) + db.commit() + + with Session(engine) as db: + loaded_item = db.exec(select(Item)).one() + # Should get a fresh empty list from default_factory, not AttributeError. + assert loaded_item._tags == [] + + SQLModel.metadata.clear()