fix(filters): Preserve contains lookup for negated multiselect filters (#23128)
Add FILTER_ARRAY_BASED_LOOKUP_MAP to maintain 'contains' lookup under negation for MultiValueArrayFilter, preventing fallback to exact match. Negation now correctly excludes objects whose array contains the value rather than matching it exactly. Fixes #23117
This commit is contained in:
parent
5685c5218e
commit
7ae8e4461f
|
|
@ -1091,6 +1091,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
|
|||
|
||||
# Multiselect
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
# Do not pin lookup_expr: FILTER_ARRAY_BASED_LOOKUP_MAP preserves the class default under negation
|
||||
filter_class = filters.MultiValueArrayFilter
|
||||
|
||||
# Object
|
||||
|
|
|
|||
|
|
@ -2752,6 +2752,7 @@ class CustomFieldModelFilterTestCase(TestCase):
|
|||
'cf4': None,
|
||||
'cf6': None,
|
||||
'cf7': None,
|
||||
'cf10': None,
|
||||
})
|
||||
|
||||
for filter_name, value in (
|
||||
|
|
@ -2766,6 +2767,7 @@ class CustomFieldModelFilterTestCase(TestCase):
|
|||
('cf_cf7__nic', 'a'),
|
||||
('cf_cf7__nisw', 'http://'),
|
||||
('cf_cf7__niew', '.com'),
|
||||
('cf_cf10__n', 'A'),
|
||||
):
|
||||
with self.subTest(filter_name):
|
||||
pks = set(
|
||||
|
|
@ -2836,6 +2838,15 @@ class CustomFieldModelFilterTestCase(TestCase):
|
|||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
|
||||
# Negation excludes the objects whose array holds the value, not those whose array equals it
|
||||
self.assertEqual(
|
||||
set(self.filterset({'cf_cf10__n': ['A']}, self.queryset).qs.values_list('slug', flat=True)),
|
||||
{'site-2', 'site-3', 'site-4'}
|
||||
)
|
||||
self.assertEqual(
|
||||
set(self.filterset({'cf_cf10__n': ['A', 'C']}, self.queryset).qs.values_list('slug', flat=True)),
|
||||
{'site-3', 'site-4'}
|
||||
)
|
||||
# Matches both the object holding a literal null and the one carrying no key, as `empty` does
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf10__empty': True}, self.queryset).qs.count(), 2)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from extras.models import CustomField, SavedFilter
|
|||
from users.filterset_mixins import OwnerFilterMixin
|
||||
from utilities import filters
|
||||
from utilities.constants import (
|
||||
FILTER_ARRAY_BASED_LOOKUP_MAP,
|
||||
FILTER_CHAR_BASED_LOOKUP_MAP,
|
||||
FILTER_NEGATION_LOOKUP_MAP,
|
||||
FILTER_NUMERIC_BASED_LOOKUP_MAP,
|
||||
|
|
@ -170,6 +171,12 @@ class BaseFilterSet(django_filters.FilterSet):
|
|||
# These filter types support only negation
|
||||
return FILTER_NEGATION_LOOKUP_MAP
|
||||
|
||||
if isinstance(existing_filter, (
|
||||
filters.MultiValueArrayFilter,
|
||||
)):
|
||||
# Must precede the char-based branch below, which would otherwise shadow this subclass
|
||||
return FILTER_ARRAY_BASED_LOOKUP_MAP
|
||||
|
||||
if isinstance(existing_filter, (
|
||||
django_filters.filters.CharFilter,
|
||||
django_filters.ChoiceFilter,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
|
|||
iregex='iregex',
|
||||
)
|
||||
|
||||
# A member is a scalar inside a stored array, so negation cannot fall back to equality
|
||||
FILTER_ARRAY_BASED_LOOKUP_MAP = {
|
||||
**FILTER_CHAR_BASED_LOOKUP_MAP,
|
||||
'n': 'contains',
|
||||
}
|
||||
|
||||
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
|
||||
n='exact',
|
||||
lte='lte',
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from ipam.filtersets import ASNFilterSet
|
|||
from ipam.models import ASN, RIR
|
||||
from netbox.filtersets import BaseFilterSet
|
||||
from utilities.filters import (
|
||||
MultiValueArrayFilter,
|
||||
MultiValueCharFilter,
|
||||
MultiValueDateFilter,
|
||||
MultiValueDateTimeFilter,
|
||||
|
|
@ -209,6 +210,9 @@ class BaseFilterSetTestCase(TestCase):
|
|||
multiplechoicefield = django_filters.MultipleChoiceFilter(
|
||||
field_name='choicefield'
|
||||
)
|
||||
multivaluearrayfield = MultiValueArrayFilter(
|
||||
field_name='charfield' # We're pretending this is an array field
|
||||
)
|
||||
multivaluecharfield = MultiValueCharFilter(
|
||||
field_name='charfield'
|
||||
)
|
||||
|
|
@ -326,6 +330,13 @@ class BaseFilterSetTestCase(TestCase):
|
|||
self.assertEqual(self.filters['modelmultiplechoicefield__n'].lookup_expr, 'exact')
|
||||
self.assertEqual(self.filters['modelmultiplechoicefield__n'].exclude, True)
|
||||
|
||||
def test_multi_value_array_filter(self):
|
||||
self.assertIsInstance(self.filters['multivaluearrayfield'], MultiValueArrayFilter)
|
||||
self.assertEqual(self.filters['multivaluearrayfield'].lookup_expr, 'contains')
|
||||
self.assertEqual(self.filters['multivaluearrayfield'].exclude, False)
|
||||
self.assertEqual(self.filters['multivaluearrayfield__n'].lookup_expr, 'contains')
|
||||
self.assertEqual(self.filters['multivaluearrayfield__n'].exclude, True)
|
||||
|
||||
def test_multi_value_char_filter(self):
|
||||
self.assertIsInstance(self.filters['multivaluecharfield'], MultiValueCharFilter)
|
||||
self.assertEqual(self.filters['multivaluecharfield'].lookup_expr, 'exact')
|
||||
|
|
|
|||
Loading…
Reference in New Issue