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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy