SQLModel inheritance breaks sqlalchemy mutation tracking #1558
-
First Check
Commit to Help
Example Code#!/usr/bin/env python3 from typing import Optional, Any import sqlalchemy from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.mutable import MutableDict, MutableSet from sqlalchemy.orm import sessionmaker from sqlalchemy import JSON, Column from sqlmodel import SQLModel, Field, create_engine Session = sessionmaker() Base = declarative_base() class BadTrackBase(SQLModel): custom: dict[str, Any] = Field( sa_column=Column(MutableDict.as_mutable(JSON(none_as_null=True))), default={} ) class BadTrack(BadTrackBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) class Track(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) custom: dict[str, Any] = Field( sa_column=Column(MutableDict.as_mutable(JSON(none_as_null=True))), default={} ) engine = create_engine("sqlite:///:memory:") Session.configure(bind=engine) SQLModel.metadata.create_all(engine) session = Session() good_track = Track(id=1, custom={"test": "good"}) bad_track = BadTrack(id=1, custom={"test": "bad"}) session.add(good_track) session.add(bad_track) session.commit() good_track.custom["test"] = "changed" bad_track.custom["test"] = "changed" assert good_track in session.dirty assert bad_track in session.dirtyDescriptionWhen setting a field to one of the fields supported by sqlalchemy mutation tracking, it seems the mutation tracking isn't working as intended. I attached the above code sample to illustrate what I mean. The assertions on the bottom of the script should pass under normal circumstance. Operating SystemLinux Operating System DetailsNo response SQLModel Version0.08 Python Version3.9.13 Additional ContextNo response |
Beta Was this translation helpful? Give feedback.
Replies: 12 comments 1 reply
-
| I have the same issue in SQLModel 0.08 and it's kind of a big deal. Is there a workaround for this besides re-assigning the entire dict i.e. |
Beta Was this translation helpful? Give feedback.
-
| Any update here, A big blocker before I can migrate from sqlalchemy+pydantic to sqlmodel. |
Beta Was this translation helpful? Give feedback.
-
| Also a blocker here, commenting to stay in the loop! |
Beta Was this translation helpful? Give feedback.
-
| yep, same here, blocker. |
Beta Was this translation helpful? Give feedback.
-
| Blocker here as well. Thanks for your post, saved me from burning any more time on figuring out what was going on with mutation tracking. |
Beta Was this translation helpful? Give feedback.
-
Problem still present with sqlmodel 0.0.22. |
Beta Was this translation helpful? Give feedback.
-
| I tested it and that's what I found:
good_track.custom["test"] = "changed first time" assert good_track in session.dirty # good_track is in dirty bad_track.custom["test"] = "changed first time" assert bad_track in session.dirty # bad_track is in dirty assert good_track not in session.dirty # good_track is NOT in dirty anymore !!!At the same time, mutation is handled correctly - if you commit, the object will be updated. session.commit() session.flush() good_track_db = session.get(Track, 1) bad_track_db = session.get(BadTrack, 1) assert good_track_db.custom["test"] == "changed first time" # Mutation worked out assert bad_track_db.custom["test"] == "changed first time" # Mutation worked outAlso, it only happens if you add object and commit, then mutate it. good_track_db = session.get(Track, 1) bad_track_db = session.get(BadTrack, 1) good_track_db.custom["test"] = "changed" bad_track_db.custom["test"] = "changed" assert good_track_db in session.dirty assert bad_track_db in session.dirtyFull code in details: Detailsfrom typing import Any, Optional from sqlalchemy import JSON, Column from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.orm import sessionmaker from sqlmodel import Field, SQLModel, create_engine Session = sessionmaker() class BadTrackBase(SQLModel): custom: dict[str, Any] = Field( sa_column=Column(MutableDict.as_mutable(JSON(none_as_null=True))), default={} ) class BadTrack(BadTrackBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) class Track(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) custom: dict[str, Any] = Field( sa_column=Column(MutableDict.as_mutable(JSON(none_as_null=True))), default={} ) engine = create_engine("sqlite:///:memory:") Session.configure(bind=engine) SQLModel.metadata.create_all(engine) session = Session() # ==================================================================================== good_track = Track(id=1, custom={"test": "good"}) bad_track = BadTrack(id=1, custom={"test": "bad"}) session.add(good_track) session.add(bad_track) session.commit() good_track.custom["test"] = "changed first time" assert good_track in session.dirty # good_track is in dirty bad_track.custom["test"] = "changed first time" assert bad_track in session.dirty # bad_track is in dirty assert good_track not in session.dirty # good_track is NOT in dirty anymore !!! session.commit() session.flush() good_track_db = session.get(Track, 1) bad_track_db = session.get(BadTrack, 1) assert good_track_db.custom["test"] == "changed first time" # Mutation worked out assert bad_track_db.custom["test"] == "changed first time" # Mutation worked out # ==================================================================================== # But now session.dirty works fine good_track_db.custom["test"] = "changed" assert good_track_db in session.dirty, session.dirty bad_track_db.custom["test"] = "changed" assert good_track_db in session.dirty, session.dirty assert bad_track_db in session.dirtySo, I think there is no issue here. The @jtpavlock, do you think we can close this issue now? |
Beta Was this translation helpful? Give feedback.
This comment was marked as off-topic.
This comment was marked as off-topic.
-
| Why close this issue? The issue documents broken behavior. Things like |
Beta Was this translation helpful? Give feedback.
-
| @YuriiMotov came back to say that the "weird" behavior isn't actually broken or weird. I think it's the expected behavior when autoflush is enabled (which it is by default). With |
Beta Was this translation helpful? Give feedback.
-
| This is my workaround that is working for now. class WithDataModel(SQLModel): data: dict = Field(sa_type=JSONB, sa_column_kwargs={'server_default': '{}'}) def __setattr__(self, name: str, value: Any) -> None: if name == 'data': set_attribute(self, name, value) super().__setattr__(name, self.data) else: super().__setattr__(name, value) MutableDict.associate_with(JSONB) |
Beta Was this translation helpful? Give feedback.
-
| I just ran across this; it does actually seem like inheritance breaks mutability tracking, as measured by "does #!/usr/bin/env python3 from typing import Any from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.mutable import MutableDict, MutableSet from sqlalchemy.orm import sessionmaker from sqlalchemy import JSON, Column from sqlmodel import SQLModel, Field, create_engine Session = sessionmaker() Base = declarative_base() class BaseModel(SQLModel): custom: dict[str, Any] = Field( sa_column=Column(MutableDict.as_mutable(JSON(none_as_null=True))), default={} ) class ConcreteModel(BaseModel, table=True): id: int | None = Field(default=None, primary_key=True) engine = create_engine("sqlite:///:memory:") Session.configure(bind=engine) SQLModel.metadata.create_all(engine) session = Session() instance = ConcreteModel(id=1, custom={"test": "old"}) session.add(instance) session.commit() instance = session.get(ConcreteModel, 1) assert instance.custom["test"] == "old" instance.custom["test"] = "new" session.add(instance) session.commit() instance = session.get(ConcreteModel, 1) assert instance.custom["test"] == "new", f"Actual: {instance.custom['test']}"Contrast to the same code, without inheritance: #!/usr/bin/env python3 from typing import Any from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.mutable import MutableDict, MutableSet from sqlalchemy.orm import sessionmaker from sqlalchemy import JSON, Column from sqlmodel import SQLModel, Field, create_engine Session = sessionmaker() Base = declarative_base() class Model(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) custom: dict[str, Any] = Field( sa_column=Column(MutableDict.as_mutable(JSON(none_as_null=True))), default={} ) engine = create_engine("sqlite:///:memory:") Session.configure(bind=engine) SQLModel.metadata.create_all(engine) session = Session() instance = Model(id=1, custom={"test": "old"}) session.add(instance) session.commit() instance = session.get(Model, 1) assert instance.custom["test"] == "old" instance.custom["test"] = "new" session.add(instance) session.commit() instance = session.get(Model, 1) assert instance.custom["test"] == "new", f"Actual: {instance.custom['test']}"(no output) This is especially problematic because this inheritance pattern seems like maybe the sanest way to work around the lack of Pydantic validations in SQLModel (see option 7 in #52 (comment)). |
Beta Was this translation helpful? Give feedback.
I tested it and that's what I found:
session.dirtybehaves a bit strange, but it seems that it's not related to inheritance.If you try mutating only one of
good_trackorbad_trackit will work well (object will be insession.dirtyas expected).But after you mutate second object, first one is not in
session.dirtyanymore... But second object is INsession.dirty.At the same time, mutation is h…