Skip to content

Commit 5b4c9e4

Browse files
committed
📝 Add docs page for self-referential model
1 parent 75ce455 commit 5b4c9e4

File tree

6 files changed

+308
-0
lines changed

6 files changed

+308
-0
lines changed

docs/advanced/self-referential.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Self-referential relationships
2+
3+
Oftentimes we need to model a relationship between one entity of some class and another entity (or multiple entities) of that **same** class. This is called a **self-referential** or **recursive** relationship. (The pattern is also sometimes referred to as an **adjacency list**.)
4+
5+
In database terms this means having a table with a foreign key reference to the primary key in the same table.
6+
7+
Say, for example, we want to introduce a `Villain` class. 😈 Every villain can have a **boss**, who also must be a villain. If a villain is the boss to other villains, we want to call those his **minions**.
8+
9+
Let's do this with **SQLModel**. 🤓
10+
11+
## Using SQLAlchemy arguments
12+
13+
We already learned a lot about [Relationship attributes](../tutorial/relationship-attributes/index.md){.internal-link target=_blank} in previous chapters. We know that **SQLModel** is built on top of **SQLAlchemy** and we know that the latter allows defining self-referential relationships (see [their documentation](https://docs.sqlalchemy.org/en/14/orm/self_referential.html){.external-link target=_blank}).
14+
15+
To allow more fine-grained control over it, the `Relationship` constructor allows explicitly passing additional keyword-arguments to the [`sqlalchemy.orm.relationship`](https://docs.sqlalchemy.org/en/14/orm/relationship_api.html#sqlalchemy.orm.relationship){.external-link target=_blank} constructor that is being called under the hood via the `sa_relationship_kwargs` parameter. This is supposed to be a mapping (e.g. a dictionary) of strings representing the SQLAlchemy **parameter names** to the **values** we want to pass through as arguments.
16+
17+
Since SQLAlchemy relationships provide the [`remote_side`](https://docs.sqlalchemy.org/en/14/orm/relationship_api.html#sqlalchemy.orm.relationship.params.remote_side){.external-link target=_blank} parameter for just such an occasion, we can leverage that directly to construct the self-referential pattern with minimal code.
18+
19+
```Python hl_lines="12"
20+
# Code above omitted 👆
21+
22+
{!./docs_src/advanced/self_referential/tutorial001.py[ln:6-17]!}
23+
24+
# Code below omitted 👇
25+
```
26+
27+
<details>
28+
<summary>👀 Full file preview</summary>
29+
30+
```Python
31+
{!./docs_src/advanced/self_referential/tutorial001.py!}
32+
```
33+
34+
</details>
35+
36+
Using the `sa_relationship_kwargs` parameter, we pass the keyword-argument `remote_side='Villain.id'` to the underlying relationship property.
37+
38+
!!! info
39+
The SQLAlchemy documentation mentions this in passing, but crucially the `remote_side` value _"may be passed as a Python-evaluable string when using Declarative."_
40+
41+
This allows us to pass the `id` field of the class we are just now defining as the remote side of that relationship.
42+
43+
## Back-populating and self-referencing
44+
45+
Notice that we explicitly defined the relationship attributes we wanted for referring to the `boss` **as well as** the `minions` of a `Villain`.
46+
47+
For our purposes, it is necessary that we also provide the `back_populates` parameter to both relationships as explained in detail in a [dedicated chapter](../tutorial/relationship-attributes/back-populates.md){.internal-link target=_blank}.
48+
49+
In addition, the type annotations were made by enclosing our `Villain` class name in quotes, since we are referencing a class that is not yet fully defined by the time the interpreter reaches those lines. (See the chapter on [type annotation strings](../tutorial/relationship-attributes/type-annotation-strings.md){.internal-link target=_blank} for a detailed explanation.)
50+
51+
Finally, as with regular (i.e. non-self-referential) foreign key relationships, it is up to us to decide, whether it makes sense to allow the field to be **empty** or not. In our example, not every villain must have a boss. (In fact, we would otherwise introduce a circular reference chain, which would not make sense in this context.) Therefore we declare `boss_id: Optional[int]` and `boss: Optional['Villain']`. This is analogous to the `Hero``Team` relationship we saw [in an earlier chapter](../tutorial/relationship-attributes/define-relationships-attributes.md#optional-relationship-attributes){.internal-link target=_blank}.
52+
53+
## Creating instances
54+
55+
Now let us see how we can create villains with a boss:
56+
57+
```Python hl_lines="6-7"
58+
# Code above omitted 👆
59+
60+
{!./docs_src/advanced/self_referential/tutorial001.py[ln:30-49]!}
61+
62+
# Code below omitted 👇
63+
```
64+
65+
<details>
66+
<summary>👀 Full file preview</summary>
67+
68+
```Python
69+
{!./docs_src/advanced/self_referential/tutorial001.py!}
70+
```
71+
72+
</details>
73+
74+
Just as with regular relationships, we can simply pass our boss villain as an argument to the constructor with `boss=thinnus`.
75+
76+
If we only learn that a villain actually had a secret boss after we have already created him, we can just as easily assign him that boss retroactively:
77+
78+
```Python hl_lines="8"
79+
# Code above omitted 👆
80+
81+
{!./docs_src/advanced/self_referential/tutorial001.py[ln:30-31]!}
82+
83+
# Previous code here omitted 👈
84+
85+
{!./docs_src/advanced/self_referential/tutorial001.py[ln:51-55]!}
86+
87+
# Code below omitted 👇
88+
```
89+
90+
<details>
91+
<summary>👀 Full file preview</summary>
92+
93+
```Python
94+
{!./docs_src/advanced/self_referential/tutorial001.py!}
95+
```
96+
97+
</details>
98+
99+
And if we want to add minions to a boss after the fact, this is as easy as adding items to a Python list (because that's all it is 🤓):
100+
101+
```Python hl_lines="11"
102+
# Code above omitted 👆
103+
104+
{!./docs_src/advanced/self_referential/tutorial001.py[ln:30-31]!}
105+
106+
# Previous code here omitted 👈
107+
108+
{!./docs_src/advanced/self_referential/tutorial001.py[ln:57-68]!}
109+
110+
# Code below omitted 👇
111+
```
112+
113+
<details>
114+
<summary>👀 Full file preview</summary>
115+
116+
```Python
117+
{!./docs_src/advanced/self_referential/tutorial001.py!}
118+
```
119+
120+
</details>
121+
122+
Since our relationships work both ways, we don't even need to add all our `clone_bot_`s to the session individually. Instead we can simply add `ultra_bot` once again and commit the changes. We do need to refresh them all individually though, if we want to get their updated attributes.
123+
124+
## Traversing the relationship graph
125+
126+
By setting up our relationships this way, we can easily go back and forth along the graph representing all relationships we have created so far.
127+
128+
For example, we can verify that our `clone_bot_1` has a boss, who has his own boss, and one of that top-boss' minions is `ebonite_mew`:
129+
130+
```Python
131+
top_boss_minions = clone_bot_3.boss.boss.minions
132+
assert any(minion is ebonite_mew for minion in top_boss_minions) # passes
133+
```
134+
135+
!!! info
136+
Notice that we can in fact check for **identity** using `is` as opposed to `==` here, since we are dealing with those exact same objects, not just objects that hold the same **data**.

docs_src/advanced/self_referential/__init__.py

Whitespace-only changes.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from typing import List, Optional
2+
3+
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine
4+
5+
6+
class Villain(SQLModel, table=True):
7+
id: Optional[int] = Field(default=None, primary_key=True)
8+
name: str = Field(index=True)
9+
power_level: int
10+
11+
boss_id: Optional[int] = Field(
12+
foreign_key="villain.id", default=None, nullable=True
13+
)
14+
boss: Optional["Villain"] = Relationship(
15+
back_populates="minions", sa_relationship_kwargs=dict(remote_side="Villain.id")
16+
)
17+
minions: List["Villain"] = Relationship(back_populates="boss")
18+
19+
20+
sqlite_file_name = "database.db"
21+
sqlite_url = f"sqlite:///{sqlite_file_name}"
22+
23+
engine = create_engine(sqlite_url, echo=False)
24+
25+
26+
def create_db_and_tables() -> None:
27+
SQLModel.metadata.create_all(engine)
28+
29+
30+
def create_villains() -> None:
31+
with Session(engine) as session:
32+
thinnus = Villain(name="Thinnus", power_level=9001)
33+
ebonite_mew = Villain(name="Ebonite Mew", power_level=400, boss=thinnus)
34+
dark_shorty = Villain(name="Dark Shorty", power_level=200, boss=thinnus)
35+
ultra_bot = Villain(name="Ultra Bot", power_level=2 ** 9)
36+
session.add(ebonite_mew)
37+
session.add(dark_shorty)
38+
session.add(ultra_bot)
39+
session.commit()
40+
41+
session.refresh(thinnus)
42+
session.refresh(ebonite_mew)
43+
session.refresh(dark_shorty)
44+
session.refresh(ultra_bot)
45+
46+
print("Created villain:", thinnus)
47+
print("Created villain:", ebonite_mew)
48+
print("Created villain:", dark_shorty)
49+
print("Created villain:", ultra_bot)
50+
51+
ultra_bot.boss = thinnus
52+
session.add(ultra_bot)
53+
session.commit()
54+
session.refresh(ultra_bot)
55+
print("Updated villain:", ultra_bot)
56+
57+
clone_bot_1 = Villain(name="Clone Bot 1", power_level=2 ** 6)
58+
clone_bot_2 = Villain(name="Clone Bot 2", power_level=2 ** 6)
59+
clone_bot_3 = Villain(name="Clone Bot 3", power_level=2 ** 6)
60+
ultra_bot.minions.extend([clone_bot_1, clone_bot_2, clone_bot_3])
61+
session.add(ultra_bot)
62+
session.commit()
63+
session.refresh(clone_bot_1)
64+
session.refresh(clone_bot_2)
65+
session.refresh(clone_bot_3)
66+
print("Added minion:", clone_bot_1)
67+
print("Added minion:", clone_bot_2)
68+
print("Added minion:", clone_bot_3)
69+
70+
71+
def main() -> None:
72+
create_db_and_tables()
73+
create_villains()
74+
75+
76+
if __name__ == "__main__":
77+
main()

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ nav:
8585
- Advanced User Guide:
8686
- advanced/index.md
8787
- advanced/decimal.md
88+
- advanced/self-referential.md
8889
- alternatives.md
8990
- help.md
9091
- contributing.md

tests/test_advanced/test_self_referential/__init__.py

Whitespace-only changes.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from unittest.mock import patch
2+
3+
from sqlmodel import create_engine
4+
5+
from ...conftest import get_testing_print_function
6+
7+
expected_calls = [
8+
[
9+
"Created villain:",
10+
{
11+
"name": "Thinnus",
12+
"power_level": 9001,
13+
"id": 1,
14+
"boss_id": None,
15+
},
16+
],
17+
[
18+
"Created villain:",
19+
{
20+
"name": "Ebonite Mew",
21+
"power_level": 400,
22+
"id": 3,
23+
"boss_id": 1,
24+
},
25+
],
26+
[
27+
"Created villain:",
28+
{
29+
"name": "Dark Shorty",
30+
"power_level": 200,
31+
"id": 4,
32+
"boss_id": 1,
33+
},
34+
],
35+
[
36+
"Created villain:",
37+
{
38+
"name": "Ultra Bot",
39+
"power_level": 2 ** 9,
40+
"id": 2,
41+
"boss_id": None,
42+
},
43+
],
44+
[
45+
"Updated villain:",
46+
{
47+
"name": "Ultra Bot",
48+
"power_level": 2 ** 9,
49+
"id": 2,
50+
"boss_id": 1,
51+
},
52+
],
53+
[
54+
"Added minion:",
55+
{
56+
"name": "Clone Bot 1",
57+
"power_level": 2 ** 6,
58+
"id": 5,
59+
"boss_id": 2,
60+
},
61+
],
62+
[
63+
"Added minion:",
64+
{
65+
"name": "Clone Bot 2",
66+
"power_level": 2 ** 6,
67+
"id": 6,
68+
"boss_id": 2,
69+
},
70+
],
71+
[
72+
"Added minion:",
73+
{
74+
"name": "Clone Bot 3",
75+
"power_level": 2 ** 6,
76+
"id": 7,
77+
"boss_id": 2,
78+
},
79+
],
80+
]
81+
82+
83+
def test_tutorial(clear_sqlmodel):
84+
from docs_src.advanced.self_referential import tutorial001 as mod
85+
86+
mod.sqlite_url = "sqlite://"
87+
mod.engine = create_engine(mod.sqlite_url)
88+
calls = []
89+
90+
new_print = get_testing_print_function(calls)
91+
92+
with patch("builtins.print", new=new_print):
93+
mod.main()
94+
assert calls == expected_calls

0 commit comments

Comments
 (0)