Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
23 changes: 22 additions & 1 deletion sqlalchemy_serializer/serializer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import inspect
import logging
import math
import typing as t
import uuid
from collections import namedtuple
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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(
Expand All @@ -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
)


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
163 changes: 163 additions & 0 deletions tests/test_max_serialization_depth.py
Original file line number Diff line number Diff line change
@@ -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]