diff --git a/README.md b/README.md index df57ecf..73c0f52 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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) diff --git a/sqlalchemy_serializer/serializer.py b/sqlalchemy_serializer/serializer.py index 0ab1c97..529ff84 100644 --- a/sqlalchemy_serializer/serializer.py +++ b/sqlalchemy_serializer/serializer.py @@ -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: @@ -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) @@ -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 = {} @@ -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) diff --git a/tests/test_custom_serializer.py b/tests/test_custom_model.py similarity index 100% rename from tests/test_custom_serializer.py rename to tests/test_custom_model.py diff --git a/tests/test_custom_serializer_class.py b/tests/test_custom_serializer_class.py new file mode 100644 index 0000000..8a4ada2 --- /dev/null +++ b/tests/test_custom_serializer_class.py @@ -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