diff --git a/dnscms/events/models.py b/dnscms/events/models.py index e9d6e14..46b857b 100644 --- a/dnscms/events/models.py +++ b/dnscms/events/models.py @@ -1,7 +1,7 @@ from django import forms from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Min, Q, UniqueConstraint +from django.db.models import Min, Prefetch, Q, UniqueConstraint from django.utils import timezone from django.utils.html import mark_safe from django.utils.translation import gettext_lazy as _ @@ -45,7 +45,23 @@ class EventIndex(HeadlessMixin, Page): subpage_types = ["events.EventPage"] def future_events(self, info, **kwargs): - return EventPage.objects.live().future().order_by("next_occurrence") + return ( + EventPage.objects.live() + .future() + .order_by("next_occurrence") + .select_related("featured_image") + .prefetch_related( + Prefetch( + "occurrences", + queryset=EventOccurrence.objects.select_related("venue"), + ), + "categories", + Prefetch( + "organizers", + queryset=EventOrganizer.objects.select_related("association"), + ), + ) + ) graphql_fields = [ GraphQLCollection( diff --git a/dnscms/tests/test_events.py b/dnscms/tests/test_events.py index f406b44..86fc8be 100644 --- a/dnscms/tests/test_events.py +++ b/dnscms/tests/test_events.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta import pytest from django.core.exceptions import ValidationError +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.utils import timezone from events.admin import EventDateColumn, OrganizersColumn @@ -188,6 +190,64 @@ def test_graphql_event_index_future_events_query(event_index, graphql_post): assert "Upcoming gig" in titles +def test_future_events_does_not_have_n_plus_one_queries( + event_index, venue, association_index, graphql_post +): + """Regression test: query count for futureEvents stays bounded as events grow.""" + konsert = EventCategory.objects.create(name="Konsert", slug="konsert") + association = AssociationPageFactory(parent=association_index, title="DNS") + org = EventOrganizer.objects.create(name="Forening", slug="forening", association=association) + image = CustomImageFactory(title="Cover") + now = timezone.now() + + for i in range(5): + event = EventPageFactory( + parent=event_index, + title=f"Event {i}", + body=[("paragraph", "
x
")], + featured_image=image, + ) + event.categories.add(konsert) + EventOrganizerLink.objects.create(event=event, organizer=org) + EventOccurrence.objects.create( + event=event, + start=now + timedelta(days=i + 1), + venue=venue, + ) + + home_query = """ + query { + eventIndex { + futureEvents { + id + title + subtitle + body { blockType } + featuredImage { url } + occurrences { start end venueCustom venue { title } } + categories { name slug } + organizers { name slug association { title } } + } + } + } + """ + + with CaptureQueriesContext(connection) as ctx: + response, body = graphql_post(home_query) + + assert response.status_code == 200 + assert "errors" not in body, body + assert len(body["data"]["eventIndex"]["futureEvents"]) == 5 + + # Bump only alongside an intentional resolver change. + max_queries = 6 + assert len(ctx) <= max_queries, ( + f"futureEvents took {len(ctx)} queries for 5 events — likely N+1. " + f"Captured queries:\n" + + "\n".join(f" {i + 1}. {q['sql'][:120]}" for i, q in enumerate(ctx.captured_queries)) + ) + + def test_graphql_event_index_future_events_ordered_by_next_occurrence(event_index, graphql_post): now = timezone.now()