Skip to content

Commit 0152510

Browse files
committed
Let unread hydration respect an existing annotation
hydrate_total_unread_counts() now fills only games that do not already carry total_unread_message_count. filter_can_join() annotates zero, so joinable listings skip the unread query without the view re-parsing can_join, and the joinable_only flag and lists_joinable_games() go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sj2eM1KW37VqohqwKViPh
1 parent 9872abc commit 0152510

3 files changed

Lines changed: 9 additions & 14 deletions

File tree

‎.claude/rules/backend/models.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ A QuerySet method must earn its place by encapsulating something a caller would
5757

5858
**Never select a "latest row per parent" with a global subquery inside a prefetch.** A `DISTINCT ON (game_id)` subquery in a `Prefetch` queryset is not correlated to the prefetched parents, so it scans every game's phases on every request. Prefetch the parent's children on the queryset, then derive the latest ones in Python once the page is known and hydrate their relations with `prefetch_related_objects()` — `GameManager.hydrate_list_phases()` is the pattern, called from the view's `paginate_queryset()`. Give the non-target rows an empty cache (`queryset.none()` issues no query) so touching them stays free.
5959

60-
**Keep per-row correlated annotations off a paginated list queryset.** The base queryset is what filtering, ordering and the pagination `COUNT` run over, so a correlated subquery on it (per-user unread counts, for example) runs in the `COUNT` too, and a grouped ordering can run it once per joined row. Compute the value in one set-based query over the page's ids once the page is known, and attach it to every instance, zero included, so the serializer never falls back to a per-row query. `GameManager.hydrate_total_unread_counts()` is the pattern, called from `GameListView.paginate_queryset()` beside the phase hydration. Skip the query entirely when the filter already guarantees the answer, as `can_join=true` does for unread counts.
60+
**Keep per-row correlated annotations off a paginated list queryset.** The base queryset is what filtering, ordering and the pagination `COUNT` run over, so a correlated subquery on it (per-user unread counts, for example) runs in the `COUNT` too, and a grouped ordering can run it once per joined row. Compute the value in one set-based query over the page's ids once the page is known, and attach it to every instance, zero included, so the serializer never falls back to a per-row query. `GameManager.hydrate_total_unread_counts()` is the pattern, called from `GameListView.paginate_queryset()` beside the phase hydration. Hydration fills only instances that do not already carry the value, so a filter that guarantees the answer annotates it on the queryset and the query is skipped: `filter_can_join()` annotates zero with `with_zero_unread_counts()`. Do not re-parse filter params in the view to decide.
6161

6262
Add an index to a live table with `AddIndexConcurrently` in a migration that sets `atomic = False`, so the deploy does not block writes while it builds.
6363

‎service/game/models.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,17 +236,20 @@ def hydrate_list_phases(self, games):
236236
phases, current_phases, "supply_centers", SupplyCenter.objects.select_related("nation", "province")
237237
)
238238

239-
def hydrate_total_unread_counts(self, games, user, joinable_only=False):
239+
def hydrate_total_unread_counts(self, games, user):
240+
games_without_counts = [
241+
game for game in games if not hasattr(game, "total_unread_message_count")
242+
]
240243
counts = {}
241-
if games and user.is_authenticated and not joinable_only:
244+
if games_without_counts and user.is_authenticated:
242245
counts = dict(
243246
ChannelMessage.objects.unread_by(user)
244-
.filter(channel__game__in=[game.id for game in games])
247+
.filter(channel__game__in=[game.id for game in games_without_counts])
245248
.order_by()
246249
.values_list("channel__game")
247250
.annotate(count=Count("id", distinct=True))
248251
)
249-
for game in games:
252+
for game in games_without_counts:
250253
game.total_unread_message_count = counts.get(game.id, 0)
251254

252255
def hydrate_retrieve_phases(self, games):

‎service/game/views.py‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,9 @@ def get_queryset(self):
6969
def paginate_queryset(self, queryset):
7070
page = super().paginate_queryset(queryset)
7171
Game.objects.hydrate_list_phases(page)
72-
Game.objects.hydrate_total_unread_counts(
73-
page,
74-
self.request.user,
75-
joinable_only=self.lists_joinable_games(),
76-
)
72+
Game.objects.hydrate_total_unread_counts(page, self.request.user)
7773
return page
7874

79-
def lists_joinable_games(self):
80-
form = self.filterset_class(self.request.query_params, request=self.request).form
81-
return form.is_valid() and form.cleaned_data["can_join"] is True
82-
8375

8476
class GameCreateView(generics.CreateAPIView):
8577
permission_classes = [permissions.IsAuthenticated]

0 commit comments

Comments
 (0)