Add PostgreSQL full-text search support and optimize indexes for DropCampaign and related models
This commit is contained in:
parent
c72533c14f
commit
06d1cebdac
3 changed files with 114 additions and 0 deletions
23
twitch/management/commands/update_search_vectors.py
Normal file
23
twitch/management/commands/update_search_vectors.py
Normal 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"))
|
||||
52
twitch/migrations/0009_postgresql_optimizations_fixed.py
Normal file
52
twitch/migrations/0009_postgresql_optimizations_fixed.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue