1010from django .db import models
1111from django .forms .utils import ErrorList
1212from django .urls import Resolver404 , resolve
13- from django_filters .constants import EMPTY_VALUES
13+ from django_filters .constants import EMPTY_VALUES as STANDARD_EMPTY_VALUES
1414from django_filters .rest_framework import BaseInFilter , DjangoFilterBackend , filters , filterset
1515from drf_spectacular .contrib .django_filters import DjangoFilterExtension
1616from drf_spectacular .plumbing import build_basic_type
2020
2121from pulpcore .app .util import extract_pk , get_domain_pk , resolve_prn
2222
23- EMPTY_VALUES = (* EMPTY_VALUES , "null" )
23+ EMPTY_VALUES = (* STANDARD_EMPTY_VALUES , "null" )
2424UPMatch = namedtuple ("UPMatch" , ("model" , "pk" ))
2525
2626
@@ -185,6 +185,23 @@ class PulpTypeInFilter(BaseInFilter, PulpTypeFilter):
185185
186186
187187class ExpressionFilterField (forms .CharField ):
188+ """Parse and compile a ``q=`` complex-filter expression into a queryset transform.
189+
190+ Each parsed node (leaf ``_FilterAction`` and the ``NOT``/``AND``/``OR`` combinators) implements
191+ a two-method protocol:
192+
193+ * ``as_flat_q()`` -> ``Q | None``: a pure, side-effect-free attempt to represent the *whole*
194+ subtree as a single flat ``Q`` object. It returns ``None`` if any operand is "opaque" (a
195+ filter with a custom ``.filter()`` override, a ``method``, or ``.distinct()``) and therefore
196+ cannot be expressed as a plain ``WHERE`` condition.
197+ * ``evaluate(qs)`` -> ``QuerySet``: applies the subtree to ``qs``. It uses ``as_flat_q()`` when
198+ the subtree is fully reducible (one flat ``WHERE``) and otherwise falls back to composing the
199+ opaque operands as ``pk__in`` semi-/anti-join subqueries.
200+
201+ ``evaluate()`` is the sole external entry point: :meth:`ExpressionFilter.filter` calls
202+ ``value.evaluate(qs)`` on the outermost node.
203+ """
204+
188205 class _FilterAction :
189206 def __init__ (self , filterset , tokens ):
190207 key = tokens [0 ].key
@@ -206,25 +223,33 @@ def __init__(self, filterset, tokens):
206223 self .value = form .cleaned_data [key ]
207224 self .complexity = 1
208225
209- def as_q (self ):
210- # A leaf reduces to a plain WHERE condition (Q) only when it is a standard
211- # django-filter Filter: no custom .filter() override, no method, no .distinct().
212- # Anything else (href/prn resolvers, repository_version, method filters, ...) is
213- # opaque and must be applied via its own queryset transform in evaluate().
226+ def as_flat_q (self ):
227+ """Return this leaf as a flat ``Q``, or ``None`` if the filter is opaque.
228+
229+ A leaf reduces to a plain ``WHERE`` condition only when it is a standard django-filter
230+ ``Filter``: no custom ``.filter()`` override, no ``method``, and no ``.distinct()``.
231+ Anything else (href/prn resolvers, ``repository_version``, method filters, ...) is
232+ opaque and must be applied via its own queryset transform in :meth:`evaluate`.
233+ """
214234 f = self .filter
215235 if (
216236 type (f ).filter is not filters .Filter .filter
217237 or getattr (f , "method" , None ) is not None
218238 or getattr (f , "distinct" , False )
219239 ):
220240 return None
221- if self .value in EMPTY_VALUES :
241+ # Mirror filters.Filter.filter exactly: an empty/unspecified value means the filter is
242+ # not applied (identity Q), not "IS NULL". Use django-filter's own EMPTY_VALUES rather
243+ # than the module-augmented set so the sentinel "null" is not swallowed here; null-aware
244+ # filters override .filter() and are handled on the opaque evaluate() path above.
245+ if self .value in STANDARD_EMPTY_VALUES :
222246 return models .Q ()
223247 q = models .Q (** {f"{ f .field_name } __{ f .lookup_expr } " : self .value })
224248 return ~ q if f .exclude else q
225249
226250 def evaluate (self , qs ):
227- q = self .as_q ()
251+ """Apply the leaf: a flat ``Q`` when reducible, else the filter's own transform."""
252+ q = self .as_flat_q ()
228253 if q is not None :
229254 return qs .filter (q )
230255 return self .filter .filter (qs , self .value )
@@ -234,12 +259,14 @@ def __init__(self, tokens):
234259 self .expr = tokens [0 ][0 ]
235260 self .complexity = self .expr .complexity + 1
236261
237- def as_q (self ):
238- child = self .expr .as_q ()
262+ def as_flat_q (self ):
263+ """Negate the child's flat ``Q``; ``None`` if the child is opaque."""
264+ child = self .expr .as_flat_q ()
239265 return None if child is None else ~ child
240266
241267 def evaluate (self , qs ):
242- q = self .as_q ()
268+ """Apply ``NOT``: negate a flat ``Q`` when reducible, else anti-join the operand."""
269+ q = self .as_flat_q ()
243270 if q is not None :
244271 return qs .filter (q )
245272 # Opaque operand: anti-join via a NOT IN subquery.
@@ -250,8 +277,9 @@ def __init__(self, tokens):
250277 self .exprs = tokens [0 ]
251278 self .complexity = sum ((expr .complexity for expr in self .exprs )) + 1
252279
253- def as_q (self ):
254- children = [expr .as_q () for expr in self .exprs ]
280+ def as_flat_q (self ):
281+ """AND every child's flat ``Q`` into one; ``None`` if any child is opaque."""
282+ children = [expr .as_flat_q () for expr in self .exprs ]
255283 if any (child is None for child in children ):
256284 return None
257285 query = models .Q ()
@@ -260,13 +288,14 @@ def as_q(self):
260288 return query
261289
262290 def evaluate (self , qs ):
291+ """Apply ``AND``: fold reducible operands into one flat ``WHERE``, semi-join the rest."""
263292 # Fold every Q-reducible operand into a single flat WHERE, and apply only the
264293 # genuinely opaque operands as pk__in semi-join subqueries.
265294 result = qs
266295 combined = models .Q ()
267296 has_q = False
268297 for expr in self .exprs :
269- child = expr .as_q ()
298+ child = expr .as_flat_q ()
270299 if child is not None :
271300 combined &= child
272301 has_q = True
@@ -281,8 +310,9 @@ def __init__(self, tokens):
281310 self .exprs = tokens [0 ]
282311 self .complexity = sum ((expr .complexity for expr in self .exprs )) + 1
283312
284- def as_q (self ):
285- children = [expr .as_q () for expr in self .exprs ]
313+ def as_flat_q (self ):
314+ """OR every child's flat ``Q`` into one; ``None`` if any child is opaque."""
315+ children = [expr .as_flat_q () for expr in self .exprs ]
286316 if any (child is None for child in children ):
287317 return None
288318 query = children [0 ]
@@ -291,11 +321,12 @@ def as_q(self):
291321 return query
292322
293323 def evaluate (self , qs ):
324+ """Apply ``OR``: reducible operands stay flat, opaque ones contribute ``pk__in``."""
294325 # Reducible operands stay as flat OR'd conditions; opaque operands contribute a
295326 # pk__in subquery. Both are combined in a single WHERE clause.
296327 query = models .Q ()
297328 for expr in self .exprs :
298- child = expr .as_q ()
329+ child = expr .as_flat_q ()
299330 if child is not None :
300331 query |= child
301332 else :
0 commit comments