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:
Martin Hauser 2026-09-08 17:43:08 +02:00 committed by GitHub
parent 5685c5218e
commit 7ae8e4461f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 36 additions and 0 deletions

View File

@ -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

View File

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

View File

@ -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,

View File

@ -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',

View File

@ -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')