Skip to content

Commit 9b51f27

Browse files
committed
Validate SQLModel on all Non-Database Sourced Data
Enable Pydantic validation on table=True model __init__, matching the existing behavior of model_validate(). Validation does not run on ORM loads from the database — SQLAlchemy does not call __init__ when hydrating from query results.
1 parent 530c268 commit 9b51f27

File tree

3 files changed

+268
-26
lines changed

3 files changed

+268
-26
lines changed

sqlmodel/_compat.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -328,18 +328,22 @@ def sqlmodel_validate(
328328

329329
def sqlmodel_init(*, self: "SQLModel", data: dict[str, Any]) -> None:
330330
old_dict = self.__dict__.copy()
331+
self.__pydantic_validator__.validate_python(
332+
data,
333+
self_instance=self,
334+
)
331335
if not is_table_model_class(self.__class__):
332-
self.__pydantic_validator__.validate_python(
333-
data,
334-
self_instance=self,
336+
object.__setattr__(
337+
self,
338+
"__dict__",
339+
{**old_dict, **self.__dict__},
335340
)
336341
else:
337-
sqlmodel_table_construct(
338-
self_instance=self,
339-
values=data,
340-
)
341-
object.__setattr__(
342-
self,
343-
"__dict__",
344-
{**old_dict, **self.__dict__},
345-
)
342+
fields_set = self.__pydantic_fields_set__.copy()
343+
for key, value in {**old_dict, **self.__dict__}.items():
344+
setattr(self, key, value)
345+
object.__setattr__(self, "__pydantic_fields_set__", fields_set)
346+
for key in self.__sqlmodel_relationships__:
347+
value = data.get(key, Undefined)
348+
if value is not Undefined:
349+
setattr(self, key, value)

tests/test_instance_no_args.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@
33
from sqlmodel import Field, Session, SQLModel, create_engine, select
44

55

6-
def test_allow_instantiation_without_arguments(clear_sqlmodel):
6+
def test_not_allow_instantiation_without_arguments(clear_sqlmodel):
7+
class Item(SQLModel, table=True):
8+
id: int | None = Field(default=None, primary_key=True)
9+
name: str
10+
description: str | None = None
11+
12+
with pytest.raises(ValidationError):
13+
Item()
14+
15+
16+
def test_allow_instantiation_with_required_arguments(clear_sqlmodel):
717
class Item(SQLModel, table=True):
818
id: int | None = Field(default=None, primary_key=True)
919
name: str
@@ -12,22 +22,11 @@ class Item(SQLModel, table=True):
1222
engine = create_engine("sqlite:///:memory:")
1323
SQLModel.metadata.create_all(engine)
1424
with Session(engine) as db:
15-
item = Item()
16-
item.name = "Rick"
25+
item = Item(name="Rick")
1726
db.add(item)
1827
db.commit()
1928
statement = select(Item)
2029
result = db.exec(statement).all()
2130
assert len(result) == 1
2231
assert isinstance(item.id, int)
2332
SQLModel.metadata.clear()
24-
25-
26-
def test_not_allow_instantiation_without_arguments_if_not_table():
27-
class Item(SQLModel):
28-
id: int | None = Field(default=None, primary_key=True)
29-
name: str
30-
description: str | None = None
31-
32-
with pytest.raises(ValidationError):
33-
Item()

tests/test_validation.py

Lines changed: 240 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22
from pydantic.error_wrappers import ValidationError
3-
from sqlmodel import SQLModel
3+
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select
44

55

66
def test_validation_pydantic_v2(clear_sqlmodel):
@@ -29,3 +29,242 @@ def reject_none(cls, v):
2929

3030
with pytest.raises(ValidationError):
3131
Hero.model_validate({"name": None, "age": 25})
32+
33+
34+
def test_table_model_field_validator(clear_sqlmodel):
35+
from pydantic import field_validator
36+
37+
class Hero(SQLModel, table=True):
38+
id: int | None = Field(default=None, primary_key=True)
39+
name: str
40+
age: int | None = None
41+
42+
@field_validator("name")
43+
@classmethod
44+
def name_must_not_be_empty(cls, v: str) -> str:
45+
if not v.strip():
46+
raise ValueError("name must not be empty")
47+
return v
48+
49+
Hero(name="Deadpond", age=25)
50+
51+
with pytest.raises(ValidationError):
52+
Hero(name="", age=25)
53+
54+
55+
def test_table_model_field_validator_before_mode(clear_sqlmodel):
56+
from pydantic import field_validator
57+
58+
class Hero(SQLModel, table=True):
59+
id: int | None = Field(default=None, primary_key=True)
60+
name: str
61+
62+
@field_validator("name", mode="before")
63+
@classmethod
64+
def coerce_name(cls, v: object) -> str:
65+
if isinstance(v, int):
66+
return f"Hero-{v}"
67+
return v
68+
69+
hero = Hero(name=42)
70+
assert hero.name == "Hero-42"
71+
72+
73+
def test_table_model_model_validator_after(clear_sqlmodel):
74+
from pydantic import model_validator
75+
76+
class Hero(SQLModel, table=True):
77+
id: int | None = Field(default=None, primary_key=True)
78+
name: str
79+
secret_name: str
80+
81+
@model_validator(mode="after")
82+
def names_must_differ(self) -> "Hero":
83+
if self.name == self.secret_name:
84+
raise ValueError("name and secret_name must differ")
85+
return self
86+
87+
Hero(name="Deadpond", secret_name="Dive Wilson")
88+
89+
with pytest.raises(ValidationError):
90+
Hero(name="Same", secret_name="Same")
91+
92+
93+
def test_table_model_model_validator_before(clear_sqlmodel):
94+
from pydantic import model_validator
95+
96+
class Hero(SQLModel, table=True):
97+
id: int | None = Field(default=None, primary_key=True)
98+
name: str
99+
100+
@model_validator(mode="before")
101+
@classmethod
102+
def uppercase_name(cls, data: dict) -> dict:
103+
if "name" in data:
104+
data["name"] = data["name"].upper()
105+
return data
106+
107+
hero = Hero(name="deadpond")
108+
assert hero.name == "DEADPOND"
109+
110+
111+
def test_table_model_before_validator_annotated(clear_sqlmodel):
112+
from typing import Annotated
113+
114+
from pydantic import BeforeValidator
115+
116+
def parse_int(v: object) -> object:
117+
if isinstance(v, str) and v.isdigit():
118+
return int(v)
119+
return v
120+
121+
class Hero(SQLModel, table=True):
122+
id: int | None = Field(default=None, primary_key=True)
123+
name: str
124+
age: Annotated[int | None, BeforeValidator(parse_int)] = None
125+
126+
hero = Hero(name="Deadpond", age="25")
127+
assert hero.age == 25
128+
129+
130+
def test_table_model_orm_round_trip_with_validator(clear_sqlmodel):
131+
from pydantic import field_validator
132+
133+
class Hero(SQLModel, table=True):
134+
id: int | None = Field(default=None, primary_key=True)
135+
name: str
136+
age: int | None = None
137+
138+
@field_validator("age")
139+
@classmethod
140+
def double_age(cls, v: int | None) -> int | None:
141+
if v is not None:
142+
return v * 2
143+
return v
144+
145+
engine = create_engine("sqlite:///:memory:")
146+
SQLModel.metadata.create_all(engine)
147+
148+
hero = Hero(name="Deadpond", age=25)
149+
assert hero.age == 50
150+
151+
with Session(engine) as session:
152+
session.add(hero)
153+
session.commit()
154+
session.refresh(hero)
155+
156+
with Session(engine) as session:
157+
loaded = session.exec(select(Hero)).first()
158+
assert loaded is not None
159+
assert loaded.name == "Deadpond"
160+
assert loaded.age == 50
161+
162+
SQLModel.metadata.clear()
163+
164+
165+
def test_validation_does_not_run_on_orm_load(clear_sqlmodel):
166+
from pydantic import field_validator
167+
168+
class Hero(SQLModel, table=True):
169+
id: int | None = Field(default=None, primary_key=True)
170+
name: str
171+
172+
@field_validator("name")
173+
@classmethod
174+
def name_must_be_short(cls, v: str) -> str:
175+
if len(v) > 5:
176+
raise ValueError("too long")
177+
return v
178+
179+
engine = create_engine("sqlite:///:memory:")
180+
SQLModel.metadata.create_all(engine)
181+
182+
with Session(engine) as session:
183+
session.add(Hero(name="short"))
184+
session.commit()
185+
186+
with engine.connect() as conn:
187+
conn.execute(
188+
Hero.__table__.update()
189+
.where(Hero.__table__.c.id == 1)
190+
.values(name="this is way too long")
191+
)
192+
conn.commit()
193+
194+
with Session(engine) as session:
195+
loaded = session.exec(select(Hero)).first()
196+
assert loaded is not None
197+
assert loaded.name == "this is way too long"
198+
199+
SQLModel.metadata.clear()
200+
201+
202+
def test_table_model_relationship_without_related_object(clear_sqlmodel):
203+
class Team(SQLModel, table=True):
204+
id: int | None = Field(default=None, primary_key=True)
205+
name: str
206+
heroes: list["Hero"] = Relationship(back_populates="team")
207+
208+
class Hero(SQLModel, table=True):
209+
id: int | None = Field(default=None, primary_key=True)
210+
name: str
211+
team_id: int | None = Field(default=None, foreign_key="team.id")
212+
team: Team | None = Relationship(back_populates="heroes")
213+
214+
team = Team(name="Preventers")
215+
hero = Hero(name="Deadpond")
216+
217+
engine = create_engine("sqlite:///:memory:")
218+
SQLModel.metadata.create_all(engine)
219+
220+
with Session(engine) as session:
221+
session.add(team)
222+
session.add(hero)
223+
session.commit()
224+
session.refresh(hero)
225+
assert hero.team is None
226+
227+
SQLModel.metadata.clear()
228+
229+
230+
def test_table_model_relationship_assigned_after_construction(clear_sqlmodel):
231+
class Team(SQLModel, table=True):
232+
id: int | None = Field(default=None, primary_key=True)
233+
name: str
234+
heroes: list["Hero"] = Relationship(back_populates="team")
235+
236+
class Hero(SQLModel, table=True):
237+
id: int | None = Field(default=None, primary_key=True)
238+
name: str
239+
team_id: int | None = Field(default=None, foreign_key="team.id")
240+
team: Team | None = Relationship(back_populates="heroes")
241+
242+
team = Team(name="Preventers")
243+
hero = Hero(name="Deadpond")
244+
hero.team = team
245+
246+
engine = create_engine("sqlite:///:memory:")
247+
SQLModel.metadata.create_all(engine)
248+
249+
with Session(engine) as session:
250+
session.add(hero)
251+
session.commit()
252+
session.refresh(hero)
253+
assert hero.team is not None
254+
assert hero.team.name == "Preventers"
255+
256+
SQLModel.metadata.clear()
257+
258+
259+
def test_table_model_model_validate_still_works(clear_sqlmodel):
260+
class Hero(SQLModel, table=True):
261+
id: int | None = Field(default=None, primary_key=True)
262+
name: str
263+
age: int | None = None
264+
265+
hero = Hero.model_validate({"name": "Deadpond", "age": 25})
266+
assert hero.name == "Deadpond"
267+
assert hero.age == 25
268+
269+
with pytest.raises(ValidationError):
270+
Hero.model_validate({"name": "Deadpond", "age": "not a number"})

0 commit comments

Comments
 (0)