Skip to content

Commit

Permalink
Add FastSerializer
Browse files Browse the repository at this point in the history
Instead of analyzing every model on-the-fly (as DefaultSerializer does),
does that during instantination

Also added lru_cache to avoid recursion calls in helpers
  • Loading branch information
mrevutskyi committed May 12, 2020
1 parent e3a27ca commit 76f2d60
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 9 deletions.
17 changes: 16 additions & 1 deletion flask_restless/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import datetime
import inspect
from functools import lru_cache
from typing import List

from dateutil.parser import parse as parse_datetime
from sqlalchemy import Date
Expand All @@ -25,7 +26,7 @@
from sqlalchemy.ext.associationproxy import ObjectAssociationProxyInstance as AssociationProxy
except ImportError:
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.hybrid import HYBRID_PROPERTY, hybrid_property
from sqlalchemy.orm import ColumnProperty
from sqlalchemy.orm import class_mapper
from sqlalchemy.orm import RelationshipProperty as RelProperty
Expand Down Expand Up @@ -182,6 +183,16 @@ def get_field_type(model, fieldname):
return None


def attribute_columns(model) -> List[str]:
"""Returns a list of model's column names that should be considered as attributes."""
inspected_model = sqlalchemy_inspect(model)
column_attrs = inspected_model.column_attrs.keys()
descriptors = inspected_model.all_orm_descriptors.items()
hybrid_columns = [k for k, d in descriptors if d.extension_type == HYBRID_PROPERTY]

return column_attrs + hybrid_columns


@lru_cache()
def primary_key_names(model):
"""Returns all the primary keys for a model."""
Expand Down Expand Up @@ -391,6 +402,7 @@ def register(self, apimanager):
class ModelFinder(KnowsAPIManagers, Singleton):
"""The singleton class that backs the :func:`model_for` function."""

@lru_cache()
def __call__(self, resource_type, _apimanager=None, **kw):
if _apimanager is not None:
# This may raise ValueError.
Expand All @@ -410,6 +422,7 @@ def __call__(self, resource_type, _apimanager=None, **kw):
class CollectionNameFinder(KnowsAPIManagers, Singleton):
"""The singleton class that backs the :func:`collection_name` function."""

@lru_cache()
def __call__(self, model, _apimanager=None, **kw):
if _apimanager is not None:
if model not in _apimanager.created_apis_for:
Expand Down Expand Up @@ -462,6 +475,7 @@ def __call__(self, model, resource_id=None, relation_name=None,
class SerializerFinder(KnowsAPIManagers, Singleton):
"""The singleton class that backs the :func:`serializer_for` function."""

@lru_cache()
def __call__(self, model, _apimanager=None, **kw):
if _apimanager is not None:
if model not in _apimanager.created_apis_for:
Expand All @@ -483,6 +497,7 @@ def __call__(self, model, _apimanager=None, **kw):
class PrimaryKeyFinder(KnowsAPIManagers, Singleton):
"""The singleton class that backs the :func:`primary_key_for` function."""

@lru_cache()
def __call__(self, instance_or_model, _apimanager=None, **kw):
if isinstance(instance_or_model, type):
model = instance_or_model
Expand Down
14 changes: 6 additions & 8 deletions flask_restless/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from .helpers import primary_key_for
from .helpers import serializer_for
from .helpers import url_for
from .serialization import DefaultSerializer
from .serialization import DefaultSerializer, FastSerializer
from .serialization import DefaultDeserializer
from .views import API
from .views import FunctionAPI
Expand Down Expand Up @@ -633,13 +633,11 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS,
# Create a default serializer and deserializer if none have been
# provided.
if serializer is None:
serializer = DefaultSerializer(only, exclude,
additional_attributes)
# if validation_exceptions is None:
# validation_exceptions = [DeserializationException]
# else:
# validation_exceptions.append(DeserializationException)
# session = self.restlessinfo.session
# serializer = DefaultSerializer(only, exclude,
# additional_attributes)
serializer = FastSerializer(model, collection_name, primary_key=primary_key,
only=only, exclude=exclude, additional_attributes=additional_attributes)

session = self.session
if deserializer is None:
deserializer = DefaultDeserializer(self.session, model,
Expand Down
150 changes: 150 additions & 0 deletions flask_restless/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"""
from __future__ import division

from copy import copy
from datetime import date
from datetime import datetime
from datetime import time
Expand All @@ -38,6 +39,7 @@
from werkzeug.routing import BuildError
from werkzeug.urls import url_quote_plus

from .helpers import attribute_columns
from .helpers import collection_name
from .helpers import is_mapped_class
from .helpers import foreign_keys
Expand All @@ -48,6 +50,7 @@
from .helpers import has_field
from .helpers import is_like_list
from .helpers import primary_key_for
from .helpers import primary_key_names
from .helpers import primary_key_value
from .helpers import serializer_for
from .helpers import strings_to_datetimes
Expand Down Expand Up @@ -647,6 +650,153 @@ def __call__(self, instance, only=None):
return result


class FastSerializer(Serializer):
"""An implementation of serializer optimized for speed.
Instead of inspecting each model (attributes, foreign keys, etc.) during serialization, it does that during instantiation.
"""

def __init__(self, model, type_name, primary_key=None, only=None, exclude=None, additional_attributes=None, **kwargs):
super().__init__(**kwargs)
if only is not None and exclude is not None:
raise ValueError('Cannot specify both `only` and `exclude` keyword arguments simultaneously')

if additional_attributes is not None and exclude is not None and any(attr in exclude for attr in additional_attributes):
raise ValueError('Cannot exclude attributes listed in the `additional_attributes` keyword argument')

self._model = model
self._type = type_name
if primary_key is None:
pk_names = primary_key_names(model)
primary_key = 'id' if 'id' in pk_names else pk_names[0]
self._primary_key = primary_key
self._relations = set(get_relations(model))
self._only = None

columns = set(attribute_columns(model))
# JSON API 1.0: Fields for a resource object MUST share a common namespace with each other and with type and id. In other words,
# a resource can not have an attribute and relationship with the same name, nor can it have an attribute or relationship named type or id.
# https://jsonapi.org/format/#document-resource-object-fields
columns -= {'type', 'id'}

# Also include any attributes specified by the user.
if additional_attributes is not None:
columns |= set(additional_attributes)

# Only include fields allowed by the user during the instantiation of this object.
if only is not None:
# Always include at least the type and ID, regardless of what the user specified.
only = {get_column_name(column) for column in only}
columns &= only
self._relations &= only
self._only = only

# Exclude columns specified by the user during the instantiation of this object.
if exclude is not None:
excluded_column_names = {get_column_name(column) for column in exclude}
columns -= excluded_column_names
self._relations -= excluded_column_names

# JSON API 1.0: Although has-one foreign keys (e.g. author_id) are often stored internally alongside other information to be represented in a resource
# object, these keys SHOULD NOT appear as attributes
# https://jsonapi.org/format/#document-resource-object-attributes
columns -= set(foreign_keys(model))

# Exclude column names that are blacklisted.
columns = {column for column in columns if not column.startswith('__') and column not in COLUMN_BLACKLIST}

self._columns = columns

def __call__(self, instance, only=None):
columns = copy(self._columns)

if only is not None:
columns &= only

# Create a dictionary mapping attribute name to attribute value for
# this particular instance.
attributes = {column: getattr(instance, column) for column in columns}

for key, value in attributes.items():
# Call any functions that appear in the result.
if callable(value):
attributes[key] = value()

# Serialize any date- or time-like objects that appear in the
# attributes.
#
# TODO In Flask 1.0, the default JSON encoder for the Flask
# application object does this automatically. Alternately, the
# user could have set a smart JSON encoder on the Flask
# application, which would cause these attributes to be
# converted to strings when the Response object is created (in
# the `jsonify` function, for example). However, we should not
# rely on that JSON encoder since the user could set any crazy
# encoder on the Flask application.
for key, value in attributes.items():
if isinstance(value, (date, datetime, time)):
attributes[key] = value.isoformat()
elif isinstance(value, timedelta):
attributes[key] = value.total_seconds()

# TODO: Drop this in the future releases
# Recursively serialize any object that appears in the
# attributes. This may happen if, for example, the return value
# of one of the callable functions is an instance of another
# SQLAlchemy model class.
for key, val in attributes.items():
# This is a bit of a fragile test for whether the object
# needs to be serialized: we simply check if the class of
# the object is a mapped class.
if is_mapped_class(type(val)):
model_ = get_model(val)
try:
serialize = serializer_for(model_)
except ValueError:
# TODO Should this cause an exception, or fail
# silently? See similar comments in `views/base.py`.
# # raise SerializationException(instance)
serialize = simple_serialize
attributes[key] = serialize(val)

# Get the ID and type of the resource.
id_ = str(getattr(instance, self._primary_key))
type_ = self._type
# Create the result dictionary and add the attributes.
result = dict(id=id_, type=type_)
if attributes:
result['attributes'] = attributes

relations = copy(self._relations)
# Only consider those relations listed in `only`.
if only is not None:
relations &= only

if relations:
result['relationships'] = {rel: create_relationship(self._model, instance, rel) for rel in relations}

# TODO: Refactor
if ((self._only is None or 'self' in self._only)
and (only is None or 'self' in only)):
instance_id = primary_key_value(instance)
# `url_for` may raise a `BuildError` if the user has not created a
# GET API endpoint for this model. In this case, we simply don't
# provide a self link.
#
# TODO This might fail if the user has set the
# `current_app.build_error_handler` attribute, in which case, the
# exception may not be raised.
try:
path = url_for(self._model, instance_id, _method='GET')
except BuildError:
pass
else:
url = urljoin(request.url_root, path)
result['links'] = dict(self=url)

return result


class DefaultRelationshipSerializer(Serializer):
"""A default implementation of a serializer for resource identifier
objects for use in relationship objects in JSON API documents.
Expand Down
1 change: 1 addition & 0 deletions tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ def tearDown(self):
url_for.created_managers.clear()
collection_name.created_managers.clear()
serializer_for.created_managers.clear()
model_for.__call__.cache_clear()

def test_url_for(self):
"""Tests the global :func:`flask_restless.url_for` function."""
Expand Down

0 comments on commit 76f2d60

Please sign in to comment.