From 78cdade884294cb939a464d8882b35ef47ec17e2 Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:10:55 +1200 Subject: [PATCH 1/2] fix(_compat): set relationships when model_validate receives a dict sqlmodel_validate read each relationship with getattr(use_obj, key), but use_obj is a dict when a dict is passed to model_validate (or when update is given, which produces a merged dict). getattr never finds dict keys, so the relationship was silently dropped, unlike the constructor and model_validate(object) which set it. Read via dict.get for dict input, mirroring sqlmodel_table_construct, and add a regression test. --- sqlmodel/_compat.py | 8 +++++++- tests/test_validation.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index a220b193f1..e1075387e7 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -320,7 +320,13 @@ def sqlmodel_validate( # Get and set any relationship objects if is_table_model_class(cls): for key in new_obj.__sqlmodel_relationships__: - value = getattr(use_obj, key, Undefined) + # use_obj can be a dict (the input obj, or the merged obj when + # update is passed), so read relationships accordingly instead of + # assuming attribute access. + if isinstance(use_obj, dict): + value = use_obj.get(key, Undefined) + else: + value = getattr(use_obj, key, Undefined) if value is not Undefined: setattr(new_obj, key, value) return new_obj diff --git a/tests/test_validation.py b/tests/test_validation.py index 47fbca87c2..d6f1eae1c3 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -29,3 +29,29 @@ def reject_none(cls, v): with pytest.raises(ValidationError): Hero.model_validate({"name": None, "age": 25}) + + +def test_validate_dict_sets_relationship(clear_sqlmodel): + """A relationship passed inside the dict given to model_validate must be + set, consistent with the constructor and with model_validate(object).""" + from typing import List, Optional + + from sqlmodel import Field, Relationship + + class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + heroes: List["Hero"] = Relationship(back_populates="team") + + class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + team_id: Optional[int] = Field(default=None, foreign_key="team.id") + team: Optional[Team] = Relationship(back_populates="heroes") + + team = Team(name="Avengers") + + # constructor already works; model_validate must match it + assert Hero(name="IronMan", team=team).team is team + assert Hero.model_validate({"name": "Thor", "team": team}).team is team + assert Hero.model_validate({"name": "Hulk"}, update={"team": team}).team is team From d549b101551738389576ee2b8f54b6c4a4ba96ce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:11:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_validation.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index d6f1eae1c3..cc8f27c74c 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -34,20 +34,19 @@ def reject_none(cls, v): def test_validate_dict_sets_relationship(clear_sqlmodel): """A relationship passed inside the dict given to model_validate must be set, consistent with the constructor and with model_validate(object).""" - from typing import List, Optional from sqlmodel import Field, Relationship class Team(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) + id: int | None = Field(default=None, primary_key=True) name: str - heroes: List["Hero"] = Relationship(back_populates="team") + heroes: list["Hero"] = Relationship(back_populates="team") class Hero(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) + id: int | None = Field(default=None, primary_key=True) name: str - team_id: Optional[int] = Field(default=None, foreign_key="team.id") - team: Optional[Team] = Relationship(back_populates="heroes") + team_id: int | None = Field(default=None, foreign_key="team.id") + team: Team | None = Relationship(back_populates="heroes") team = Team(name="Avengers")