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 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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue