Skip to content

Commit 5c842d5

Browse files
authored
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
1 parent 0589ac5 commit 5c842d5

File tree

3 files changed

+159
-107
lines changed

3 files changed

+159
-107
lines changed

apps/api/plane/utils/filters/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
# Import all utilities from base modules
44
from .filter_backend import ComplexFilterBackend
55
from .converters import LegacyToRichFiltersConverter
6-
from .filterset import IssueFilterSet
6+
from .filterset import BaseFilterSet, IssueFilterSet
77

88

99
# Public API exports
10-
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "IssueFilterSet"]
10+
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "BaseFilterSet", "IssueFilterSet"]

apps/api/plane/utils/filters/filter_backend.py

Lines changed: 61 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import json
22

33
from django.core.exceptions import ValidationError
4+
from django.db.models import Q
45
from django.http import QueryDict
5-
from django_filters.rest_framework import DjangoFilterBackend
6+
from django_filters.utils import translate_validation
67
from rest_framework import filters
78

89

@@ -38,7 +39,6 @@ def filter_queryset(self, request, queryset, view, filter_data=None):
3839
# Propagate validation errors unchanged
3940
raise
4041
except Exception as e:
41-
raise
4242
# Convert unexpected errors to ValidationError to keep response consistent
4343
raise ValidationError(f"Filter error: {str(e)}")
4444

@@ -58,7 +58,7 @@ def _normalize_filter_data(self, raw_filter, source_label):
5858
raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.")
5959

6060
def _apply_json_filter(self, queryset, filter_data, view):
61-
"""Process a JSON filter structure using OR/AND/NOT set operations."""
61+
"""Process a JSON filter structure using Q object composition."""
6262
if not filter_data:
6363
return queryset
6464

@@ -69,11 +69,13 @@ def _apply_json_filter(self, queryset, filter_data, view):
6969
# Validate against the view's FilterSet (only declared filters are allowed)
7070
self._validate_fields(filter_data, view)
7171

72-
# Build combined queryset using FilterSet-driven leaf evaluation
73-
combined_qs = self._evaluate_node(filter_data, queryset, view)
74-
if combined_qs is None:
72+
# Build combined Q object from the filter tree
73+
combined_q = self._evaluate_node(filter_data, view, queryset)
74+
if combined_q is None:
7575
return queryset
76-
return combined_qs
76+
77+
# Apply the combined Q object to the queryset once
78+
return queryset.filter(combined_q)
7779

7880
def _validate_fields(self, filter_data, view):
7981
"""Validate that filtered fields are defined in the view's FilterSet."""
@@ -115,108 +117,76 @@ def _extract_field_names(self, filter_data):
115117
return fields
116118
return []
117119

118-
def _evaluate_node(self, node, base_queryset, view):
120+
def _evaluate_node(self, node, view, queryset):
119121
"""
120-
Recursively evaluate a JSON node into a combined queryset using branch-based filtering.
122+
Recursively evaluate a JSON node into a combined Q object.
121123
122124
Rules:
123-
- leaf dict → evaluated through DjangoFilterBackend as a mini-querystring
124-
- {"or": [...]} → union (|) of children
125-
- {"and": [...]} → collect field conditions per branch and apply together
126-
- {"not": {...}} → exclude child's rows from the base queryset
127-
(complement within base scope)
125+
- leaf dict → evaluated through FilterSet to produce a Q object
126+
- {"or": [...]} → Q() | Q() | ... (OR of children)
127+
- {"and": [...]} → Q() & Q() & ... (AND of children)
128+
- {"not": {...}} → ~Q() (negation of child)
129+
130+
Returns a Q object that can be applied to a queryset.
128131
"""
129132
if not isinstance(node, dict):
130133
return None
131134

132-
# 'or' combination - requires set operations between children
135+
# 'or' combination - OR of child Q objects
133136
if "or" in node:
134137
children = node["or"]
135138
if not isinstance(children, list) or not children:
136139
return None
137-
combined = None
140+
combined_q = Q()
138141
for child in children:
139-
child_qs = self._evaluate_node(child, base_queryset, view)
140-
if child_qs is None:
142+
child_q = self._evaluate_node(child, view, queryset)
143+
if child_q is None:
141144
continue
142-
combined = child_qs if combined is None else (combined | child_qs)
143-
return combined
145+
combined_q |= child_q
146+
return combined_q
144147

145-
# 'and' combination - collect field conditions per branch
148+
# 'and' combination - AND of child Q objects
146149
if "and" in node:
147150
children = node["and"]
148151
if not isinstance(children, list) or not children:
149152
return None
150-
return self._evaluate_and_branch(children, base_queryset, view)
153+
combined_q = Q()
154+
for child in children:
155+
child_q = self._evaluate_node(child, view, queryset)
156+
if child_q is None:
157+
continue
158+
combined_q &= child_q
159+
return combined_q
151160

152-
# 'not' negation
161+
# 'not' negation - negate the child Q object
153162
if "not" in node:
154163
child = node["not"]
155164
if not isinstance(child, dict):
156165
return None
157-
child_qs = self._evaluate_node(child, base_queryset, view)
158-
if child_qs is None:
159-
return None
160-
# Use subquery instead of pk__in for better performance
161-
# This avoids evaluating child_qs and creating large IN clauses
162-
return base_queryset.exclude(pk__in=child_qs.values("pk"))
163-
164-
# Leaf dict: evaluate via DjangoFilterBackend using FilterSet
165-
return self._filter_leaf_via_backend(node, base_queryset, view)
166-
167-
def _evaluate_and_branch(self, children, base_queryset, view):
168-
"""
169-
Evaluate an AND branch by collecting field conditions and applying them together.
170-
171-
This approach is more efficient than individual leaf evaluation because:
172-
- Field conditions within the same AND branch are collected and applied together
173-
- Only logical operation children require separate evaluation and set intersection
174-
- Reduces the number of intermediate querysets and database queries
175-
"""
176-
collected_conditions = {}
177-
logical_querysets = []
178-
179-
# Separate field conditions from logical operations
180-
for child in children:
181-
if not isinstance(child, dict):
182-
continue
183-
184-
# Check if this child contains logical operators
185-
has_logical = any(k.lower() in ("or", "and", "not") for k in child.keys() if isinstance(k, str))
186-
187-
if has_logical:
188-
# This child has logical operators, evaluate separately
189-
child_qs = self._evaluate_node(child, base_queryset, view)
190-
if child_qs is not None:
191-
logical_querysets.append(child_qs)
192-
else:
193-
# This is a leaf with field conditions, collect them
194-
collected_conditions.update(child)
195-
196-
# Start with base queryset
197-
result_qs = base_queryset
198-
199-
# Apply collected field conditions together if any exist
200-
if collected_conditions:
201-
result_qs = self._filter_leaf_via_backend(collected_conditions, result_qs, view)
202-
if result_qs is None:
166+
child_q = self._evaluate_node(child, view, queryset)
167+
if child_q is None:
203168
return None
169+
return ~child_q
204170

205-
# Intersect with any logical operation results
206-
for logical_qs in logical_querysets:
207-
result_qs = result_qs & logical_qs
171+
# Leaf dict: evaluate via FilterSet to get a Q object
172+
return self._build_leaf_q(node, view, queryset)
208173

209-
return result_qs
174+
def _build_leaf_q(self, leaf_conditions, view, queryset):
175+
"""Build a Q object from leaf filter conditions using the view's FilterSet.
210176
211-
def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
212-
"""Evaluate a leaf dict by delegating to DjangoFilterBackend once.
177+
We serialize the leaf dict into a QueryDict and let the view's
178+
filterset_class perform validation and build a combined Q object
179+
from all the field filters.
213180
214-
We serialize the leaf dict into a mini querystring and let the view's
215-
filterset_class perform validation, conversion, and filtering. This returns
216-
a lazy queryset suitable for set-operations with siblings.
181+
Returns a Q object representing all the field conditions in the leaf.
217182
"""
218183
if not leaf_conditions:
219-
return None
184+
return Q()
185+
186+
# Get the filterset class from the view
187+
filterset_class = getattr(view, "filterset_class", None)
188+
if not filterset_class:
189+
raise ValidationError("Filtering requires a filterset_class to be defined on the view")
220190

221191
# Build a QueryDict from the leaf conditions
222192
qd = QueryDict(mutable=True)
@@ -231,17 +201,18 @@ def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
231201
qd = qd.copy()
232202
qd._mutable = False
233203

234-
# Temporarily patch request.GET and delegate to DjangoFilterBackend
235-
backend = DjangoFilterBackend()
236-
request = view.request
237-
original_get = request._request.GET if hasattr(request, "_request") else None
238-
try:
239-
if hasattr(request, "_request"):
240-
request._request.GET = qd
241-
return backend.filter_queryset(request, base_queryset, view)
242-
finally:
243-
if hasattr(request, "_request") and original_get is not None:
244-
request._request.GET = original_get
204+
# Instantiate the filterset with the actual queryset
205+
# Custom filter methods may need access to the queryset for filtering
206+
fs = filterset_class(data=qd, queryset=queryset)
207+
208+
if not fs.is_valid():
209+
raise translate_validation(fs.errors)
210+
211+
# Build and return the combined Q object
212+
if not hasattr(fs, "build_combined_q"):
213+
raise ValidationError("FilterSet must have build_combined_q method for complex filtering")
214+
215+
return fs.build_combined_q()
245216

246217
def _get_max_depth(self, view):
247218
"""Return the maximum allowed nesting depth for complex filters.

0 commit comments

Comments
 (0)