diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py index ce676ae..d28884f 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py @@ -8,7 +8,9 @@ verbatim through the ``result`` JSON column. """ from __future__ import annotations +from collections import defaultdict from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from uuid import UUID from kaya.core import HttpContext, KayaApp from kaya.openapi import operation @@ -48,8 +50,9 @@ def _parse_game_type( return game_type, None -async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: - participants = await MatchPlayer.filter(match_id=match.id).order_by("seat") +def _serialize_match( + match: Match, viewer: str, participants: List[MatchPlayer] +) -> Dict[str, Any]: return { "id": str(match.id), "game_type": match.game_type, @@ -109,7 +112,21 @@ def register(app: KayaApp, platform: "Platform") -> None: matches, next_cursor = await paginate( queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor ) - results = [await _serialize_match(m, user.sub) for m in matches] + # One participants query for the whole page instead of one per + # match; the grouping keeps each match's players seat-ordered. + by_match: Dict[UUID, List[MatchPlayer]] = defaultdict(list) + if matches: + participants = ( + await MatchPlayer.filter(match_id__in=[m.id for m in matches]) + .order_by("match_id", "seat") + ) + for player in participants: + # Tortoise sets the FK's raw id on instances but does not + # declare it on the model class, hence the getattr. + by_match[getattr(player, "match_id")].append(player) + results = [ + _serialize_match(m, user.sub, by_match.get(m.id, [])) for m in matches + ] await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor}) @app.GET("/api/leaderboard") diff --git a/server/packages/tavolo-platform/tests/test_stats.py b/server/packages/tavolo-platform/tests/test_stats.py index fa46372..dd5d286 100644 --- a/server/packages/tavolo-platform/tests/test_stats.py +++ b/server/packages/tavolo-platform/tests/test_stats.py @@ -4,6 +4,7 @@ from __future__ import annotations import unittest import uuid from datetime import datetime, timezone +from unittest import mock from httpx import ASGITransport, AsyncClient @@ -197,6 +198,28 @@ class StatsRouteTest(unittest.TestCase): second.json()["results"][0]["id"], ) + @async_test + async def test_my_matches_fetches_participants_in_one_query(self) -> None: + app, platform, tortoise_mixin = make_platform() + await _seed_two_matches(tortoise_mixin) + transport = ASGITransport(app=app) + with mock.patch.object( + MatchPlayer, "filter", wraps=MatchPlayer.filter + ) as spy: + async with AsyncClient( + transport=transport, base_url="http://127.0.0.1" + ) as client: + with oidc_user(platform.oidc, "alice"): + response = await client.get("/api/me/matches") + self.assertEqual(200, response.status_code) + # One query for all matches, not one per match row. + self.assertEqual(1, spy.call_count) + results = response.json()["results"] + self.assertEqual(2, len(results)) + for result in results: + seats = [player["seat"] for player in result["players"]] + self.assertEqual([0, 1], seats) + @async_test async def test_my_matches_requires_auth(self) -> None: app, _, _ = make_platform()