From 7f96c55f909847a63e1f602f1f1035edbe40a564 Mon Sep 17 00:00:00 2001 From: santichausis Date: Mon, 28 Sep 2026 13:05:27 -0300 Subject: [PATCH 1/2] Load evolution variables and natures once per list of evolution details get_condition_expression and get_allowed_natures ran a query per PokemonEvolution row. Milcery -> Alcremie has 63 rows, each with a condition_expression, so its evolution chain took 65 queries. Both tables are tiny (4 evolution variables, 25 natures), so load each once, lazily, on the serializer instance. A ListSerializer reuses its child instance for every row, so this is one query per list instead of one per row, without sharing state across requests. --- pokemon_v2/serializers.py | 19 +++++++++++--- pokemon_v2/tests.py | 54 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/pokemon_v2/serializers.py b/pokemon_v2/serializers.py index 92e442388..d5dcc0a9b 100644 --- a/pokemon_v2/serializers.py +++ b/pokemon_v2/serializers.py @@ -4,6 +4,7 @@ import itertools from collections import defaultdict +from functools import cached_property from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast from django.db.models import Q @@ -3429,14 +3430,24 @@ class Meta: "condition_expression", ) + # a ListSerializer reuses one child instance for every row, so these load once per + # list of evolution details instead of once per row + @cached_property + def _all_natures(self) -> list[Nature]: + return list(Nature.objects.order_by("id")) + + @cached_property + def _all_evolution_variables(self) -> list[EvolutionVariable]: + return list(EvolutionVariable.objects.order_by("pk")) + @extend_schema_field(NatureSummarySerializer(many=True)) def get_allowed_natures(self, obj: PokemonEvolution) -> list[dict[str, Any]] | None: if obj.nature_bitmask is None: return None - nature_ids = [i for i in range(1, 26) if (obj.nature_bitmask & (1 << (i - 1)))] + nature_ids = {i for i in range(1, 26) if (obj.nature_bitmask & (1 << (i - 1)))} if not nature_ids: return None - natures = Nature.objects.filter(id__in=nature_ids).order_by("id") + natures = [nature for nature in self._all_natures if nature.pk in nature_ids] return cast( "list[dict[str, Any]]", NatureSummarySerializer(natures, many=True, context=self.context).data, @@ -3446,8 +3457,8 @@ def get_allowed_natures(self, obj: PokemonEvolution) -> list[dict[str, Any]] | N def get_condition_expression(self, obj: PokemonEvolution) -> dict[str, Any] | None: if not obj.condition_expression: return None - tokens = obj.condition_expression.split() - variables = EvolutionVariable.objects.filter(symbol__in=tokens) + tokens = set(obj.condition_expression.split()) + variables = [variable for variable in self._all_evolution_variables if variable.symbol in tokens] return { "expression": obj.condition_expression, "percentage_chance": obj.percentage_chance, diff --git a/pokemon_v2/tests.py b/pokemon_v2/tests.py index a4b43e8e2..bff4469bf 100644 --- a/pokemon_v2/tests.py +++ b/pokemon_v2/tests.py @@ -1,6 +1,7 @@ import json from datetime import datetime, timezone +from cachalot.api import cachalot_disabled from django.db import connection from django.test.utils import CaptureQueriesContext from rest_framework import status @@ -5053,6 +5054,59 @@ def test_evolution_chain_api_evolution_details_order_with_many_rows_for_same_spe expected_min_levels, ) + # condition_expression and allowed_natures must not query once per evolution row + # (e.g. Milcery -> Alcremie has 63 rows, each with a condition_expression) + def test_evolution_chain_api_condition_and_nature_lookups_do_not_scale_with_rows(self): + ec_variable = EvolutionVariable.objects.create(name="ec for evo chn", symbol="EC") + EvolutionVariable.objects.create(name="pid for evo chn", symbol="PID") + natures = [self.setup_nature_data(name=f"ntr {i} for evo chn") for i in range(3)] + allowed = [natures[0], natures[2]] + nature_bitmask = sum(1 << (nature.pk - 1) for nature in allowed) + + def build_chain(row_count): + evolution_chain = self.setup_evolution_chain_data() + basic = self.setup_pokemon_species_data( + name=f"bsc for evo chn cnd {row_count}", evolution_chain=evolution_chain + ) + target = self.setup_pokemon_species_data( + name=f"trgt for evo chn cnd {row_count}", + evolves_from_species=basic, + evolution_chain=evolution_chain, + ) + for _ in range(row_count): + evolution = self.setup_pokemon_evolution_data(evolved_species=target) + evolution.condition_expression = "EC 100 % 0 ==" + evolution.nature_bitmask = nature_bitmask + evolution.save() + return evolution_chain + + small_chain = build_chain(row_count=1) + large_chain = build_chain(row_count=5) + + # inside the test transaction cachalot serves repeated identical queries from + # memory, which would hide the per-row queries this test is about + with cachalot_disabled(): + with CaptureQueriesContext(connection) as small_queries: + self.client.get("{}/evolution-chain/{}/".format(API_V2, small_chain.pk)) + with CaptureQueriesContext(connection) as large_queries: + response = self.client.get("{}/evolution-chain/{}/".format(API_V2, large_chain.pk)) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(large_queries.captured_queries), len(small_queries.captured_queries)) + + details = response.data["chain"]["evolves_to"][0]["evolution_details"] + self.assertEqual(len(details), 5) + for detail in details: + self.assertEqual(detail["condition_expression"]["expression"], "EC 100 % 0 ==") + self.assertEqual( + [variable["name"] for variable in detail["condition_expression"]["variables"]], + [ec_variable.name], + ) + self.assertEqual( + [nature["name"] for nature in detail["allowed_natures"]], + [nature.name for nature in allowed], + ) + # Encounter Tests def test_encounter_method_api(self): encounter_method = self.setup_encounter_method_data(name="base encntr mthd") From 44f5ccc3693adb855fbd2077f4d5961ee54641a2 Mon Sep 17 00:00:00 2001 From: santichausis Date: Sat, 24 Oct 2026 23:53:30 -0300 Subject: [PATCH 2/2] Cache serialized evolution variables and natures, not just the rows The cached lookups still ran the summary serializers once per evolution row. Serialize each table once alongside the query and reuse the results; serializing Alcremie's chain (63 rows) drops from ~52 ms to ~32 ms locally with identical output. Variables are kept as (symbol, data) pairs instead of a dict, since symbol isn't unique across version groups. --- pokemon_v2/serializers.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pokemon_v2/serializers.py b/pokemon_v2/serializers.py index d5dcc0a9b..2fc6909e3 100644 --- a/pokemon_v2/serializers.py +++ b/pokemon_v2/serializers.py @@ -3430,15 +3430,20 @@ class Meta: "condition_expression", ) - # a ListSerializer reuses one child instance for every row, so these load once per - # list of evolution details instead of once per row + # a ListSerializer reuses one child instance for every row, so these load and serialize + # once per list of evolution details instead of once per row @cached_property - def _all_natures(self) -> list[Nature]: - return list(Nature.objects.order_by("id")) + def _serialized_natures(self) -> list[tuple[int, dict[str, Any]]]: + natures = Nature.objects.order_by("id") + data = NatureSummarySerializer(natures, many=True, context=self.context).data + return [(nature.pk, item) for nature, item in zip(natures, data, strict=True)] + # a list of pairs rather than a dict, since symbols aren't unique across version groups @cached_property - def _all_evolution_variables(self) -> list[EvolutionVariable]: - return list(EvolutionVariable.objects.order_by("pk")) + def _serialized_evolution_variables(self) -> list[tuple[str, dict[str, Any]]]: + variables = EvolutionVariable.objects.order_by("pk") + data = EvolutionVariableSummarySerializer(variables, many=True, context=self.context).data + return [(variable.symbol, item) for variable, item in zip(variables, data, strict=True)] @extend_schema_field(NatureSummarySerializer(many=True)) def get_allowed_natures(self, obj: PokemonEvolution) -> list[dict[str, Any]] | None: @@ -3447,22 +3452,17 @@ def get_allowed_natures(self, obj: PokemonEvolution) -> list[dict[str, Any]] | N nature_ids = {i for i in range(1, 26) if (obj.nature_bitmask & (1 << (i - 1)))} if not nature_ids: return None - natures = [nature for nature in self._all_natures if nature.pk in nature_ids] - return cast( - "list[dict[str, Any]]", - NatureSummarySerializer(natures, many=True, context=self.context).data, - ) + return [item for nature_id, item in self._serialized_natures if nature_id in nature_ids] @extend_schema_field(EvolutionConditionExpressionSerializer(allow_null=True)) def get_condition_expression(self, obj: PokemonEvolution) -> dict[str, Any] | None: if not obj.condition_expression: return None tokens = set(obj.condition_expression.split()) - variables = [variable for variable in self._all_evolution_variables if variable.symbol in tokens] return { "expression": obj.condition_expression, "percentage_chance": obj.percentage_chance, - "variables": EvolutionVariableSummarySerializer(variables, many=True, context=self.context).data, + "variables": [item for symbol, item in self._serialized_evolution_variables if symbol in tokens], }