-
-
Notifications
You must be signed in to change notification settings - Fork 783
📝 Add docs page for self-referential model #408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
daniil-berg
wants to merge
16
commits into
fastapi:main
Choose a base branch
from
daniil-berg:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
5b4c9e4
📝 Add docs page for self-referential model
daniil-berg c702e78
Merge branch 'main' into main
tiangolo 04eb680
Merge branch 'main' into main
YuriiMotov 7065c7b
🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
pre-commit-ci[bot] d0bd01f
Fix Ruff C408 Unnecessary `dict()`
YuriiMotov cfa3541
Fix formatting and update links
YuriiMotov aac3497
Fix include and highlight intervals
YuriiMotov b399813
Fix include and highlight intervals 2
YuriiMotov 4016556
Update issue-manager.yml
YuriiMotov 7e45ae3
Apply Yurii's rephrasing suggestions
svlandeg 95df345
Move to `advanced-relationships`, fix links
YuriiMotov daf3975
Some text improvements
YuriiMotov 7463f0c
Merge branch 'main' into main
YuriiMotov a8a4cea
Revert "Update issue-manager.yml"
YuriiMotov c5de013
Apply suggestions from code review
YuriiMotov 2bc70be
Merge branch 'main' into main
YuriiMotov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # Self-referential relationships | ||
|
|
||
| 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**.) | ||
|
|
||
| In database terms this means having a table with a foreign key reference to the primary key in the same table. | ||
|
|
||
| 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**. | ||
|
|
||
| Let's do this with **SQLModel**. 🤓 | ||
|
|
||
| ## Using SQLAlchemy arguments | ||
|
|
||
| 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/20/orm/self_referential.html){.external-link target=_blank}). | ||
|
|
||
| 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/20/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. | ||
|
|
||
| Since SQLAlchemy relationships provide the [`remote_side`](https://docs.sqlalchemy.org/en/20/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. | ||
|
|
||
| {* ./docs_src/advanced/self_referential/tutorial001.py ln[6:17] hl[16] *} | ||
|
|
||
| Using the `sa_relationship_kwargs` parameter, we pass the keyword-argument `remote_side='Villain.id'` to the underlying relationship property. | ||
|
|
||
| /// info | ||
|
|
||
| The SQLAlchemy documentation mentions this in passing, but crucially the `remote_side` value _"may be passed as a Python-evaluable string when using Declarative."_ | ||
|
|
||
| This allows us to pass the `id` field of the class we are just now defining as the remote side of that relationship. | ||
svlandeg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| /// | ||
|
|
||
| ## Back-populating and self-referencing | ||
|
|
||
| Notice that we explicitly defined the relationship attributes we wanted for referring to the `boss` **as well as** the `minions` of a `Villain`. | ||
|
|
||
| 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}. | ||
|
|
||
| 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.) | ||
svlandeg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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}. | ||
svlandeg marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ## Creating instances | ||
|
|
||
| Now let us see how we can create villains with a boss: | ||
|
|
||
| {* ./docs_src/advanced/self_referential/tutorial001.py ln[30:49] hl[34:35] *} | ||
|
|
||
| Just as with regular relationships, we can simply pass our boss villain as an argument to the constructor with `boss=thinnus`. | ||
|
|
||
| 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: | ||
|
|
||
| {* ./docs_src/advanced/self_referential/tutorial001.py ln[30:31,51:55] hl[52] *} | ||
|
|
||
| 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 🤓): | ||
|
|
||
| {* ./docs_src/advanced/self_referential/tutorial001.py ln[30:31,57:68] hl[61] *} | ||
|
|
||
| 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. | ||
|
|
||
| ## Traversing the relationship graph | ||
|
|
||
| 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. | ||
|
|
||
| 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`: | ||
|
|
||
| ```Python | ||
| top_boss_minions = clone_bot_3.boss.boss.minions | ||
| assert any(minion is ebonite_mew for minion in top_boss_minions) # passes | ||
| ``` | ||
|
|
||
| /// info | ||
|
|
||
| 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**. | ||
|
|
||
| /// | ||
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| from typing import List, Optional | ||
|
|
||
| from sqlmodel import Field, Relationship, Session, SQLModel, create_engine | ||
|
|
||
|
|
||
| class Villain(SQLModel, table=True): | ||
| id: Optional[int] = Field(default=None, primary_key=True) | ||
| name: str = Field(index=True) | ||
| power_level: int | ||
|
|
||
| boss_id: Optional[int] = Field( | ||
| foreign_key="villain.id", default=None, nullable=True | ||
| ) | ||
| boss: Optional["Villain"] = Relationship( | ||
| back_populates="minions", | ||
| sa_relationship_kwargs={"remote_side": "Villain.id"}, | ||
| ) | ||
| minions: List["Villain"] = Relationship(back_populates="boss") | ||
|
|
||
|
|
||
| sqlite_file_name = "database.db" | ||
| sqlite_url = f"sqlite:///{sqlite_file_name}" | ||
|
|
||
| engine = create_engine(sqlite_url, echo=False) | ||
|
|
||
|
|
||
| def create_db_and_tables() -> None: | ||
| SQLModel.metadata.create_all(engine) | ||
|
|
||
|
|
||
| def create_villains() -> None: | ||
| with Session(engine) as session: | ||
| thinnus = Villain(name="Thinnus", power_level=9001) | ||
| ebonite_mew = Villain(name="Ebonite Mew", power_level=400, boss=thinnus) | ||
| dark_shorty = Villain(name="Dark Shorty", power_level=200, boss=thinnus) | ||
| ultra_bot = Villain(name="Ultra Bot", power_level=2**9) | ||
| session.add(ebonite_mew) | ||
| session.add(dark_shorty) | ||
| session.add(ultra_bot) | ||
| session.commit() | ||
|
|
||
| session.refresh(thinnus) | ||
| session.refresh(ebonite_mew) | ||
| session.refresh(dark_shorty) | ||
| session.refresh(ultra_bot) | ||
|
|
||
| print("Created villain:", thinnus) | ||
| print("Created villain:", ebonite_mew) | ||
| print("Created villain:", dark_shorty) | ||
| print("Created villain:", ultra_bot) | ||
|
|
||
| ultra_bot.boss = thinnus | ||
| session.add(ultra_bot) | ||
| session.commit() | ||
| session.refresh(ultra_bot) | ||
| print("Updated villain:", ultra_bot) | ||
|
|
||
| clone_bot_1 = Villain(name="Clone Bot 1", power_level=2**6) | ||
| clone_bot_2 = Villain(name="Clone Bot 2", power_level=2**6) | ||
| clone_bot_3 = Villain(name="Clone Bot 3", power_level=2**6) | ||
| ultra_bot.minions.extend([clone_bot_1, clone_bot_2, clone_bot_3]) | ||
| session.add(ultra_bot) | ||
| session.commit() | ||
| session.refresh(clone_bot_1) | ||
| session.refresh(clone_bot_2) | ||
| session.refresh(clone_bot_3) | ||
| print("Added minion:", clone_bot_1) | ||
| print("Added minion:", clone_bot_2) | ||
| print("Added minion:", clone_bot_3) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| create_db_and_tables() | ||
| create_villains() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
94 changes: 94 additions & 0 deletions
94
tests/test_advanced/test_self_referential/test_tutorial001.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| from unittest.mock import patch | ||
|
|
||
| from sqlmodel import create_engine | ||
|
|
||
| from ...conftest import get_testing_print_function | ||
|
|
||
| expected_calls = [ | ||
| [ | ||
| "Created villain:", | ||
| { | ||
| "name": "Thinnus", | ||
| "power_level": 9001, | ||
| "id": 1, | ||
| "boss_id": None, | ||
| }, | ||
| ], | ||
| [ | ||
| "Created villain:", | ||
| { | ||
| "name": "Ebonite Mew", | ||
| "power_level": 400, | ||
| "id": 3, | ||
| "boss_id": 1, | ||
| }, | ||
| ], | ||
| [ | ||
| "Created villain:", | ||
| { | ||
| "name": "Dark Shorty", | ||
| "power_level": 200, | ||
| "id": 4, | ||
| "boss_id": 1, | ||
| }, | ||
| ], | ||
| [ | ||
| "Created villain:", | ||
| { | ||
| "name": "Ultra Bot", | ||
| "power_level": 2**9, | ||
| "id": 2, | ||
| "boss_id": None, | ||
| }, | ||
| ], | ||
| [ | ||
| "Updated villain:", | ||
| { | ||
| "name": "Ultra Bot", | ||
| "power_level": 2**9, | ||
| "id": 2, | ||
| "boss_id": 1, | ||
| }, | ||
| ], | ||
| [ | ||
| "Added minion:", | ||
| { | ||
| "name": "Clone Bot 1", | ||
| "power_level": 2**6, | ||
| "id": 5, | ||
| "boss_id": 2, | ||
| }, | ||
| ], | ||
| [ | ||
| "Added minion:", | ||
| { | ||
| "name": "Clone Bot 2", | ||
| "power_level": 2**6, | ||
| "id": 6, | ||
| "boss_id": 2, | ||
| }, | ||
| ], | ||
| [ | ||
| "Added minion:", | ||
| { | ||
| "name": "Clone Bot 3", | ||
| "power_level": 2**6, | ||
| "id": 7, | ||
| "boss_id": 2, | ||
| }, | ||
| ], | ||
| ] | ||
|
|
||
|
|
||
| def test_tutorial(clear_sqlmodel): | ||
| from docs_src.advanced.self_referential 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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.