Skip to content

Commit 59da11e

Browse files
authored
Merge pull request #57 from nxet/feat-max_depth
add `max_depth` arg to control recursion
2 parents 826dce2 + b7f32e2 commit 59da11e

4 files changed

Lines changed: 220 additions & 86 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ this mixin definitely suits you.
1111
- [Advanced usage](#Advanced-usage)
1212
- [Custom formats](#Custom-formats)
1313
- [Custom types](#Custom-types)
14+
- [Custom Serializer](#Custom-serializer)
1415
- [Timezones](#Timezones)
1516
- [Troubleshooting](#Troubleshooting)
1617
- [Tests](#Tests)
@@ -186,6 +187,42 @@ item.to_dict('-children.children')
186187
In this case only the first level of `children` will be included
187188
See [Max recursion](#Max-recursion)
188189

190+
Alternatively you can pass the `max_depth` kwarg, which allows to control recursion in general without having to explicitly list each attribute you don't want recursed.
191+
Please note that this check is performed at the beginning of `serialize_model()`, which means every other nested Iterable is *not affected* by this setting.
192+
The default value returned when recursion is no longer permitted is a string in the form `tablename.id` (e.g. `users.123`), with fallback to `repr(item)` in case the `id` attribute is not available for the model.
193+
This option can be disabled using `max_depth=-1`, which is also the default.
194+
```python
195+
# Stop recursion at top level, meaning all relationships will be "dropped"
196+
# For example:
197+
# item = User(id=123, name="john", articles=[Article(id=10), Article(id=11)])
198+
# will be serialized to
199+
# {
200+
# "id": 123, "name": "john",
201+
# "articles": ["articles.10", "articles.11"]
202+
# }
203+
item.to_dict(max_depth=0)
204+
205+
# Stop at first level, meaning only the first children will be processed
206+
# For example, the same user above will be serialized to:
207+
# {
208+
# "id": 123, "name": "john",
209+
# "articles": [
210+
# {"id": 10, "title": ...},
211+
# {"id": 11, "title": ...},
212+
# ]
213+
# }
214+
item.to_dict(max_depth=1)
215+
```
216+
The `max_depth` option can be also configured at mixin level, setting the `serialize_max_depth` class attribute which avoids passing it to each `to_dict` call.
217+
```python
218+
# Set max_depth option in mixin
219+
class SomeModel(db.Model, SerializerMixin):
220+
serialize_max_depth = 0
221+
222+
# Call to_dict without max_depth arg
223+
item.to_dict()
224+
```
225+
189226
# Custom formats
190227
If you want to change datetime/date/time/decimal format in one model you can specify it like below:
191228
```python
@@ -304,6 +341,27 @@ Unfortunately you can not access formats or tzinfo in that functions.
304341
I'll implement this logic later if any of users needs it.
305342

306343

344+
# Custom Serializer
345+
To have full control over the Serializer, you can define your own subclass with custom logic, and then configure the mixin to use it.
346+
```python
347+
from sqlalchemy_serializer import Serializer, SerializerMixin
348+
349+
350+
class CustomSerializer(Serializer):
351+
352+
def serialize_model(self, value) -> dict:
353+
"""Custom override adding special case for a complex model."""
354+
if isinstance(value, ComplexModel):
355+
return complex_logic(value)
356+
return super().serialize_model(value)
357+
358+
359+
class CustomSerializerMixin(SerializerMixin):
360+
361+
serialize_class = CustomSerializer
362+
```
363+
364+
307365
# Timezones
308366
To keep `datetimes` consistent its better to store it in the database normalized to **UTC**.
309367
But when you return response, sometimes (mostly in web, mobile applications can do it themselves)

sqlalchemy_serializer/serializer.py

Lines changed: 113 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -19,88 +19,18 @@
1919
logger.setLevel(level="WARN")
2020

2121

22-
class SerializerMixin:
23-
"""
24-
Mixin for retrieving public fields of sqlAlchemy-model in json-compatible format
25-
with no pain
26-
It can be inherited to redefine get_tzinfo callback, datetime formats or to add
27-
some extra serialization logic
28-
"""
29-
30-
# Default exclusive schema.
31-
# If left blank, serializer becomes greedy and takes all SQLAlchemy-model's attributes
32-
serialize_only: tuple = ()
33-
34-
# Additions to default schema. Can include negative rules
35-
serialize_rules: tuple = ()
36-
37-
# Extra serialising functions
38-
serialize_types: tuple = ()
39-
40-
# Custom list of fields to serialize in this model
41-
serializable_keys: tuple = ()
42-
43-
date_format = "%Y-%m-%d"
44-
datetime_format = "%Y-%m-%d %H:%M:%S"
45-
time_format = "%H:%M"
46-
decimal_format = "{}"
47-
48-
# Serialize fields of the model defined as @property automatically
49-
auto_serialize_properties: bool = False
50-
51-
def get_tzinfo(self):
52-
"""
53-
Callback to make serializer aware of user's timezone. Should be redefined if needed
54-
Example:
55-
return pytz.timezone('Africa/Abidjan')
56-
57-
:return: datetime.tzinfo
58-
"""
59-
return None
60-
61-
def to_dict(
62-
self,
63-
only=(),
64-
rules=(),
65-
date_format=None,
66-
datetime_format=None,
67-
time_format=None,
68-
tzinfo=None,
69-
decimal_format=None,
70-
serialize_types=None,
71-
):
72-
"""
73-
Returns SQLAlchemy model's data in JSON compatible format
22+
Options = namedtuple(
23+
"Options",
24+
"date_format datetime_format time_format decimal_format tzinfo serialize_types max_depth",
25+
)
7426

75-
For details about datetime formats follow:
76-
https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior
7727

78-
:param only: exclusive schema to replace the default one
79-
always have higher priority than rules
80-
:param rules: schema to extend default one or schema defined in "only"
81-
:param date_format: str
82-
:param datetime_format: str
83-
:param time_format: str
84-
:param decimal_format: str
85-
:param serialize_types:
86-
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
87-
:return: data: dict
88-
"""
89-
s = Serializer(
90-
date_format=date_format or self.date_format,
91-
datetime_format=datetime_format or self.datetime_format,
92-
time_format=time_format or self.time_format,
93-
decimal_format=decimal_format or self.decimal_format,
94-
tzinfo=tzinfo or self.get_tzinfo(),
95-
serialize_types=serialize_types or self.serialize_types,
96-
)
97-
return s(self, only=only, extend=rules)
28+
class IsNotSerializable(Exception):
29+
pass
9830

9931

100-
Options = namedtuple(
101-
"Options",
102-
"date_format datetime_format time_format decimal_format tzinfo serialize_types",
103-
)
32+
def get_type(value) -> str:
33+
return type(value).__name__
10434

10535

10636
class Serializer:
@@ -193,7 +123,7 @@ def fork(self, key: str) -> "Serializer":
193123
Return new serializer for a key
194124
:return: serializer
195125
"""
196-
serializer = Serializer(**self.opts._asdict())
126+
serializer = self.__class__(**self.opts._asdict())
197127
serializer.set_serialization_depth(self.serialization_depth + 1)
198128
serializer.schema = self.schema.fork(key=key)
199129

@@ -267,7 +197,12 @@ def serialize_dict(self, value: dict) -> dict:
267197
logger.debug("Skip key:%s of dict", k)
268198
return res
269199

270-
def serialize_model(self, value) -> dict:
200+
def serialize_model(self, value) -> t.Any:
201+
# Check if user set a recursion limit and we can no longer recurse
202+
if self.serialization_depth > self.opts.max_depth > -1:
203+
# Return early calling internal helper
204+
return self.serialize_model_fallback(value)
205+
271206
self.schema.update(only=value.serialize_only, extend=value.serialize_rules)
272207

273208
res = {}
@@ -290,14 +225,106 @@ def serialize_model(self, value) -> dict:
290225
logger.debug("Skip key:%s of model:%s", k, get_type(value))
291226
return res
292227

228+
def serialize_model_fallback(self, value) -> t.Any:
229+
"""
230+
Called when serialization can no longer recurse.
231+
Default output is a string like "tablename.id", e.g. "users.123", if attributes
232+
are not available it will return "repr(value)".
233+
Override this method to implement own logic.
234+
:return: str
235+
"""
236+
try:
237+
return f'{value.__tablename__}.{value.id}'
238+
except AttributeError:
239+
return repr(value)
240+
293241

294-
class IsNotSerializable(Exception):
295-
pass
242+
def serialize_collection(iterable: t.Iterable, *args, **kwargs) -> list:
243+
return [item.to_dict(*args, **kwargs) for item in iterable]
296244

297245

298-
def get_type(value) -> str:
299-
return type(value).__name__
246+
class SerializerMixin:
247+
"""
248+
Mixin for retrieving public fields of sqlAlchemy-model in json-compatible format
249+
with no pain
250+
It can be inherited to redefine get_tzinfo callback, datetime formats or to add
251+
some extra serialization logic
252+
"""
300253

254+
# Default Serializer class
255+
serialize_class: Serializer = Serializer
301256

302-
def serialize_collection(iterable: t.Iterable, *args, **kwargs) -> list:
303-
return [item.to_dict(*args, **kwargs) for item in iterable]
257+
# Default exclusive schema.
258+
# If left blank, serializer becomes greedy and takes all SQLAlchemy-model's attributes
259+
serialize_only: tuple = ()
260+
261+
# Additions to default schema. Can include negative rules
262+
serialize_rules: tuple = ()
263+
264+
# Extra serialising functions
265+
serialize_types: tuple = ()
266+
267+
# Custom list of fields to serialize in this model
268+
serializable_keys: tuple = ()
269+
270+
date_format = "%Y-%m-%d"
271+
datetime_format = "%Y-%m-%d %H:%M:%S"
272+
time_format = "%H:%M"
273+
decimal_format = "{}"
274+
275+
# Maximum recursion depth, setting to "-1" disables logic
276+
serialize_max_depth: int = -1
277+
278+
# Serialize fields of the model defined as @property automatically
279+
auto_serialize_properties: bool = False
280+
281+
def get_tzinfo(self):
282+
"""
283+
Callback to make serializer aware of user's timezone. Should be redefined if needed
284+
Example:
285+
return pytz.timezone('Africa/Abidjan')
286+
287+
:return: datetime.tzinfo
288+
"""
289+
return None
290+
291+
def to_dict(
292+
self,
293+
only=(),
294+
rules=(),
295+
date_format=None,
296+
datetime_format=None,
297+
time_format=None,
298+
tzinfo=None,
299+
decimal_format=None,
300+
serialize_types=None,
301+
max_depth=None
302+
):
303+
"""
304+
Returns SQLAlchemy model's data in JSON compatible format
305+
306+
For details about datetime formats follow:
307+
https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior
308+
309+
:param only: exclusive schema to replace the default one
310+
always have higher priority than rules
311+
:param rules: schema to extend default one or schema defined in "only"
312+
:param date_format: str
313+
:param datetime_format: str
314+
:param time_format: str
315+
:param decimal_format: str
316+
:param serialize_types:
317+
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
318+
:param max_depth: int maximum recursion depth allowed, "-1" disables logic
319+
:return: data: dict
320+
"""
321+
s = self.serialize_class(
322+
date_format=date_format or self.date_format,
323+
datetime_format=datetime_format or self.datetime_format,
324+
time_format=time_format or self.time_format,
325+
decimal_format=decimal_format or self.decimal_format,
326+
tzinfo=tzinfo or self.get_tzinfo(),
327+
serialize_types=serialize_types or self.serialize_types,
328+
max_depth=max_depth if max_depth is not None else self.serialize_max_depth,
329+
)
330+
return s(self, only=only, extend=rules)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import sqlalchemy as sa
2+
from sqlalchemy_serializer import SerializerMixin, Serializer
3+
4+
from .models import (
5+
DATETIME,
6+
Base,
7+
)
8+
9+
10+
CUSTOM_DICT_VALUE = {'CustomModelTwo': 'test value'}
11+
12+
13+
class CustomSerializer(Serializer):
14+
def serialize(self, value, **kwargs):
15+
# special case for CustomModelTwo, returning string instead of real work
16+
if isinstance(value, CustomModelTwo):
17+
return CUSTOM_DICT_VALUE
18+
return super().serialize(value, **kwargs)
19+
20+
21+
class CustomSerializerMixin(SerializerMixin):
22+
serializer_class = CustomSerializer
23+
24+
25+
class CustomModelOne(Base, CustomSerializerMixin):
26+
__tablename__ = "custom_model_one"
27+
id = sa.Column(sa.Integer, primary_key=True)
28+
datetime = sa.Column(sa.DateTime, default=DATETIME)
29+
30+
31+
class CustomModelTwo(CustomModelOne):
32+
__tablename__ = "custom_model_two"
33+
34+
35+
36+
def test_custom_serializer(get_instance):
37+
"""
38+
Very basic test to ensure custom serializer is used
39+
"""
40+
# Get instance for CustomModelOne, which should serialize normally
41+
i = get_instance(CustomModelOne)
42+
data = i.to_dict()
43+
# Check model was processed correctly
44+
assert "datetime" in data
45+
assert data["datetime"] == DATETIME.strftime(i.datetime_format)
46+
# Same for CustomModelTwo, which should instead return only a simple dict
47+
i = get_instance(CustomModelTwo)
48+
data = i.to_dict()
49+
assert data == CUSTOM_DICT_VALUE

0 commit comments

Comments
 (0)