From 461e181f2d0906868fd99a8ca9d66027c094cbd6 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Sun, 11 Sep 2022 14:44:06 +0200 Subject: [PATCH 1/2] Add config option to load relationship fields. Due to the recursive loading problem loading relations is not yet possible. This change introduces the config option 'include_relations' to also load specifically chosen relations. For the current use case nothing changes unless the user specifically sets fields to be included (and carefully considers the risks of circular includes). The option is very valuable if the table design contains many 1:n relations. --- sqlmodel/main.py | 14 +- tests/test_relation_resolution.py | 279 ++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 tests/test_relation_resolution.py diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 2b69dd2a75..ee68dba841 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -783,10 +783,16 @@ def _calculate_keys( if include is None and exclude is None and not exclude_unset: # Original in Pydantic: # return None - # Updated to not return SQLAlchemy attributes - # Do not include relationships as that would easily lead to infinite - # recursion, or traversing the whole database - return self.__fields__.keys() # | self.__sqlmodel_relationships__.keys() + # updated to only return SQLAlchemy attributes + # if include_relations is set in the Config for a model + # Otherwise do not include relationships as that would easily lead + # to infinite recursion, or traversing the whole database + model_keys = set(self.__fields__.keys()) + include_relations = getattr(self.Config(), "include_relations", {}) + for relation_key in self.__sqlmodel_relationships__.keys(): + if relation_key in include_relations: + model_keys.add(relation_key) + return model_keys keys: AbstractSet[str] if exclude_unset: diff --git a/tests/test_relation_resolution.py b/tests/test_relation_resolution.py new file mode 100644 index 0000000000..26eb54f86f --- /dev/null +++ b/tests/test_relation_resolution.py @@ -0,0 +1,279 @@ +from typing import List, Optional + +from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select + + +def test_relation_resolution_if_include_relations_not_set(clear_sqlmodel): + class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + heroes: List["Hero"] = Relationship(back_populates="team") # noqa: F821 + + class Config: + orm_mode = True + + class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + team_id: Optional[int] = Field(default=None, foreign_key="team.id") + team: Optional[Team] = Relationship(back_populates="heroes") + + hero_1 = Hero(name="Deadpond") + hero_2 = Hero(name="PhD Strange") + team = Team(name="Marble", heroes=[hero_1, hero_2]) + + engine = create_engine("sqlite://") + + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(team) + session.commit() + session.refresh(team) + keys = team._calculate_keys(include=None, exclude=None, exclude_unset=False) + + # expected not to include the relationship "heroes" since this + # fields since the relationship field was not enabled in + # Config.include_relations + assert keys == {"id", "name"} + + +def test_relation_resolution_if_include_relations_is_set(clear_sqlmodel): + class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + heroes: List["Hero"] = Relationship(back_populates="team") # noqa: F821 + + class Config: + orm_mode = True + include_relations = {"heroes"} + + class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + team_id: Optional[int] = Field(default=None, foreign_key="team.id") + team: Optional[Team] = Relationship(back_populates="heroes") + + hero_1 = Hero(name="Deadpond") + hero_2 = Hero(name="PhD Strange") + team = Team(name="Marble", heroes=[hero_1, hero_2]) + + engine = create_engine("sqlite://") + + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(team) + session.commit() + session.refresh(team) + keys = team._calculate_keys(include=None, exclude=None, exclude_unset=False) + + # expected to include the relationship "heroes" since this + # fields was enabled in Config.include_relations + assert keys == {"id", "name", "heroes"} + + +def test_relation_resolution_if_include_relations_is_set_for_nested(clear_sqlmodel): + class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + heroes: List["Hero"] = Relationship(back_populates="team") # noqa: F821 + + class Config: + orm_mode = True + include_relations = {"heroes"} + + class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + powers: List["Power"] = Relationship(back_populates="hero") # noqa: F821 + team_id: Optional[int] = Field(default=None, foreign_key="team.id") + team: Optional[Team] = Relationship(back_populates="heroes") + + class Config: + orm_mode = True + include_relations = {"powers"} + + class Power(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + description: str + hero_id: Optional[int] = Field(default=None, foreign_key="hero.id") + hero: Optional[Hero] = Relationship(back_populates="powers") + + power_hero_1 = Power(description="Healing Power") + power_hero_2 = Power(description="Levitating Cloak") + hero_1 = Hero(name="Deadpond", powers=[power_hero_1]) + hero_2 = Hero(name="PhD Strange", powers=[power_hero_2]) + team = Team(name="Marble", heroes=[hero_1, hero_2]) + + engine = create_engine("sqlite://") + + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(team) + session.commit() + session.refresh(team) + session.refresh(hero_1) + team_keys = team._calculate_keys(include=None, exclude=None, exclude_unset=False) + hero_1_keys = hero_1._calculate_keys( + include=None, exclude=None, exclude_unset=False + ) + + assert team_keys == {"id", "name", "heroes"} + assert hero_1_keys == {"id", "name", "powers", "team_id"} + + +def test_relation_resolution_if_lazy_selectin_not_set_with_fastapi(clear_sqlmodel): + class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + heroes: List["Hero"] = Relationship(back_populates="team") # noqa: F821 + + class Config: + orm_mode = True + include_relations = {"heroes"} + + class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + powers: List["Power"] = Relationship(back_populates="hero") # noqa: F821 + team_id: Optional[int] = Field(default=None, foreign_key="team.id") + team: Optional[Team] = Relationship(back_populates="heroes") + + class Config: + orm_mode = True + include_relations = {"powers"} + + class Power(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + description: str + hero_id: Optional[int] = Field(default=None, foreign_key="hero.id") + hero: Optional[Hero] = Relationship(back_populates="powers") + + power_hero_1 = Power(description="Healing Power") + power_hero_2 = Power(description="Levitating Cloak") + hero_1 = Hero(name="Deadpond", powers=[power_hero_1]) + hero_2 = Hero(name="PhD Strange", powers=[power_hero_2]) + team = Team(name="Marble", heroes=[hero_1, hero_2]) + + engine = create_engine("sqlite://") + + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(team) + session.commit() + session.refresh(team) + + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + + @app.get("/") + async def read_main(response_model=List[Team]): + with Session(engine) as session: + teams = session.execute(select(Team)).all() + return teams + + client = TestClient(app) + teams = client.get("/") + expected_json = [{"Team": {"name": "Marble", "id": 1}}] + + # if sa_relationship_kwargs={"lazy": "selectin"}) not set in relation + # there is no effect on the relations even though the Config was set + # to load the relation fields. + assert teams.json() == expected_json + + +def test_relation_resolution_if_lazy_selectin_is_set_with_fastapi(clear_sqlmodel): + class Team(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + heroes: List["Hero"] = Relationship( # noqa: F821 + back_populates="team", sa_relationship_kwargs={"lazy": "selectin"} + ) + + class Config: + orm_mode = True + include_relations = {"heroes"} + + class Hero(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + powers: List["Power"] = Relationship( # noqa: F821 + back_populates="hero", sa_relationship_kwargs={"lazy": "selectin"} + ) + team_id: Optional[int] = Field(default=None, foreign_key="team.id") + team: Optional[Team] = Relationship(back_populates="heroes") + + class Config: + orm_mode = True + include_relations = {"powers"} + + class Power(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + description: str + hero_id: Optional[int] = Field(default=None, foreign_key="hero.id") + hero: Optional[Hero] = Relationship(back_populates="powers") + + power_hero_1 = Power(description="Healing Power") + power_hero_2 = Power(description="Levitating Cloak") + hero_1 = Hero(name="Deadpond", powers=[power_hero_1]) + hero_2 = Hero(name="PhD Strange", powers=[power_hero_2]) + team = Team(name="Marble", heroes=[hero_1, hero_2]) + + engine = create_engine("sqlite://") + + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(team) + session.commit() + session.refresh(team) + + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + + @app.get("/") + async def read_main(response_model=List[Team]): + with Session(engine) as session: + teams = session.execute(select(Team)).all() + return teams + + client = TestClient(app) + teams = client.get("/") + expected_json = [ + { + "Team": { + "name": "Marble", + "id": 1, + "heroes": [ + { + "id": 1, + "team_id": 1, + "name": "Deadpond", + "powers": [ + {"id": 1, "hero_id": 1, "description": "Healing Power"} + ], + }, + { + "id": 2, + "team_id": 1, + "name": "PhD Strange", + "powers": [ + {"id": 2, "hero_id": 2, "description": "Levitating Cloak"} + ], + }, + ], + } + } + ] + + # if sa_relationship_kwargs={"lazy": "selectin"}) is set + # the relations in the Config are considered and the relation fields are + # included in the response. + assert teams.json() == expected_json From cbc9a0d2d9b9dd08e8ed074954983c2639d4c294 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Sun, 11 Sep 2022 15:12:29 +0200 Subject: [PATCH 2/2] Add brief docs section for the new option. --- docs/tutorial/fastapi/relationships.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/tutorial/fastapi/relationships.md b/docs/tutorial/fastapi/relationships.md index f152b231c7..30d4dc4efa 100644 --- a/docs/tutorial/fastapi/relationships.md +++ b/docs/tutorial/fastapi/relationships.md @@ -158,6 +158,16 @@ So we start again, and in the end, the server would just crash trying to get all So, we need to carefully choose in which cases we want to include data and in which not. +### Specifically include a relation + +If recursion is not an issue for a specific relations it might be handy not to +duplicate the models for reading. + +This can be done by adding `sa_relationship_kwargs={"lazy": "selectin"})` to +your relationship (be careful with that option, this will not lazy load anymore 😱). +Then add the config option `include_relations = {"field_to_include"}` for the +field you want to **always** include. + ## What Data to Include This is a decision that will depend on **each application**.