diff --git a/README.md b/README.md index e2f6c8a..a8802aa 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,22 @@ result = item.to_dict(exclude_values=(None, True, '')) ``` **Note** that `exclude_values` works with hashable values only. It filters values after serialization, so it works with nested dictionaries and models as well. +If you want to control the maximum depth for relationship recursion: +```python +# Limit to one level of nesting (prevents infinite recursion) +result = item.to_dict(max_serialization_depth=1) + +# Disable relationship serialization entirely +result = item.to_dict(max_serialization_depth=0) + +# Set default for all instances of a model +class SomeModel(db.Model, SerializerMixin): + max_serialization_depth = 1 # Only serialize direct relationships + ... +``` +By default, `max_serialization_depth` is `math.inf` (unlimited), maintaining backward compatibility. +See [Max recursion](#Max-recursion) for more details. + If you want to define schema for all instances of particular SQLAlchemy model, add serialize properties to model definition: ```python @@ -392,7 +408,23 @@ response = serialize_collection(Category.query.all(), **some_params) ## Max recursion If you've faced with **maximum recursion depth exceeded** exception, most likely the serializer have found instance of the same class somewhere among model's relationships. -Especially if you use backrefs. In this case you need to tell it where to stop like below: +Especially if you use backrefs. + +**Solution 1: Use `max_serialization_depth` (Recommended)** +The easiest way to prevent infinite recursion is to set a maximum depth: +```python +# Per-call limit +user.to_dict(max_serialization_depth=1) # Only serialize direct relationships + +# Model-level default +class User(Base, SerializerMixin): + max_serialization_depth = 1 # Prevent recursion for all instances + ... + related_models = relationship("RelatedModel", backref='user') +``` + +**Solution 2: Use rules to exclude specific relationships** +You can also use rules to tell the serializer where to stop: ```python class User(Base, SerializerMixin): __tablename__ = 'users' diff --git a/sqlalchemy_serializer/serializer.py b/sqlalchemy_serializer/serializer.py index 3e00195..fbea72d 100644 --- a/sqlalchemy_serializer/serializer.py +++ b/sqlalchemy_serializer/serializer.py @@ -1,5 +1,6 @@ import inspect import logging +import math import typing as t import uuid from collections import namedtuple @@ -57,6 +58,9 @@ class SerializerMixin: # Serialize fields of the model defined as @property automatically auto_serialize_properties: bool = False + # Maximum depth for relationship recursion (default: unlimited) + max_serialization_depth: float = math.inf + def get_tzinfo(self): """Callback to make serializer aware of user's timezone. Should be redefined if needed Example: @@ -77,6 +81,7 @@ def to_dict( decimal_format=None, serialize_types=None, exclude_values=None, + max_serialization_depth=None, ): """Returns SQLAlchemy model's data in JSON compatible format @@ -93,6 +98,8 @@ def to_dict( :param serialize_types: :param tzinfo: datetime.tzinfo converts datetimes to local user timezone :param exclude_values: iterable of hashable values to exclude from serialized output + :param max_serialization_depth: maximum depth for relationship recursion + (default: unlimited) :return: data: dict """ s = Serializer( @@ -103,13 +110,18 @@ def to_dict( tzinfo=tzinfo or self.get_tzinfo(), serialize_types=serialize_types or self.serialize_types, exclude_values=exclude_values or self.exclude_values, + max_serialization_depth=( + max_serialization_depth + if max_serialization_depth is not None + else self.max_serialization_depth + ), ) return s(self, only=only, extend=rules) Options = namedtuple( "Options", - "date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values", # noqa: E501 + "date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values max_serialization_depth", # noqa: E501 ) @@ -139,6 +151,7 @@ def __init__(self, **kwargs): "tzinfo": kwargs.get("tzinfo"), "serialize_types": kwargs.get("serialize_types", ()), "exclude_values": exclude_values_set, + "max_serialization_depth": kwargs.get("max_serialization_depth", math.inf), } self.set_options(Options(**options_kwargs)) self.init_callbacks() @@ -271,6 +284,14 @@ def serialize_with_fork(self, value, key): """Serialize value with a forked serializer""" serializer = self if self.is_forkable(value): + # Check depth limit before forking + if self.serialization_depth >= self.opts.max_serialization_depth: + logger.debug( + "Max serialization depth reached at depth:%s for key:%s", + self.serialization_depth, + key, + ) + return _UNSPECIFIED serializer = self.fork(key=key) return serializer.apply_callback(value) diff --git a/tests/test_max_serialization_depth.py b/tests/test_max_serialization_depth.py new file mode 100644 index 0000000..0cc05b9 --- /dev/null +++ b/tests/test_max_serialization_depth.py @@ -0,0 +1,163 @@ +import math + +import pytest + +from .models import FlatModel, NestedModel, RecursiveModel + + +@pytest.fixture +def three_level_recursive_model(get_instance): + """Create a 3-level recursive model structure: root -> child -> grandchild""" + root = get_instance(RecursiveModel) + child_full = get_instance(RecursiveModel, parent_id=root.id) + get_instance(RecursiveModel, parent_id=child_full.id) + return root + + +def test_default_unlimited_depth(three_level_recursive_model): + """Test that default behavior maintains unlimited depth (backward compatible)""" + # Default should be unlimited (math.inf) + data = three_level_recursive_model.to_dict() + + assert "children" in data + assert "children" in data["children"][0] + assert "children" in data["children"][0]["children"][0] + + assert "name" in data + assert "name" in data["children"][0] + assert "name" in data["children"][0]["children"][0] + + +def test_model_level_max_depth(three_level_recursive_model): + """Test max_serialization_depth set on model class""" + # Set max depth to 1 (only serialize direct children, not grandchildren) + three_level_recursive_model.max_serialization_depth = 1 + data = three_level_recursive_model.to_dict() + + assert "children" in data + # But grandchildren should not be serialized + assert "children" not in data["children"][0] + + assert "name" in data + assert "name" in data["children"][0] # Name is still there + + +def test_per_call_max_depth(three_level_recursive_model): + """Test max_serialization_depth passed to to_dict() method""" + # Per-call max depth should override model-level setting + three_level_recursive_model.max_serialization_depth = 2 + data = three_level_recursive_model.to_dict(max_serialization_depth=0) + + # At depth 0, no relationships should be serialized + assert "children" not in data + assert "name" in data + + +def test_depth_limit_enforcement_nested_model(get_instance): + """Test depth limit enforcement with NestedModel""" + nested = get_instance(NestedModel, model_id=get_instance(FlatModel).id) + + # At depth 0, nested relationships should not be serialized + data = nested.to_dict(max_serialization_depth=0) + assert "model" not in data + + # At depth 1, one level of nesting should work + data = nested.to_dict(max_serialization_depth=1) + assert "model" in data + nested_model = data["model"] + assert "id" in nested_model + assert "string" in nested_model + # FlatModel doesn't have a relationship back, so no further nesting + + +def test_depth_limit_with_rules(three_level_recursive_model): + """Test that depth limit works together with existing rules""" + # Combine depth limit with rules + data = three_level_recursive_model.to_dict( + max_serialization_depth=1, rules=("-children.id",) + ) + + assert "children" in data + assert "id" not in data["children"][0] # Rule applied + assert "name" in data["children"][0] + # Depth limit still enforced + assert "children" not in data["children"][0] + + +def test_depth_limit_zero(get_instance): + """Test that depth 0 prevents all relationship serialization""" + root = get_instance(RecursiveModel) + get_instance(RecursiveModel, parent_id=root.id) + + data = root.to_dict(max_serialization_depth=0) + + # Only top-level fields should be present + assert "id" in data + assert "name" in data + assert "children" not in data + + +@pytest.mark.parametrize("depth", [1, 2]) +def test_depth_limit_multiple_levels(three_level_recursive_model, depth): + data = three_level_recursive_model.to_dict(max_serialization_depth=depth) + + for i in range(depth): + assert "id" in data + assert "name" in data + if i < depth: + assert "children" in data + data = data["children"][0] + + +def test_depth_limit_infinity(three_level_recursive_model): + """Test that math.inf allows unlimited depth""" + data = three_level_recursive_model.to_dict(max_serialization_depth=math.inf) + + # Should serialize all levels + assert "children" in data + assert "children" in data["children"][0] + assert "children" in data["children"][0]["children"][0] + + +def test_model_level_depth_override(get_instance): + """Test that per-call parameter overrides model-level setting""" + root = get_instance(RecursiveModel) + get_instance(RecursiveModel, parent_id=root.id) + + # Set model-level depth to 1 + root.max_serialization_depth = 1 + # But override with per-call parameter + data = root.to_dict(max_serialization_depth=0) + + # Per-call parameter should win + assert "children" not in data + + +def test_to_dict_param_overrides_class_level_max_depth(three_level_recursive_model): + """Test that max_serialization_depth parameter in to_dict() + overrides class-level setting""" + three_level_recursive_model.max_serialization_depth = 1 + + # First, verify class-level setting works when no parameter is passed + data_without_param = three_level_recursive_model.to_dict() + assert "children" in data_without_param + assert "children" not in data_without_param["children"][0] # Child has children field + + # Now verify that passing parameter overrides class-level setting + # Override with depth 2 (should allow grandchildren) + data_with_override = three_level_recursive_model.to_dict(max_serialization_depth=2) + assert "children" in data_with_override + # Parameter override allows deeper nesting + assert "children" in data_with_override["children"][0] + + # Override with depth 0 (should prevent all relationships) + data_with_zero = three_level_recursive_model.to_dict(max_serialization_depth=0) + assert "children" not in data_with_zero + assert "id" in data_with_zero + assert "name" in data_with_zero + + # Override with math.inf (should allow unlimited depth) + data_with_inf = three_level_recursive_model.to_dict(max_serialization_depth=math.inf) + assert "children" in data_with_inf + assert "children" in data_with_inf["children"][0] + assert "children" in data_with_inf["children"][0]["children"][0]