Skip to content

Commit 07eb575

Browse files
blakeaowensclaude
andauthored
feat(search): FTS + trigram GIN indexes for global search (#15220)
* feat(search): add FTS + trigram GIN indexes for global search Adds a weighted tsvector GIN index and a gin_trgm_ops index on the short display column of the ten models the Pro global search queries (finding, product, product_type, engagement, test, endpoint, location, finding_template, app_analysis, vulnerability_id), plus the pg_trgm extension. The indexes are built from SearchVector/OpClass ORM objects so the index expression tracks the query's compiled SQL across Django upgrades, and are database-only (SeparateDatabaseAndState with no state operations) since they are search-performance indexes, not part of any model's public schema. atomic=False because CONCURRENTLY / CREATE EXTENSION cannot run in a transaction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(search): build trigram indexes with opclasses, not OpClass The 0278 trigram GIN indexes used a django.contrib.postgres OpClass expression, which renders to invalid SQL ("syntax error at or near gin_trgm_ops") unless django.contrib.postgres is in INSTALLED_APPS, and it is not in the OSS standalone settings. Switch to the base Index opclasses option, which renders the identical `USING gin (col gin_trgm_ops)` SQL without requiring that app. The tsvector indexes are unaffected (SearchVector renders without the app). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7ad4702 commit 07eb575

10 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
Full-text-search and fuzzy-match GIN indexes for global search.
3+
4+
Adds, on the ten models the Pro global search (``pro/search/``) queries:
5+
6+
* one weighted ``tsvector`` GIN index per model, built from the same
7+
``SearchVector`` objects the query annotates with, so the index expression
8+
can never drift from the compiled SQL across Django upgrades; and
9+
* one ``gin_trgm_ops`` index on each model's short display column for the
10+
fuzzy ``%>`` (``__trigram_word_similar``) lookup.
11+
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.
19+
20+
``AddIndexConcurrently`` / ``CREATE EXTENSION`` cannot run inside a
21+
transaction, hence ``atomic = False``. ``TrigramExtension`` is the sole
22+
creator of ``pg_trgm`` here, so its symmetric reverse (dropping the extension
23+
after the trigram indexes are already gone) is correct.
24+
"""
25+
26+
from django.contrib.postgres.indexes import GinIndex
27+
from django.contrib.postgres.operations import AddIndexConcurrently, TrigramExtension
28+
from django.contrib.postgres.search import SearchVector
29+
from django.db import migrations
30+
31+
# (model_name, ((column, weight), ...), index_name) -- weighted tsvector GIN.
32+
_FTS_SPECS = (
33+
("finding", (("title", "A"), ("description", "B")), "dojo_finding_fts_gin"),
34+
("product", (("name", "A"), ("description", "B")), "dojo_product_fts_gin"),
35+
("product_type", (("name", "A"), ("description", "B")), "dojo_product_type_fts_gin"),
36+
("engagement", (("name", "A"), ("description", "B")), "dojo_engagement_fts_gin"),
37+
("test", (("title", "A"), ("description", "B")), "dojo_test_fts_gin"),
38+
("endpoint", (("host", "A"), ("path", "B")), "dojo_endpoint_fts_gin"),
39+
("location", (("location_value", "A"), ("location_type", "B")), "dojo_location_fts_gin"),
40+
("finding_template", (("title", "A"), ("description", "B")), "dojo_finding_template_fts_gin"),
41+
("app_analysis", (("name", "A"),), "dojo_app_analysis_fts_gin"),
42+
("vulnerability_id", (("vulnerability_id", "A"),), "dojo_vulnerability_id_fts_gin"),
43+
)
44+
45+
# (model_name, column, index_name) -- gin_trgm_ops fuzzy-match index. Three
46+
# names are abbreviated to stay within the 30-char index-name limit (E033):
47+
# location_value -> locval, finding_template -> findtmpl, vulnerability_id -> vuln_id.
48+
_TRGM_SPECS = (
49+
("finding", "title", "dojo_finding_title_trgm"),
50+
("product", "name", "dojo_product_name_trgm"),
51+
("product_type", "name", "dojo_product_type_name_trgm"),
52+
("engagement", "name", "dojo_engagement_name_trgm"),
53+
("test", "title", "dojo_test_title_trgm"),
54+
("endpoint", "host", "dojo_endpoint_host_trgm"),
55+
("location", "location_value", "dojo_location_locval_trgm"),
56+
("finding_template", "title", "dojo_findtmpl_title_trgm"),
57+
("app_analysis", "name", "dojo_app_analysis_name_trgm"),
58+
("vulnerability_id", "vulnerability_id", "dojo_vuln_id_trgm"),
59+
)
60+
61+
62+
def _fts_vector(fields):
63+
vector = None
64+
for column, weight in fields:
65+
component = SearchVector(column, weight=weight, config="english")
66+
vector = component if vector is None else vector + component
67+
return vector
68+
69+
70+
def _index_operations():
71+
add_index = []
72+
for model_name, fields, name in _FTS_SPECS:
73+
add_index.append(
74+
AddIndexConcurrently(
75+
model_name=model_name,
76+
index=GinIndex(_fts_vector(fields), name=name),
77+
),
78+
)
79+
for model_name, column, name in _TRGM_SPECS:
80+
add_index.append(
81+
AddIndexConcurrently(
82+
model_name=model_name,
83+
index=GinIndex(fields=[column], opclasses=["gin_trgm_ops"], name=name),
84+
),
85+
)
86+
return [TrigramExtension(), *add_index]
87+
88+
89+
class Migration(migrations.Migration):
90+
atomic = False
91+
92+
dependencies = [
93+
("dojo", "0277_seed_deduplication_execution_mode"),
94+
]
95+
96+
operations = _index_operations()

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)