Add PostgreSQL full-text search support and optimize indexes for DropCampaign and related models

This commit is contained in:
Joakim Hellsén 2025-09-05 00:28:21 +02:00
commit 06d1cebdac
3 changed files with 114 additions and 0 deletions

View file

@ -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"))

View file

@ -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'),
),
]

View file

@ -5,6 +5,8 @@ import re
from typing import TYPE_CHECKING, ClassVar from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlsplit, urlunsplit 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.db import models
from django.utils import timezone from django.utils import timezone
@ -46,6 +48,7 @@ class Organization(models.Model):
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]
indexes: ClassVar[list] = [ indexes: ClassVar[list] = [
# Regular B-tree index for name lookups
models.Index(fields=["name"]), models.Index(fields=["name"]),
] ]
@ -111,8 +114,11 @@ class Game(models.Model):
ordering = ["display_name"] ordering = ["display_name"]
indexes: ClassVar[list] = [ indexes: ClassVar[list] = [
models.Index(fields=["slug"]), models.Index(fields=["slug"]),
# Regular B-tree indexes for name lookups
models.Index(fields=["display_name"]), models.Index(fields=["display_name"]),
models.Index(fields=["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: def __str__(self) -> str:
@ -198,6 +204,9 @@ class DropCampaign(models.Model):
help_text="Indicates if the user account is linked.", 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 = models.ForeignKey(
Game, Game,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -218,9 +227,22 @@ class DropCampaign(models.Model):
class Meta: class Meta:
ordering = ["-start_at"] 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] = [ indexes: ClassVar[list] = [
# Regular B-tree index for campaign name lookups
models.Index(fields=["name"]), 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"]), 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: def __str__(self) -> str:
@ -321,9 +343,12 @@ class DropBenefit(models.Model):
class Meta: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]
indexes: ClassVar[list] = [ indexes: ClassVar[list] = [
# Regular B-tree index for benefit name lookups
models.Index(fields=["name"]), models.Index(fields=["name"]),
models.Index(fields=["created_at"]), models.Index(fields=["created_at"]),
models.Index(fields=["distribution_type"]), 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: def __str__(self) -> str:
@ -393,10 +418,24 @@ class TimeBasedDrop(models.Model):
class Meta: class Meta:
ordering = ["start_at"] 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] = [ indexes: ClassVar[list] = [
# Regular B-tree index for drop name lookups
models.Index(fields=["name"]), models.Index(fields=["name"]),
models.Index(fields=["start_at", "end_at"]), models.Index(fields=["start_at", "end_at"]),
models.Index(fields=["required_minutes_watched"]), 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: def __str__(self) -> str: