Skip to content

Commit b105b6d

Browse files
committed
fix: re-initialize SQLAlchemy InstanceState on table model_copy
1 parent 7fec3bc commit b105b6d

2 files changed

Lines changed: 156 additions & 0 deletions

File tree

sqlmodel/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
init_pydantic_private_attrs,
7171
is_field_noneable,
7272
is_table_model_class,
73+
partial_init,
7374
sqlmodel_init,
7475
sqlmodel_validate,
7576
)
@@ -995,6 +996,38 @@ def dict(
995996
exclude_none=exclude_none,
996997
)
997998

999+
def model_copy(
1000+
self: _TSQLModel,
1001+
*,
1002+
update: Mapping[str, Any] | None = None,
1003+
deep: bool = False,
1004+
) -> _TSQLModel:
1005+
new_copy = super().model_copy(update=update, deep=deep)
1006+
if is_table_model_class(self.__class__):
1007+
new_copy.__dict__.pop("_sa_instance_state", None)
1008+
with partial_init():
1009+
self.__class__.__init__(new_copy)
1010+
return new_copy
1011+
1012+
@deprecated(
1013+
"""
1014+
🚨 `obj.copy()` was deprecated in SQLModel 0.0.14, you should
1015+
instead use `obj.model_copy()`.
1016+
"""
1017+
)
1018+
def copy(
1019+
self: _TSQLModel,
1020+
*,
1021+
include: IncEx | None = None,
1022+
exclude: IncEx | None = None,
1023+
update: Mapping[str, Any] | None = None,
1024+
deep: bool = False,
1025+
) -> _TSQLModel:
1026+
return self.model_copy(
1027+
update=update,
1028+
deep=deep,
1029+
)
1030+
9981031
@classmethod
9991032
@deprecated(
10001033
"""
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import pytest
2+
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select
3+
4+
5+
def test_model_copy_non_table(clear_sqlmodel):
6+
class Item(SQLModel):
7+
name: str
8+
value: int
9+
10+
item = Item(name="Test Item", value=42)
11+
copied = item.model_copy()
12+
assert copied.name == "Test Item"
13+
assert copied.value == 42
14+
assert copied is not item
15+
16+
updated_copy = item.model_copy(update={"name": "New Name"})
17+
assert updated_copy.name == "New Name"
18+
assert updated_copy.value == 42
19+
20+
21+
def test_model_copy_unpersisted_table(clear_sqlmodel):
22+
class Hero(SQLModel, table=True):
23+
id: int | None = Field(default=None, primary_key=True)
24+
name: str
25+
secret_name: str
26+
27+
hero = Hero(name="Deadpond", secret_name="Dive Wilson")
28+
copied = hero.model_copy()
29+
assert copied.name == "Deadpond"
30+
assert copied.secret_name == "Dive Wilson"
31+
assert copied.id is None
32+
assert copied is not hero
33+
assert copied._sa_instance_state is not hero._sa_instance_state
34+
assert copied._sa_instance_state.obj() is copied
35+
36+
37+
def test_model_copy_persisted_table(clear_sqlmodel):
38+
class Hero(SQLModel, table=True):
39+
id: int | None = Field(default=None, primary_key=True)
40+
name: str
41+
secret_name: str
42+
age: int | None = None
43+
44+
engine = create_engine("sqlite://")
45+
SQLModel.metadata.create_all(engine)
46+
47+
with Session(engine) as session:
48+
hero = Hero(name="Deadpond", secret_name="Dive Wilson", age=30)
49+
session.add(hero)
50+
session.commit()
51+
session.refresh(hero)
52+
53+
# Create copy of persisted instance
54+
hero_copy = hero.model_copy(update={"name": "Spider-Boy"}, deep=True)
55+
56+
assert hero_copy is not hero
57+
assert hero_copy._sa_instance_state is not hero._sa_instance_state
58+
assert hero_copy._sa_instance_state.obj() is hero_copy
59+
assert hero_copy.name == "Spider-Boy"
60+
assert hero_copy.secret_name == "Dive Wilson"
61+
assert hero_copy.age == 30
62+
63+
# Verify mutating the copy does not alter the original in session
64+
hero_copy.secret_name = "Peter Parker"
65+
assert hero.secret_name == "Dive Wilson"
66+
67+
# Verify copy can be persisted independently
68+
hero_copy.id = None
69+
session.add(hero_copy)
70+
session.commit()
71+
session.refresh(hero_copy)
72+
73+
assert hero.id == 1
74+
assert hero.name == "Deadpond"
75+
assert hero_copy.id == 2
76+
assert hero_copy.name == "Spider-Boy"
77+
78+
with Session(engine) as session:
79+
all_heroes = session.exec(select(Hero)).all()
80+
assert len(all_heroes) == 2
81+
assert {h.name for h in all_heroes} == {"Deadpond", "Spider-Boy"}
82+
83+
84+
def test_model_copy_with_relationships(clear_sqlmodel):
85+
class Team(SQLModel, table=True):
86+
id: int | None = Field(default=None, primary_key=True)
87+
name: str
88+
heroes: list["Hero"] = Relationship(back_populates="team")
89+
90+
class Hero(SQLModel, table=True):
91+
id: int | None = Field(default=None, primary_key=True)
92+
name: str
93+
team_id: int | None = Field(default=None, foreign_key="team.id")
94+
team: Team | None = Relationship(back_populates="heroes")
95+
96+
engine = create_engine("sqlite://")
97+
SQLModel.metadata.create_all(engine)
98+
99+
with Session(engine) as session:
100+
team = Team(name="Avengers")
101+
hero = Hero(name="Deadpond", team=team)
102+
session.add(team)
103+
session.add(hero)
104+
session.commit()
105+
session.refresh(hero)
106+
107+
hero_copy = hero.model_copy(update={"name": "Rusty-Man"})
108+
assert hero_copy._sa_instance_state is not hero._sa_instance_state
109+
assert hero_copy._sa_instance_state.obj() is hero_copy
110+
assert hero_copy.name == "Rusty-Man"
111+
112+
113+
def test_deprecated_copy(clear_sqlmodel):
114+
class Hero(SQLModel, table=True):
115+
id: int | None = Field(default=None, primary_key=True)
116+
name: str
117+
118+
hero = Hero(name="Deadpond")
119+
with pytest.deprecated_call():
120+
hero_copy = hero.copy(update={"name": "Spider-Boy"})
121+
assert hero_copy.name == "Spider-Boy"
122+
assert hero_copy._sa_instance_state is not hero._sa_instance_state
123+
assert hero_copy._sa_instance_state.obj() is hero_copy

0 commit comments

Comments
 (0)