Skip to content

Commit 10b79b9

Browse files
committed
Docs: multiple relationships to the same model-expand examples to show multiple solutions
1 parent fe0187c commit 10b79b9

File tree

7 files changed

+269
-133
lines changed

7 files changed

+269
-133
lines changed

docs/tutorial/relationship-attributes/aliased-relationships.md

Lines changed: 0 additions & 99 deletions
This file was deleted.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Multiple Relationships to the Same Model
2+
3+
We've seen how tables are related to each other via a single relationship attribute but what if more than
4+
one attribute links to the same table?
5+
6+
What if you have a `User` model and an `Address` model and would like
7+
to have `User.home_address` and `User.work_address` relationships to the same
8+
`Address` model? In SQL you do this one of two ways: 1) by creating a table alias using `AS` or 2)
9+
by using a correlated sub-query.
10+
11+
Query: Find users with home address zipcode "100000" and work address zipcode = "10001":
12+
13+
### Alias Query
14+
15+
Using an alias, JOIN the address table twice:
16+
```
17+
SELECT *
18+
FROM user
19+
JOIN address AS home_address_alias
20+
ON user.home_address_id == home_address_alias.id
21+
JOIN address AS work_address_alias
22+
ON user.work_address_id == work_address_alias.id
23+
WHERE
24+
home_address_alias.zipcode == "10000"
25+
AND work_address_alias.zipcode == "10001"
26+
```
27+
28+
### Correlated Sub-Query
29+
Using sub-queries, filter the matches with EXISTS:
30+
```
31+
SELECT *
32+
FROM user
33+
WHERE (
34+
EXISTS (
35+
SELECT 1 FROM address
36+
WHERE
37+
address.id = user.home_address_id
38+
AND address.zipcode = "10000"
39+
)
40+
) AND (
41+
EXISTS (
42+
SELECT 1 FROM address
43+
WHERE
44+
address.id = user.work_address_id
45+
AND address.zipcode = "10001"
46+
)
47+
48+
```
49+
50+
### Key differences
51+
52+
Duplicates: JOIN (alias query) can produce them, EXISTS will not. The duplicates will be removed by the ORM
53+
as rows are marshalled into objects.
54+
55+
Performance: Both can be optimized similarly, but JOIN often wins when you’re retrieving columns from the related table.
56+
57+
Readability: JOIN reads like “combine these tables.” EXISTS reads like “filter by a condition.”
58+
59+
✅ Rule of thumb:
60+
61+
If you need columns from the foreign table → use JOIN. For example, if you are using `lazy=joined` or `selectin` you may prefer this.
62+
63+
If you only care whether a row exists in the foreign table → use EXISTS.
64+
65+
If the foreign table search criteria (address.zipcode) is not unique, prefer EXISTS unless you also want the duplicates.
66+
67+
## The Relationships
68+
69+
Let's define a `winter_team` and `summer_team` relationship for our heros. They can be on different
70+
winter and summer teams or on the same team for both seasons.
71+
72+
{* ./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001_py310.py ln[13:26] hl[11,15] *}
73+
74+
The `sa_relationship_kwargs={"foreign_keys": ...}` is a new bit of info we need for **SQLAlchemy** to
75+
figure out which SQL join we should use depending on which attribute is in our query.
76+
77+
## Creating Heros
78+
79+
Creating `Heros` with the multiple teams is no different from before. We set the same or different
80+
team to the `winter_team` and `summer_team` attributes:
81+
82+
83+
```Python hl_lines="11-12 18-19"
84+
# Code above omitted 👆
85+
86+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py[ln:39-65]!}
87+
88+
# Code below omitted 👇
89+
```
90+
91+
/// details | 👀 Full file preview
92+
93+
```Python
94+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py!}
95+
```
96+
97+
///
98+
## Searching for Heros
99+
100+
Querying `Heros` based on the winter or summer teams adds a bit of complication. As
101+
mentioned above, we can solve this with an aliased join or correlated subquery.
102+
103+
### Alias Join
104+
105+
To use the alias method we need to: 1) create the alias(es) and 2) provide the join in our query.
106+
107+
#### Aliases
108+
109+
We create the alias using `sqlalchemy.orm.aliased` function and use the alias in the `where` function. We also
110+
need to provide an `onclause` argument to the `join`.
111+
112+
The aliases we create are `home_address_alias` and `work_address_alias`. You can think of them
113+
as a view to the same underlying `address` table. We can do this with **SQLModel** and **SQLAlchemy** using `sqlalchemy.orm.aliased`
114+
and a couple of extra bits of info in our **SQLModel** join statements.
115+
116+
```Python hl_lines="2"
117+
# Code above omitted 👆
118+
119+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py[ln:70-71]!}
120+
121+
# Code below omitted 👇
122+
```
123+
124+
#### Join
125+
126+
Query Heros filtering by Team attributes by manually specifying the `join` with an `onclause` to tell **SQLAlchemy** to join the `hero` and `team` tables.
127+
128+
```Python hl_lines="7"
129+
# Code above omitted 👆
130+
131+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py[ln:70-87]!}
132+
133+
# Code below omitted 👇
134+
```
135+
136+
The value for the `onclause` is the join using the same foreign key
137+
when the relationship is defined in the `Hero` model.
138+
139+
To use both team attributes in a query, create another `alias` and add the join.
140+
141+
For more information see [SQLAlchemy: Handling Multiple Join Paths](https://docs.sqlalchemy.org/en/20/orm/join_conditions.html#handling-multiple-join-paths).
142+
143+
/// details | 👀 Full file preview
144+
145+
```Python
146+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py!}
147+
```
148+
149+
///
150+
151+
#### Correlated Sub Query
152+
153+
From a query perspecitve, this is a much simpler solution. We use the `has` function in the query:
154+
155+
```Python hl_lines="5"
156+
# Code above omitted 👆
157+
158+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py[ln:90-113]!}
159+
160+
# Code below omitted 👇
161+
```
162+
/// details | 👀 Full file preview
163+
164+
```Python
165+
{!./docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py!}
166+
```
167+
168+
///

docs_src/tutorial/relationship_attributes/aliased_relationship/tutorial001.py renamed to docs_src/tutorial/relationship_attributes/multiple_relationships_same_model/tutorial001.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,19 @@ class Hero(SQLModel, table=True):
1818

1919
winter_team_id: Optional[int] = Field(default=None, foreign_key="team.id")
2020
winter_team: Optional[Team] = Relationship(
21-
sa_relationship_kwargs={"primaryjoin": "Hero.winter_team_id == Team.id"}
21+
sa_relationship_kwargs={"foreign_keys": "Hero.winter_team_id"}
2222
)
2323
summer_team_id: Optional[int] = Field(default=None, foreign_key="team.id")
2424
summer_team: Optional[Team] = Relationship(
25-
sa_relationship_kwargs={"primaryjoin": "Hero.summer_team_id == Team.id"}
25+
sa_relationship_kwargs={"foreign_keys": "Hero.summer_team_id"}
2626
)
2727

2828

29-
sqlite_file_name = "database.db"
29+
sqlite_file_name = ":memory:"
3030
sqlite_url = f"sqlite:///{sqlite_file_name}"
31+
mysql_url = "mysql+pymysql://[email protected]/test"
3132

32-
engine = create_engine(sqlite_url, echo=True)
33+
engine = create_engine(mysql_url, echo=True)
3334

3435

3536
def create_db_and_tables():
@@ -69,31 +70,51 @@ def select_heroes():
6970
with Session(engine) as session:
7071
winter_alias = aliased(Team)
7172

72-
# Heros with winter team as the Preventers
73+
# Heros with winter team as the Preventers using "aliases" and "onclause"
7374
result = session.exec(
7475
select(Hero)
7576
.join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id)
7677
.where(winter_alias.name == "Preventers")
7778
)
79+
"""
80+
SQL Looks like:
81+
82+
SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.winter_team_id, hero.summer_team_id
83+
FROM hero JOIN team AS team_1 ON hero.winter_team_id = team_1.id
84+
WHERE team_1.name = ?
85+
86+
"""
7887
heros = result.all()
7988
print("Heros with Preventers as their winter team:", heros)
80-
assert len(heros) == 2
8189

82-
summer_alias = aliased(Team)
83-
# Heros with Preventers as their winter team and Z-Force as their summer team
90+
# Heros with Preventers as their winter team and Z-Force as their summer team using "has" function.
8491
result = session.exec(
8592
select(Hero)
86-
.join(winter_alias, onclause=Hero.winter_team_id == winter_alias.id)
87-
.where(winter_alias.name == "Preventers")
88-
.join(summer_alias, onclause=Hero.summer_team_id == summer_alias.id)
89-
.where(summer_alias.name == "Z-Force")
93+
.where(Hero.winter_team.has(Team.name == "Preventers"))
94+
.where(Hero.summer_team.has(Team.name == "Z-Force"))
95+
)
96+
"""
97+
SQL Looks like:
98+
99+
SELECT hero.id, hero.name, hero.secret_name, hero.age, hero.winter_team_id, hero.summer_team_id
100+
FROM hero
101+
WHERE (
102+
EXISTS (
103+
SELECT 1 FROM team
104+
WHERE team.id = hero.winter_team_id AND team.name = ?
105+
)
106+
) AND (
107+
EXISTS (
108+
SELECT 1 FROM team
109+
WHERE team.id = hero.summer_team_id AND team.name = ?
110+
)
90111
)
112+
"""
91113
heros = result.all()
92114
print(
93115
"Heros with Preventers as their winter and Z-Force as their summer team:",
94116
heros,
95117
)
96-
assert len(heros) == 1
97118
assert heros[0].name == "Deadpond"
98119

99120

0 commit comments

Comments
 (0)