From d58908203f3b85dac202a17ef6fe9d6c2fbd0bfc Mon Sep 17 00:00:00 2001 From: xianren Date: Wed, 18 Mar 2026 15:38:13 +0800 Subject: [PATCH 1/3] Fixed PrivateAttr defaults lost when loading from database (#149) When SQLAlchemy reconstructs a model instance from a database query (bypassing __init__), it goes through __new__ which calls init_pydantic_private_attrs(). That function was setting __pydantic_private__ = None instead of initializing it with the private attribute defaults. This caused AttributeError when accessing PrivateAttr fields on database-loaded instances. The fix introspects the class's __private_attributes__ dict and calls get_default() on each entry, mirroring what Pydantic's own __init__ does. Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlmodel/_compat.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index a220b193f1..8505b052b3 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -94,7 +94,18 @@ 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__). + pydantic_private = {} + for k, v in new_object.__class__.__private_attributes__.items(): + pydantic_private[k] = v.get_default(call_default_factory=True) + object.__setattr__( + new_object, + "__pydantic_private__", + pydantic_private if pydantic_private else None, + ) def get_annotations(class_dict: dict[str, Any]) -> dict[str, Any]: From e0e7863b420e43fad32916da7d8cf350b07be8f3 Mon Sep 17 00:00:00 2001 From: xianren Date: Wed, 18 Mar 2026 21:51:10 +0800 Subject: [PATCH 2/3] Fixed mypy error and added tests for PrivateAttr defaults (#149) - Used getattr() instead of direct attribute access to satisfy mypy's union-attr check on InstanceOrType. - Added regression tests verifying PrivateAttr with default and default_factory work correctly on database-loaded instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlmodel/_compat.py | 4 ++- tests/test_private_attr.py | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/test_private_attr.py diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index 8505b052b3..ede2f7f175 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -98,8 +98,10 @@ def init_pydantic_private_attrs(new_object: InstanceOrType["SQLModel"]) -> None: # 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 new_object.__class__.__private_attributes__.items(): + for k, v in private_attributes.items(): pydantic_private[k] = v.get_default(call_default_factory=True) object.__setattr__( new_object, 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() From 56cd0cf12f776eaef9a8af7f3f3cb220a7f3b271 Mon Sep 17 00:00:00 2001 From: xianren Date: Thu, 19 Mar 2026 07:39:16 +0800 Subject: [PATCH 3/3] Fixed Pydantic API compatibility for ModelPrivateAttr.get_default (#149) Pydantic 2.x's ModelPrivateAttr.get_default() takes no arguments and handles both default and default_factory internally. Removed the call_default_factory=True kwarg that caused TypeError on Pydantic >=2.11. Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlmodel/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index ede2f7f175..818410f3bd 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -102,7 +102,7 @@ def init_pydantic_private_attrs(new_object: InstanceOrType["SQLModel"]) -> None: private_attributes = getattr(cls, "__private_attributes__", {}) pydantic_private = {} for k, v in private_attributes.items(): - pydantic_private[k] = v.get_default(call_default_factory=True) + pydantic_private[k] = v.get_default() object.__setattr__( new_object, "__pydantic_private__",