From 8d9b001d52488992fb33a4b2e1ffddb2b8bdc934 Mon Sep 17 00:00:00 2001 From: Cameron Hobbs Date: Tue, 9 Jun 2026 14:17:38 +0100 Subject: [PATCH] Add regression tests nested relations --- tests/test_project/serializers.py | 14 ++++++++++ tests/test_project/views.py | 16 ++++++++++- tests/test_unused.py | 46 ++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/tests/test_project/serializers.py b/tests/test_project/serializers.py index 6b02afc..15e4bd0 100644 --- a/tests/test_project/serializers.py +++ b/tests/test_project/serializers.py @@ -17,6 +17,17 @@ def get_related(self, eagle: Eagle) -> dict: if "previous_locations" in access: related["previous_locations"] = [loc.id for loc in eagle.previous_locations.all()] + if "previous_locations__climates" in access: + related["previous_locations__climates"] = [ + [climate.id for climate in loc.climates.all()] for loc in eagle.previous_locations.all() + ] + + if "previous_locations__climates__locations" in access: + related["previous_locations__climates__locations"] = [ + [[home.id for home in climate.locations.all()] for climate in loc.climates.all()] + for loc in eagle.previous_locations.all() + ] + if "previous_locations_raw" in access: cache = eagle._prefetched_objects_cache related["previous_locations_raw"] = [loc.id for loc in cache["previous_locations"]] @@ -24,6 +35,9 @@ def get_related(self, eagle: Eagle) -> dict: if "eaglet" in access: related["eaglet"] = eagle.eaglet.id + if "eaglet__eagle" in access: + related["eaglet__eagle"] = eagle.eaglet.eagle.id + if "burrow" in access: related["burrow"] = eagle.burrow.depth diff --git a/tests/test_project/views.py b/tests/test_project/views.py index e4f5c7b..b11076b 100644 --- a/tests/test_project/views.py +++ b/tests/test_project/views.py @@ -1,6 +1,6 @@ import contextlib -from django.db.models import Prefetch +from django.db.models import Model, Prefetch from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -14,6 +14,13 @@ def _csv(value: str | None) -> list[str]: return [item for item in (value or "").split(",") if item] +def _resolve_related_model(model: type[Model], lookup: str) -> type[Model]: + # Walk a ``__``-separated relation lookup from *model* and return the model at the end. + for part in lookup.split("__"): + model = model._meta.get_field(part).related_model + return model + + class EagleView(APIView): def get(self, request: Request, pk: int) -> Response: params = request.query_params @@ -36,6 +43,13 @@ def get(self, request: Request, pk: int) -> Response: lookup, _, to_attr_name = to_attr_spec.partition(":") queryset = queryset.prefetch_related(Prefetch(lookup, to_attr=to_attr_name)) + prefetch_select_spec = params.get("prefetch_select") + if prefetch_select_spec: + lookup, _, sr_field = prefetch_select_spec.partition(":") + related_model = _resolve_related_model(Eagle, lookup) + child_queryset = related_model.objects.select_related(sr_field) + queryset = queryset.prefetch_related(Prefetch(lookup, queryset=child_queryset)) + eagle = queryset.get(pk=pk) data = EagleSerializer(eagle, context={"access": _csv(params.get("access")), "to_attr": to_attr_name}).data diff --git a/tests/test_unused.py b/tests/test_unused.py index d5a1262..d2748bc 100644 --- a/tests/test_unused.py +++ b/tests/test_unused.py @@ -1,3 +1,5 @@ +import pytest + from eagle import unused from test_project import views from test_project.models import Eagle @@ -78,10 +80,15 @@ def test_to_attr_prefetch_with_rows_accessed_no_warning(self): class TestWarnUnusedNestedPrefetch(BaseRequestTest): - def test_nested_prefetched_child_unused_warns_with_propagated_location(self): + @pytest.fixture + def previous_location_with_climate(self): + # Give the eagle a previous location that itself has a climate, so a + # ``previous_locations__climates__locations`` chain has rows at every depth. previous = LocationFactory(climates=[ClimateFactory()]) self.graph.eagle.previous_locations.add(previous) + return previous + def test_nested_prefetched_child_unused_warns_with_propagated_location(self, previous_location_with_climate): warns = self.request( prefetch_related="previous_locations__climates", access="previous_locations", @@ -91,3 +98,40 @@ def test_nested_prefetched_child_unused_warns_with_propagated_location(self): assert 'prefetch_related("climates")' in str(warns[0]) assert "" in str(warns[0]) assert views.__file__ in str(warns[0]) + + def test_depth_three_nested_prefetch_unused_warns(self, previous_location_with_climate): + # Prefetch three levels deep but stop reading at the climate level: the + # deepest reverse-M2M (``Climate.locations``) is loaded but never accessed. + warns = self.request( + prefetch_related="previous_locations__climates__locations", + access="previous_locations__climates", + ) + + assert len(warns) == 1 + assert 'prefetch_related("locations")' in str(warns[0]) + assert "" in str(warns[0]) + assert views.__file__ in str(warns[0]) + + def test_depth_three_nested_prefetch_accessed_no_warning(self, previous_location_with_climate): + assert ( + self.request( + prefetch_related="previous_locations__climates__locations", + access="previous_locations__climates__locations", + ) + == [] + ) + + +class TestWarnUnusedSelectRelatedWithinPrefetch(BaseRequestTest): + def test_select_related_within_prefetch_queryset_unused_warns(self): + # Prefetch("eaglet", queryset=Eaglet.objects.select_related("eagle")): the prefetched + # eaglet is read, but the select_related FK back to its eagle never is. + warns = self.request(prefetch_select="eaglet:eagle", access="eaglet") + + assert len(warns) == 1 + assert 'select_related("eagle")' in str(warns[0]) + assert "" in str(warns[0]) + assert views.__file__ in str(warns[0]) + + def test_select_related_within_prefetch_queryset_accessed_no_warning(self): + assert self.request(prefetch_select="eaglet:eagle", access="eaglet__eagle") == []