From 6e10a41ac9be436871ab7f7824a3e85178a50cea Mon Sep 17 00:00:00 2001 From: Daniil Fajnberg Date: Thu, 25 Aug 2022 17:49:02 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docs=20page=20for=20self-r?= =?UTF-8?q?eferential=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/advanced/self-referential.md | 136 ++++++++++++++++++ .../advanced/self_referential/__init__.py | 0 .../advanced/self_referential/tutorial001.py | 77 ++++++++++ mkdocs.yml | 1 + .../test_self_referential/__init__.py | 0 .../test_self_referential/test_tutorial001.py | 95 ++++++++++++ 6 files changed, 309 insertions(+) create mode 100644 docs/advanced/self-referential.md create mode 100644 docs_src/advanced/self_referential/__init__.py create mode 100644 docs_src/advanced/self_referential/tutorial001.py create mode 100644 tests/test_advanced/test_self_referential/__init__.py create mode 100644 tests/test_advanced/test_self_referential/test_tutorial001.py diff --git a/docs/advanced/self-referential.md b/docs/advanced/self-referential.md new file mode 100644 index 0000000000..f168e5ff92 --- /dev/null +++ b/docs/advanced/self-referential.md @@ -0,0 +1,136 @@ +# 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/14/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/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. + +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. + +```Python hl_lines="12" +# Code above omitted 👆 + +{!./docs_src/advanced/self_referential/tutorial001.py[ln:6-17]!} + +# Code below omitted 👇 +``` + +
+👀 Full file preview + +```Python +{!./docs_src/advanced/self_referential/tutorial001.py!} +``` + +
+ +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. + +## 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#optional-relationship-attributes){.internal-link target=_blank}. + +## Creating instances + +Now let us see how we can create villains with a boss: + +```Python hl_lines="6-7" +# Code above omitted 👆 + +{!./docs_src/advanced/self_referential/tutorial001.py[ln:30-49]!} + +# Code below omitted 👇 +``` + +
+👀 Full file preview + +```Python +{!./docs_src/advanced/self_referential/tutorial001.py!} +``` + +
+ +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: + +```Python hl_lines="8" +# Code above omitted 👆 + +{!./docs_src/advanced/self_referential/tutorial001.py[ln:30-31]!} + + # Previous code here omitted 👈 + +{!./docs_src/advanced/self_referential/tutorial001.py[ln:51-55]!} + +# Code below omitted 👇 +``` + +
+👀 Full file preview + +```Python +{!./docs_src/advanced/self_referential/tutorial001.py!} +``` + +
+ +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 🤓): + +```Python hl_lines="11" +# Code above omitted 👆 + +{!./docs_src/advanced/self_referential/tutorial001.py[ln:30-31]!} + + # Previous code here omitted 👈 + +{!./docs_src/advanced/self_referential/tutorial001.py[ln:57-68]!} + +# Code below omitted 👇 +``` + +
+👀 Full file preview + +```Python +{!./docs_src/advanced/self_referential/tutorial001.py!} +``` + +
+ +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**. 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..f8d1418f04 --- /dev/null +++ b/docs_src/advanced/self_referential/tutorial001.py @@ -0,0 +1,77 @@ +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=dict(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 a27bbde8a1..65ffde2f85 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - Advanced User Guide: - advanced/index.md - advanced/decimal.md + - advanced/self-referential.md - alternatives.md - help.md - contributing.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..7fbf1efc61 --- /dev/null +++ b/tests/test_advanced/test_self_referential/test_tutorial001.py @@ -0,0 +1,95 @@ +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