diff --git a/CHANGELOG.md b/CHANGELOG.md index 98ba680c1..8949108c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to ### Fixed - 🐛(frontend) overlap of block menu dropdown #2406 +- ⚡️(backend) fix N+1 queries when serializing thread comments #2415 ## [v5.2.1] - 2026-06-05 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 4dfa31465..1476831d3 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -3168,7 +3168,16 @@ class ThreadViewSet( permission_classes = [permissions.CommentPermission] pagination_class = None serializer_class = serializers.ThreadSerializer - queryset = models.Thread.objects.select_related("creator", "document") + queryset = models.Thread.objects.select_related( + "creator", "document" + ).prefetch_related( + db.Prefetch( + "comments", + queryset=models.Comment.objects.select_related("user").prefetch_related( + "reactions__users" + ), + ), + ) resource_field_name = "document" def perform_create(self, serializer): diff --git a/src/backend/core/tests/documents/test_api_documents_threads.py b/src/backend/core/tests/documents/test_api_documents_threads.py index cd6961284..34bd34d12 100644 --- a/src/backend/core/tests/documents/test_api_documents_threads.py +++ b/src/backend/core/tests/documents/test_api_documents_threads.py @@ -512,6 +512,42 @@ def test_api_documents_threads_list_restricted_document_editor(role): assert len(response.json()) == 3 +@pytest.mark.parametrize("nb_threads", [1, 3]) +def test_api_documents_threads_list_number_of_queries( + nb_threads, django_assert_num_queries +): + """ + Listing threads should run a constant number of queries whatever the number + of threads, comments, reactions and reaction users. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + for _ in range(nb_threads): + thread = factories.ThreadFactory(document=document) + for _ in range(2): + comment = factories.CommentFactory(thread=thread) + for emoji in ["👍", "🎉"]: + factories.ReactionFactory( + comment=comment, + emoji=emoji, + users=factories.UserFactory.create_batch(2), + ) + + client = APIClient() + # 1 query for the document (permission check), 1 for the threads and 1 per + # prefetched relation: comments, reactions and reaction users. + with django_assert_num_queries(5): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + + assert response.status_code == 200 + assert len(response.json()) == nb_threads + + # Retrieve @@ -857,6 +893,41 @@ def test_api_documents_threads_retrieve_restricted_document_privileged_roles(rol } +@pytest.mark.parametrize("nb_comments", [1, 3]) +def test_api_documents_threads_retrieve_number_of_queries( + nb_comments, django_assert_num_queries +): + """ + Retrieving a thread should run a constant number of queries whatever the + number of comments, reactions and reaction users. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + for _ in range(nb_comments): + comment = factories.CommentFactory(thread=thread) + for emoji in ["👍", "🎉"]: + factories.ReactionFactory( + comment=comment, + emoji=emoji, + users=factories.UserFactory.create_batch(2), + ) + + client = APIClient() + # 1 query for the thread and 1 per prefetched relation: comments, reactions + # and reaction users. + with django_assert_num_queries(4): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + + assert response.status_code == 200 + assert len(response.json()["comments"]) == nb_comments + + # Destroy