diff --git a/docs/advanced/advanced-relationships/self-referential.md b/docs/advanced/advanced-relationships/self-referential.md new file mode 100644 index 0000000000..e44be8b8fe --- /dev/null +++ b/docs/advanced/advanced-relationships/self-referential.md @@ -0,0 +1,78 @@ +# 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 known as a **self-referential** or **recursive** relationship, sometimes also called 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 of other villains, we want to call those his **minions**. + +Let's implement 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**, which supports 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 should 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:18] hl[16] *} + +Using the `sa_relationship_kwargs` parameter, we pass the keyword argument `remote_side='Villain.id'` to the underlying relationship property. + +/// info + +The `remote_side` parameter accepts a Python-evaluable string when using Declarative. This allows us to reference `Villain.id` even though the class is still being defined. + +Alternatively, you can use a callable: + +```py +sa_relationship_kwargs={"remote_side": lambda : Villain.id} +``` + +/// + +## 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. + +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#relationship-attributes-or-none){.internal-link target=_blank}. + +## Creating instances + +Now let's see how we can create villains with a boss: + +{* ./docs_src/advanced/self_referential/tutorial001.py ln[31:50] hl[34:35] *} + +Just as with regular relationships, we can simply pass our boss villain as an argument to the constructor using `boss=thinnus`. + +If we later learn that a villain actually had a secret boss after we've already created him, we can just as easily assign that boss retroactively: + +{* ./docs_src/advanced/self_referential/tutorial001.py ln[31:32,52:56] hl[52] *} + +And if we want to add minions to a boss afterward, it's as easy as adding items to a Python list (because that's all it is 🤓): + +{* ./docs_src/advanced/self_referential/tutorial001.py ln[31:32,58:69] 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` again and commit the changes. We do need to refresh them individually, though, if we want to access 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 the relationships we've created so far. + +For example, we can verify that our `clone_bot_1` has a boss, who has his own boss, and that one of that top boss's 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` instead of `==` here, since we are dealing with the exact same objects, not just objects containing the same **data**. + +/// diff --git a/docs_src/advanced/self_referential/__init__.py b/docs_src/advanced/self_referential/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/advanced/self_referential/tutorial001.py b/docs_src/advanced/self_referential/tutorial001.py new file mode 100644 index 0000000000..d5752d41aa --- /dev/null +++ b/docs_src/advanced/self_referential/tutorial001.py @@ -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() diff --git a/mkdocs.yml b/mkdocs.yml index c59ccd245a..7c9ccad1c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,6 +128,8 @@ nav: - advanced/index.md - advanced/decimal.md - advanced/uuid.md + - Advanced Relationships: + - advanced/advanced-relationships/self-referential.md - Resources: - resources/index.md - help.md diff --git a/tests/test_advanced/test_self_referential/__init__.py b/tests/test_advanced/test_self_referential/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_advanced/test_self_referential/test_tutorial001.py b/tests/test_advanced/test_self_referential/test_tutorial001.py new file mode 100644 index 0000000000..2864f27703 --- /dev/null +++ b/tests/test_advanced/test_self_referential/test_tutorial001.py @@ -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