From 06d1cebdacfeb1ec296eb791a0b415cdc609c611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Fri, 5 Sep 2025 00:28:21 +0200 Subject: [PATCH] Add PostgreSQL full-text search support and optimize indexes for DropCampaign and related models --- .../commands/update_search_vectors.py | 23 ++++++++ .../0009_postgresql_optimizations_fixed.py | 52 +++++++++++++++++++ twitch/models.py | 39 ++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 twitch/management/commands/update_search_vectors.py create mode 100644 twitch/migrations/0009_postgresql_optimizations_fixed.py diff --git a/twitch/management/commands/update_search_vectors.py b/twitch/management/commands/update_search_vectors.py new file mode 100644 index 0000000..7e4219f --- /dev/null +++ b/twitch/management/commands/update_search_vectors.py @@ -0,0 +1,23 @@ +"""Management command to update PostgreSQL search vectors.""" + +from __future__ import annotations + +from django.contrib.postgres.search import SearchVector +from django.core.management.base import BaseCommand + +from twitch.models import DropCampaign + + +class Command(BaseCommand): + """Update search vectors for existing campaign records.""" + + help = "Update PostgreSQL search vectors for existing campaigns" + + def handle(self, *_args, **_options) -> None: + """Update search vectors for all campaigns.""" + self.stdout.write("Updating search vectors for campaigns...") + + DropCampaign.objects.update(search_vector=SearchVector("name", "description", weight="A")) + + campaign_count: int = DropCampaign.objects.count() + self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {campaign_count} campaigns")) diff --git a/twitch/migrations/0009_postgresql_optimizations_fixed.py b/twitch/migrations/0009_postgresql_optimizations_fixed.py new file mode 100644 index 0000000..26e4b98 --- /dev/null +++ b/twitch/migrations/0009_postgresql_optimizations_fixed.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.5 on 2025-09-04 22:22 + +import django.contrib.postgres.indexes +import django.contrib.postgres.search +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twitch', '0008_rename_created_at_dropcampaign_added_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='dropcampaign', + name='search_vector', + field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True), + ), + migrations.AddIndex( + model_name='dropbenefit', + index=models.Index(condition=models.Q(('is_ios_available', True)), fields=['is_ios_available'], name='benefit_ios_available_idx'), + ), + migrations.AddIndex( + model_name='dropcampaign', + index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='campaign_search_vector_idx'), + ), + migrations.AddIndex( + model_name='dropcampaign', + index=models.Index(condition=models.Q(('end_at__isnull', False), ('start_at__isnull', False)), fields=['start_at', 'end_at'], name='campaign_active_partial_idx'), + ), + migrations.AddIndex( + model_name='game', + index=models.Index(condition=models.Q(('owner__isnull', False)), fields=['owner'], name='game_owner_partial_idx'), + ), + migrations.AddIndex( + model_name='timebaseddrop', + index=models.Index(fields=['campaign', 'start_at', 'required_minutes_watched'], name='twitch_time_campaig_4cc3b7_idx'), + ), + migrations.AddConstraint( + model_name='dropcampaign', + constraint=models.CheckConstraint(condition=models.Q(('start_at__isnull', True), ('end_at__isnull', True), ('end_at__gt', models.F('start_at')), _connector='OR'), name='campaign_valid_date_range'), + ), + migrations.AddConstraint( + model_name='timebaseddrop', + constraint=models.CheckConstraint(condition=models.Q(('start_at__isnull', True), ('end_at__isnull', True), ('end_at__gt', models.F('start_at')), _connector='OR'), name='drop_valid_date_range'), + ), + migrations.AddConstraint( + model_name='timebaseddrop', + constraint=models.CheckConstraint(condition=models.Q(('required_minutes_watched__isnull', True), ('required_minutes_watched__gt', 0), _connector='OR'), name='drop_positive_minutes'), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index a12b482..01679c3 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -5,6 +5,8 @@ import re from typing import TYPE_CHECKING, ClassVar from urllib.parse import urlsplit, urlunsplit +from django.contrib.postgres.indexes import GinIndex +from django.contrib.postgres.search import SearchVectorField from django.db import models from django.utils import timezone @@ -46,6 +48,7 @@ class Organization(models.Model): class Meta: ordering = ["name"] indexes: ClassVar[list] = [ + # Regular B-tree index for name lookups models.Index(fields=["name"]), ] @@ -111,8 +114,11 @@ class Game(models.Model): ordering = ["display_name"] indexes: ClassVar[list] = [ models.Index(fields=["slug"]), + # Regular B-tree indexes for name lookups models.Index(fields=["display_name"]), models.Index(fields=["name"]), + # Partial index for games with owners only + models.Index(fields=["owner"], condition=models.Q(owner__isnull=False), name="game_owner_partial_idx"), ] def __str__(self) -> str: @@ -198,6 +204,9 @@ class DropCampaign(models.Model): help_text="Indicates if the user account is linked.", ) + # PostgreSQL full-text search field + search_vector = SearchVectorField(null=True, blank=True) + game = models.ForeignKey( Game, on_delete=models.CASCADE, @@ -218,9 +227,22 @@ class DropCampaign(models.Model): class Meta: ordering = ["-start_at"] + constraints = [ + # Ensure end_at is after start_at when both are set + models.CheckConstraint( + condition=models.Q(start_at__isnull=True) | models.Q(end_at__isnull=True) | models.Q(end_at__gt=models.F("start_at")), + name="campaign_valid_date_range", + ), + ] indexes: ClassVar[list] = [ + # Regular B-tree index for campaign name lookups models.Index(fields=["name"]), + # Full-text search index (GIN works with SearchVectorField) + GinIndex(fields=["search_vector"], name="campaign_search_vector_idx"), + # Composite index for time range queries models.Index(fields=["start_at", "end_at"]), + # Partial index for active campaigns + models.Index(fields=["start_at", "end_at"], condition=models.Q(start_at__isnull=False, end_at__isnull=False), name="campaign_active_partial_idx"), ] def __str__(self) -> str: @@ -321,9 +343,12 @@ class DropBenefit(models.Model): class Meta: ordering = ["-created_at"] indexes: ClassVar[list] = [ + # Regular B-tree index for benefit name lookups models.Index(fields=["name"]), models.Index(fields=["created_at"]), models.Index(fields=["distribution_type"]), + # Partial index for iOS available benefits + models.Index(fields=["is_ios_available"], condition=models.Q(is_ios_available=True), name="benefit_ios_available_idx"), ] def __str__(self) -> str: @@ -393,10 +418,24 @@ class TimeBasedDrop(models.Model): class Meta: ordering = ["start_at"] + constraints = [ + # Ensure end_at is after start_at when both are set + models.CheckConstraint( + condition=models.Q(start_at__isnull=True) | models.Q(end_at__isnull=True) | models.Q(end_at__gt=models.F("start_at")), + name="drop_valid_date_range", + ), + # Ensure required_minutes_watched is positive when set + models.CheckConstraint( + condition=models.Q(required_minutes_watched__isnull=True) | models.Q(required_minutes_watched__gt=0), name="drop_positive_minutes" + ), + ] indexes: ClassVar[list] = [ + # Regular B-tree index for drop name lookups models.Index(fields=["name"]), models.Index(fields=["start_at", "end_at"]), models.Index(fields=["required_minutes_watched"]), + # Covering index for common queries (includes campaign_id from FK) + models.Index(fields=["campaign", "start_at", "required_minutes_watched"]), ] def __str__(self) -> str: