diff --git a/docs/tutorial/relationship-attributes/aliased-relationships.md b/docs/tutorial/relationship-attributes/aliased-relationships.md new file mode 100644 index 0000000000..b55618617d --- /dev/null +++ b/docs/tutorial/relationship-attributes/aliased-relationships.md @@ -0,0 +1,113 @@ +# Aliased Relationships + +## Multiple Relationships to the Same Model + +We've seen how tables are related to each other via a single relationship attribute but what if more than +one attribute links to the same table? + +What if you have a `User` model and an `Address` model and would like +to have `User.home_address` and `User.work_address` relationships to the same +`Address` model? In SQL you do this by creating a table alias using `AS` like this: + +``` +SELECT * +FROM user +JOIN address AS home_address_alias + ON user.home_address_id == home_address_alias.id +JOIN address AS work_address_alias + ON user.work_address_id == work_address_alias.id +``` + +The aliases we create are `home_address_alias` and `work_address_alias`. You can think of them +as a view to the same underlying `address` table. + +We can do this with **SQLModel** and **SQLAlchemy** using `sqlalchemy.orm.aliased` +and a couple of extra bits of info in our **SQLModel** relationship definition and join statements. + +## The Relationships + +Let's define a `winter_team` and `summer_team` relationship for our heros. They can be on different +winter and summer teams or on the same team for both seasons. + +```Python hl_lines="11 15" +# Code above omitted 👆 + +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py[ln:13-26]!} + +# Code below omitted 👇 +``` + +/// details | 👀 Full file preview + +```Python +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py!} +``` + +/// + +The `sa_relationship_kwargs={"primaryjoin": ...}` is a new bit of info we need for **SQLAlchemy** to +figure out which SQL join we should use depending on which attribute is in our query. + +## Creating Heros + +Creating `Heros` with the multiple teams is no different from before. We set the same or different +team to the `winter_team` and `summer_team` attributes: + + +```Python hl_lines="11-12 18-19" +# Code above omitted 👆 + +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py[ln:39-65]!} + +# Code below omitted 👇 +``` + +/// details | 👀 Full file preview + +```Python +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py!} +``` + +/// +## Searching for Heros + +Querying `Heros` based on the winter or summer teams adds a bit of complication. We need to create the +alias and we also need to be a bit more explicit in how we tell **SQLAlchemy** to join the `hero` and `team` tables. + +We create the alias using `sqlalchemy.orm.aliased` function and use the alias in the `where` function. We also +need to provide an `onclause` argument to the `join`. + +```Python hl_lines="3 8 9" +# Code above omitted 👆 + +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py[ln:70-79]!} + +# Code below omitted 👇 +``` + +/// details | 👀 Full file preview + +```Python +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py!} +``` + +/// +The value for the `onclause` is the same value that you used in the `primaryjoin` argument +when the relationship is defined in the `Hero` model. + +To use both team attributes in a query, create another `alias` and add the join: + +```Python hl_lines="3 9 10" +# Code above omitted 👆 + +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py[ln:82-95]!} + +# Code below omitted 👇 +``` +/// details | 👀 Full file preview + +```Python +{!./docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py!} +``` + +/// diff --git a/docs_src/tutorial/relationship_attributes/aliased_relationship/__init__.py b/docs_src/tutorial/relationship_attributes/aliased_relationship/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py b/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py new file mode 100644 index 0000000000..6b3172316a --- /dev/null +++ b/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py @@ -0,0 +1,107 @@ +from typing import Optional + +from sqlalchemy.orm import aliased +from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select + + +class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + headquarters: str + + +class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: Optional[int] = Field(default=None, index=True) + + winter_team_id: Optional[int] = Field(default=None, foreign_key="team.id") + winter_team: Optional[Team] = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.winter_team_id == Team.id"} + ) + summer_team_id: Optional[int] = Field(default=None, foreign_key="team.id") + summer_team: Optional[Team] = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.summer_team_id == Team.id"} + ) + + +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", + winter_team=team_preventers, + summer_team=team_z_force, + ) + hero_rusty_man = Hero( + name="Rusty-Man", + secret_name="Tommy Sharp", + age=48, + winter_team=team_preventers, + summer_team=team_preventers, + ) + session.add(hero_deadpond) + session.add(hero_rusty_man) + session.commit() + + session.refresh(hero_deadpond) + session.refresh(hero_rusty_man) + + print("Created hero:", hero_deadpond) + print("Created hero:", hero_rusty_man) + + +def select_heroes(): + with Session(engine) as session: + winter_alias = aliased(Team) + + # Heros with winter team as the Preventers + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Preventers") + ) + heros = result.all() + print("Heros with Preventers as their winter team:", heros) + assert len(heros) == 2 + + summer_alias = aliased(Team) + # Heros with Preventers as their winter team and Z-Force as their summer team + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Preventers") + .join(summer_alias, onclause=Hero.summer_team_id == summer_alias.id) + .where(summer_alias.name == "Z-Force") + ) + heros = result.all() + print( + "Heros with Preventers as their winter and Z-Force as their summer team:", + heros, + ) + assert len(heros) == 1 + assert heros[0].name == "Deadpond" + + +def main(): + create_db_and_tables() + create_heroes() + select_heroes() + + +if __name__ == "__main__": + main() diff --git a/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001_py310.py b/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001_py310.py new file mode 100644 index 0000000000..7e08522a36 --- /dev/null +++ b/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001_py310.py @@ -0,0 +1,106 @@ +from sqlalchemy.orm import aliased +from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select + + +class Team(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + headquarters: str + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: int | None = Field(default=None, index=True) + + winter_team_id: int | None = Field(default=None, foreign_key="team.id") + winter_team: Team | None = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.winter_team_id == Team.id"} + ) + summer_team_id: int | None = Field(default=None, foreign_key="team.id") + summer_team: Team | None = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.summer_team_id == Team.id"} + ) + + +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", + winter_team=team_preventers, + summer_team=team_z_force, + ) + hero_rusty_man = Hero( + name="Rusty-Man", + secret_name="Tommy Sharp", + age=48, + winter_team=team_preventers, + summer_team=team_preventers, + ) + session.add(hero_deadpond) + session.add(hero_rusty_man) + session.commit() + + session.refresh(hero_deadpond) + session.refresh(hero_rusty_man) + + print("Created hero:", hero_deadpond) + print("Created hero:", hero_rusty_man) + + +def select_heroes(): + with Session(engine) as session: + winter_alias = aliased(Team) + + # Heros with winter team as the Preventers + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Preventers") + ) + heros = result.all() + print("Heros with Preventers as their winter team:", heros) + assert len(heros) == 2 + + summer_alias = aliased(Team) + + # Heros with Preventers as their winter team and Z-Force as their summer team + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Preventers") + .join(summer_alias, onclause=Hero.summer_team_id == summer_alias.id) + .where(summer_alias.name == "Z-Force") + ) + heros = result.all() + print( + "Heros with Preventers as their winter and Z-Force as their summer team:", + heros, + ) + assert len(heros) == 1 + assert heros[0].name == "Deadpond" + + +def main(): + create_db_and_tables() + create_heroes() + select_heroes() + + +if __name__ == "__main__": + main() diff --git a/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001_py39.py b/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001_py39.py new file mode 100644 index 0000000000..c71d1b898d --- /dev/null +++ b/docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001_py39.py @@ -0,0 +1,108 @@ +from typing import Optional + +from sqlalchemy.orm import aliased +from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select + + +class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + headquarters: str + + +class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + secret_name: str + age: Optional[int] = Field(default=None, index=True) + + winter_team_id: Optional[int] = Field(default=None, foreign_key="team.id") + winter_team: Optional[Team] = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.winter_team_id == Team.id"} + ) + summer_team_id: Optional[int] = Field(default=None, foreign_key="team.id") + summer_team: Optional[Team] = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.summer_team_id == Team.id"} + ) + + +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", + winter_team=team_preventers, + summer_team=team_z_force, + ) + hero_rusty_man = Hero( + name="Rusty-Man", + secret_name="Tommy Sharp", + age=48, + winter_team=team_preventers, + summer_team=team_preventers, + ) + session.add(hero_deadpond) + session.add(hero_rusty_man) + session.commit() + + session.refresh(hero_deadpond) + session.refresh(hero_rusty_man) + + print("Created hero:", hero_deadpond) + print("Created hero:", hero_rusty_man) + + +def select_heroes(): + with Session(engine) as session: + winter_alias = aliased(Team) + + # Heros with winter team as the Preventers + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Preventers") + ) + heros = result.all() + print("Heros with Preventers as their winter team:", heros) + assert len(heros) == 2 + + summer_alias = aliased(Team) + + # Heros with Preventers as their winter team and Z-Force as their summer team + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Preventers") + .join(summer_alias, onclause=Hero.summer_team_id == summer_alias.id) + .where(summer_alias.name == "Z-Force") + ) + heros = result.all() + print( + "Heros with Preventers as their winter and Z-Force as their summer team:", + heros, + ) + assert len(heros) == 1 + assert heros[0].name == "Deadpond" + + +def main(): + create_db_and_tables() + create_heroes() + select_heroes() + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index fa85062a8b..1e0d891223 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - tutorial/relationship-attributes/read-relationships.md - tutorial/relationship-attributes/remove-relationships.md - tutorial/relationship-attributes/back-populates.md + - tutorial/relationship-attributes/aliased-relationships.md - tutorial/relationship-attributes/type-annotation-strings.md - Many to Many: - tutorial/many-to-many/index.md diff --git a/tests/test_field_sa_relationship_alias.py b/tests/test_field_sa_relationship_alias.py new file mode 100644 index 0000000000..262e87df83 --- /dev/null +++ b/tests/test_field_sa_relationship_alias.py @@ -0,0 +1,62 @@ +from typing import Optional + +from sqlalchemy import create_engine +from sqlalchemy.orm import aliased +from sqlmodel import Field, Relationship, Session, SQLModel, select + + +def test_sa_multi_relationship_alias(clear_sqlmodel) -> None: + class Team(SQLModel, table=True): + """Team model.""" + + __tablename__ = "team_multi" + id: Optional[int] = Field(default=None, primary_key=True) + name: str + + class Hero(SQLModel, table=True): + """Hero model.""" + + __tablename__ = "hero_multi" + id: Optional[int] = Field(default=None, primary_key=True) + name: str + winter_team_id: Optional[int] = Field(default=None, foreign_key="team_multi.id") + winter_team: Optional[Team] = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.winter_team_id == Team.id"} + ) + summer_team_id: Optional[int] = Field(default=None, foreign_key="team_multi.id") + summer_team: Optional[Team] = Relationship( + sa_relationship_kwargs={"primaryjoin": "Hero.summer_team_id == Team.id"} + ) + + engine = create_engine("sqlite://", echo=True) + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + blue_team = Team(name="Blue") + red_team = Team(name="Red") + session.add_all([blue_team, red_team]) + session.commit() + session.refresh(blue_team) + session.refresh(red_team) + + hero1 = Hero( + name="dual_team", winter_team_id=blue_team.id, summer_team_id=red_team.id + ) + session.add(hero1) + hero2 = Hero( + name="single_team", winter_team_id=red_team.id, summer_team_id=red_team.id + ) + session.add_all([hero1, hero2]) + session.commit() + + winter_alias = aliased(Team) + summer_alias = aliased(Team) + result = session.exec( + select(Hero) + .join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id) + .where(winter_alias.name == "Blue") + .join(summer_alias, onclause=Hero.summer_team_id == summer_alias.id) + .where(summer_alias.name == "Red") + ).all() + assert len(result) == 1 + assert result[0].name == "dual_team" diff --git a/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/__init__.py b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001.py b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001.py new file mode 100644 index 0000000000..f62a65187a --- /dev/null +++ b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001.py @@ -0,0 +1,80 @@ +from unittest.mock import patch + +from sqlmodel import create_engine + +from ....conftest import get_testing_print_function + +expected_calls = [ + [ + "Created hero:", + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + }, + ], + [ + "Created hero:", + { + "age": 48, + "id": 2, + "name": "Rusty-Man", + "secret_name": "Tommy Sharp", + "summer_team_id": 1, + "winter_team_id": 1, + }, + ], + [ + "Heros with Preventers as their winter team:", + [ + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + }, + { + "age": 48, + "id": 2, + "name": "Rusty-Man", + "secret_name": "Tommy Sharp", + "summer_team_id": 1, + "winter_team_id": 1, + }, + ], + ], + [ + "Heros with Preventers as their winter and Z-Force as their summer team:", + [ + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + } + ], + ], +] + + +def test_tutorial(clear_sqlmodel): + from docs_src.tutorial.relationship_attributes.aliased_relationship import ( + tutorial001 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 diff --git a/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001_py310.py b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001_py310.py new file mode 100644 index 0000000000..002512afa9 --- /dev/null +++ b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001_py310.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +from sqlmodel import create_engine + +from ....conftest import get_testing_print_function, needs_py310 + +expected_calls = [ + [ + "Created hero:", + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + }, + ], + [ + "Created hero:", + { + "age": 48, + "id": 2, + "name": "Rusty-Man", + "secret_name": "Tommy Sharp", + "summer_team_id": 1, + "winter_team_id": 1, + }, + ], + [ + "Heros with Preventers as their winter team:", + [ + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + }, + { + "age": 48, + "id": 2, + "name": "Rusty-Man", + "secret_name": "Tommy Sharp", + "summer_team_id": 1, + "winter_team_id": 1, + }, + ], + ], + [ + "Heros with Preventers as their winter and Z-Force as their summer team:", + [ + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + } + ], + ], +] + + +@needs_py310 +def test_tutorial(clear_sqlmodel): + from docs_src.tutorial.relationship_attributes.aliased_relationship import ( + tutorial001_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 diff --git a/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001_py39.py b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001_py39.py new file mode 100644 index 0000000000..2acf294ee6 --- /dev/null +++ b/tests/test_tutorial/test_relationship_attributes/test_aliased_relationship/test_tutorial001_py39.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +from sqlmodel import create_engine + +from ....conftest import get_testing_print_function, needs_py39 + +expected_calls = [ + [ + "Created hero:", + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + }, + ], + [ + "Created hero:", + { + "age": 48, + "id": 2, + "name": "Rusty-Man", + "secret_name": "Tommy Sharp", + "summer_team_id": 1, + "winter_team_id": 1, + }, + ], + [ + "Heros with Preventers as their winter team:", + [ + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + }, + { + "age": 48, + "id": 2, + "name": "Rusty-Man", + "secret_name": "Tommy Sharp", + "summer_team_id": 1, + "winter_team_id": 1, + }, + ], + ], + [ + "Heros with Preventers as their winter and Z-Force as their summer team:", + [ + { + "age": None, + "id": 1, + "name": "Deadpond", + "secret_name": "Dive Wilson", + "summer_team_id": 2, + "winter_team_id": 1, + } + ], + ], +] + + +@needs_py39 +def test_tutorial(clear_sqlmodel): + from docs_src.tutorial.relationship_attributes.aliased_relationship import ( + tutorial001_py39 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