Coverage for extras/filters.py: 42%
45 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from functools import cache
3import django_filters
4from django.db.models import Q
6from .models import Tag
8__all__ = (
9 'MissingKeyAwareFilterMixin',
10 'TagFilter',
11 'TagIDFilter',
12 'missing_key_aware_filter_factory',
13)
16class MissingKeyAwareFilterMixin:
17 """
18 Treat a JSON key which is absent as equivalent to one holding a null value: an object storing
19 no value for a custom field must filter identically however that absence is represented.
21 Custom field data materializes a key only once a value is assigned to it (see
22 CustomField.populate_initial_data()), so an object predating a field carries no key for it at
23 all, whereas one whose value has been cleared holds a JSON null. Postgres treats the two
24 differently, in two places:
26 * Django compiles `exclude(custom_field_data__foo='x')` to a bare `NOT (data -> 'foo' = 'x')`.
27 A row which does not carry the key yields SQL NULL there, so the negation evaluates to NULL
28 and the row is discarded. A row holding a JSON null fares no better under any of the text
29 lookups (icontains, istartswith, etc.), which compare `data ->> 'foo'` and so are NULL for a
30 JSON null as well.
31 * The null sentinel (`?cf_foo=null`; see FILTERS_NULL_CHOICE_VALUE) asks for the objects holding
32 no value. MultipleChoiceFilter.filter() translates it to None and hands it to
33 get_filter_predicate(), which builds a lookup matching a JSON null only -- silently omitting
34 every object which predates the field.
36 Both directions are handled: the sentinel is mapped onto "holds no value" rather than onto a
37 predicate of its own, and a negation is built explicitly so that valueless rows are admitted.
39 Two constraints on where this may be mixed in, both satisfied by every filter class
40 CustomField.to_filter() can select:
42 * filter() is reimplemented rather than delegated to, so any custom filter() on the base class
43 is bypassed. Do not mix this into a class which overrides filter() (e.g.
44 MultiValueMACAddressFilter, MultiValueContentTypeFilter).
45 missing_key_aware_filter_factory() rejects such classes.
46 * `conjoined` is not honored: multiple values are always OR'ed. Passing it raises TypeError.
47 """
48 def __init__(self, *args, **kwargs):
49 if kwargs.get('conjoined'):
50 raise TypeError(
51 f"{type(self).__name__} does not support conjoined filtering: multiple values are "
52 f"always OR'ed."
53 )
54 super().__init__(*args, **kwargs)
56 def filter(self, qs, value):
57 if not value:
58 return super().filter(qs, value)
60 # `<key>__isnull` matches only a missing key and `<key>=None` only a JSON null, so together
61 # they select exactly the objects holding no value. Both are null-safe, which is what makes
62 # them usable inside the negation below.
63 unset = Q(**{f'{self.field_name}__isnull': True}) | Q(**{self.field_name: None})
65 values = set(value)
66 match_unset = self.null_value in values
67 values.discard(self.null_value)
69 q = Q()
70 for v in values:
71 q |= Q(**self.get_filter_predicate(v))
72 if match_unset:
73 q |= unset
75 if self.exclude:
76 # Negate explicitly rather than deferring to exclude(), whose bare NOT discards the
77 # rows carrying no key. Those rows are admitted, unless holding no value is itself one
78 # of the things being excluded.
79 q = ~q if match_unset else ~q | unset
81 qs = qs.filter(q)
83 return qs.distinct() if self.distinct else qs
86@cache
87def missing_key_aware_filter_factory(filter_class):
88 """
89 Return a subclass of the given filter class which treats an absent JSON key as equivalent to a
90 null one. Results are cached so that each filter class yields a single stable subclass.
92 The class must inherit MultipleChoiceFilter.filter() unmodified: the mixin reimplements it, so a
93 filter() of its own (and with it any custom predicate or short-circuit) would be silently
94 bypassed, yielding a wrong result set rather than an error.
95 """
96 if filter_class.filter is not django_filters.MultipleChoiceFilter.filter:
97 raise TypeError(
98 f"{filter_class.__name__} cannot be made missing-key aware: it defines its own "
99 f"filter(), which MissingKeyAwareFilterMixin would bypass."
100 )
102 return type(
103 f'MissingKeyAware{filter_class.__name__}',
104 (MissingKeyAwareFilterMixin, filter_class),
105 {}
106 )
109class TagFilter(django_filters.ModelMultipleChoiceFilter):
110 """
111 Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered
112 to objects matching all tags.
113 """
114 def __init__(self, *args, **kwargs):
116 kwargs.setdefault('field_name', 'tags__slug')
117 kwargs.setdefault('to_field_name', 'slug')
118 kwargs.setdefault('conjoined', True)
119 kwargs.setdefault('queryset', Tag.objects.all())
121 super().__init__(*args, **kwargs)
124class TagIDFilter(django_filters.ModelMultipleChoiceFilter):
125 """
126 Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=1&tag=2), the queryset is filtered
127 to objects matching all tags.
128 """
129 def __init__(self, *args, **kwargs):
131 kwargs.setdefault('field_name', 'tags__id')
132 kwargs.setdefault('to_field_name', 'id')
133 kwargs.setdefault('conjoined', True)
134 kwargs.setdefault('queryset', Tag.objects.all())
136 super().__init__(*args, **kwargs)