From 435ad5304847cc266a9ce80c93523d51ddab5bf0 Mon Sep 17 00:00:00 2001 From: bolu61 Date: Tue, 29 Oct 2024 18:22:13 -0400 Subject: [PATCH 01/10] support PEP 593 for relationships --- sqlmodel/main.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 3532e81a8e..434ff05d10 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -52,7 +52,7 @@ from sqlalchemy.orm.instrumentation import is_instrumented from sqlalchemy.sql.schema import MetaData from sqlalchemy.sql.sqltypes import LargeBinary, Time, Uuid -from typing_extensions import Literal, TypeAlias, deprecated, get_origin +from typing_extensions import Annotated, Literal, TypeAlias, deprecated, get_args, get_origin from ._compat import ( # type: ignore[attr-defined] IS_PYDANTIC_V2, @@ -475,6 +475,16 @@ def Relationship( return relationship_info +def get_annotated_relationshipinfo(t: Type) -> RelationshipInfo | None: + """Get the first RelationshipInfo from Annotated or None if not Annotated with RelationshipInfo.""" + if get_origin(t) is not Annotated: + return None + for a in get_args(t): + if isinstance(a, RelationshipInfo): + return a + return None + + @__dataclass_transform__(kw_only_default=True, field_descriptors=(Field, FieldInfo)) class SQLModelMetaclass(ModelMetaclass, DeclarativeMeta): __sqlmodel_relationships__: Dict[str, RelationshipInfo] @@ -515,7 +525,12 @@ def __new__( else: dict_for_pydantic[k] = v for k, v in original_annotations.items(): - if k in relationships: + # check for `field: Annotated[Any, Relationship()]` + t = get_annotated_relationshipinfo(v) + if t: + relationships[k] = t + relationship_annotations[k] = get_args(v)[0] + elif k in relationships: relationship_annotations[k] = v else: pydantic_annotations[k] = v From c5d7df863ea1b936abd7672bba19cd54ff9719a8 Mon Sep 17 00:00:00 2001 From: bolu61 Date: Tue, 29 Oct 2024 18:34:25 -0400 Subject: [PATCH 02/10] Fix formatting --- sqlmodel/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 434ff05d10..88f0fea15d 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -52,7 +52,14 @@ from sqlalchemy.orm.instrumentation import is_instrumented from sqlalchemy.sql.schema import MetaData from sqlalchemy.sql.sqltypes import LargeBinary, Time, Uuid -from typing_extensions import Annotated, Literal, TypeAlias, deprecated, get_args, get_origin +from typing_extensions import ( + Annotated, + Literal, + TypeAlias, + deprecated, + get_args, + get_origin, +) from ._compat import ( # type: ignore[attr-defined] IS_PYDANTIC_V2, From 9f356db8826389e8e3a72c88dea9df18bf4fddef Mon Sep 17 00:00:00 2001 From: bolu61 Date: Tue, 29 Oct 2024 18:41:17 -0400 Subject: [PATCH 03/10] Use Optional instead of `|` --- sqlmodel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 88f0fea15d..01acfcdb6e 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -482,7 +482,7 @@ def Relationship( return relationship_info -def get_annotated_relationshipinfo(t: Type) -> RelationshipInfo | None: +def get_annotated_relationshipinfo(t: Type) -> Optional[RelationshipInfo]: """Get the first RelationshipInfo from Annotated or None if not Annotated with RelationshipInfo.""" if get_origin(t) is not Annotated: return None From 33604b4fb540e67734dafca293bf9d0291b9e64b Mon Sep 17 00:00:00 2001 From: bolu61 Date: Tue, 29 Oct 2024 18:45:11 -0400 Subject: [PATCH 04/10] Fix type annotation --- sqlmodel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 01acfcdb6e..04a7f3ccd5 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -482,7 +482,7 @@ def Relationship( return relationship_info -def get_annotated_relationshipinfo(t: Type) -> Optional[RelationshipInfo]: +def get_annotated_relationshipinfo(t: type[Any]) -> Optional[RelationshipInfo]: """Get the first RelationshipInfo from Annotated or None if not Annotated with RelationshipInfo.""" if get_origin(t) is not Annotated: return None From 25340990206331e55ff3eb454fdc60c057554a2f Mon Sep 17 00:00:00 2001 From: bolu61 Date: Tue, 29 Oct 2024 18:47:59 -0400 Subject: [PATCH 05/10] Use Any instead of type[Any] --- sqlmodel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 04a7f3ccd5..127b3db7c6 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -482,7 +482,7 @@ def Relationship( return relationship_info -def get_annotated_relationshipinfo(t: type[Any]) -> Optional[RelationshipInfo]: +def get_annotated_relationshipinfo(t: Any) -> Optional[RelationshipInfo]: """Get the first RelationshipInfo from Annotated or None if not Annotated with RelationshipInfo.""" if get_origin(t) is not Annotated: return None From 9d96e1dca25b5feb66237810cd491dfab7529652 Mon Sep 17 00:00:00 2001 From: bolu61 Date: Thu, 6 Nov 2025 19:33:36 -0500 Subject: [PATCH 06/10] Fix handling of Annotated types in relationship annotations --- sqlmodel/_compat.py | 3 +++ sqlmodel/main.py | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index 230f8cc362..6e6436b090 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -178,6 +178,9 @@ def get_relationship_to( # If a list, then also get the real field elif origin is list: use_annotation = get_args(annotation)[0] + + elif origin is Annotated: + use_annotation = get_args(annotation)[0] return get_relationship_to( name=name, rel_info=rel_info, annotation=use_annotation diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 7fa1871c8c..32df3a6041 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -528,17 +528,16 @@ def __new__( pydantic_annotations = {} relationship_annotations = {} for k, v in class_dict.items(): - if isinstance(v, RelationshipInfo): + a = original_annotations.get(k, None) + r = get_annotated_relationshipinfo(a) + if r is not None: + relationships[k] = r + elif isinstance(v, RelationshipInfo): relationships[k] = v else: dict_for_pydantic[k] = v for k, v in original_annotations.items(): - # check for `field: Annotated[Any, Relationship()]` - t = get_annotated_relationshipinfo(v) - if t: - relationships[k] = t - relationship_annotations[k] = get_args(v)[0] - elif k in relationships: + if k in relationships: relationship_annotations[k] = v else: pydantic_annotations[k] = v @@ -628,6 +627,9 @@ def __init__( origin: Any = get_origin(raw_ann) if origin is Mapped: ann = raw_ann.__args__[0] + if origin is Annotated: + ann = get_args(raw_ann)[0] + cls.__annotations__[rel_name] = Mapped[ann] else: ann = raw_ann # Plain forward references, for models not yet defined, are not From bf364f83f8a4bafaed1c2a592575664ed05d6825 Mon Sep 17 00:00:00 2001 From: bolu61 Date: Thu, 6 Nov 2025 19:33:45 -0500 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20Add=20tutorial=20and=20tes?= =?UTF-8?q?ts=20for=20defining=20relationship=20attributes=20in=20SQLModel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tutorial001_an_py310.py | 70 +++++++++++++++++++ .../test_tutorial001_an_py310.py | 55 +++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs_src/tutorial/relationship_attributes/define_relationship_attributes/tutorial001_an_py310.py create mode 100644 tests/test_tutorial/test_relationship_attributes/test_define_relationship_attributes/test_tutorial001_an_py310.py diff --git a/docs_src/tutorial/relationship_attributes/define_relationship_attributes/tutorial001_an_py310.py b/docs_src/tutorial/relationship_attributes/define_relationship_attributes/tutorial001_an_py310.py new file mode 100644 index 0000000000..a4584836f7 --- /dev/null +++ b/docs_src/tutorial/relationship_attributes/define_relationship_attributes/tutorial001_an_py310.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from sqlmodel import Field, Relationship, Session, SQLModel, create_engine + + +class Team(SQLModel, table=True): + id: Annotated[int | None, Field(primary_key=True)] = None + name: Annotated[str, Field(index=True)] + headquarters: str + + heroes: Annotated[list["Hero"] | None, Relationship(back_populates="team")] = None + + +class Hero(SQLModel, table=True): + id: Annotated[int | None, Field(primary_key=True)] = None + name: Annotated[str, Field(index=True)] + secret_name: str + age: Annotated[int | None, Field(index=True)] = None + + team_id: Annotated[int | None, Field(foreign_key="team.id")] = None + team: Annotated[Team | None, Relationship(back_populates="heroes")] = None + + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=True) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def create_heroes(): + with Session(engine) as session: + team_preventers = Team(name="Preventers", headquarters="Sharp Tower") + team_z_force = Team(name="Z-Force", headquarters="Sister Margaret's Bar") + + hero_deadpond = Hero( + name="Deadpond", secret_name="Dive Wilson", team=team_z_force + ) + hero_rusty_man = Hero( + name="Rusty-Man", secret_name="Tommy Sharp", age=48, team=team_preventers + ) + hero_spider_boy = Hero(name="Spider-Boy", secret_name="Pedro Parqueador") + session.add(hero_deadpond) + session.add(hero_rusty_man) + session.add(hero_spider_boy) + session.commit() + + session.refresh(hero_deadpond) + session.refresh(hero_rusty_man) + session.refresh(hero_spider_boy) + + print("Created hero:", hero_deadpond) + print("Created hero:", hero_rusty_man) + print("Created hero:", hero_spider_boy) + + hero_spider_boy.team = team_preventers + session.add(hero_spider_boy) + session.commit() + + +def main(): + create_db_and_tables() + create_heroes() + + +if __name__ == "__main__": + main() diff --git a/tests/test_tutorial/test_relationship_attributes/test_define_relationship_attributes/test_tutorial001_an_py310.py b/tests/test_tutorial/test_relationship_attributes/test_define_relationship_attributes/test_tutorial001_an_py310.py new file mode 100644 index 0000000000..584b35df8f --- /dev/null +++ b/tests/test_tutorial/test_relationship_attributes/test_define_relationship_attributes/test_tutorial001_an_py310.py @@ -0,0 +1,55 @@ +from unittest.mock import patch + +from sqlmodel import create_engine + +from ....conftest import get_testing_print_function, needs_py310 + +expected_calls = [ + [ + "Created hero:", + { + "name": "Deadpond", + "age": None, + "team_id": 1, + "id": 1, + "secret_name": "Dive Wilson", + }, + ], + [ + "Created hero:", + { + "name": "Rusty-Man", + "age": 48, + "team_id": 2, + "id": 2, + "secret_name": "Tommy Sharp", + }, + ], + [ + "Created hero:", + { + "name": "Spider-Boy", + "age": None, + "team_id": None, + "id": 3, + "secret_name": "Pedro Parqueador", + }, + ], +] + + +@needs_py310 +def test_tutorial(clear_sqlmodel): + from docs_src.tutorial.relationship_attributes.define_relationship_attributes import ( + tutorial001_an_py310 as mod, + ) + + mod.sqlite_url = "sqlite://" + mod.engine = create_engine(mod.sqlite_url) + calls = [] + + new_print = get_testing_print_function(calls) + + with patch("builtins.print", new=new_print): + mod.main() + assert calls == expected_calls From 7c1919ecff0d0844ba634025fb1d61ffd6fd7045 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 00:33:59 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqlmodel/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index 6e6436b090..3a7880341f 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -178,7 +178,7 @@ def get_relationship_to( # If a list, then also get the real field elif origin is list: use_annotation = get_args(annotation)[0] - + elif origin is Annotated: use_annotation = get_args(annotation)[0] From 40af6901b885c2c80b164ca3c11c96c29f32d805 Mon Sep 17 00:00:00 2001 From: bolu61 Date: Thu, 6 Nov 2025 19:38:01 -0500 Subject: [PATCH 09/10] Fix type hinting for relationship annotations in SQLModelMetaclass --- sqlmodel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 32df3a6041..a6795ee94f 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -629,7 +629,7 @@ def __init__( ann = raw_ann.__args__[0] if origin is Annotated: ann = get_args(raw_ann)[0] - cls.__annotations__[rel_name] = Mapped[ann] + cls.__annotations__[rel_name] = Mapped[ann] # type: ignore[valid-type] else: ann = raw_ann # Plain forward references, for models not yet defined, are not From de8a2f4601c413d666f2913175c3e9f4c3dd74f4 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, 3 Mar 2026 20:37:56 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqlmodel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index f3b5a96fb8..0d2217267e 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -12,8 +12,8 @@ from pathlib import Path from typing import ( TYPE_CHECKING, - Any, Annotated, + Any, ClassVar, Literal, TypeAlias,