Chore: Filter backend optimizations (#7900)

* refactor: enhance ComplexFilterBackend and BaseFilterSet for Q object filtering

- Introduced BaseFilterSet to support Q object construction for complex filtering.
- Updated ComplexFilterBackend to utilize Q objects for building querysets.
- Improved error handling and validation in filter methods.
- Refactored filter evaluation logic to streamline query construction.

* fix: improve filter processing in BaseFilterSet to handle empty cleaned_data and optimize filter evaluation

- Added handling for cases where cleaned_data is None or empty, returning an empty Q object.
- Optimized filter evaluation by only processing filters that are provided in the request data.

* update ComplexFilterBackend to pass queryset in filter evaluation
This commit is contained in:
Dheeraj Kumar Ketireddy
2025-10-06 23:36:16 +05:30
committed by GitHub
parent 0589ac56d5
commit 5c842d592e
3 changed files with 159 additions and 107 deletions

View File

@@ -3,8 +3,8 @@
# Import all utilities from base modules
from .filter_backend import ComplexFilterBackend
from .converters import LegacyToRichFiltersConverter
from .filterset import IssueFilterSet
from .filterset import BaseFilterSet, IssueFilterSet
# Public API exports
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "IssueFilterSet"]
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "BaseFilterSet", "IssueFilterSet"]

View File

@@ -1,8 +1,9 @@
import json
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import QueryDict
from django_filters.rest_framework import DjangoFilterBackend
from django_filters.utils import translate_validation
from rest_framework import filters
@@ -38,7 +39,6 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Propagate validation errors unchanged
raise
except Exception as e:
raise
# Convert unexpected errors to ValidationError to keep response consistent
raise ValidationError(f"Filter error: {str(e)}")
@@ -58,7 +58,7 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.")
def _apply_json_filter(self, queryset, filter_data, view):
"""Process a JSON filter structure using OR/AND/NOT set operations."""
"""Process a JSON filter structure using Q object composition."""
if not filter_data:
return queryset
@@ -69,11 +69,13 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
# Validate against the view's FilterSet (only declared filters are allowed)
self._validate_fields(filter_data, view)
# Build combined queryset using FilterSet-driven leaf evaluation
combined_qs = self._evaluate_node(filter_data, queryset, view)
if combined_qs is None:
# Build combined Q object from the filter tree
combined_q = self._evaluate_node(filter_data, view, queryset)
if combined_q is None:
return queryset
return combined_qs
# Apply the combined Q object to the queryset once
return queryset.filter(combined_q)
def _validate_fields(self, filter_data, view):
"""Validate that filtered fields are defined in the view's FilterSet."""
@@ -115,108 +117,76 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
return fields
return []
def _evaluate_node(self, node, base_queryset, view):
def _evaluate_node(self, node, view, queryset):
"""
Recursively evaluate a JSON node into a combined queryset using branch-based filtering.
Recursively evaluate a JSON node into a combined Q object.
Rules:
- leaf dict → evaluated through DjangoFilterBackend as a mini-querystring
- {"or": [...]} → union (|) of children
- {"and": [...]} → collect field conditions per branch and apply together
- {"not": {...}} → exclude child's rows from the base queryset
(complement within base scope)
- leaf dict → evaluated through FilterSet to produce a Q object
- {"or": [...]} → Q() | Q() | ... (OR of children)
- {"and": [...]} → Q() & Q() & ... (AND of children)
- {"not": {...}} → ~Q() (negation of child)
Returns a Q object that can be applied to a queryset.
"""
if not isinstance(node, dict):
return None
# 'or' combination - requires set operations between children
# 'or' combination - OR of child Q objects
if "or" in node:
children = node["or"]
if not isinstance(children, list) or not children:
return None
combined = None
combined_q = Q()
for child in children:
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is None:
child_q = self._evaluate_node(child, view, queryset)
if child_q is None:
continue
combined = child_qs if combined is None else (combined | child_qs)
return combined
combined_q |= child_q
return combined_q
# 'and' combination - collect field conditions per branch
# 'and' combination - AND of child Q objects
if "and" in node:
children = node["and"]
if not isinstance(children, list) or not children:
return None
return self._evaluate_and_branch(children, base_queryset, view)
combined_q = Q()
for child in children:
child_q = self._evaluate_node(child, view, queryset)
if child_q is None:
continue
combined_q &= child_q
return combined_q
# 'not' negation
# 'not' negation - negate the child Q object
if "not" in node:
child = node["not"]
if not isinstance(child, dict):
return None
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is None:
child_q = self._evaluate_node(child, view, queryset)
if child_q is None:
return None
# Use subquery instead of pk__in for better performance
# This avoids evaluating child_qs and creating large IN clauses
return base_queryset.exclude(pk__in=child_qs.values("pk"))
return ~child_q
# Leaf dict: evaluate via DjangoFilterBackend using FilterSet
return self._filter_leaf_via_backend(node, base_queryset, view)
# Leaf dict: evaluate via FilterSet to get a Q object
return self._build_leaf_q(node, view, queryset)
def _evaluate_and_branch(self, children, base_queryset, view):
"""
Evaluate an AND branch by collecting field conditions and applying them together.
def _build_leaf_q(self, leaf_conditions, view, queryset):
"""Build a Q object from leaf filter conditions using the view's FilterSet.
This approach is more efficient than individual leaf evaluation because:
- Field conditions within the same AND branch are collected and applied together
- Only logical operation children require separate evaluation and set intersection
- Reduces the number of intermediate querysets and database queries
"""
collected_conditions = {}
logical_querysets = []
We serialize the leaf dict into a QueryDict and let the view's
filterset_class perform validation and build a combined Q object
from all the field filters.
# Separate field conditions from logical operations
for child in children:
if not isinstance(child, dict):
continue
# Check if this child contains logical operators
has_logical = any(k.lower() in ("or", "and", "not") for k in child.keys() if isinstance(k, str))
if has_logical:
# This child has logical operators, evaluate separately
child_qs = self._evaluate_node(child, base_queryset, view)
if child_qs is not None:
logical_querysets.append(child_qs)
else:
# This is a leaf with field conditions, collect them
collected_conditions.update(child)
# Start with base queryset
result_qs = base_queryset
# Apply collected field conditions together if any exist
if collected_conditions:
result_qs = self._filter_leaf_via_backend(collected_conditions, result_qs, view)
if result_qs is None:
return None
# Intersect with any logical operation results
for logical_qs in logical_querysets:
result_qs = result_qs & logical_qs
return result_qs
def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
"""Evaluate a leaf dict by delegating to DjangoFilterBackend once.
We serialize the leaf dict into a mini querystring and let the view's
filterset_class perform validation, conversion, and filtering. This returns
a lazy queryset suitable for set-operations with siblings.
Returns a Q object representing all the field conditions in the leaf.
"""
if not leaf_conditions:
return None
return Q()
# Get the filterset class from the view
filterset_class = getattr(view, "filterset_class", None)
if not filterset_class:
raise ValidationError("Filtering requires a filterset_class to be defined on the view")
# Build a QueryDict from the leaf conditions
qd = QueryDict(mutable=True)
@@ -231,17 +201,18 @@ class ComplexFilterBackend(filters.BaseFilterBackend):
qd = qd.copy()
qd._mutable = False
# Temporarily patch request.GET and delegate to DjangoFilterBackend
backend = DjangoFilterBackend()
request = view.request
original_get = request._request.GET if hasattr(request, "_request") else None
try:
if hasattr(request, "_request"):
request._request.GET = qd
return backend.filter_queryset(request, base_queryset, view)
finally:
if hasattr(request, "_request") and original_get is not None:
request._request.GET = original_get
# Instantiate the filterset with the actual queryset
# Custom filter methods may need access to the queryset for filtering
fs = filterset_class(data=qd, queryset=queryset)
if not fs.is_valid():
raise translate_validation(fs.errors)
# Build and return the combined Q object
if not hasattr(fs, "build_combined_q"):
raise ValidationError("FilterSet must have build_combined_q method for complex filtering")
return fs.build_combined_q()
def _get_max_depth(self, view):
"""Return the maximum allowed nesting depth for complex filters.

View File

@@ -1,5 +1,7 @@
import copy
from django.db import models
from django.db.models import Q
from django_filters import FilterSet, filters
from plane.db.models import Issue
@@ -35,6 +37,85 @@ class BaseFilterSet(FilterSet):
filters.update(exact_filters)
return filters
def build_combined_q(self):
"""
Build a combined Q object from all bound filters.
For filters with custom methods, we call them and expect Q objects (or wrap
QuerySets as subqueries for backward compatibility).
For standard field filters, we build Q objects directly from field lookups.
Returns:
Q object representing all filter conditions combined.
"""
# Ensure form validation has occurred
self.errors
combined_q = Q()
# Handle case where cleaned_data might be None or empty
if not self.form.cleaned_data:
return combined_q
# Only process filters that were actually provided in the request data
# This avoids processing all declared filters with None/empty default values
provided_filters = set(self.data.keys()) if self.data else set()
for name, value in self.form.cleaned_data.items():
# Skip filters that weren't provided in the request
if name not in provided_filters:
continue
f = self.filters[name]
# Build the Q object for this filter
if f.method is not None:
# Custom filter method - call it to get Q object
res = f.filter(self.queryset, value)
if isinstance(res, Q):
q_piece = res
elif isinstance(res, models.QuerySet):
# Backward compatibility: wrap QuerySet as subquery
q_piece = Q(pk__in=res.values("pk"))
else:
raise TypeError(
f"Filter method '{name}' must return Q object or QuerySet, got {type(res).__name__}"
)
else:
# Standard field filter - build Q object directly
lookup = f"{f.field_name}__{f.lookup_expr}"
q_piece = Q(**{lookup: value})
# Apply exclude/include logic
if getattr(f, "exclude", False):
combined_q &= ~q_piece
else:
combined_q &= q_piece
return combined_q
def filter_queryset(self, queryset):
"""
Override to use Q-based filtering for compatibility with DjangoFilterBackend.
This allows the same filterset to work with both ComplexFilterBackend
(which calls build_combined_q directly) and DjangoFilterBackend
(which calls this method).
"""
# Ensure form validation
self.errors
# Build combined Q and apply to queryset
combined_q = self.build_combined_q()
qs = queryset.filter(combined_q)
# Apply distinct if any filter requires it (typically for many-to-many relations)
for f in self.filters.values():
if getattr(f, "distinct", False):
return qs.distinct()
return qs
class IssueFilterSet(BaseFilterSet):
# Custom filter methods to handle soft delete exclusion for relations
@@ -88,93 +169,93 @@ class IssueFilterSet(BaseFilterSet):
archived=false -> archived_at is null
"""
if value in (True, "true", "True", 1, "1"):
return queryset.filter(archived_at__isnull=False)
return Q(archived_at__isnull=False)
if value in (False, "false", "False", 0, "0"):
return queryset.filter(archived_at__isnull=True)
return queryset
return Q(archived_at__isnull=True)
return Q() # No filter
# Filter methods with soft delete exclusion for relations
def filter_assignee_id(self, queryset, name, value):
"""Filter by assignee ID, excluding soft deleted users"""
return queryset.filter(
return Q(
issue_assignee__assignee_id=value,
issue_assignee__deleted_at__isnull=True,
)
def filter_assignee_id_in(self, queryset, name, value):
"""Filter by assignee IDs (in), excluding soft deleted users"""
return queryset.filter(
return Q(
issue_assignee__assignee_id__in=value,
issue_assignee__deleted_at__isnull=True,
)
def filter_cycle_id(self, queryset, name, value):
"""Filter by cycle ID, excluding soft deleted cycles"""
return queryset.filter(
return Q(
issue_cycle__cycle_id=value,
issue_cycle__deleted_at__isnull=True,
)
def filter_cycle_id_in(self, queryset, name, value):
"""Filter by cycle IDs (in), excluding soft deleted cycles"""
return queryset.filter(
return Q(
issue_cycle__cycle_id__in=value,
issue_cycle__deleted_at__isnull=True,
)
def filter_module_id(self, queryset, name, value):
"""Filter by module ID, excluding soft deleted modules"""
return queryset.filter(
return Q(
issue_module__module_id=value,
issue_module__deleted_at__isnull=True,
)
def filter_module_id_in(self, queryset, name, value):
"""Filter by module IDs (in), excluding soft deleted modules"""
return queryset.filter(
return Q(
issue_module__module_id__in=value,
issue_module__deleted_at__isnull=True,
)
def filter_mention_id(self, queryset, name, value):
"""Filter by mention ID, excluding soft deleted users"""
return queryset.filter(
return Q(
issue_mention__mention_id=value,
issue_mention__deleted_at__isnull=True,
)
def filter_mention_id_in(self, queryset, name, value):
"""Filter by mention IDs (in), excluding soft deleted users"""
return queryset.filter(
return Q(
issue_mention__mention_id__in=value,
issue_mention__deleted_at__isnull=True,
)
def filter_label_id(self, queryset, name, value):
"""Filter by label ID, excluding soft deleted labels"""
return queryset.filter(
return Q(
label_issue__label_id=value,
label_issue__deleted_at__isnull=True,
)
def filter_label_id_in(self, queryset, name, value):
"""Filter by label IDs (in), excluding soft deleted labels"""
return queryset.filter(
return Q(
label_issue__label_id__in=value,
label_issue__deleted_at__isnull=True,
)
def filter_subscriber_id(self, queryset, name, value):
"""Filter by subscriber ID, excluding soft deleted users"""
return queryset.filter(
return Q(
issue_subscribers__subscriber_id=value,
issue_subscribers__deleted_at__isnull=True,
)
def filter_subscriber_id_in(self, queryset, name, value):
"""Filter by subscriber IDs (in), excluding soft deleted users"""
return queryset.filter(
return Q(
issue_subscribers__subscriber_id__in=value,
issue_subscribers__deleted_at__isnull=True,
)