diff --git a/twitch/migrations/0018_rename_twitch_drop_start_a_929f09_idx_tw_drop_start_desc_idx_and_more.py b/twitch/migrations/0018_rename_twitch_drop_start_a_929f09_idx_tw_drop_start_desc_idx_and_more.py new file mode 100644 index 0000000..d95a542 --- /dev/null +++ b/twitch/migrations/0018_rename_twitch_drop_start_a_929f09_idx_tw_drop_start_desc_idx_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.4 on 2026-04-10 23:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + """Rename some indexes on DropCampaign and RewardCampaign to be more descriptive.""" + + dependencies = [ + ("twitch", "0017_dropbenefit_twitch_drop_distrib_70d961_idx"), + ] + + operations = [ + migrations.RenameIndex( + model_name="dropcampaign", + new_name="tw_drop_start_desc_idx", + old_name="twitch_drop_start_a_929f09_idx", + ), + migrations.RenameIndex( + model_name="dropcampaign", + new_name="tw_drop_start_end_idx", + old_name="twitch_drop_start_a_6e5fb6_idx", + ), + migrations.RenameIndex( + model_name="dropcampaign", + new_name="tw_drop_start_end_game_idx", + old_name="twitch_drop_start_a_b02d4c_idx", + ), + migrations.RenameIndex( + model_name="rewardcampaign", + new_name="tw_reward_starts_desc_idx", + old_name="twitch_rewa_starts__4df564_idx", + ), + migrations.RenameIndex( + model_name="rewardcampaign", + new_name="tw_reward_starts_ends_idx", + old_name="twitch_rewa_starts__dd909d_idx", + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 73daa0e..ad3232b 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -2,7 +2,6 @@ import logging from collections import OrderedDict from typing import TYPE_CHECKING from typing import Any -from typing import cast import auto_prefetch from django.conf import settings @@ -492,7 +491,7 @@ class DropCampaign(auto_prefetch.Model): class Meta(auto_prefetch.Model.Meta): ordering = ["-start_at"] indexes = [ - models.Index(fields=["-start_at"]), + models.Index(fields=["-start_at"], name="tw_drop_start_desc_idx"), models.Index(fields=["end_at"]), models.Index(fields=["game"]), models.Index(fields=["twitch_id"]), @@ -504,9 +503,12 @@ class DropCampaign(auto_prefetch.Model): models.Index(fields=["updated_at"]), # Composite indexes for common queries models.Index(fields=["game", "-start_at"]), - models.Index(fields=["start_at", "end_at"]), + models.Index(fields=["start_at", "end_at"], name="tw_drop_start_end_idx"), # For dashboard and game_detail active campaign filtering - models.Index(fields=["start_at", "end_at", "game"]), + models.Index( + fields=["start_at", "end_at", "game"], + name="tw_drop_start_end_game_idx", + ), models.Index(fields=["end_at", "-start_at"]), ] @@ -584,71 +586,28 @@ class DropCampaign(auto_prefetch.Model): """ campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict() - campaigns_list: list[DropCampaign] = list(campaigns) - game_pks: list[int] = sorted({ - cast("Any", campaign).game_id for campaign in campaigns_list - }) - games: models.QuerySet[Game, Game] = ( - Game.objects - .filter(pk__in=game_pks) - .only( - "pk", - "twitch_id", - "display_name", - "slug", - "box_art", - "box_art_file", - "box_art_width", - "box_art_height", - ) - .prefetch_related( - models.Prefetch( - "owners", - queryset=Organization.objects.only("twitch_id", "name"), - ), - ) - ) - games_by_pk: dict[int, Game] = {game.pk: game for game in games} + for campaign in campaigns: + game: Game = campaign.game + game_id: str = game.twitch_id + game_display_name: str = game.display_name - def _clean_name(campaign_name: str, game_display_name: str) -> str: - if not game_display_name: - return campaign_name - - game_variations: list[str] = [game_display_name] - if "&" in game_display_name: - game_variations.append(game_display_name.replace("&", "and")) - if "and" in game_display_name: - game_variations.append(game_display_name.replace("and", "&")) - - for game_name in game_variations: - for separator in [" - ", " | ", " "]: - prefix_to_check: str = game_name + separator - if campaign_name.startswith(prefix_to_check): - return campaign_name.removeprefix(prefix_to_check).strip() - - return campaign_name - - for campaign in campaigns_list: - game_pk: int = cast("Any", campaign).game_id - game: Game | None = games_by_pk.get(game_pk) - game_id: str = game.twitch_id if game else "" - game_display_name: str = game.display_name if game else "" - - if game_id not in campaigns_by_game: - campaigns_by_game[game_id] = { + game_bucket: dict[str, Any] = campaigns_by_game.setdefault( + game_id, + { "name": game_display_name, - "box_art": game.box_art_best_url if game else "", - "owners": list(game.owners.all()) if game else [], + "box_art": game.box_art_best_url, + "owners": list(game.owners.all()), "campaigns": [], - } + }, + ) - campaigns_by_game[game_id]["campaigns"].append({ + game_bucket["campaigns"].append({ "campaign": campaign, - "clean_name": _clean_name(campaign.name, game_display_name), + "clean_name": campaign.clean_name, "image_url": campaign.listing_image_url, "allowed_channels": getattr(campaign, "channels_ordered", []), "game_display_name": game_display_name, - "game_twitch_directory_url": game.twitch_directory_url if game else "", + "game_twitch_directory_url": game.twitch_directory_url, }) return campaigns_by_game @@ -1165,7 +1124,7 @@ class RewardCampaign(auto_prefetch.Model): class Meta(auto_prefetch.Model.Meta): ordering = ["-starts_at"] indexes = [ - models.Index(fields=["-starts_at"]), + models.Index(fields=["-starts_at"], name="tw_reward_starts_desc_idx"), models.Index(fields=["ends_at"]), models.Index(fields=["twitch_id"]), models.Index(fields=["name"]), @@ -1176,7 +1135,10 @@ class RewardCampaign(auto_prefetch.Model): models.Index(fields=["added_at"]), models.Index(fields=["updated_at"]), # Composite indexes for common queries - models.Index(fields=["starts_at", "ends_at"]), + models.Index( + fields=["starts_at", "ends_at"], + name="tw_reward_starts_ends_idx", + ), models.Index(fields=["status", "-starts_at"]), ] diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index 2297d33..d4106b5 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -650,6 +650,97 @@ class TestChannelListView: assert campaigns_uses_index, campaigns_plan assert rewards_uses_index, reward_plan + @pytest.mark.django_db + def test_dashboard_query_plans_reference_expected_index_names(self) -> None: + """Dashboard active-window plans should mention concrete index names.""" + now: datetime.datetime = timezone.now() + + org: Organization = Organization.objects.create( + twitch_id="org_index_name_test", + name="Org Index Name Test", + ) + game: Game = Game.objects.create( + twitch_id="game_index_name_test", + name="Game Index Name Test", + display_name="Game Index Name Test", + ) + game.owners.add(org) + + DropCampaign.objects.create( + twitch_id="active_for_dashboard_index_name_test", + name="Active campaign index-name test", + game=game, + operation_names=["DropCampaignDetails"], + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + RewardCampaign.objects.create( + twitch_id="reward_active_for_dashboard_index_name_test", + name="Active reward campaign index-name test", + game=game, + starts_at=now - timedelta(hours=1), + ends_at=now + timedelta(hours=1), + ) + + # Keep this assertion scoped to engines whose plans typically include index names. + if connection.vendor not in {"sqlite", "postgresql"}: + pytest.skip( + f"Unsupported DB vendor for index-name plan assertion: {connection.vendor}", + ) + + def _index_names(table_name: str) -> set[str]: + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints( + cursor, + table_name, + ) + + names: set[str] = set() + for name, meta in constraints.items(): + if not meta.get("index"): + continue + names.add(name) + return names + + expected_drop_indexes: set[str] = { + "tw_drop_start_desc_idx", + "tw_drop_start_end_idx", + "tw_drop_start_end_game_idx", + } + expected_reward_indexes: set[str] = { + "tw_reward_starts_desc_idx", + "tw_reward_starts_ends_idx", + } + + drop_index_names: set[str] = _index_names(DropCampaign._meta.db_table) + reward_index_names: set[str] = _index_names(RewardCampaign._meta.db_table) + + missing_drop_indexes: set[str] = expected_drop_indexes - drop_index_names + missing_reward_indexes: set[str] = expected_reward_indexes - reward_index_names + + assert not missing_drop_indexes, ( + "Missing expected DropCampaign dashboard indexes: " + f"{sorted(missing_drop_indexes)}" + ) + assert not missing_reward_indexes, ( + "Missing expected RewardCampaign dashboard indexes: " + f"{sorted(missing_reward_indexes)}" + ) + + campaigns_plan: str = DropCampaign.active_for_dashboard(now).explain().lower() + reward_plan: str = RewardCampaign.active_for_dashboard(now).explain().lower() + + assert any(name.lower() in campaigns_plan for name in expected_drop_indexes), ( + "DropCampaign active-for-dashboard plan did not reference an expected " + "named dashboard index. " + f"Expected one of {sorted(expected_drop_indexes)}. Plan={campaigns_plan}" + ) + assert any(name.lower() in reward_plan for name in expected_reward_indexes), ( + "RewardCampaign active-for-dashboard plan did not reference an expected " + "named dashboard index. " + f"Expected one of {sorted(expected_reward_indexes)}. Plan={reward_plan}" + ) + @pytest.mark.django_db def test_dashboard_query_count_stays_flat_with_more_data( self, @@ -758,6 +849,57 @@ class TestChannelListView: f"baseline={baseline_select_count}, scaled={scaled_select_count}" ) + @pytest.mark.django_db + def test_dashboard_grouping_reuses_selected_game_relation(self) -> None: + """Dashboard grouping should not issue extra standalone Game queries.""" + now: datetime.datetime = timezone.now() + + org: Organization = Organization.objects.create( + twitch_id="org_grouping_no_extra_game_select", + name="Org Grouping No Extra Game Select", + ) + game: Game = Game.objects.create( + twitch_id="game_grouping_no_extra_game_select", + name="game_grouping_no_extra_game_select", + display_name="Game Grouping No Extra Game Select", + ) + game.owners.add(org) + + campaigns: list[DropCampaign] = [ + DropCampaign( + twitch_id=f"grouping_campaign_{i}", + name=f"Grouping campaign {i}", + game=game, + operation_names=["DropCampaignDetails"], + start_at=now - timedelta(hours=1), + end_at=now + timedelta(hours=1), + ) + for i in range(5) + ] + DropCampaign.objects.bulk_create(campaigns) + + with CaptureQueriesContext(connection) as queries: + grouped: dict[str, dict[str, Any]] = ( + DropCampaign.campaigns_by_game_for_dashboard(now) + ) + + assert game.twitch_id in grouped + assert len(grouped[game.twitch_id]["campaigns"]) == 5 + + game_select_queries: list[str] = [ + query_info["sql"] + for query_info in queries.captured_queries + if query_info["sql"].lstrip().upper().startswith("SELECT") + and 'from "twitch_game"' in query_info["sql"].lower() + and " join " not in query_info["sql"].lower() + ] + + assert not game_select_queries, ( + "Dashboard grouping should reuse DropCampaign.active_for_dashboard() " + "select_related game rows instead of standalone Game SELECTs. " + f"Queries: {game_select_queries}" + ) + @pytest.mark.django_db def test_dashboard_avoids_n_plus_one_game_queries_in_drop_loop( self,