diff --git a/pyproject.toml b/pyproject.toml index 8fd4ccc0..6bb38dc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,13 +28,8 @@ dependencies = [ readme = "README.md" requires-python = ">= 3.10" -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.uv] -managed = true -dev-dependencies = [ +[dependency-groups] +dev = [ "GitPython", "anys>=0.3.1", "bumpver", @@ -70,6 +65,13 @@ dev-dependencies = [ "lxml<=5.3.2", ] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +managed = true + [tool.hatch.metadata] allow-direct-references = true diff --git a/src/common/mitol/common/mixins.py b/src/common/mitol/common/mixins.py new file mode 100644 index 00000000..bdc3c4dc --- /dev/null +++ b/src/common/mitol/common/mixins.py @@ -0,0 +1,33 @@ +import logging + +from mitol.common.serializers import QuerySetSerializer + +log = logging.getLogger() + + +class PrefetchQuerySetSerializerMixin: + """ + APIView mixin that derives prefetches from the serializer(s) used to + render responses. + + This allows for the prefetches to be specific to what we intend to serialize + to JSON and it also allows this to vary if the serializer_class that is used + also varies. + """ + + def get_queryset(self): + """Get the queryset""" + serializer_class = self.get_serializer_class() + + if not issubclass(serializer_class, QuerySetSerializer): + log.error( + "Serializer %s does not subclass QuerySetSerializer, skipping", + serializer_class, + ) + + return super().get_queryset() + + # `self.queryset` defaults to None + serializer = serializer_class() + + return serializer.get_queryset_tree(self.queryset, self.request) diff --git a/src/common/mitol/common/serializers.py b/src/common/mitol/common/serializers.py new file mode 100644 index 00000000..f9d0c67c --- /dev/null +++ b/src/common/mitol/common/serializers.py @@ -0,0 +1,106 @@ +from collections.abc import Callable + +from django.db.models import Prefetch, QuerySet +from django.http import HttpRequest +from rest_framework import serializers + + +class QuerySetSerializer(serializers.ModelSerializer): + """ + A serializer for serializing QuerySets. + + This provides more functionality over ModelSerializer by implementing functionality + analogous to ViewSet's queryset and get_queryset. It recursively constructucts a + QuerySet that has been annotated and prefetched appropriately to fulfill serializers + without incurring further queries during serialization + + Subclasses can set either queryset or override `get_queryset` if access + to `request` is needed. + + Optionally when the `QuerySetSerializer` is initialized `queryset` can be passed + and this will override both `queryset` and `get_queryset`. + """ + + queryset: QuerySet | None = None + + def get_base_queryset(self) -> QuerySet: + # critical to compare against None to avoid an evaluation + return ( + self.queryset + if self.queryset is not None + else self.Meta.model.objects.all() + ) + + def get_queryset(self, queryset: QuerySet, request: HttpRequest) -> QuerySet: + """ + Get the queryset for this serializer + + Override this method to customize the queryset used for fetching models + to be serialized. + + It is *required* that you call `super().get_queryset(queryset, request)` and + extend the return value. + + If you need to customize the queryset used for a nested serializer field, define + a `get_{field_name}_queryset` with the following signature: + + ` + def get_books_queryset( + self, + queryset: QuerySet, + request: HttpRequest + ) -> QuerySet: + ... + ` + + It is *required* that you extend the queryset passed in so you get the + prefetches defined on the nested serializer too. + + """ + return self.get_nested_prefetches(queryset, request) + + def get_prefetch_for_field( + self, + name: str, + field: serializers.Field, + serializer: "QuerySetSerializer", + request: HttpRequest, + ) -> Prefetch: + queryset = serializer.get_queryset_tree(serializer.get_base_queryset(), request) + + get_serializer_queryset_func = getattr(self, f"get_{name}_queryset", None) + + if get_serializer_queryset_func is not None and isinstance( + get_serializer_queryset_func, + Callable, + ): + queryset = get_serializer_queryset_func(queryset, request) + + return Prefetch( + field.source, + queryset=queryset, + to_attr=name if name != field.source else None, + ) + + def get_nested_prefetches( + self, queryset: QuerySet, request: HttpRequest + ) -> QuerySet: + """Get prefetches for nested serializers""" + for name, field in self.fields.items(): + serializer = field + + if isinstance(field, serializers.ListSerializer): + serializer = field.child + + if isinstance(serializer, QuerySetSerializer): + queryset = queryset.prefetch_related( + self.get_prefetch_for_field(name, field, serializer, request) + ) + + return queryset + + def get_queryset_tree(self, queryset: QuerySet, request: HttpRequest) -> QuerySet: + """Get the queryset required for the serializer""" + queryset = queryset if queryset is not None else self.get_base_queryset() + + return self.get_queryset(queryset, request) diff --git a/testapp/libraries/__init__.py b/testapp/libraries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testapp/libraries/apps.py b/testapp/libraries/apps.py new file mode 100644 index 00000000..374217a1 --- /dev/null +++ b/testapp/libraries/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LibrariesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "libraries" diff --git a/testapp/libraries/factories.py b/testapp/libraries/factories.py new file mode 100644 index 00000000..0e2ac7cf --- /dev/null +++ b/testapp/libraries/factories.py @@ -0,0 +1,35 @@ +import factory +import factory.fuzzy +from factory.django import DjangoModelFactory + + +class AuthorFactory(DjangoModelFactory): + name = factory.Faker("name") + + class Meta: + model = "libraries.Author" + + +class BookFactory(DjangoModelFactory): + title = factory.Faker("words") + + author = factory.SubFactory(AuthorFactory) + + class Meta: + model = "libraries.Book" + + +class LibraryFactory(DjangoModelFactory): + name = factory.fuzzy.FuzzyText(suffix=" Library") + + @factory.post_generation + def books(self, create, extracted, **kwargs): + extracted = extracted or BookFactory.create_batch(50, **kwargs) + + if not create: + return + + self.books.add(*extracted) + + class Meta: + model = "libraries.Library" diff --git a/testapp/libraries/migrations/0001_initial.py b/testapp/libraries/migrations/0001_initial.py new file mode 100644 index 00000000..e080c630 --- /dev/null +++ b/testapp/libraries/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 5.1.7 on 2025-12-09 19:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Author", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name="Book", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="books", + to="libraries.author", + ), + ), + ], + ), + migrations.CreateModel( + name="Library", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "books", + models.ManyToManyField( + related_name="libraries", to="libraries.book" + ), + ), + ], + ), + ] diff --git a/testapp/libraries/migrations/__init__.py b/testapp/libraries/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testapp/libraries/models.py b/testapp/libraries/models.py new file mode 100644 index 00000000..82d38135 --- /dev/null +++ b/testapp/libraries/models.py @@ -0,0 +1,16 @@ +from django.db import models + + +class Author(models.Model): + name = models.CharField(max_length=255) + + +class Book(models.Model): + title = models.CharField(max_length=255) + author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE) + + +class Library(models.Model): + name = models.CharField(max_length=255) + + books = models.ManyToManyField(Book, related_name="libraries") diff --git a/testapp/libraries/serializers.py b/testapp/libraries/serializers.py new file mode 100644 index 00000000..0c7c86b6 --- /dev/null +++ b/testapp/libraries/serializers.py @@ -0,0 +1,29 @@ +from mitol.common.serializers import QuerySetSerializer + +from libraries.models import Author, Book, Library + + +class AuthorSerializer(QuerySetSerializer): + class Meta: + fields = ("name",) + model = Author + + +class BookSerializer(QuerySetSerializer): + author = AuthorSerializer() + also_the_author = AuthorSerializer(source="author") + + class Meta: + fields = ("title", "author", "also_the_author") + model = Book + + +class LibrarySerializer(QuerySetSerializer): + books = BookSerializer(many=True, queryset=Book.objects.order_by("id")) + + class Meta: + fields = ( + "name", + "books", + ) + model = Library diff --git a/testapp/libraries/urls.py b/testapp/libraries/urls.py new file mode 100644 index 00000000..e3b60860 --- /dev/null +++ b/testapp/libraries/urls.py @@ -0,0 +1,8 @@ +from rest_framework import routers + +from libraries import views + +router = routers.DefaultRouter() +router.register(r"libraries", views.LibrariesViewSet, basename="libraries_api") + +urlpatterns = router.urls diff --git a/testapp/libraries/views.py b/testapp/libraries/views.py new file mode 100644 index 00000000..0433a726 --- /dev/null +++ b/testapp/libraries/views.py @@ -0,0 +1,10 @@ +from mitol.common.mixins import PrefetchQuerySetSerializerMixin +from rest_framework import viewsets + +from libraries.models import Library +from libraries.serializers import LibrarySerializer + + +class LibrariesViewSet(PrefetchQuerySetSerializerMixin, viewsets.ModelViewSet): + serializer_class = LibrarySerializer + queryset = Library.objects.all() diff --git a/testapp/main/settings/shared.py b/testapp/main/settings/shared.py index 739bb257..be6b0814 100644 --- a/testapp/main/settings/shared.py +++ b/testapp/main/settings/shared.py @@ -82,6 +82,7 @@ # test app, integrates the reusable apps "main", "users", + "libraries", ] MIDDLEWARE = [ diff --git a/testapp/main/urls.py b/testapp/main/urls.py index 823b0f6f..5ed71f0f 100644 --- a/testapp/main/urls.py +++ b/testapp/main/urls.py @@ -27,6 +27,7 @@ path("api/", include("mitol.transcoding.urls")), path("", include("mitol.scim.urls")), path("", include("mitol.apigateway.urls")), + path("api/", include("libraries.urls")), ] if sys.version_info < (3, 13): diff --git a/tests/common/test_views.py b/tests/common/test_views.py new file mode 100644 index 00000000..40ab33e4 --- /dev/null +++ b/tests/common/test_views.py @@ -0,0 +1,30 @@ +import pytest +from django.urls import reverse +from libraries.factories import LibraryFactory + + +@pytest.mark.django_db +def test_list_view(client, django_assert_max_num_queries): + libraries = LibraryFactory.create_batch(5) + + with django_assert_max_num_queries(4): + resp = client.get(reverse("libraries_api-list")) + + assert resp.json() == [ + { + "name": library.name, + "books": [ + { + "title": book.title, + "author": { + "name": book.author.name, + }, + "also_the_author": { + "name": book.author.name, + }, + } + for book in library.books.order_by("id") + ], + } + for library in libraries + ]