Skip to content

Commit 0948ad3

Browse files
bugfix: use subquery for (finding) counts (#12784)
* view_test: use subquery for finding counts * fix two more group by errors * change all counts to subqueries * fix query
1 parent ae90b6b commit 0948ad3

12 files changed

Lines changed: 163 additions & 128 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,4 @@ docs/.devcontainer/devcontainer.json
147147
docs/.devcontainer/Dockerfile
148148
docs/LICENSE
149149
docs/.hugo_build.lock
150+
.cursor-rules

dojo/endpoint/views.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from django.contrib.admin.utils import NestedObjects
99
from django.core.exceptions import PermissionDenied
1010
from django.db import DEFAULT_DB_ALIAS
11-
from django.db.models import Count, Q, QuerySet
11+
from django.db.models import OuterRef, QuerySet, Value
12+
from django.db.models.functions import Coalesce
1213
from django.http import HttpResponseRedirect
1314
from django.shortcuts import get_object_or_404, render
1415
from django.urls import reverse
@@ -22,6 +23,7 @@
2223
from dojo.filters import EndpointFilter, EndpointFilterWithoutObjectLookups
2324
from dojo.forms import AddEndpointForm, DeleteEndpointForm, DojoMetaDataForm, EditEndpointForm, ImportEndpointMetaForm
2425
from dojo.models import DojoMeta, Endpoint, Endpoint_Status, Finding, Product
26+
from dojo.query_utils import build_count_subquery
2527
from dojo.utils import (
2628
Product_Tab,
2729
add_breadcrumb,
@@ -454,7 +456,11 @@ def endpoint_status_bulk_update(request, fid):
454456
def prefetch_for_endpoints(endpoints):
455457
if isinstance(endpoints, QuerySet):
456458
endpoints = endpoints.prefetch_related("product", "tags", "product__tags")
457-
endpoints = endpoints.annotate(active_finding_count=Count("finding__id", filter=Q(finding__active=True)))
459+
active_finding_subquery = build_count_subquery(
460+
Finding.objects.filter(endpoints=OuterRef("pk"), active=True),
461+
group_field="endpoints",
462+
)
463+
endpoints = endpoints.annotate(active_finding_count=Coalesce(active_finding_subquery, Value(0)))
458464
else:
459465
logger.debug("unable to prefetch because query was already executed")
460466

dojo/engagement/views.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.contrib.auth.models import User
1717
from django.core.exceptions import PermissionDenied, ValidationError
1818
from django.db import DEFAULT_DB_ALIAS
19-
from django.db.models import Count, OuterRef, Q, Value
19+
from django.db.models import OuterRef, Q, Value
2020
from django.db.models.functions import Coalesce
2121
from django.db.models.query import Prefetch, QuerySet
2222
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, QueryDict, StreamingHttpResponse
@@ -95,6 +95,7 @@
9595
LargeScanSizeProductAnnouncement,
9696
ScanTypeProductAnnouncement,
9797
)
98+
from dojo.query_utils import build_count_subquery
9899
from dojo.risk_acceptance.helper import prefetch_for_expiration
99100
from dojo.tools.factory import get_scan_types_sorted
100101
from dojo.user.queries import get_authorized_users
@@ -105,7 +106,6 @@
105106
add_error_message_to_response,
106107
add_success_message_to_response,
107108
async_delete,
108-
build_count_subquery,
109109
calculate_grade,
110110
generate_file_response_from_file_path,
111111
get_cal_event,
@@ -217,7 +217,11 @@ def engagements_all(request):
217217
# count using prefetch instead of just using 'engagement__set_test_test` to avoid loading all test in memory just to count them
218218
filter_string_matching = get_system_setting("filter_string_matching", False)
219219
products_filter_class = ProductEngagementsFilterWithoutObjectLookups if filter_string_matching else ProductEngagementsFilter
220-
engagement_query = Engagement.objects.annotate(test_count=Count("test__id"))
220+
test_count_subquery = build_count_subquery(
221+
Test.objects.filter(engagement=OuterRef("pk")),
222+
group_field="engagement_id",
223+
)
224+
engagement_query = Engagement.objects.annotate(test_count=Coalesce(test_count_subquery, Value(0)))
221225
filter_qs = products_with_engagements.prefetch_related(
222226
Prefetch("engagement_set", queryset=products_filter_class(request.GET, engagement_query).qs),
223227
)
@@ -417,7 +421,13 @@ def get_template(self):
417421
return "dojo/view_eng.html"
418422

419423
def get_risks_accepted(self, eng):
420-
return eng.risk_acceptance.all().select_related("owner").annotate(accepted_findings_count=Count("accepted_findings__id"))
424+
accepted_findings_subquery = build_count_subquery(
425+
Finding.objects.filter(risk_acceptance=OuterRef("pk")),
426+
group_field="risk_acceptance",
427+
)
428+
return eng.risk_acceptance.all().select_related("owner").annotate(
429+
accepted_findings_count=Coalesce(accepted_findings_subquery, Value(0)),
430+
)
421431

422432
def get_filtered_tests(
423433
self,

dojo/finding/queries.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1+
import logging
2+
from functools import partial
3+
14
from crum import get_current_user
2-
from django.db.models import Exists, OuterRef, Q
5+
from django.db.models import Exists, OuterRef, Q, Value
6+
from django.db.models.functions import Coalesce
7+
from django.db.models.query import Prefetch, QuerySet
38

49
from dojo.authorization.authorization import get_roles_for_permission, user_has_global_permission
510
from dojo.models import (
11+
IMPORT_UNTOUCHED_FINDING,
12+
Endpoint_Status,
613
Finding,
714
Product_Group,
815
Product_Member,
916
Product_Type_Group,
1017
Product_Type_Member,
1118
Stub_Finding,
19+
Test_Import_Finding_Action,
1220
Vulnerability_Id,
1321
)
22+
from dojo.query_utils import build_count_subquery
23+
24+
logger = logging.getLogger(__name__)
1425

1526

1627
def get_authorized_groups(permission, user=None):
@@ -146,3 +157,70 @@ def get_authorized_vulnerability_ids(permission, queryset=None, user=None):
146157
| Q(finding__test__engagement__product__member=True)
147158
| Q(finding__test__engagement__product__prod_type__authorized_group=True)
148159
| Q(finding__test__engagement__product__authorized_group=True))
160+
161+
162+
def prefetch_for_findings(findings, prefetch_type="all", *, exclude_untouched=True):
163+
"""
164+
Unified prefetch function for findings across the application.
165+
166+
Args:
167+
findings: QuerySet of findings to prefetch
168+
prefetch_type: "all" or "open" - controls risk acceptance prefetching
169+
exclude_untouched: Whether to exclude untouched import actions
170+
171+
"""
172+
if not isinstance(findings, QuerySet):
173+
logger.debug("unable to prefetch because query was already executed")
174+
return findings
175+
176+
# Base prefetches - always needed
177+
prefetched_findings = findings.prefetch_related(
178+
"reviewers",
179+
"jira_issue__jira_project__jira_instance",
180+
"test__test_type",
181+
"test__engagement__jira_project__jira_instance",
182+
"test__engagement__product__jira_project_set__jira_instance",
183+
"found_by",
184+
"reporter",
185+
)
186+
187+
# Conditional prefetches for non-open findings
188+
if prefetch_type != "open":
189+
prefetched_findings = prefetched_findings.prefetch_related(
190+
"risk_acceptance_set",
191+
"risk_acceptance_set__accepted_findings",
192+
"original_finding",
193+
"duplicate_finding",
194+
)
195+
196+
# Import actions - configurable filtering
197+
if exclude_untouched:
198+
prefetched_findings = prefetched_findings.prefetch_related(
199+
Prefetch(
200+
"test_import_finding_action_set",
201+
queryset=Test_Import_Finding_Action.objects.exclude(action=IMPORT_UNTOUCHED_FINDING),
202+
),
203+
)
204+
else:
205+
prefetched_findings = prefetched_findings.prefetch_related("test_import_finding_action_set")
206+
207+
# Standard prefetches
208+
prefetched_findings = prefetched_findings.prefetch_related(
209+
"notes",
210+
"tags",
211+
"endpoints",
212+
"status_finding",
213+
"finding_group_set",
214+
"finding_group_set__jira_issue", # Include both variants
215+
"test__engagement__product__members",
216+
"test__engagement__product__prod_type__members",
217+
"vulnerability_id_set",
218+
)
219+
220+
# Endpoint counts using optimized subqueries
221+
base_status = Endpoint_Status.objects.filter(finding_id=OuterRef("pk"))
222+
count_subquery = partial(build_count_subquery, group_field="finding_id")
223+
return prefetched_findings.annotate(
224+
active_endpoint_count=Coalesce(count_subquery(base_status.filter(mitigated=False)), Value(0)),
225+
mitigated_endpoint_count=Coalesce(count_subquery(base_status.filter(mitigated=True)), Value(0)),
226+
)

dojo/finding/views.py

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import logging
77
import mimetypes
88
from collections import OrderedDict, defaultdict
9-
from functools import partial
109
from itertools import chain
1110
from pathlib import Path
1211

@@ -15,8 +14,8 @@
1514
from django.core import serializers
1615
from django.core.exceptions import PermissionDenied, ValidationError
1716
from django.db import models
18-
from django.db.models import OuterRef, QuerySet, Value
19-
from django.db.models.functions import Coalesce, Length
17+
from django.db.models import QuerySet
18+
from django.db.models.functions import Length
2019
from django.db.models.query import Prefetch
2120
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse, StreamingHttpResponse
2221
from django.shortcuts import get_object_or_404, render
@@ -50,7 +49,7 @@
5049
TestImportFilter,
5150
TestImportFindingActionFilter,
5251
)
53-
from dojo.finding.queries import get_authorized_findings
52+
from dojo.finding.queries import get_authorized_findings, prefetch_for_findings
5453
from dojo.forms import (
5554
ApplyFindingTemplateForm,
5655
ClearFindingReviewForm,
@@ -111,7 +110,6 @@
111110
add_field_errors_to_response,
112111
add_success_message_to_response,
113112
apply_cwe_to_template,
114-
build_count_subquery,
115113
calculate_grade,
116114
close_external_issue,
117115
do_false_positive_history,
@@ -133,61 +131,6 @@
133131
logger = logging.getLogger(__name__)
134132

135133

136-
def prefetch_for_findings(findings, prefetch_type="all", *, exclude_untouched=True):
137-
# old code can arrive here with prods being a list because the query was already executed
138-
if not isinstance(findings, QuerySet):
139-
logger.debug("unable to prefetch because query was already executed")
140-
return findings
141-
142-
prefetched_findings = findings.prefetch_related(
143-
"reviewers",
144-
"reporter",
145-
"jira_issue__jira_project__jira_instance",
146-
"test__test_type",
147-
"test__engagement__jira_project__jira_instance",
148-
"test__engagement__product__jira_project_set__jira_instance",
149-
"found_by",
150-
)
151-
152-
# for open/active findings, the following 4 prefetches are not needed
153-
if prefetch_type != "open":
154-
prefetched_findings = prefetched_findings.prefetch_related(
155-
"risk_acceptance_set",
156-
"risk_acceptance_set__accepted_findings",
157-
"original_finding",
158-
"duplicate_finding",
159-
)
160-
161-
if exclude_untouched:
162-
# filter out noop reimport actions from finding status history
163-
prefetched_findings = prefetched_findings.prefetch_related(
164-
Prefetch(
165-
"test_import_finding_action_set",
166-
queryset=Test_Import_Finding_Action.objects.exclude(action=IMPORT_UNTOUCHED_FINDING),
167-
),
168-
)
169-
else:
170-
prefetched_findings = prefetched_findings.prefetch_related("test_import_finding_action_set")
171-
172-
prefetched_findings = prefetched_findings.prefetch_related(
173-
"notes",
174-
"tags",
175-
"endpoints",
176-
"status_finding",
177-
"finding_group_set",
178-
"test__engagement__product__members",
179-
"test__engagement__product__prod_type__members",
180-
"vulnerability_id_set",
181-
)
182-
183-
base_status = Endpoint_Status.objects.filter(finding_id=OuterRef("pk"))
184-
count_subquery = partial(build_count_subquery, group_field="finding_id")
185-
return prefetched_findings.annotate(
186-
active_endpoint_count=Coalesce(count_subquery(base_status.filter(mitigated=False)), Value(0)),
187-
mitigated_endpoint_count=Coalesce(count_subquery(base_status.filter(mitigated=True)), Value(0)),
188-
)
189-
190-
191134
def prefetch_for_similar_findings(findings):
192135
prefetched_findings = findings
193136
if isinstance(

dojo/finding_group/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from dojo.authorization.authorization_decorators import user_is_authorized
1414
from dojo.authorization.roles_permissions import Permissions
1515
from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups
16-
from dojo.finding.views import prefetch_for_findings
16+
from dojo.finding.queries import prefetch_for_findings
1717
from dojo.forms import DeleteFindingGroupForm, EditFindingGroupForm, FindingBulkUpdateForm
1818
from dojo.models import Engagement, Finding, Finding_Group, GITHUB_PKey, Product
1919
from dojo.utils import Product_Tab, add_breadcrumb, get_page_items, get_setting, get_system_setting, get_words_for_field

dojo/product/views.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
get_authorized_members_for_product_type,
108108
get_authorized_product_types,
109109
)
110+
from dojo.query_utils import build_count_subquery
110111
from dojo.templatetags.display_tags import asvs_calc_level
111112
from dojo.tool_config.factory import create_API
112113
from dojo.tools.factory import get_api_scan_configuration_hints
@@ -117,7 +118,6 @@
117118
add_external_issue,
118119
add_field_errors_to_response,
119120
async_delete,
120-
build_count_subquery,
121121
calculate_finding_age,
122122
get_enabled_notifications_list,
123123
get_open_findings_burndown,
@@ -701,7 +701,11 @@ def view_product_metrics(request, pid):
701701
accepted_objs_by_severity[finding.get("severity")] += 1
702702

703703
tests = Test.objects.filter(engagement__product=prod).prefetch_related("finding_set", "test_type")
704-
tests = tests.annotate(verified_finding_count=Count("finding__id", filter=Q(finding__verified=True)))
704+
verified_finding_subquery = build_count_subquery(
705+
Finding.objects.filter(test=OuterRef("pk"), verified=True),
706+
group_field="test_id",
707+
)
708+
tests = tests.annotate(verified_finding_count=Coalesce(verified_finding_subquery, Value(0)))
705709

706710
test_data = {}
707711
for t in tests:
@@ -828,9 +832,7 @@ def view_engagements(request, pid):
828832

829833

830834
def prefetch_for_view_engagements(engagements, recent_test_day_count):
831-
engagements = engagements.select_related(
832-
"lead",
833-
).prefetch_related(
835+
engagements = engagements.prefetch_related(
834836
Prefetch("test_set", queryset=Test.objects.filter(
835837
id__in=Subquery(
836838
Test.objects.filter(
@@ -840,15 +842,41 @@ def prefetch_for_view_engagements(engagements, recent_test_day_count):
840842
)),
841843
),
842844
"test_set__test_type",
843-
).annotate(
844-
count_tests=Count("test", distinct=True),
845-
count_findings_all=Count("test__finding__id"),
846-
count_findings_open=Count("test__finding__id", filter=Q(test__finding__active=True)),
847-
count_findings_open_verified=Count("test__finding__id",
848-
filter=Q(test__finding__active=True) & Q(test__finding__verified=True)),
849-
count_findings_close=Count("test__finding__id", filter=Q(test__finding__is_mitigated=True)),
850-
count_findings_duplicate=Count("test__finding__id", filter=Q(test__finding__duplicate=True)),
851-
count_findings_accepted=Count("test__finding__id", filter=Q(test__finding__risk_accepted=True)),
845+
).select_related(
846+
"lead",
847+
)
848+
849+
# Use subqueries to avoid GROUP BY issues
850+
test_subquery = build_count_subquery(
851+
Test.objects.filter(engagement=OuterRef("pk")), group_field="engagement_id",
852+
)
853+
finding_subquery = build_count_subquery(
854+
Finding.objects.filter(test__engagement=OuterRef("pk")), group_field="test__engagement_id",
855+
)
856+
finding_open_subquery = build_count_subquery(
857+
Finding.objects.filter(test__engagement=OuterRef("pk"), active=True), group_field="test__engagement_id",
858+
)
859+
finding_open_verified_subquery = build_count_subquery(
860+
Finding.objects.filter(test__engagement=OuterRef("pk"), active=True, verified=True), group_field="test__engagement_id",
861+
)
862+
finding_close_subquery = build_count_subquery(
863+
Finding.objects.filter(test__engagement=OuterRef("pk"), is_mitigated=True), group_field="test__engagement_id",
864+
)
865+
finding_duplicate_subquery = build_count_subquery(
866+
Finding.objects.filter(test__engagement=OuterRef("pk"), duplicate=True), group_field="test__engagement_id",
867+
)
868+
finding_accepted_subquery = build_count_subquery(
869+
Finding.objects.filter(test__engagement=OuterRef("pk"), risk_accepted=True), group_field="test__engagement_id",
870+
)
871+
872+
engagements = engagements.annotate(
873+
count_tests=Coalesce(test_subquery, Value(0)),
874+
count_findings_all=Coalesce(finding_subquery, Value(0)),
875+
count_findings_open=Coalesce(finding_open_subquery, Value(0)),
876+
count_findings_open_verified=Coalesce(finding_open_verified_subquery, Value(0)),
877+
count_findings_close=Coalesce(finding_close_subquery, Value(0)),
878+
count_findings_duplicate=Coalesce(finding_duplicate_subquery, Value(0)),
879+
count_findings_accepted=Coalesce(finding_accepted_subquery, Value(0)),
852880
)
853881

854882
if System_Settings.objects.get().enable_jira:

dojo/product_type/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@
3535
get_authorized_members_for_product_type,
3636
get_authorized_product_types,
3737
)
38+
from dojo.query_utils import build_count_subquery
3839
from dojo.utils import (
3940
add_breadcrumb,
4041
async_delete,
41-
build_count_subquery,
4242
get_page_items,
4343
get_setting,
4444
get_system_setting,

0 commit comments

Comments
 (0)