Coverage for extras/filters.py: 42%

45 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from functools import cache 

2 

3import django_filters 

4from django.db.models import Q 

5 

6from .models import Tag 

7 

8__all__ = ( 

9 'MissingKeyAwareFilterMixin', 

10 'TagFilter', 

11 'TagIDFilter', 

12 'missing_key_aware_filter_factory', 

13) 

14 

15 

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. 

20 

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: 

25 

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. 

35 

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. 

38 

39 Two constraints on where this may be mixed in, both satisfied by every filter class 

40 CustomField.to_filter() can select: 

41 

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) 

55 

56 def filter(self, qs, value): 

57 if not value: 

58 return super().filter(qs, value) 

59 

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}) 

64 

65 values = set(value) 

66 match_unset = self.null_value in values 

67 values.discard(self.null_value) 

68 

69 q = Q() 

70 for v in values: 

71 q |= Q(**self.get_filter_predicate(v)) 

72 if match_unset: 

73 q |= unset 

74 

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 

80 

81 qs = qs.filter(q) 

82 

83 return qs.distinct() if self.distinct else qs 

84 

85 

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. 

91 

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 ) 

101 

102 return type( 

103 f'MissingKeyAware{filter_class.__name__}', 

104 (MissingKeyAwareFilterMixin, filter_class), 

105 {} 

106 ) 

107 

108 

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): 

115 

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()) 

120 

121 super().__init__(*args, **kwargs) 

122 

123 

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): 

130 

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()) 

135 

136 super().__init__(*args, **kwargs)