Skip to content

Commit bc92d3b

Browse files
authored
Fix N+1 query in evolution chain serializer (#1688)
* Fix N+1 query in evolution chain serializer build_chain_link_entry ran a fresh PokemonEvolution query (with ~13 select_related joins) for every non-root species while recursing through the evolution tree, so branching chains like Eevee's fired one query per branch instead of one query for the whole chain. Fetch all PokemonEvolution rows for the chain once, group them by evolved_species_id, and look them up from that dict during the recursion instead of querying per node. * Skip evolution query for single-species chains and pin detail order Chains with no evolutions (199 of 540 in the current data) don't need the batched PokemonEvolution query at all; the old per-node code ran zero queries for them, so the unconditional batch query made those requests one query heavier. Only run it when some species in the chain evolves from another. Also make the pk ordering explicit on the batched query. PokeApiManager already orders by pk, so this doesn't change behavior; it just keeps the ordering visible at the call site. Add tests for both: a single-species chain issues no PokemonEvolution query, and evolution_details keeps creation order for a species with several evolution rows (e.g. Milcery -> Alcremie).
1 parent 89289e3 commit bc92d3b

2 files changed

Lines changed: 96 additions & 6 deletions

File tree

‎pokemon_v2/serializers.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import itertools
6+
from collections import defaultdict
67
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast
78

89
from django.db.models import Q
@@ -3487,8 +3488,17 @@ def build_chain(self, obj: EvolutionChain) -> dict[str, Any]:
34873488
PokemonSpeciesEvolutionSerializer(pokemon_objects, many=True, context=self.context).data,
34883489
)
34893490

3491+
evolutions_by_species: dict[int, list[PokemonEvolution]] = defaultdict(list)
3492+
if any(species["evolves_from_species"] for species in ref_data):
3493+
for evolution in (
3494+
PokemonEvolution.objects.filter(evolved_species__evolution_chain=obj)
3495+
.select_related(*self.POKEMON_EVOLUTION_FK_FIELDS)
3496+
.order_by("pk")
3497+
):
3498+
evolutions_by_species[evolution.evolved_species_id].append(evolution) # pyright: ignore[reportAttributeAccessIssue]
3499+
34903500
evolution_tree = self.build_evolution_tree(ref_data)
3491-
return self.build_chain_link_entry(evolution_tree, summary_data)
3501+
return self.build_chain_link_entry(evolution_tree, summary_data, evolutions_by_species)
34923502

34933503
# converts a list of Pokemon species evolution data into a tree representing the evolution chain
34943504
def build_evolution_tree(self, species_evolution_data: ReturnList[ReturnDict[str, Any]]) -> dict[str, Any]:
@@ -3525,15 +3535,16 @@ def build_evolution_tree(self, species_evolution_data: ReturnList[ReturnDict[str
35253535
# serializes an evolution chain link recursively
35263536
# chain_link is a tree representing an evolution chain
35273537
def build_chain_link_entry(
3528-
self, chain_link: dict[str, Any], summary_data: ReturnList[ReturnDict[str, Any]]
3538+
self,
3539+
chain_link: dict[str, Any],
3540+
summary_data: ReturnList[ReturnDict[str, Any]],
3541+
evolutions_by_species: dict[int, list[PokemonEvolution]],
35293542
) -> dict[str, Any]:
35303543
species = chain_link["species"]
35313544
evolution_data = None
35323545

35333546
if species["evolves_from_species"]:
3534-
evolution_objects = PokemonEvolution.objects.filter(evolved_species=species["id"]).select_related(
3535-
*self.POKEMON_EVOLUTION_FK_FIELDS
3536-
)
3547+
evolution_objects = evolutions_by_species.get(species["id"], [])
35373548
evolution_data = cast(
35383549
"ReturnList[ReturnDict[str, Any]]",
35393550
PokemonEvolutionSerializer(evolution_objects, many=True, context=self.context).data,
@@ -3543,7 +3554,9 @@ def build_chain_link_entry(
35433554
"is_baby": species["is_baby"],
35443555
"species": next(x for x in summary_data if x["name"] == species["name"]),
35453556
"evolution_details": evolution_data or [],
3546-
"evolves_to": [self.build_chain_link_entry(c, summary_data) for c in chain_link["children"]],
3557+
"evolves_to": [
3558+
self.build_chain_link_entry(c, summary_data, evolutions_by_species) for c in chain_link["children"]
3559+
],
35473560
}
35483561

35493562

‎pokemon_v2/tests.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22
from datetime import datetime, timezone
33

4+
from django.db import connection
5+
from django.test.utils import CaptureQueriesContext
46
from rest_framework import status
57
from rest_framework.test import APITestCase
68

@@ -4976,6 +4978,81 @@ def test_evolution_chain_api_wurmple_bugfix(self):
49764978
stage_one_second_data = basic_data["evolves_to"][1]
49774979
self.assertEqual(len(stage_one_second_data["evolves_to"]), 1)
49784980

4981+
# verifies that building the evolution chain tree issues a constant number of
4982+
# queries instead of one PokemonEvolution query per non-root species in the chain
4983+
def test_evolution_chain_api_query_count_does_not_scale_with_chain_size(self):
4984+
def build_branching_chain(branch_count):
4985+
evolution_chain = self.setup_evolution_chain_data()
4986+
basic = self.setup_pokemon_species_data(
4987+
name=f"bsc for evo chn qc {branch_count}",
4988+
evolution_chain=evolution_chain,
4989+
)
4990+
for i in range(branch_count):
4991+
branch_species = self.setup_pokemon_species_data(
4992+
name=f"brnch {i} for evo chn qc {branch_count}",
4993+
evolves_from_species=basic,
4994+
evolution_chain=evolution_chain,
4995+
)
4996+
self.setup_pokemon_evolution_data(evolved_species=branch_species, min_level=7)
4997+
return evolution_chain
4998+
4999+
small_chain = build_branching_chain(branch_count=1)
5000+
large_chain = build_branching_chain(branch_count=6)
5001+
5002+
with CaptureQueriesContext(connection) as small_queries:
5003+
small_response = self.client.get("{}/evolution-chain/{}/".format(API_V2, small_chain.pk))
5004+
with CaptureQueriesContext(connection) as large_queries:
5005+
large_response = self.client.get("{}/evolution-chain/{}/".format(API_V2, large_chain.pk))
5006+
5007+
self.assertEqual(small_response.status_code, status.HTTP_200_OK)
5008+
self.assertEqual(large_response.status_code, status.HTTP_200_OK)
5009+
self.assertEqual(len(large_response.data["chain"]["evolves_to"]), 6)
5010+
5011+
# before the fix, each additional branch added its own PokemonEvolution query
5012+
# (one per non-root species), so 5 extra branches meant 5 extra queries here
5013+
self.assertEqual(
5014+
len(large_queries.captured_queries),
5015+
len(small_queries.captured_queries),
5016+
)
5017+
5018+
def test_evolution_chain_api_single_species_chain_skips_evolution_query(self):
5019+
evolution_chain = self.setup_evolution_chain_data()
5020+
self.setup_pokemon_species_data(name="sngl for evo chn", evolution_chain=evolution_chain)
5021+
5022+
with CaptureQueriesContext(connection) as queries:
5023+
response = self.client.get("{}/evolution-chain/{}/".format(API_V2, evolution_chain.pk))
5024+
5025+
self.assertEqual(response.status_code, status.HTTP_200_OK)
5026+
self.assertEqual(response.data["chain"]["evolution_details"], [])
5027+
self.assertFalse(any("pokemon_v2_pokemonevolution" in query["sql"] for query in queries.captured_queries))
5028+
5029+
# evolution_details must keep creation (pk) order when a species has many PokemonEvolution
5030+
# rows, e.g. Milcery -> Alcremie has one row per flavor/decoration combination
5031+
def test_evolution_chain_api_evolution_details_order_with_many_rows_for_same_species(self):
5032+
evolution_chain = self.setup_evolution_chain_data()
5033+
basic = self.setup_pokemon_species_data(
5034+
name="bsc for evo chn ordr",
5035+
evolution_chain=evolution_chain,
5036+
)
5037+
target = self.setup_pokemon_species_data(
5038+
name="trgt for evo chn ordr",
5039+
evolves_from_species=basic,
5040+
evolution_chain=evolution_chain,
5041+
)
5042+
5043+
expected_min_levels = [30, 10, 50, 20, 40]
5044+
for min_level in expected_min_levels:
5045+
self.setup_pokemon_evolution_data(evolved_species=target, min_level=min_level)
5046+
5047+
response = self.client.get("{}/evolution-chain/{}/".format(API_V2, evolution_chain.pk))
5048+
5049+
self.assertEqual(response.status_code, status.HTTP_200_OK)
5050+
target_data = response.data["chain"]["evolves_to"][0]
5051+
self.assertEqual(
5052+
[detail["min_level"] for detail in target_data["evolution_details"]],
5053+
expected_min_levels,
5054+
)
5055+
49795056
# Encounter Tests
49805057
def test_encounter_method_api(self):
49815058
encounter_method = self.setup_encounter_method_data(name="base encntr mthd")

0 commit comments

Comments
 (0)