Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add max_depth arg to control recursion #57

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ this mixin definitely suits you.
- [Advanced usage](#Advanced-usage)
- [Custom formats](#Custom-formats)
- [Custom types](#Custom-types)
- [Custom Serializer](#Custom-serializer)
- [Timezones](#Timezones)
- [Troubleshooting](#Troubleshooting)
- [Tests](#Tests)
Expand Down Expand Up @@ -186,6 +187,42 @@ item.to_dict('-children.children')
In this case only the first level of `children` will be included
See [Max recursion](#Max-recursion)

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.
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.
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.
This option can be disabled using `max_depth=-1`, which is also the default.
```python
# Stop recursion at top level, meaning all relationships will be "dropped"
# For example:
# item = User(id=123, name="john", articles=[Article(id=10), Article(id=11)])
# will be serialized to
# {
# "id": 123, "name": "john",
# "articles": ["articles.10", "articles.11"]
# }
item.to_dict(max_depth=0)

# Stop at first level, meaning only the first children will be processed
# For example, the same user above will be serialized to:
# {
# "id": 123, "name": "john",
# "articles": [
# {"id": 10, "title": ...},
# {"id": 11, "title": ...},
# ]
# }
item.to_dict(max_depth=1)
```
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.
```python
# Set max_depth option in mixin
class SomeModel(db.Model, SerializerMixin):
serialize_max_depth = 0

# Call to_dict without max_depth arg
item.to_dict()
```

# Custom formats
If you want to change datetime/date/time/decimal format in one model you can specify it like below:
```python
Expand Down Expand Up @@ -304,6 +341,27 @@ Unfortunately you can not access formats or tzinfo in that functions.
I'll implement this logic later if any of users needs it.


# Custom Serializer
To have full control over the Serializer, you can define your own subclass with custom logic, and then configure the mixin to use it.
```python
from sqlalchemy_serializer import Serializer, SerializerMixin


class CustomSerializer(Serializer):

def serialize_model(self, value) -> dict:
"""Custom override adding special case for a complex model."""
if isinstance(value, ComplexModel):
return complex_logic(value)
return super().serialize_model(value)


class CustomSerializerMixin(SerializerMixin):

serialize_class = CustomSerializer
```


# Timezones
To keep `datetimes` consistent its better to store it in the database normalized to **UTC**.
But when you return response, sometimes (mostly in web, mobile applications can do it themselves)
Expand Down
199 changes: 113 additions & 86 deletions sqlalchemy_serializer/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,88 +19,18 @@
logger.setLevel(level="WARN")


class SerializerMixin:
"""
Mixin for retrieving public fields of sqlAlchemy-model in json-compatible format
with no pain
It can be inherited to redefine get_tzinfo callback, datetime formats or to add
some extra serialization logic
"""

# Default exclusive schema.
# If left blank, serializer becomes greedy and takes all SQLAlchemy-model's attributes
serialize_only: tuple = ()

# Additions to default schema. Can include negative rules
serialize_rules: tuple = ()

# Extra serialising functions
serialize_types: tuple = ()

# Custom list of fields to serialize in this model
serializable_keys: tuple = ()

date_format = "%Y-%m-%d"
datetime_format = "%Y-%m-%d %H:%M:%S"
time_format = "%H:%M"
decimal_format = "{}"

# Serialize fields of the model defined as @property automatically
auto_serialize_properties: bool = False

def get_tzinfo(self):
"""
Callback to make serializer aware of user's timezone. Should be redefined if needed
Example:
return pytz.timezone('Africa/Abidjan')

:return: datetime.tzinfo
"""
return None

def to_dict(
self,
only=(),
rules=(),
date_format=None,
datetime_format=None,
time_format=None,
tzinfo=None,
decimal_format=None,
serialize_types=None,
):
"""
Returns SQLAlchemy model's data in JSON compatible format
Options = namedtuple(
"Options",
"date_format datetime_format time_format decimal_format tzinfo serialize_types max_depth",
)

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

:param only: exclusive schema to replace the default one
always have higher priority than rules
:param rules: schema to extend default one or schema defined in "only"
:param date_format: str
:param datetime_format: str
:param time_format: str
:param decimal_format: str
:param serialize_types:
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
:return: data: dict
"""
s = Serializer(
date_format=date_format or self.date_format,
datetime_format=datetime_format or self.datetime_format,
time_format=time_format or self.time_format,
decimal_format=decimal_format or self.decimal_format,
tzinfo=tzinfo or self.get_tzinfo(),
serialize_types=serialize_types or self.serialize_types,
)
return s(self, only=only, extend=rules)
class IsNotSerializable(Exception):
pass


Options = namedtuple(
"Options",
"date_format datetime_format time_format decimal_format tzinfo serialize_types",
)
def get_type(value) -> str:
return type(value).__name__


class Serializer:
Expand Down Expand Up @@ -193,7 +123,7 @@ def fork(self, key: str) -> "Serializer":
Return new serializer for a key
:return: serializer
"""
serializer = Serializer(**self.opts._asdict())
serializer = self.__class__(**self.opts._asdict())
serializer.set_serialization_depth(self.serialization_depth + 1)
serializer.schema = self.schema.fork(key=key)

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

def serialize_model(self, value) -> dict:
def serialize_model(self, value) -> t.Any:
# Check if user set a recursion limit and we can no longer recurse
if self.serialization_depth > self.opts.max_depth > -1:
# Return early calling internal helper
return self.serialize_model_fallback(value)

self.schema.update(only=value.serialize_only, extend=value.serialize_rules)

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

def serialize_model_fallback(self, value) -> t.Any:
"""
Called when serialization can no longer recurse.
Default output is a string like "tablename.id", e.g. "users.123", if attributes
are not available it will return "repr(value)".
Override this method to implement own logic.
:return: str
"""
try:
return f'{value.__tablename__}.{value.id}'
except AttributeError:
return repr(value)


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


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

# Default Serializer class
serialize_class: Serializer = Serializer

def serialize_collection(iterable: t.Iterable, *args, **kwargs) -> list:
return [item.to_dict(*args, **kwargs) for item in iterable]
# Default exclusive schema.
# If left blank, serializer becomes greedy and takes all SQLAlchemy-model's attributes
serialize_only: tuple = ()

# Additions to default schema. Can include negative rules
serialize_rules: tuple = ()

# Extra serialising functions
serialize_types: tuple = ()

# Custom list of fields to serialize in this model
serializable_keys: tuple = ()

date_format = "%Y-%m-%d"
datetime_format = "%Y-%m-%d %H:%M:%S"
time_format = "%H:%M"
decimal_format = "{}"

# Maximum recursion depth, setting to "-1" disables logic
serialize_max_depth: int = -1

# Serialize fields of the model defined as @property automatically
auto_serialize_properties: bool = False

def get_tzinfo(self):
"""
Callback to make serializer aware of user's timezone. Should be redefined if needed
Example:
return pytz.timezone('Africa/Abidjan')

:return: datetime.tzinfo
"""
return None

def to_dict(
self,
only=(),
rules=(),
date_format=None,
datetime_format=None,
time_format=None,
tzinfo=None,
decimal_format=None,
serialize_types=None,
max_depth=None
):
"""
Returns SQLAlchemy model's data in JSON compatible format

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

:param only: exclusive schema to replace the default one
always have higher priority than rules
:param rules: schema to extend default one or schema defined in "only"
:param date_format: str
:param datetime_format: str
:param time_format: str
:param decimal_format: str
:param serialize_types:
:param tzinfo: datetime.tzinfo converts datetimes to local user timezone
:param max_depth: int maximum recursion depth allowed, "-1" disables logic
:return: data: dict
"""
s = self.serialize_class(
date_format=date_format or self.date_format,
datetime_format=datetime_format or self.datetime_format,
time_format=time_format or self.time_format,
decimal_format=decimal_format or self.decimal_format,
tzinfo=tzinfo or self.get_tzinfo(),
serialize_types=serialize_types or self.serialize_types,
max_depth=max_depth if max_depth is not None else self.serialize_max_depth,
)
return s(self, only=only, extend=rules)
File renamed without changes.
49 changes: 49 additions & 0 deletions tests/test_custom_serializer_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import sqlalchemy as sa
from sqlalchemy_serializer import SerializerMixin, Serializer

from .models import (
DATETIME,
Base,
)


CUSTOM_DICT_VALUE = {'CustomModelTwo': 'test value'}


class CustomSerializer(Serializer):
def serialize(self, value, **kwargs):
# special case for CustomModelTwo, returning string instead of real work
if isinstance(value, CustomModelTwo):
return CUSTOM_DICT_VALUE
return super().serialize(value, **kwargs)


class CustomSerializerMixin(SerializerMixin):
serializer_class = CustomSerializer


class CustomModelOne(Base, CustomSerializerMixin):
__tablename__ = "custom_model_one"
id = sa.Column(sa.Integer, primary_key=True)
datetime = sa.Column(sa.DateTime, default=DATETIME)


class CustomModelTwo(CustomModelOne):
__tablename__ = "custom_model_two"



def test_custom_serializer(get_instance):
"""
Very basic test to ensure custom serializer is used
"""
# Get instance for CustomModelOne, which should serialize normally
i = get_instance(CustomModelOne)
data = i.to_dict()
# Check model was processed correctly
assert "datetime" in data
assert data["datetime"] == DATETIME.strftime(i.datetime_format)
# Same for CustomModelTwo, which should instead return only a simple dict
i = get_instance(CustomModelTwo)
data = i.to_dict()
assert data == CUSTOM_DICT_VALUE