From 6d78778da39f693352d1378f7234ee5c40d1bd13 Mon Sep 17 00:00:00 2001 From: Bharadwaj Yarlagadda Date: Tue, 18 Nov 2025 14:33:44 -0500 Subject: [PATCH 1/4] Add max depth parameter to be able load nested relationships on a model --- src/sqlservice/model.py | 54 ++++++++++++++++++++++++--- tests/fixtures.py | 21 +++++++++++ tests/test_model.py | 83 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/src/sqlservice/model.py b/src/sqlservice/model.py index 85e99c1..f6de8ec 100644 --- a/src/sqlservice/model.py +++ b/src/sqlservice/model.py @@ -63,7 +63,11 @@ def pk(self) -> t.Tuple[t.Any, ...]: return self.__mapper__.primary_key_from_instance(self) def to_dict( - self, *, exclude_relationships: bool = False, lazyload: bool = False + self, + *, + exclude_relationships: bool = False, + lazyload: bool = False, + max_depth: t.Optional[int] = None, ) -> t.Dict[str, t.Any]: """ Serialize ORM loaded data to dictionary. @@ -74,8 +78,13 @@ def to_dict( By default, only table columns will be included. To include relationship fields, set ``include_relationships=True``. This will nest ``to_dict()`` calls to the relationship models. + + ``max_depth``: Maximum depth for nested relationships. ``None`` means unlimited. + ``0`` = no relationships, ``1`` = one level, etc. """ - serializer = ModelSerializer(exclude_relationships=exclude_relationships, lazyload=lazyload) + serializer = ModelSerializer( + exclude_relationships=exclude_relationships, lazyload=lazyload, max_depth=max_depth + ) return serializer.to_dict(self) def __iter__(self): @@ -110,12 +119,19 @@ def delete(cls) -> Delete: class ModelSerializer: - def __init__(self, *, exclude_relationships: bool = False, lazyload: bool = False): + def __init__( + self, + *, + exclude_relationships: bool = False, + lazyload: bool = False, + max_depth: t.Optional[int] = None, + ): self.exclude_relationships = exclude_relationships self.lazyload = lazyload + self.max_depth = max_depth def to_dict(self, model: ModelBase) -> t.Dict[str, t.Any]: - ctx: t.Dict[str, t.Any] = {"seen": set()} + ctx: t.Dict[str, t.Any] = {"seen": set(), "cache": {}, "depth": 0} return self.from_value(ctx, model) def from_value(self, ctx: dict, value: t.Any) -> t.Any: @@ -129,16 +145,35 @@ def from_value(self, ctx: dict, value: t.Any) -> t.Any: def from_model(self, ctx: dict, value: t.Any) -> t.Dict[str, t.Any]: ctx.setdefault("seen", set()) + ctx.setdefault("cache", {}) + ctx.setdefault("depth", 0) + + # Return the cached data if the model has already been seen + if value in ctx["seen"]: + # Return the cached data to break the cycle + return ctx["cache"][id(value)].copy() + + # Add the model to the seen and path ctx["seen"].add(value) + data: t.Dict[str, t.Any] = {} + ctx["cache"][id(value)] = data + state: orm.state.InstanceState = sa.inspect(value) mapper: orm.Mapper = sa.inspect(type(value)) + current_depth = ctx["depth"] + include_relationships = not self.exclude_relationships + + # If max_depth is set, and the current depth is greater than or equal to max_depth, + # exclude relationships + if self.max_depth is not None and current_depth >= self.max_depth: + include_relationships = False + fields = mapper.columns.keys() - if not self.exclude_relationships: + if include_relationships: fields += mapper.relationships.keys() - data = {} for key in fields: loaded_value = state.attrs[key].loaded_value @@ -152,8 +187,15 @@ def from_model(self, ctx: dict, value: t.Any) -> t.Dict[str, t.Any]: ): continue + is_relationship = key in mapper.relationships.keys() + if is_relationship: + ctx["depth"] += 1 + data[key] = self.from_value(ctx, loaded_value) + if is_relationship: + ctx["depth"] -= 1 + return data def from_dict(self, ctx: dict, value: dict) -> dict: diff --git a/tests/fixtures.py b/tests/fixtures.py index b1de901..4cde68e 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -36,6 +36,21 @@ class Model(ModelBase): pass +class Book(Model): + __tablename__ = "books" + + id: Mapped[int] = mapped_column(sa.Integer(), primary_key=True) + title: Mapped[str] = mapped_column(sa.String()) + created_by: Mapped[int] = mapped_column(sa.Integer(), sa.ForeignKey("users.id"), nullable=False) + created_by_user: Mapped["User"] = relationship( + "User", foreign_keys=[created_by], back_populates="created_books" + ) + updated_by: Mapped[int] = mapped_column(sa.Integer(), sa.ForeignKey("users.id"), nullable=False) + updated_by_user: Mapped["User"] = relationship( + "User", foreign_keys=[updated_by], back_populates="updated_books" + ) + + class User(Model): __tablename__ = "users" @@ -48,6 +63,12 @@ class User(Model): "GroupMembership", back_populates="user" ) items: Mapped[t.List["GroupMembership"]] = relationship("Item") + created_books: Mapped[t.List["Book"]] = relationship( + "Book", foreign_keys=[Book.created_by], back_populates="created_by_user" + ) + updated_books: Mapped[t.List["Book"]] = relationship( + "Book", foreign_keys=[Book.updated_by], back_populates="updated_by_user" + ) class Address(Model): diff --git a/tests/test_model.py b/tests/test_model.py index 3ccd15b..fd3621e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -9,7 +9,7 @@ from sqlservice import Database, ModelBase, ModelMeta, as_declarative, declarative_base -from .fixtures import Address, Group, GroupMembership, Item, Note, User +from .fixtures import Address, Book, Group, GroupMembership, Item, Note, User parametrize = pytest.mark.parametrize @@ -217,6 +217,8 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e "addresses": [{"id": 1, "user_id": 1, "addr": "a", "zip_code": "1"}], "group_memberships": [], "items": [], + "created_books": [], + "updated_books": [], }, id="1:M_all_loaded_include_relationships", ), @@ -260,6 +262,8 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e {"group_id": 2, "user_id": 1, "group": {"id": 2, "name": "g2"}}, ], "items": [], + "created_books": [], + "updated_books": [], }, id="nested_relationships_all_loaded", ), @@ -323,9 +327,86 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e }, } ], + "created_books": [], + "updated_books": [], }, id="nested_relationships_lazy_loaded", ), + param( + Book( + id=1, + title="t", + created_by_user=User(id=1, name="n"), + updated_by_user=User(id=2, name="n"), + ), + {"lazyload": True, "max_depth": 1}, + [], + { + "id": 1, + "title": "t", + "created_by": 1, + "updated_by": 2, + "created_by_user": {"id": 1, "name": "n", "active": True}, + "updated_by_user": {"id": 2, "name": "n", "active": True}, + }, + id="circular_reference_lazy_loaded", + ), + param( + Book( + id=1, + title="t", + created_by_user=User(id=1, name="n"), + updated_by_user=User(id=2, name="n"), + ), + {"lazyload": True, "max_depth": 2}, + [], + { + "id": 1, + "title": "t", + "created_by": 1, + "updated_by": 2, + "created_by_user": { + "id": 1, + "name": "n", + "active": True, + "addresses": [], + "group_memberships": [], + "items": [], + "created_books": [{"id": 1, "title": "t", "created_by": 1, "updated_by": 2}], + "updated_books": [], + }, + "updated_by_user": { + "id": 2, + "name": "n", + "active": True, + "addresses": [], + "group_memberships": [], + "items": [], + "created_books": [], + "updated_books": [ + { + "id": 1, + "title": "t", + "created_by": 1, + "updated_by": 2, + "created_by_user": { + "id": 1, + "name": "n", + "active": True, + "addresses": [], + "group_memberships": [], + "items": [], + "created_books": [ + {"id": 1, "title": "t", "created_by": 1, "updated_by": 2} + ], + "updated_books": [], + }, + } + ], + }, + }, + id="circular_reference_lazy_loaded_with_max_depth", + ), ], ) def test_model_to_dict__loaded_from_database( From 775190f1076f21a906bc15b0ec2f1a92246677ab Mon Sep 17 00:00:00 2001 From: Bharadwaj Yarlagadda Date: Wed, 19 Nov 2025 18:29:45 -0500 Subject: [PATCH 2/4] Replace the max_depth with flags for including/excluding nested relationships --- src/sqlservice/model.py | 27 +++++++++++++++++---------- tests/test_model.py | 4 ++-- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/sqlservice/model.py b/src/sqlservice/model.py index f6de8ec..25ba070 100644 --- a/src/sqlservice/model.py +++ b/src/sqlservice/model.py @@ -67,7 +67,8 @@ def to_dict( *, exclude_relationships: bool = False, lazyload: bool = False, - max_depth: t.Optional[int] = None, + exclude_nested_relationships: bool = False, + include_nested_relationships: bool = False, ) -> t.Dict[str, t.Any]: """ Serialize ORM loaded data to dictionary. @@ -79,11 +80,14 @@ def to_dict( ``include_relationships=True``. This will nest ``to_dict()`` calls to the relationship models. - ``max_depth``: Maximum depth for nested relationships. ``None`` means unlimited. - ``0`` = no relationships, ``1`` = one level, etc. + ``exclude_nested_relationships``: Exclude nested relationships. Defaults to ``False``. + ``include_nested_relationships``: Include nested relationships. Defaults to ``False``. """ serializer = ModelSerializer( - exclude_relationships=exclude_relationships, lazyload=lazyload, max_depth=max_depth + exclude_relationships=exclude_relationships, + lazyload=lazyload, + exclude_nested_relationships=exclude_nested_relationships, + include_nested_relationships=include_nested_relationships, ) return serializer.to_dict(self) @@ -124,11 +128,13 @@ def __init__( *, exclude_relationships: bool = False, lazyload: bool = False, - max_depth: t.Optional[int] = None, + exclude_nested_relationships: bool = False, + include_nested_relationships: bool = False, ): self.exclude_relationships = exclude_relationships self.lazyload = lazyload - self.max_depth = max_depth + self.exclude_nested_relationships = exclude_nested_relationships + self.include_nested_relationships = include_nested_relationships def to_dict(self, model: ModelBase) -> t.Dict[str, t.Any]: ctx: t.Dict[str, t.Any] = {"seen": set(), "cache": {}, "depth": 0} @@ -165,10 +171,11 @@ def from_model(self, ctx: dict, value: t.Any) -> t.Dict[str, t.Any]: current_depth = ctx["depth"] include_relationships = not self.exclude_relationships - # If max_depth is set, and the current depth is greater than or equal to max_depth, - # exclude relationships - if self.max_depth is not None and current_depth >= self.max_depth: - include_relationships = False + if current_depth > 0: + if self.include_nested_relationships: + include_relationships = True + elif self.exclude_nested_relationships: + include_relationships = False fields = mapper.columns.keys() if include_relationships: diff --git a/tests/test_model.py b/tests/test_model.py index fd3621e..48e9313 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -339,7 +339,7 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e created_by_user=User(id=1, name="n"), updated_by_user=User(id=2, name="n"), ), - {"lazyload": True, "max_depth": 1}, + {"lazyload": True, "exclude_nested_relationships": True}, [], { "id": 1, @@ -358,7 +358,7 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e created_by_user=User(id=1, name="n"), updated_by_user=User(id=2, name="n"), ), - {"lazyload": True, "max_depth": 2}, + {"lazyload": True, "include_nested_relationships": True}, [], { "id": 1, From 222cf6ce1cfd3760bda7b43f49abc288c8e4d603 Mon Sep 17 00:00:00 2001 From: Bharadwaj Yarlagadda Date: Wed, 19 Nov 2025 20:42:34 -0500 Subject: [PATCH 3/4] Removed exclude_nested_relationships from ModelSerializer and fixed some stale docs --- src/sqlservice/model.py | 17 ++++------------- tests/test_model.py | 27 ++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/sqlservice/model.py b/src/sqlservice/model.py index 25ba070..9d94701 100644 --- a/src/sqlservice/model.py +++ b/src/sqlservice/model.py @@ -67,7 +67,6 @@ def to_dict( *, exclude_relationships: bool = False, lazyload: bool = False, - exclude_nested_relationships: bool = False, include_nested_relationships: bool = False, ) -> t.Dict[str, t.Any]: """ @@ -76,17 +75,14 @@ def to_dict( Only the loaded data, i.e. data previously fetched from the database, will be serialized. Lazy-loaded columns and relationships will be excluded to avoid extra database queries. - By default, only table columns will be included. To include relationship fields, set - ``include_relationships=True``. This will nest ``to_dict()`` calls to the relationship - models. + By default, only table columns will be included. To exclude relationships, set + ``exclude_relationships=True``. - ``exclude_nested_relationships``: Exclude nested relationships. Defaults to ``False``. - ``include_nested_relationships``: Include nested relationships. Defaults to ``False``. + To include nested relationships, set ``include_nested_relationships=True``. """ serializer = ModelSerializer( exclude_relationships=exclude_relationships, lazyload=lazyload, - exclude_nested_relationships=exclude_nested_relationships, include_nested_relationships=include_nested_relationships, ) return serializer.to_dict(self) @@ -128,12 +124,10 @@ def __init__( *, exclude_relationships: bool = False, lazyload: bool = False, - exclude_nested_relationships: bool = False, include_nested_relationships: bool = False, ): self.exclude_relationships = exclude_relationships self.lazyload = lazyload - self.exclude_nested_relationships = exclude_nested_relationships self.include_nested_relationships = include_nested_relationships def to_dict(self, model: ModelBase) -> t.Dict[str, t.Any]: @@ -172,10 +166,7 @@ def from_model(self, ctx: dict, value: t.Any) -> t.Dict[str, t.Any]: include_relationships = not self.exclude_relationships if current_depth > 0: - if self.include_nested_relationships: - include_relationships = True - elif self.exclude_nested_relationships: - include_relationships = False + include_relationships = self.include_nested_relationships fields = mapper.columns.keys() if include_relationships: diff --git a/tests/test_model.py b/tests/test_model.py index 48e9313..8cd26d6 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -247,7 +247,7 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e GroupMembership(group=Group(name="g2")), ], ), - {}, + {"include_nested_relationships": True}, [orm.joinedload("*")], { "id": 1, @@ -286,7 +286,7 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e ) ], ), - {"lazyload": True}, + {"lazyload": True, "include_nested_relationships": True}, [orm.lazyload("*")], { "id": 1, @@ -339,7 +339,7 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e created_by_user=User(id=1, name="n"), updated_by_user=User(id=2, name="n"), ), - {"lazyload": True, "exclude_nested_relationships": True}, + {"lazyload": True}, [], { "id": 1, @@ -349,7 +349,24 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e "created_by_user": {"id": 1, "name": "n", "active": True}, "updated_by_user": {"id": 2, "name": "n", "active": True}, }, - id="circular_reference_lazy_loaded", + id="exclude_nested_relationships_lazy_loaded_with_circular_reference", + ), + param( + Book( + id=1, + title="t", + created_by_user=User(id=1, name="n"), + updated_by_user=User(id=2, name="n"), + ), + {}, + [], + { + "id": 1, + "title": "t", + "created_by": 1, + "updated_by": 2, + }, + id="exclude_nested_relationships_with_circular_reference", ), param( Book( @@ -405,7 +422,7 @@ def test_model_to_dict__with_non_persisted_model(model: ModelBase, args: dict, e ], }, }, - id="circular_reference_lazy_loaded_with_max_depth", + id="include_nested_relationships_lazy_loaded_with_circular_reference", ), ], ) From 77b93c56c97ce42c7bda230f5fcc8f46402a1f16 Mon Sep 17 00:00:00 2001 From: Bharadwaj Yarlagadda Date: Thu, 20 Nov 2025 02:30:41 -0500 Subject: [PATCH 4/4] Grouped the relationship related arguments together in ModelSerializer --- src/sqlservice/model.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/sqlservice/model.py b/src/sqlservice/model.py index 9d94701..af11a52 100644 --- a/src/sqlservice/model.py +++ b/src/sqlservice/model.py @@ -65,8 +65,8 @@ def pk(self) -> t.Tuple[t.Any, ...]: def to_dict( self, *, - exclude_relationships: bool = False, lazyload: bool = False, + exclude_relationships: bool = False, include_nested_relationships: bool = False, ) -> t.Dict[str, t.Any]: """ @@ -75,10 +75,9 @@ def to_dict( Only the loaded data, i.e. data previously fetched from the database, will be serialized. Lazy-loaded columns and relationships will be excluded to avoid extra database queries. - By default, only table columns will be included. To exclude relationships, set - ``exclude_relationships=True``. - - To include nested relationships, set ``include_nested_relationships=True``. + By default, table columns and relationships will be included while nested relationships + will be excluded. To exclude relationships, set ``exclude_relationships=True``. To + include nested relationships, set ``include_nested_relationships=True``. """ serializer = ModelSerializer( exclude_relationships=exclude_relationships, @@ -122,12 +121,12 @@ class ModelSerializer: def __init__( self, *, - exclude_relationships: bool = False, lazyload: bool = False, + exclude_relationships: bool = False, include_nested_relationships: bool = False, ): - self.exclude_relationships = exclude_relationships self.lazyload = lazyload + self.exclude_relationships = exclude_relationships self.include_nested_relationships = include_nested_relationships def to_dict(self, model: ModelBase) -> t.Dict[str, t.Any]: