Skip to content

Commit f9b9718

Browse files
blakeaowensclaude
andcommitted
refactor(search): declare FTS/trigram indexes on model Meta
Move the global-search GIN indexes from a database-only migration into each model's Meta.indexes (house style, matching the other functional-index migrations), and register django.contrib.postgres in INSTALLED_APPS so the SearchVector index expressions and trigram lookups are supported. 0278 becomes a plain AddIndexConcurrently migration matching model state. Indexes: weighted tsvector FTS + gin_trgm_ops trigram on finding, product, product_type, engagement, test, endpoint, location, finding_template, app_analysis, vulnerability_id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1ca3789 commit f9b9718

10 files changed

Lines changed: 108 additions & 17 deletions

File tree

dojo/db_migrations/0278_global_search_fts_trigram_indexes.py

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,13 @@
99
* one ``gin_trgm_ops`` index on each model's short display column for the
1010
fuzzy ``%>`` (``__trigram_word_similar``) lookup.
1111
12-
The indexes are database-only (wrapped in ``SeparateDatabaseAndState`` with no
13-
state operations, so they stay out of every model's ``Meta.indexes``): they
14-
exist purely for global-search performance and are not part of any model's
15-
public schema. The tsvector indexes are built from the same ``SearchVector``
16-
objects the query annotates with (rather than raw SQL), so the index
17-
expression cannot drift from the query's compiled SQL across Django upgrades.
18-
19-
The trigram indexes use ``opclasses`` (a base ``Index`` option) rather than a
20-
``django.contrib.postgres`` ``OpClass`` expression, so they render without
21-
requiring that app in ``INSTALLED_APPS`` (only the Pro query side needs it,
22-
for the ``__trigram_word_similar`` lookup).
12+
Each index is declared on its model's ``Meta.indexes``; this migration is the
13+
paired ``AddIndexConcurrently`` that actually builds them (the same house
14+
style as the other functional-index migrations, e.g. 0273). The tsvector
15+
indexes are built from the same ``SearchVector`` objects the query annotates
16+
with, so the index expression cannot drift from the compiled SQL across Django
17+
upgrades. The trigram indexes use ``opclasses`` (a base ``Index`` option), so
18+
they need no ``OpClass`` expression.
2319
2420
``AddIndexConcurrently`` / ``CREATE EXTENSION`` cannot run inside a
2521
transaction, hence ``atomic = False``. ``TrigramExtension`` is the sole
@@ -87,12 +83,7 @@ def _index_operations():
8783
index=GinIndex(fields=[column], opclasses=["gin_trgm_ops"], name=name),
8884
),
8985
)
90-
# database_operations only: create the indexes without recording them in
91-
# model state, so no model's Meta.indexes has to carry a search-only index.
92-
return [
93-
TrigramExtension(),
94-
migrations.SeparateDatabaseAndState(database_operations=add_index, state_operations=[]),
95-
]
86+
return [TrigramExtension(), *add_index]
9687

9788

9889
class Migration(migrations.Migration):

dojo/endpoint/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import hyperlink
77
from django.conf import settings
8+
from django.contrib.postgres.indexes import GinIndex
9+
from django.contrib.postgres.search import SearchVector
810
from django.core.exceptions import ValidationError
911
from django.core.validators import validate_ipv46_address
1012
from django.db import connection, models
@@ -123,6 +125,13 @@ class Meta:
123125
Lower("host"),
124126
name="idx_ep_product_lower_host",
125127
),
128+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
129+
GinIndex(
130+
SearchVector("host", weight="A", config="english")
131+
+ SearchVector("path", weight="B", config="english"),
132+
name="dojo_endpoint_fts_gin",
133+
),
134+
GinIndex(fields=["host"], opclasses=["gin_trgm_ops"], name="dojo_endpoint_host_trgm"),
126135
]
127136

128137
def __init__(self, *args, **kwargs):

dojo/engagement/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from contextlib import suppress
33

44
from dateutil.relativedelta import relativedelta
5+
from django.contrib.postgres.indexes import GinIndex
6+
from django.contrib.postgres.search import SearchVector
57
from django.db import models
68
from django.urls import reverse
79
from django.utils import timezone
@@ -96,6 +98,13 @@ class Meta:
9698
ordering = ["-target_start"]
9799
indexes = [
98100
models.Index(fields=["product", "active"]),
101+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
102+
GinIndex(
103+
SearchVector("name", weight="A", config="english")
104+
+ SearchVector("description", weight="B", config="english"),
105+
name="dojo_engagement_fts_gin",
106+
),
107+
GinIndex(fields=["name"], opclasses=["gin_trgm_ops"], name="dojo_engagement_name_trgm"),
99108
]
100109

101110
def __str__(self):

dojo/finding/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from dateutil.parser import parse as datetutilsparse
1111
from dateutil.relativedelta import relativedelta
1212
from django.conf import settings
13+
from django.contrib.postgres.indexes import GinIndex
14+
from django.contrib.postgres.search import SearchVector
1315
from django.core.validators import MaxValueValidator, MinValueValidator
1416
from django.db import models
1517
from django.urls import reverse
@@ -441,6 +443,14 @@ class Finding(BaseModel):
441443
class Meta:
442444
ordering = ("numerical_severity", "-date", "title", "epss_score", "epss_percentile")
443445
indexes = [
446+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
447+
GinIndex(
448+
SearchVector("title", weight="A", config="english")
449+
+ SearchVector("description", weight="B", config="english"),
450+
name="dojo_finding_fts_gin",
451+
),
452+
GinIndex(fields=["title"], opclasses=["gin_trgm_ops"], name="dojo_finding_title_trgm"),
453+
444454
models.Index(fields=["test", "active", "verified"]),
445455

446456
models.Index(fields=["test", "is_mitigated"]),
@@ -1367,6 +1377,16 @@ class Vulnerability_Id(models.Model):
13671377
finding = models.ForeignKey("dojo.Finding", editable=False, on_delete=models.CASCADE)
13681378
vulnerability_id = models.TextField(max_length=50, blank=False, null=False)
13691379

1380+
class Meta:
1381+
indexes = [
1382+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
1383+
GinIndex(
1384+
SearchVector("vulnerability_id", weight="A", config="english"),
1385+
name="dojo_vulnerability_id_fts_gin",
1386+
),
1387+
GinIndex(fields=["vulnerability_id"], opclasses=["gin_trgm_ops"], name="dojo_vuln_id_trgm"),
1388+
]
1389+
13701390
def __str__(self):
13711391
return self.vulnerability_id
13721392

@@ -1506,6 +1526,15 @@ class Finding_Template(models.Model):
15061526

15071527
class Meta:
15081528
ordering = ["-cwe"]
1529+
indexes = [
1530+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
1531+
GinIndex(
1532+
SearchVector("title", weight="A", config="english")
1533+
+ SearchVector("description", weight="B", config="english"),
1534+
name="dojo_finding_template_fts_gin",
1535+
),
1536+
GinIndex(fields=["title"], opclasses=["gin_trgm_ops"], name="dojo_findtmpl_title_trgm"),
1537+
]
15091538

15101539
def __str__(self):
15111540
return self.title

dojo/location/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import hashlib
44
from typing import TYPE_CHECKING, Self
55

6+
from django.contrib.postgres.indexes import GinIndex
7+
from django.contrib.postgres.search import SearchVector
68
from django.core.validators import MinLengthValidator
79
from django.db import transaction
810
from django.db.models import (
@@ -280,6 +282,13 @@ class Meta:
280282
indexes = [
281283
Index(fields=["location_type"]),
282284
Index(fields=["location_value"]),
285+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
286+
GinIndex(
287+
SearchVector("location_value", weight="A", config="english")
288+
+ SearchVector("location_type", weight="B", config="english"),
289+
name="dojo_location_fts_gin",
290+
),
291+
GinIndex(fields=["location_value"], opclasses=["gin_trgm_ops"], name="dojo_location_locval_trgm"),
283292
]
284293

285294

dojo/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import tagulous.admin
88
from django.contrib import admin
99
from django.contrib.auth import get_user_model
10+
from django.contrib.postgres.indexes import GinIndex
11+
from django.contrib.postgres.search import SearchVector
1012
from django.core.exceptions import ValidationError
1113
from django.db import models
1214
from django.db.models import Count
@@ -531,6 +533,16 @@ class App_Analysis(models.Model):
531533

532534
tags = TagField(blank=True, force_lowercase=True)
533535

536+
class Meta:
537+
indexes = [
538+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
539+
GinIndex(
540+
SearchVector("name", weight="A", config="english"),
541+
name="dojo_app_analysis_fts_gin",
542+
),
543+
GinIndex(fields=["name"], opclasses=["gin_trgm_ops"], name="dojo_app_analysis_name_trgm"),
544+
]
545+
534546
def __str__(self):
535547
return self.name + " | " + self.product.name
536548

dojo/product/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from decimal import Decimal
22

3+
from django.contrib.postgres.indexes import GinIndex
4+
from django.contrib.postgres.search import SearchVector
35
from django.core.validators import MinValueValidator
46
from django.db import models
57
from django.db.models.functions import Upper
@@ -130,6 +132,13 @@ class Meta:
130132
# (WHERE UPPER(name) = UPPER(%s)) used when filtering findings by
131133
# product name; the plain unique btree on name can't (Sentry DJANGO-D2M).
132134
models.Index(Upper("name"), name="dojo_product_upper_name_idx"),
135+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
136+
GinIndex(
137+
SearchVector("name", weight="A", config="english")
138+
+ SearchVector("description", weight="B", config="english"),
139+
name="dojo_product_fts_gin",
140+
),
141+
GinIndex(fields=["name"], opclasses=["gin_trgm_ops"], name="dojo_product_name_trgm"),
133142
]
134143

135144
def __str__(self):

dojo/product_type/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from django.contrib.postgres.indexes import GinIndex
2+
from django.contrib.postgres.search import SearchVector
13
from django.db import models
24
from django.urls import reverse
35
from django.utils.functional import cached_property
@@ -25,6 +27,15 @@ class Product_Type(BaseModel):
2527

2628
class Meta:
2729
ordering = ("name",)
30+
indexes = [
31+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
32+
GinIndex(
33+
SearchVector("name", weight="A", config="english")
34+
+ SearchVector("description", weight="B", config="english"),
35+
name="dojo_product_type_fts_gin",
36+
),
37+
GinIndex(fields=["name"], opclasses=["gin_trgm_ops"], name="dojo_product_type_name_trgm"),
38+
]
2839

2940
def __str__(self):
3041
return self.name

dojo/settings/settings.dist.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,9 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
773773
INSTALLED_APPS = (
774774
"django.contrib.auth",
775775
"django.contrib.contenttypes",
776+
# Registers the postgres-specific lookups and index expressions
777+
# (trigram word similarity, tsvector search) used by global search.
778+
"django.contrib.postgres",
776779
"django.contrib.sessions",
777780
"django.contrib.sites",
778781
"django.contrib.messages",

dojo/test/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from contextlib import suppress
33

44
from django.conf import settings
5+
from django.contrib.postgres.indexes import GinIndex
6+
from django.contrib.postgres.search import SearchVector
57
from django.db import models
68
from django.db.models import Count, Q
79
from django.urls import reverse
@@ -81,6 +83,13 @@ class Test(models.Model):
8183
class Meta:
8284
indexes = [
8385
models.Index(fields=["engagement", "test_type"]),
86+
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
87+
GinIndex(
88+
SearchVector("title", weight="A", config="english")
89+
+ SearchVector("description", weight="B", config="english"),
90+
name="dojo_test_fts_gin",
91+
),
92+
GinIndex(fields=["title"], opclasses=["gin_trgm_ops"], name="dojo_test_title_trgm"),
8493
]
8594

8695
def __init__(self, *args, **kwargs):

0 commit comments

Comments
 (0)