Skip to content

Commit 210cc0b

Browse files
authored
Merge pull request #69 from n0nSmoker/31-globally-disabling-relationship-recursion
Max recursion depth param
2 parents 58fb6e8 + b8b8ffc commit 210cc0b

3 files changed

Lines changed: 218 additions & 2 deletions

File tree

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ result = item.to_dict(exclude_values=(None, True, ''))
9090
```
9191
**Note** that `exclude_values` works with hashable values only. It filters values after serialization, so it works with nested dictionaries and models as well.
9292

93+
If you want to control the maximum depth for relationship recursion:
94+
```python
95+
# Limit to one level of nesting (prevents infinite recursion)
96+
result = item.to_dict(max_serialization_depth=1)
97+
98+
# Disable relationship serialization entirely
99+
result = item.to_dict(max_serialization_depth=0)
100+
101+
# Set default for all instances of a model
102+
class SomeModel(db.Model, SerializerMixin):
103+
max_serialization_depth = 1 # Only serialize direct relationships
104+
...
105+
```
106+
By default, `max_serialization_depth` is `math.inf` (unlimited), maintaining backward compatibility.
107+
See [Max recursion](#Max-recursion) for more details.
108+
93109
If you want to define schema for all instances of particular SQLAlchemy model,
94110
add serialize properties to model definition:
95111
```python
@@ -392,7 +408,23 @@ response = serialize_collection(Category.query.all(), **some_params)
392408
## Max recursion
393409
If you've faced with **maximum recursion depth exceeded** exception,
394410
most likely the serializer have found instance of the same class somewhere among model's relationships.
395-
Especially if you use backrefs. In this case you need to tell it where to stop like below:
411+
Especially if you use backrefs.
412+
413+
**Solution 1: Use `max_serialization_depth` (Recommended)**
414+
The easiest way to prevent infinite recursion is to set a maximum depth:
415+
```python
416+
# Per-call limit
417+
user.to_dict(max_serialization_depth=1) # Only serialize direct relationships
418+
419+
# Model-level default
420+
class User(Base, SerializerMixin):
421+
max_serialization_depth = 1 # Prevent recursion for all instances
422+
...
423+
related_models = relationship("RelatedModel", backref='user')
424+
```
425+
426+
**Solution 2: Use rules to exclude specific relationships**
427+
You can also use rules to tell the serializer where to stop:
396428
```python
397429
class User(Base, SerializerMixin):
398430
__tablename__ = 'users'

sqlalchemy_serializer/serializer.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import inspect
22
import logging
3+
import math
34
import typing as t
45
import uuid
56
from collections import namedtuple
@@ -57,6 +58,9 @@ class SerializerMixin:
5758
# Serialize fields of the model defined as @property automatically
5859
auto_serialize_properties: bool = False
5960

61+
# Maximum depth for relationship recursion (default: unlimited)
62+
max_serialization_depth: float = math.inf
63+
6064
def get_tzinfo(self):
6165
"""Callback to make serializer aware of user's timezone. Should be redefined if needed
6266
Example:
@@ -77,6 +81,7 @@ def to_dict(
7781
decimal_format=None,
7882
serialize_types=None,
7983
exclude_values=None,
84+
max_serialization_depth=None,
8085
):
8186
"""Returns SQLAlchemy model's data in JSON compatible format
8287
@@ -93,6 +98,8 @@ def to_dict(
9398
:param serialize_types:
9499
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
95100
:param exclude_values: iterable of hashable values to exclude from serialized output
101+
:param max_serialization_depth: maximum depth for relationship recursion
102+
(default: unlimited)
96103
:return: data: dict
97104
"""
98105
s = Serializer(
@@ -103,13 +110,18 @@ def to_dict(
103110
tzinfo=tzinfo or self.get_tzinfo(),
104111
serialize_types=serialize_types or self.serialize_types,
105112
exclude_values=exclude_values or self.exclude_values,
113+
max_serialization_depth=(
114+
max_serialization_depth
115+
if max_serialization_depth is not None
116+
else self.max_serialization_depth
117+
),
106118
)
107119
return s(self, only=only, extend=rules)
108120

109121

110122
Options = namedtuple(
111123
"Options",
112-
"date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values", # noqa: E501
124+
"date_format datetime_format time_format decimal_format tzinfo serialize_types exclude_values max_serialization_depth", # noqa: E501
113125
)
114126

115127

@@ -139,6 +151,7 @@ def __init__(self, **kwargs):
139151
"tzinfo": kwargs.get("tzinfo"),
140152
"serialize_types": kwargs.get("serialize_types", ()),
141153
"exclude_values": exclude_values_set,
154+
"max_serialization_depth": kwargs.get("max_serialization_depth", math.inf),
142155
}
143156
self.set_options(Options(**options_kwargs))
144157
self.init_callbacks()
@@ -271,6 +284,14 @@ def serialize_with_fork(self, value, key):
271284
"""Serialize value with a forked serializer"""
272285
serializer = self
273286
if self.is_forkable(value):
287+
# Check depth limit before forking
288+
if self.serialization_depth >= self.opts.max_serialization_depth:
289+
logger.debug(
290+
"Max serialization depth reached at depth:%s for key:%s",
291+
self.serialization_depth,
292+
key,
293+
)
294+
return _UNSPECIFIED
274295
serializer = self.fork(key=key)
275296

276297
return serializer.apply_callback(value)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import math
2+
3+
import pytest
4+
5+
from .models import FlatModel, NestedModel, RecursiveModel
6+
7+
8+
@pytest.fixture
9+
def three_level_recursive_model(get_instance):
10+
"""Create a 3-level recursive model structure: root -> child -> grandchild"""
11+
root = get_instance(RecursiveModel)
12+
child_full = get_instance(RecursiveModel, parent_id=root.id)
13+
get_instance(RecursiveModel, parent_id=child_full.id)
14+
return root
15+
16+
17+
def test_default_unlimited_depth(three_level_recursive_model):
18+
"""Test that default behavior maintains unlimited depth (backward compatible)"""
19+
# Default should be unlimited (math.inf)
20+
data = three_level_recursive_model.to_dict()
21+
22+
assert "children" in data
23+
assert "children" in data["children"][0]
24+
assert "children" in data["children"][0]["children"][0]
25+
26+
assert "name" in data
27+
assert "name" in data["children"][0]
28+
assert "name" in data["children"][0]["children"][0]
29+
30+
31+
def test_model_level_max_depth(three_level_recursive_model):
32+
"""Test max_serialization_depth set on model class"""
33+
# Set max depth to 1 (only serialize direct children, not grandchildren)
34+
three_level_recursive_model.max_serialization_depth = 1
35+
data = three_level_recursive_model.to_dict()
36+
37+
assert "children" in data
38+
# But grandchildren should not be serialized
39+
assert "children" not in data["children"][0]
40+
41+
assert "name" in data
42+
assert "name" in data["children"][0] # Name is still there
43+
44+
45+
def test_per_call_max_depth(three_level_recursive_model):
46+
"""Test max_serialization_depth passed to to_dict() method"""
47+
# Per-call max depth should override model-level setting
48+
three_level_recursive_model.max_serialization_depth = 2
49+
data = three_level_recursive_model.to_dict(max_serialization_depth=0)
50+
51+
# At depth 0, no relationships should be serialized
52+
assert "children" not in data
53+
assert "name" in data
54+
55+
56+
def test_depth_limit_enforcement_nested_model(get_instance):
57+
"""Test depth limit enforcement with NestedModel"""
58+
nested = get_instance(NestedModel, model_id=get_instance(FlatModel).id)
59+
60+
# At depth 0, nested relationships should not be serialized
61+
data = nested.to_dict(max_serialization_depth=0)
62+
assert "model" not in data
63+
64+
# At depth 1, one level of nesting should work
65+
data = nested.to_dict(max_serialization_depth=1)
66+
assert "model" in data
67+
nested_model = data["model"]
68+
assert "id" in nested_model
69+
assert "string" in nested_model
70+
# FlatModel doesn't have a relationship back, so no further nesting
71+
72+
73+
def test_depth_limit_with_rules(three_level_recursive_model):
74+
"""Test that depth limit works together with existing rules"""
75+
# Combine depth limit with rules
76+
data = three_level_recursive_model.to_dict(
77+
max_serialization_depth=1, rules=("-children.id",)
78+
)
79+
80+
assert "children" in data
81+
assert "id" not in data["children"][0] # Rule applied
82+
assert "name" in data["children"][0]
83+
# Depth limit still enforced
84+
assert "children" not in data["children"][0]
85+
86+
87+
def test_depth_limit_zero(get_instance):
88+
"""Test that depth 0 prevents all relationship serialization"""
89+
root = get_instance(RecursiveModel)
90+
get_instance(RecursiveModel, parent_id=root.id)
91+
92+
data = root.to_dict(max_serialization_depth=0)
93+
94+
# Only top-level fields should be present
95+
assert "id" in data
96+
assert "name" in data
97+
assert "children" not in data
98+
99+
100+
@pytest.mark.parametrize("depth", [1, 2])
101+
def test_depth_limit_multiple_levels(three_level_recursive_model, depth):
102+
data = three_level_recursive_model.to_dict(max_serialization_depth=depth)
103+
104+
for i in range(depth):
105+
assert "id" in data
106+
assert "name" in data
107+
if i < depth:
108+
assert "children" in data
109+
data = data["children"][0]
110+
111+
112+
def test_depth_limit_infinity(three_level_recursive_model):
113+
"""Test that math.inf allows unlimited depth"""
114+
data = three_level_recursive_model.to_dict(max_serialization_depth=math.inf)
115+
116+
# Should serialize all levels
117+
assert "children" in data
118+
assert "children" in data["children"][0]
119+
assert "children" in data["children"][0]["children"][0]
120+
121+
122+
def test_model_level_depth_override(get_instance):
123+
"""Test that per-call parameter overrides model-level setting"""
124+
root = get_instance(RecursiveModel)
125+
get_instance(RecursiveModel, parent_id=root.id)
126+
127+
# Set model-level depth to 1
128+
root.max_serialization_depth = 1
129+
# But override with per-call parameter
130+
data = root.to_dict(max_serialization_depth=0)
131+
132+
# Per-call parameter should win
133+
assert "children" not in data
134+
135+
136+
def test_to_dict_param_overrides_class_level_max_depth(three_level_recursive_model):
137+
"""Test that max_serialization_depth parameter in to_dict()
138+
overrides class-level setting"""
139+
three_level_recursive_model.max_serialization_depth = 1
140+
141+
# First, verify class-level setting works when no parameter is passed
142+
data_without_param = three_level_recursive_model.to_dict()
143+
assert "children" in data_without_param
144+
assert "children" not in data_without_param["children"][0] # Child has children field
145+
146+
# Now verify that passing parameter overrides class-level setting
147+
# Override with depth 2 (should allow grandchildren)
148+
data_with_override = three_level_recursive_model.to_dict(max_serialization_depth=2)
149+
assert "children" in data_with_override
150+
# Parameter override allows deeper nesting
151+
assert "children" in data_with_override["children"][0]
152+
153+
# Override with depth 0 (should prevent all relationships)
154+
data_with_zero = three_level_recursive_model.to_dict(max_serialization_depth=0)
155+
assert "children" not in data_with_zero
156+
assert "id" in data_with_zero
157+
assert "name" in data_with_zero
158+
159+
# Override with math.inf (should allow unlimited depth)
160+
data_with_inf = three_level_recursive_model.to_dict(max_serialization_depth=math.inf)
161+
assert "children" in data_with_inf
162+
assert "children" in data_with_inf["children"][0]
163+
assert "children" in data_with_inf["children"][0]["children"][0]

0 commit comments

Comments
 (0)