diff --git a/AUTHORS b/AUTHORS index 8d2af17d..0e22345b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,6 +11,7 @@ David Vogt Felix Viernickel Greg Aker Jamie Bliss +Jarek Głowacki Jason Housley Jeppe Fihl-Pearson Jerel Unruh diff --git a/CHANGELOG.md b/CHANGELOG.md index d9eaf504..17b42099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Moved resolving of `included_serialzers` and `related_serializers` classes to serializer's meta class. +* `AutoPrefetchMixin` updated to be more clever about how relationships are prefetched, with recursion all the way down. +* Expensive reverse relations are now automatically excluded from queries that don't explicitly name them in sparsefieldsets. Set `INCLUDE_EXPENSVE_FIELDS` to revert to old behaviour. +* Removed `PreloadIncludesMixin`, as the logic did not work when nesting includes, and the laborious effort needed in its manual config was unnecessary. This removes support for `prefetch_for_includes` and `select_for_includes` ### Deprecated diff --git a/docs/usage.md b/docs/usage.md index 1e45976b..e49d52b7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -226,8 +226,10 @@ from models import MyModel class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.QueryParameterValidationFilter, filters.OrderingFilter, - django_filters.DjangoFilterBackend, SearchFilter) + filter_backends = ( + filters.QueryParameterValidationFilter, filters.OrderingFilter, + django_filters.DjangoFilterBackend, SearchFilter + ) filterset_fields = { 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), 'descriptuon': ('icontains', 'iexact', 'contains'), @@ -387,7 +389,7 @@ Example without format conversion: ``` js { - "data": [{ + "data": [{ "type": "blog_identity", "id": "3", "attributes": { @@ -410,7 +412,7 @@ When set to dasherize: ``` js { - "data": [{ + "data": [{ "type": "blog-identity", "id": "3", "attributes": { @@ -436,7 +438,7 @@ Example without pluralization: ``` js { - "data": [{ + "data": [{ "type": "identity", "id": "3", "attributes": { @@ -459,7 +461,7 @@ When set to pluralize: ``` js { - "data": [{ + "data": [{ "type": "identities", "id": "3", "attributes": { @@ -643,7 +645,7 @@ and increase performance. #### SerializerMethodResourceRelatedField -`relations.SerializerMethodResourceRelatedField` combines behaviour of DRF `SerializerMethodField` and +`relations.SerializerMethodResourceRelatedField` combines behaviour of DRF `SerializerMethodField` and `ResourceRelatedField`, so it accepts `method_name` together with `model` and links-related arguments. `data` is rendered in `ResourceRelatedField` manner. @@ -940,28 +942,12 @@ class QuestSerializer(serializers.ModelSerializer): #### Performance improvements -Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m\*(n+1) queries. +Be aware that reverse relationships and M2Ms can be expensive to prepare. -A viewset helper was therefore designed to automatically preload data when possible. Such is automatically available when subclassing `ModelViewSet` or `ReadOnlyModelViewSet`. +As a result, these are excluded by default unless explicitly demanded with sparsefieldsets. -It also allows to define custom `select_related` and `prefetch_related` for each requested `include` when needed in special cases: +You can opt out of this auto-exclusion with the `JSON_API_INCLUDE_EXPENSVE_FIELDS` setting. -`rest_framework_json_api.views.ModelViewSet`: -```python -from rest_framework_json_api import views - -# When MyViewSet is called with ?include=author it will dynamically prefetch author and author.bio -class MyViewSet(views.ModelViewSet): - queryset = Book.objects.all() - select_for_includes = { - 'author': ['author__bio'], - } - prefetch_for_includes = { - '__all__': [], - 'all_authors': [Prefetch('all_authors', queryset=Author.objects.select_related('bio'))], - 'category.section': ['category'] - } -``` An additional convenience DJA class exists for read-only views, just as it does in DRF. ```python @@ -971,31 +957,6 @@ class MyReadOnlyViewSet(views.ReadOnlyModelViewSet): # ... ``` -The special keyword `__all__` can be used to specify a prefetch which should be done regardless of the include, similar to making the prefetch yourself on the QuerySet. - -Using the helper to prefetch, rather than attempting to minimise queries via `select_related` might give you better performance depending on the characteristics of your data and database. - -For example: - -If you have a single model, e.g. Book, which has four relations e.g. Author, Publisher, CopyrightHolder, Category. - -To display 25 books and related models, you would need to either do: - -a) 1 query via selected_related, e.g. SELECT * FROM books LEFT JOIN author LEFT JOIN publisher LEFT JOIN CopyrightHolder LEFT JOIN Category - -b) 4 small queries via prefetch_related. - -If you have 1M books, 50k authors, 10k categories, 10k copyrightholders -in the `select_related` scenario, you've just created a in-memory table -with 1e18 rows which will likely exhaust any available memory and -slow your database to crawl. - -The `prefetch_related` case will issue 4 queries, but they will be small and fast queries. - - ## Generating an OpenAPI Specification (OAS) 3.0 schema document DRF >= 3.12 has a [new OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an @@ -1115,4 +1076,3 @@ urlpatterns = [ ... ] ``` - diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index 9c78dc61..6dd187ce 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -20,8 +20,6 @@ class DummyEntryViewSet(EntryViewSet): } def __init__(self, **kwargs): - # dummy up self.request since PreloadIncludesMixin expects it to be defined - self.request = None super(DummyEntryViewSet, self).__init__(**kwargs) diff --git a/example/views.py b/example/views.py index 0b35d4e4..c2a05dd5 100644 --- a/example/views.py +++ b/example/views.py @@ -236,11 +236,6 @@ def get_serializer_class(self): class CommentViewSet(ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer - select_for_includes = {"writer": ["author__bio"]} - prefetch_for_includes = { - "__all__": [], - "author": ["author__bio", "author__entries"], - } def get_queryset(self, *args, **kwargs): entry_pk = self.kwargs.get("entry_pk", None) @@ -285,7 +280,3 @@ class AuthorRelationshipView(RelationshipView): class LabResultViewSet(ReadOnlyModelViewSet): queryset = LabResults.objects.all() serializer_class = LabResultsSerializer - prefetch_for_includes = { - "__all__": [], - "author": ["author__bio", "author__entries"], - } diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index bc60f193..fef3dd1c 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -29,6 +29,9 @@ get_resource_type_from_serializer, ) +from .settings import json_api_settings +from .utils.serializers import get_expensive_relational_fields + class ResourceIdentifierObjectSerializer(BaseSerializer): default_error_messages = { @@ -153,6 +156,43 @@ def validate_path(serializer_class, field_path, path): super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) +class OnDemandFieldsMixin: + """ + Automatically certain fields from the serializer that have been deemed expensive. + In order to see these fields, the client must explcitly request them. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Pop any fields off the serializer that shouldn't come through. + for field in self.get_excluded_ondemand_fields(): + self.fields.pop(field, None) + + def get_excluded_ondemand_fields(self) -> list[str]: + """ + Determine which fields should be popped off if not explicitly asked for. + Will not nominate any fields that have been designated as `demanded_fields` in context. + Ondemand fields are determined in like so: + - Fields that we automatically determine to be expensive, and thus automatically remove + from the default offering. Currently such fields are M2Ms and reverse FKs. + """ + if json_api_settings.INCLUDE_EXPENSVE_FIELDS: + return set() + + # If we've instantiated the serializer ourselves, we'll have fed `demanded_fields` into its context. + # If it's happened as part of drf render internals, then we have a fallback where the view + # has provided the entire sparsefields context for us to pick through. + if 'demanded_fields' in self.context: + demanded_fields = set(self.context.get('demanded_fields')) + else: + resource_name = get_resource_type_from_serializer(type(self)) + demanded_fields = set(self.context.get('all_sparsefields', {}).get(resource_name, [])) + + # We only want to exclude those ondemand fields that haven't been explicitly requested. + return set(get_expensive_relational_fields(type(self))) - set(demanded_fields) + + class LazySerializersDict(Mapping): """ A dictionary of serializers which lazily import dotted class path and self. @@ -207,6 +247,7 @@ def __new__(cls, name, bases, attrs): # If user imports serializer from here we can catch class definition and check # nested serializers for depricated use. class Serializer( + OnDemandFieldsMixin, IncludedResourcesValidationMixin, SparseFieldsetsMixin, Serializer, @@ -230,6 +271,7 @@ class Serializer( class HyperlinkedModelSerializer( + OnDemandFieldsMixin, IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer, @@ -250,6 +292,7 @@ class HyperlinkedModelSerializer( class ModelSerializer( + OnDemandFieldsMixin, IncludedResourcesValidationMixin, SparseFieldsetsMixin, ModelSerializer, diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0e790847..b63ac896 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -15,6 +15,7 @@ "FORMAT_RELATED_LINKS": False, "PLURALIZE_TYPES": False, "UNIFORM_EXCEPTIONS": False, + "INCLUDE_EXPENSVE_FIELDS": False, } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils/__init__.py similarity index 95% rename from rest_framework_json_api/utils.py rename to rest_framework_json_api/utils/__init__.py index 19c72809..565e7e77 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils/__init__.py @@ -16,7 +16,7 @@ from rest_framework import exceptions from rest_framework.exceptions import APIException -from .settings import json_api_settings +from ..settings import json_api_settings # Generic relation descriptor from django.contrib.contenttypes. if "django.contrib.contenttypes" not in settings.INSTALLED_APPS: # pragma: no cover @@ -472,3 +472,33 @@ def format_errors(data): if len(data) > 1 and isinstance(data, list): data.sort(key=lambda x: x.get("source", {}).get("pointer", "")) return {"errors": data} + + +def includes_to_dict(includes: list[str]) -> dict: + """ + Converts a bunch of jsonapi includes + [ + 'property.client', + 'property.client.clientgroup', + 'property.client.task_set.branch', + 'property.branch', + ] + to a nested dict, ready for traversal + { + property: { + client: { + clientgroup: {}, + task_set: { + branch: {}, + }, + }, + branch: {}, + }, + } + """ + res = {} + for include in includes: + pos = res + for relational_field in include.split('.'): + pos = pos.setdefault(relational_field, {}) + return res diff --git a/rest_framework_json_api/utils/serializers.py b/rest_framework_json_api/utils/serializers.py new file mode 100644 index 00000000..640bf808 --- /dev/null +++ b/rest_framework_json_api/utils/serializers.py @@ -0,0 +1,161 @@ +import logging + +from django.db.models import Prefetch +from django.db.models.fields.related_descriptors import ( + ForwardManyToOneDescriptor, + ManyToManyDescriptor, + ReverseManyToOneDescriptor, + ReverseOneToOneDescriptor, +) +from django.db.models.query import QuerySet +from rest_framework.relations import RelatedField +from rest_framework.request import Request +from rest_framework.serializers import ModelSerializer, ValidationError + +from rest_framework_json_api.utils import ( + get_included_serializers, + get_resource_type_from_serializer, +) + +logger = logging.getLogger(__name__) + + +def get_expensive_relational_fields(serializer_class) -> list[str]: + """ + We define 'expensive' as relational fields on the serializer that don't correspond to a + forward relation on the model. + """ + return [ + field + for field in getattr(serializer_class, 'included_serializers', {}) + if not isinstance(getattr(serializer_class.Meta.model, field, None), ForwardManyToOneDescriptor) + ] + + +def get_cheap_relational_fields(serializer_class) -> list[str]: + """ + We define 'cheap' as relational fields on the serializer that _do_ correspond to a + forward relation on the model. + """ + return [ + field + for field in getattr(serializer_class, 'included_serializers', {}) + if isinstance(getattr(serializer_class.Meta.model, field, None), ForwardManyToOneDescriptor) + ] + + +def get_queryset_for_field(field: RelatedField) -> QuerySet: + model_field_descriptor = getattr(field.parent.Meta.model, field.field_name) + # NOTE: Important to check in this order, as some of these classes are ancestors of one + # another (ie `ManyToManyDescriptor` subclasses `ReverseManyToOneDescriptor`) + if isinstance(model_field_descriptor, ForwardManyToOneDescriptor): + if (qs := field.queryset) is None: + qs = model_field_descriptor.field.related_model._default_manager + elif isinstance(model_field_descriptor, ManyToManyDescriptor): + qs = field.child_relation.queryset + elif isinstance(model_field_descriptor, ReverseManyToOneDescriptor): + if (qs := field.child_relation.queryset) is None: + qs = model_field_descriptor.field.model._default_manager + elif isinstance(model_field_descriptor, ReverseOneToOneDescriptor): + qs = model_field_descriptor.get_queryset() + + # Note: We call `.all()` before returning, as `_default_manager` may on occasion return a Manager + # instance rather than a QuerySet, and we strictly want to be working with the latter. + # (_default_manager is being used both direclty by us here, and by drf behind the scenes) + # See: https://github.com/encode/django-rest-framework/blame/master/rest_framework/utils/field_mapping.py#L243 + return qs.all() + + +def add_nested_prefetches_to_qs( + serializer_class: ModelSerializer, + qs: QuerySet, + request: Request, + sparsefields: dict[str, list[str]], + includes: dict, # TODO: Define typing as recursive once supported. + select_related: str = '', +) -> QuerySet: + """ + Prefetch all required data onto the supplied queryset, calling this method recursively for child + serializers where needed. + There is some added built-in optimisation here, attempting to opt for select_related calls over + prefetches where possible -- it's only possible if the child serializers are interested + exclusively in select_relating also. This is controlled with the `select_related` param. + If `select_related` comes through, will attempt to instead build further onto this and return + a dundered list of strings for the caller to use in a select_related call. If that fails, + returns a qs as normal. + """ + # Determine fields that'll be returned by this serializer. + resource_name = get_resource_type_from_serializer(serializer_class) + logger.debug(f'ADDING NESTED PREFETCHES FOR: {resource_name}') + dummy_serializer = serializer_class(context={'request': request, 'demanded_fields': sparsefields.get(resource_name, [])}) + requested_fields = dummy_serializer.fields.keys() + + # Ensure any requested includes are in the fields list, else error loudly! + if not includes.keys() <= requested_fields: + errors = {f'{resource_name}.{field}': 'Field marked as include but not requested for serialization.' for field in includes.keys() - requested_fields} + raise ValidationError(errors) + + included_serializers = get_included_serializers(serializer_class) + + # Iterate over all expensive relations and prefetch_related where needed. + for field in get_expensive_relational_fields(serializer_class): + if field in requested_fields: + logger.debug(f'EXPENSIVE_FIELD: {field}') + select_related = '' # wipe, cannot be used. :( + if not hasattr(qs.model, field): + # We might fall into here if, for example, there's an expensive + # SerializerMethodResourceRelatedField defined. + continue + if field in includes: + logger.debug('- PREFETCHING DEEP') + # Prefetch and recurse. + child_serializer_class = included_serializers[field] + prefetch_qs = add_nested_prefetches_to_qs( + child_serializer_class, + get_queryset_for_field(dummy_serializer.fields[field]), + request=request, + sparsefields=sparsefields, + includes=includes[field], + ) + qs = qs.prefetch_related(Prefetch(field, prefetch_qs)) + else: + logger.debug('- PREFETCHING SHALLOW') + # Prefetch "shallowly"; we only care about ids. + qs = qs.prefetch_related(field) # TODO: Still use ResourceRelatedField.qs if present! + + # Iterate over all cheap (forward) relations and select_related (or prefetch) where needed. + new_select_related = [select_related] + for field in get_cheap_relational_fields(serializer_class): + if field in requested_fields: + logger.debug(f'CHEAP_FIELD: {field}') + if field in includes: + logger.debug('- present in includes') + # Recurse and see if we get a prefetch qs back, or a select_related string. + child_serializer_class = included_serializers[field] + prefetch_qs_or_select_related_str = add_nested_prefetches_to_qs( + child_serializer_class, + get_queryset_for_field(dummy_serializer.fields[field]), + request=request, + sparsefields=sparsefields, + includes=includes[field], + select_related=field, + ) + if isinstance(prefetch_qs_or_select_related_str, list): + logger.debug(f'SELECTING RELATED: {prefetch_qs_or_select_related_str}') + # Prefetch has come back as a list of (dundered) strings. + # We append onto existing select_related string, to potentially pass back up + # and also feed it directly into a select_related call in case the former + # falls through. + if select_related: + for sr in prefetch_qs_or_select_related_str: + new_select_related.append(f'{select_related}__{sr}') + qs = qs.select_related(*prefetch_qs_or_select_related_str) + else: + # Select related option fell through, we need to do a prefetch. :( + logger.debug(f'PREFETCHING RELATED: {field}') + select_related = '' + qs = qs.prefetch_related(Prefetch(field, prefetch_qs_or_select_related_str)) + + if select_related: + return new_select_related + return qs diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 6b739582..7e3cf3bb 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -1,13 +1,8 @@ +import re from collections.abc import Iterable from django.core.exceptions import ImproperlyConfigured from django.db.models import Model -from django.db.models.fields.related_descriptors import ( - ForwardManyToOneDescriptor, - ManyToManyDescriptor, - ReverseManyToOneDescriptor, - ReverseOneToOneDescriptor, -) from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.urls import NoReverseMatch @@ -21,110 +16,77 @@ from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer -from rest_framework_json_api.utils import ( + +from .utils import ( Hyperlink, OrderedDict, - get_included_resources, get_resource_type_from_instance, + includes_to_dict, undo_format_link_segment, ) +from .utils.serializers import add_nested_prefetches_to_qs -class PreloadIncludesMixin(object): - """ - This mixin provides a helper attributes to select or prefetch related models - based on the include specified in the URL. - - __all__ can be used to specify a prefetch which should be done regardless of the include - - - .. code:: python +class AutoPrefetchMixin(object): + """ Hides "expensive" fields by default, and calculates automatic prefetching when said fields + are explicitly requested. - # When MyViewSet is called with ?include=author it will prefetch author and authorbio - class MyViewSet(viewsets.ModelViewSet): - queryset = Book.objects.all() - prefetch_for_includes = { - '__all__': [], - 'category.section': ['category'] - } - select_for_includes = { - '__all__': [], - 'author': ['author', 'author__authorbio'], - } + "Expensive" fields are ones that require additional SQL queries to prepare, such as + reverse or M2M relations. """ + def __init_subclass__(cls, **kwargs): + """Run a smidge of validation at class declaration, to avoid silly mistakes.""" + + # Throw error if a `prefetch_for_includes` is defined. + if hasattr(cls, 'prefetch_for_includes'): + raise AttributeError( + f"{cls.__name__!r} defines `prefetch_for_includes`. This manual legacy form of" + " prefetching is no longer supported! It's all automatically handled now." + ) + # Throw error if a `select_for_includes` is defined. + if hasattr(cls, 'select_for_includes'): + raise AttributeError( + f"{cls.__name__!r} defines `select_for_includes`. This manual legacy form of" + " prefetching is no longer supported! It's all automatically handled now." + ) - def get_select_related(self, include): - return getattr(self, "select_for_includes", {}).get(include, None) - - def get_prefetch_related(self, include): - return getattr(self, "prefetch_for_includes", {}).get(include, None) - - def get_queryset(self, *args, **kwargs): - qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) - - included_resources = get_included_resources( - self.request, self.get_serializer_class() - ) - for included in included_resources + ["__all__"]: - - select_related = self.get_select_related(included) - if select_related is not None: - qs = qs.select_related(*select_related) - - prefetch_related = self.get_prefetch_related(included) - if prefetch_related is not None: - qs = qs.prefetch_related(*prefetch_related) - - return qs - - -class AutoPrefetchMixin(object): - def get_queryset(self, *args, **kwargs): - """This mixin adds automatic prefetching for OneToOne and ManyToMany fields.""" - qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) + return super().__init_subclass__(**kwargs) - included_resources = get_included_resources( - self.request, self.get_serializer_class() + def get_sparsefields_as_dict(self): + if not hasattr(self, '_sparsefields'): + self._sparsefields = { + match.groupdict()['resource_name']: queryvalues.split(',') + for queryparam, queryvalues in self.request.query_params.items() + if (match := re.match(r'fields\[(?P\w+)\]', queryparam)) + } + return self._sparsefields + + def get_queryset(self, *args, **kwargs) -> QuerySet: + qs = super().get_queryset(*args, **kwargs) + # Since we're going to be recursing through serializers (to cover nested cases), we hand + # the prefetching work off to the top-level serializer here. We give it: + # - the base qs. + # - the request, in case the serializer wants to perform any user-permission-based logic. + # - sparsefields & includes. + # The serializer will return a qs with all required prefetches, select_related calls and + # annotations tacked on. If the serializer encounters any includes, it'll + # itself pass the work down to additional serializers to get their contribution. + return add_nested_prefetches_to_qs( + self.get_serializer_class(), + qs, + request=self.request, + sparsefields=self.get_sparsefields_as_dict(), + includes=includes_to_dict(self.request.query_params.get('include', '').replace(',', ' ').split()), # See https://bugs.python.org/issue28937#msg282923 ) - for included in included_resources + ["__all__"]: - # If include was not defined, trying to resolve it automatically - included_model = None - levels = included.split(".") - level_model = qs.model - for level in levels: - if not hasattr(level_model, level): - break - field = getattr(level_model, level) - field_class = field.__class__ - - is_forward_relation = issubclass( - field_class, (ForwardManyToOneDescriptor, ManyToManyDescriptor) - ) - is_reverse_relation = issubclass( - field_class, (ReverseManyToOneDescriptor, ReverseOneToOneDescriptor) - ) - if not (is_forward_relation or is_reverse_relation): - break - - if level == levels[-1]: - included_model = field - else: - - if issubclass(field_class, ReverseOneToOneDescriptor): - model_field = field.related.field - else: - model_field = field.field - - if is_forward_relation: - level_model = model_field.related_model - else: - level_model = model_field.model - - if included_model is not None: - qs = qs.prefetch_related(included.replace(".", "__")) - - return qs + def get_serializer_context(self): + """ Pass args into the serializer's context, for field-level access. """ + context = super().get_serializer_context() + # We don't have direct control over some serializers, so we can't always feed them their + # specific `demanded_fields` into context how we'd like. Next best thing is to make the + # entire sparsefields dict available for them to pick through. + context['all_sparsefields'] = self.get_sparsefields_as_dict() + return context class RelatedMixin(object): @@ -214,15 +176,11 @@ def get_related_instance(self): raise NotFound -class ModelViewSet( - AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin, viewsets.ModelViewSet -): +class ModelViewSet(AutoPrefetchMixin, RelatedMixin, viewsets.ModelViewSet): http_method_names = ["get", "post", "patch", "delete", "head", "options"] -class ReadOnlyModelViewSet( - AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet -): +class ReadOnlyModelViewSet(AutoPrefetchMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet): http_method_names = ["get", "post", "patch", "delete", "head", "options"] diff --git a/tests/test_utils.py b/tests/test_utils.py index 43c12dcf..2b904103 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -15,6 +15,7 @@ get_included_serializers, get_related_resource_type, get_resource_name, + includes_to_dict, undo_format_field_name, undo_format_field_names, undo_format_link_segment, @@ -377,3 +378,24 @@ class Meta: } assert included_serializers == expected_included_serializers + + +def test_includes_to_dict(): + result = includes_to_dict([ + 'property.client', + 'property.client.clientgroup', + 'property.client.task_set.branch', + 'property.branch', + ]) + expected = { + 'property': { + 'client': { + 'clientgroup': {}, + 'task_set': { + 'branch': {}, + }, + }, + 'branch': {}, + }, + } + assert result == expected