Skip to content

perf(api): eliminate N+1 queries in product and asset list endpoints - #16037

Open
Jaimin2687 wants to merge 3 commits into
DefectDojo:devfrom
Jaimin2687:feat/optimize-product-api-queryset
Open

Jaimin2687 wants to merge 3 commits into
DefectDojo:devfrom
Jaimin2687:feat/optimize-product-api-queryset

Conversation

@Jaimin2687

Copy link
Copy Markdown
Contributor

Description

The /api/v2/products/ and /api/v3/assets/ list endpoints return a bare queryset from get_queryset() with zero prefetching. Every serialized relation is lazy-loaded per product in the response: tags, product_meta, authorized_users, regulations, the active-finding count, and the three SlugRelatedField FKs (platform/lifecycle/origin). For a page with N products, this produces roughly 7N extra queries on top of the base query.

This PR applies the same optimization strategy that the Product UI views (dojo/product/ui/views.py:204-256) already use:

  • select_related for platform, lifecycle, origin — these are rendered via SlugRelatedField(slug_field="value"), which reads .value on the related object and triggers a lazy FK load without select_related.
  • prefetch_related for tags, product_meta, authorized_users, regulations — each fires a single bulk IN query regardless of product count.
  • annotate(active_finding_count=...) using the project's own build_count_subquery utility (correlated subquery, no multi-table JOIN row-multiplication). The Product.findings_count @cached_property already checks for this attribute before falling back to a per-product count(), so the annotation satisfies it in bulk.

The open_findings_list() method still fires one query per product. It returns a variable-length list of finding IDs that cannot be collapsed into a scalar annotation without either a PostgreSQL-specific ArrayAgg (risky 4-table JOIN) or deprecating the findings_list response field. The model method already carries a TODO comment acknowledging this. All other per-product queries are eliminated.

Test results

Added unittests/test_api_product_prefetch.py — a regression test following the exact pattern from test_api_notes_nplusone.py (PR #15274):

  • Creates 1 product with a full relation graph (tags, DojoMeta, authorized_users, regulations, engagement/test/finding chain)
  • Warm-up request to fill ContentType and other one-time caches
  • Records query count with 1 product
  • Creates 4 more products with identical relation shapes
  • Records query count with 5 products
  • Asserts that query growth equals exactly 4 (one per additional product for open_findings_list) — proving all other N+1 sources are eliminated

Verified locally against PostgreSQL in Docker. Ruff clean.

Checklist

  • Rebased against dev
  • Ruff compliant
  • Python 3.13+ compliant
  • No model changes, no new migrations
  • Unit test added

ProductViewSet and AssetViewSet.get_queryset() returned a bare queryset
with zero prefetching, so every serialized relation was lazy-loaded per
product: tags, product_meta, authorized_users, regulations, and the
active-finding count — roughly 6 extra queries per product in the
response.

Apply the same optimization strategy the Product UI views already use:

- select_related for the platform/lifecycle/origin FKs (SlugRelatedField
  reads .value on the related object)
- prefetch_related for tags, product_meta, authorized_users, regulations
- annotate active_finding_count via a correlated subquery using the
  project's own build_count_subquery utility — the Product.findings_count
  cached_property already checks for this attribute before falling back
  to a per-product count()

The only remaining per-product query is open_findings_list(), which
returns a variable-length list of finding IDs and cannot be collapsed
into a scalar annotation.  This is a known limitation (the model method
carries a TODO comment) that requires either a PostgreSQL-specific
ArrayAgg or deprecating the findings_list field to resolve.

Add a regression test (test_api_product_prefetch) that creates 1 vs 5
products with full relation graphs and asserts the query-count growth
equals exactly the number of extra products — proving all N+1 sources
except the documented open_findings_list are eliminated.
@dryrunsecurity

dryrunsecurity Bot commented Sep 22, 2026

Copy link
Copy Markdown

DryRun Security

This pull request modifies a sensitive authorization file by an author who is not on the allowed list. The finding is classified as low severity and is not blocking.

Configured Sensitive Codepath Modified by Non-Allowed Author in dojo/authorization/query_registrations.py (drs_35675091)
Vulnerability Configured Sensitive Codepath Modified by Non-Allowed Author
Description File 'dojo/authorization/query_registrations.py' matches configured sensitive codepath pattern 'dojo/authorization/*.py' and was modified by 'Jaimin2687' (commit 0b3f210) who is not in the allowed authors list.

Comment to provide feedback on these findings.

Report false positive: @dryrunsecurity fp [FINDING ID] [FEEDBACK]
Report low-impact: @dryrunsecurity nit [FINDING ID] [FEEDBACK]

Example: @dryrunsecurity fp drs_90eda195 This code is not user-facing

All finding details can be found in the DryRun Security Dashboard.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant