Просмотр исходного кода

Closes #23572: Evaluate only the filter parameters present in a request

Jeremy Stretch 2 дней назад
Родитель
Сommit
bfb63f989e

+ 11 - 0
docs/plugins/development/filtersets.md

@@ -90,6 +90,17 @@ class MyFilterSet(NetBoxModelFilterSet):
 
 
 The `search` method is also used by the `q` filter in `NetBoxModelFilterSet` which in turn is used by the Search field in the filters tab.
 The `search` method is also used by the `q` filter in `NetBoxModelFilterSet` which in turn is used by the Search field in the filters tab.
 
 
+### Legacy Evaluation
+
+!!! info "This feature was introduced in NetBox v4.8."
+
+NetBox evaluates only the filters for which a request supplies a value; filters which receive no value are not called. A filter set which depends on every filter being evaluated for each request, as in earlier releases, can set `legacy_evaluation` to restore that behavior. This is intended as a temporary measure, and will be revisited in NetBox v5.0.
+
+```python
+class MyFilterSet(NetBoxModelFilterSet):
+    legacy_evaluation = True
+```
+
 ## Filter Classes
 ## Filter Classes
 
 
 ### TagFilter
 ### TagFilter

+ 1 - 1
netbox/extras/tests/query_counts.json

@@ -3,7 +3,7 @@
   "bookmark:list_objects_with_permission": 16,
   "bookmark:list_objects_with_permission": 16,
   "configcontext:api_list_objects": 22,
   "configcontext:api_list_objects": 22,
   "configcontext:list_objects_with_permission": 15,
   "configcontext:list_objects_with_permission": 15,
-  "configcontextprofile:api_list_objects": 12,
+  "configcontextprofile:api_list_objects": 11,
   "configcontextprofile:list_objects_with_permission": 15,
   "configcontextprofile:list_objects_with_permission": 15,
   "configtemplate:api_list_objects": 10,
   "configtemplate:api_list_objects": 10,
   "configtemplate:list_objects_with_permission": 16,
   "configtemplate:list_objects_with_permission": 16,

+ 2 - 2
netbox/ipam/filtersets.py

@@ -1312,7 +1312,7 @@ class ServicePortMappingFilterMixin(django_filters.FilterSet):
         """
         """
         queryset = super().filter_queryset(queryset)
         queryset = super().filter_queryset(queryset)
 
 
-        cleaned_data = self.form.cleaned_data
+        cleaned_data = self.cleaned_data
         protocols = cleaned_data.get('protocol') or []
         protocols = cleaned_data.get('protocol') or []
         port_tests = [
         port_tests = [
             (lookup, values)
             (lookup, values)
@@ -1361,7 +1361,7 @@ class ServicePortMappingFilterMixin(django_filters.FilterSet):
         # ?protocol=tcp&port__n=80 excludes only services exposing tcp/80 (not those exposing udp/80).
         # ?protocol=tcp&port__n=80 excludes only services exposing tcp/80 (not those exposing udp/80).
         if not value:
         if not value:
             return queryset
             return queryset
-        protocols = self.form.cleaned_data.get('protocol') or []
+        protocols = self.cleaned_data.get('protocol') or []
         return queryset.exclude(port_mapping_q(protocols, [('exact', value)]))
         return queryset.exclude(port_mapping_q(protocols, [('exact', value)]))
 
 
 
 

+ 4 - 9
netbox/ipam/views.py

@@ -573,11 +573,8 @@ class ChildAvailabilityMixin:
     def _get_detection_filterset(self, request):
     def _get_detection_filterset(self, request):
         """
         """
         Return the bound FilterSet used to evaluate the request, or None if the view declares no
         Return the bound FilterSet used to evaluate the request, or None if the view declares no
-        filterset. ObjectChildrenView.get() has already built one and validated its form while
-        resolving the child queryset, so reuse it rather than paying for a second FilterSet:
-        get_filters() regenerates every dynamic lookup variant and NetBoxModelFilterSet.__init__
-        queries the custom fields for the model. Build one only for direct calls where get() has
-        not run.
+        filterset. Reuse the one ObjectChildrenView.get() built while resolving the child queryset,
+        if any.
         """
         """
         if self.filterset_instance is not None:
         if self.filterset_instance is not None:
             return self.filterset_instance
             return self.filterset_instance
@@ -600,7 +597,7 @@ class ChildAvailabilityMixin:
 
 
         self._active_child_filters = False
         self._active_child_filters = False
 
 
-        # An empty request cannot activate a filter, so skip validating a form for nothing.
+        # An empty request cannot activate a filter.
         if not request.GET:
         if not request.GET:
             return False
             return False
 
 
@@ -610,9 +607,7 @@ class ChildAvailabilityMixin:
 
 
         # A non-empty cleaned value means the request activated a declared filter. Emptiness
         # A non-empty cleaned value means the request activated a declared filter. Emptiness
         # follows each filter's own semantics, so absent multi-value fields stay inactive.
         # follows each filter's own semantics, so absent multi-value fields stay inactive.
-        # This is a no-op for a reused FilterSet: .qs validated the form to build the queryset.
-        filterset.form.is_valid()
-        for name, value in filterset.form.cleaned_data.items():
+        for name, value in filterset.cleaned_data.items():
             if isinstance(filterset.filters[name], django_filters.MultipleChoiceFilter):
             if isinstance(filterset.filters[name], django_filters.MultipleChoiceFilter):
                 if value:  # mirrors MultipleChoiceFilter.filter()
                 if value:  # mirrors MultipleChoiceFilter.filter()
                     self._active_child_filters = True
                     self._active_child_filters = True

+ 281 - 8
netbox/netbox/filtersets.py

@@ -1,14 +1,21 @@
 import json
 import json
+from collections.abc import MutableMapping
 from copy import deepcopy
 from copy import deepcopy
 
 
 import django_filters
 import django_filters
+from django import forms
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.db.models import Q
 from django.db.models import Q
+from django.forms.utils import ErrorDict, ErrorList
+from django.http import QueryDict
+from django.utils.datastructures import MultiValueDict
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from django_filters.exceptions import FieldLookupError
 from django_filters.exceptions import FieldLookupError
 from django_filters.utils import get_model_field, resolve_field
 from django_filters.utils import get_model_field, resolve_field
+from django_filters.widgets import SuffixedMultiWidget
 
 
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange
 from core.models import ObjectChange
@@ -52,6 +59,167 @@ BOOLEAN_LOOKUPS = (
 )
 )
 
 
 
 
+#
+# Filter resolution
+#
+
+def get_data_keys(name, filter_):
+    """
+    Return the keys of request data from which the given filter reads its value.
+    """
+    widget = filter_.extra.get('widget') or getattr(filter_.field_class, 'widget', None)
+    widget_class = widget if isinstance(widget, type) else type(widget)
+    if not issubclass(widget_class, forms.MultiWidget):
+        return [name]
+    if issubclass(widget_class, SuffixedMultiWidget):
+        return [f'{name}_{suffix}' if suffix else name for suffix in widget.suffixes]
+    return [f'{name}{suffix}' for suffix in deepcopy(filter_).field.widget.widgets_names]
+
+
+class FilterIndex:
+    """
+    The filters of a FilterSet class, indexed for resolving request data.
+    """
+    def __init__(self, filters):
+        self.filters = filters
+        self.positions = {name: i for i, name in enumerate(filters)}
+        # Data keys which differ from the name of the filter reading them (e.g. "created_after")
+        self.data_keys = {
+            key: name
+            for name, filter_ in filters.items()
+            for key in get_data_keys(name, filter_)
+            if key != name
+        }
+        self.required = [name for name, filter_ in filters.items() if filter_.extra.get('required')]
+
+
+class LazyFilters:
+    """
+    Filters built by calling a loader once a filter whose name begins with the given prefix is requested.
+    """
+    def __init__(self, prefix, loader):
+        self.prefix = prefix
+        self.loader = loader
+        self.names = None
+
+
+class FilterDict(MutableMapping):
+    """
+    The filters of a FilterSet instance. Each of the class's filters is copied on first access, so that a request
+    copies only the filters it uses.
+    """
+    def __init__(self, filterset, index):
+        self.filterset = filterset
+        self.index = index
+        self.filters = {}  # Filters copied or added for this instance
+        self.removed = set()  # Names of class filters removed from this instance
+        self.extra = []  # Names of added filters, and LazyFilters, in order
+        self.extra_data_keys = {}
+
+    def __getitem__(self, name):
+        if name not in self.filters:
+            if self._is_class_filter(name):
+                filter_ = deepcopy(self.index.filters[name])
+                filter_.model = self.filterset.queryset.model
+                filter_.parent = self.filterset
+                self.filters[name] = filter_
+            elif not self._load(name):
+                raise KeyError(name)
+        return self.filters[name]
+
+    def __setitem__(self, name, filter_):
+        if name not in self:
+            self.extra.append(name)
+            self._add_data_keys(name, filter_)
+        self.filters[name] = filter_
+
+    def __delitem__(self, name):
+        if name not in self:
+            raise KeyError(name)
+        self.filters.pop(name, None)
+        if self._is_class_filter(name):
+            self.removed.add(name)
+        elif name in self.extra:
+            self.extra.remove(name)
+        else:
+            for entry in self.extra:
+                if isinstance(entry, LazyFilters) and name in (entry.names or []):
+                    entry.names.remove(name)
+        self.extra_data_keys = {key: value for key, value in self.extra_data_keys.items() if value != name}
+
+    def __iter__(self):
+        self._load()
+        for name in self.index.filters:
+            if name not in self.removed:
+                yield name
+        yield from self._extra_names()
+
+    def __len__(self):
+        self._load()
+        return len(self.index.filters) - len(self.removed) + len(self._extra_names())
+
+    def __contains__(self, name):
+        return name in self.filters or self._is_class_filter(name) or self._load(name)
+
+    def add_lazy(self, prefix, loader):
+        """
+        Register a callable which returns filters named with the given prefix, to be called once one is needed.
+        """
+        self.extra.append(LazyFilters(prefix, loader))
+
+    def get_names(self, key):
+        """
+        Return the names of the filters which read the given key of the request data.
+        """
+        names = [key] if key in self else []
+        name = self.extra_data_keys.get(key) or self.index.data_keys.get(key)
+        if name is not None and name != key and name in self:
+            names.append(name)
+        return names
+
+    def position(self, name):
+        """
+        Return the position of the named filter in declaration order.
+        """
+        if self._is_class_filter(name):
+            return self.index.positions[name]
+        return len(self.index.positions) + self._extra_names().index(name)
+
+    def _is_class_filter(self, name):
+        return name in self.index.filters and name not in self.removed
+
+    def _extra_names(self):
+        names = []
+        for entry in self.extra:
+            if isinstance(entry, LazyFilters):
+                names.extend(entry.names or [])
+            else:
+                names.append(entry)
+        return names
+
+    def _add_data_keys(self, name, filter_):
+        for key in get_data_keys(name, filter_):
+            if key != name:
+                self.extra_data_keys[key] = name
+
+    def _load(self, name=None):
+        """
+        Call any pending loaders for the given filter name (or all of them). Return True if the name now exists.
+        """
+        for entry in self.extra:
+            if not isinstance(entry, LazyFilters) or entry.names is not None:
+                continue
+            if name is not None and not name.startswith(entry.prefix):
+                continue
+            entry.names = []
+            for filter_name, filter_ in entry.loader().items():
+                if filter_name not in self.filters and not self._is_class_filter(filter_name):
+                    entry.names.append(filter_name)
+                    self._add_data_keys(filter_name, filter_)
+                self.filters[filter_name] = filter_
+        return name is not None and name in self.filters
+
+
 #
 #
 # FilterSets
 # FilterSets
 #
 #
@@ -112,12 +280,10 @@ class BaseFilterSet(django_filters.FilterSet):
         },
         },
     })
     })
 
 
-    def __init__(self, data=None, *args, **kwargs):
-        # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
-        # however FilterSet Factory is setup before this which creates the
-        # initial filters.  This recreates the filters so Empty is picked up correctly.
-        self.base_filters = self.__class__.get_filters()
+    # Set to True to evaluate every filter on each request, as before NetBox v4.8 (to be revisited in v5.0)
+    legacy_evaluation = False
 
 
+    def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
         # Apply any referenced SavedFilters
         # Apply any referenced SavedFilters
         if data and ('filter' in data or 'filter_id' in data):
         if data and ('filter' in data or 'filter_id' in data):
             data = data.copy()  # Get a mutable copy
             data = data.copy()  # Get a mutable copy
@@ -132,7 +298,6 @@ class BaseFilterSet(django_filters.FilterSet):
 
 
             # Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
             # Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
             # anonymous visibility (shared filters only) when no request is available.
             # anonymous visibility (shared filters only) when no request is available.
-            request = kwargs.get('request')
             user = request.user if request else AnonymousUser()
             user = request.user if request else AnonymousUser()
             saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
             saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
                 Q(slug__in=data.pop('filter', [])) |
                 Q(slug__in=data.pop('filter', [])) |
@@ -149,7 +314,105 @@ class BaseFilterSet(django_filters.FilterSet):
                     else:
                     else:
                         data.setlist(key, value)
                         data.setlist(key, value)
 
 
-        super().__init__(data, *args, **kwargs)
+        if self.legacy_evaluation or prefix is not None or self._has_custom_form():
+            # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
+            # however FilterSet Factory is setup before this which creates the
+            # initial filters.  This recreates the filters so Empty is picked up correctly.
+            self.base_filters = self.__class__.get_filters()
+            super().__init__(data, queryset, request=request, prefix=prefix)
+            return
+
+        # Copy each filter only once it's used, rather than deep-copying all of them as django-filter does
+        if queryset is None:
+            queryset = self._meta.model._default_manager.all()
+        self.is_bound = data is not None
+        self.data = data or QueryDict()
+        self.queryset = queryset
+        self.request = request
+        self.form_prefix = prefix
+        self.filters = FilterDict(self, self._get_filter_index())
+
+    @classmethod
+    def _get_filter_index(cls):
+        """
+        Return the class's filters, generated on first use (after Empty has been registered; see #9231). A class
+        which overrides get_filters() regenerates them on every call.
+        """
+        if cls.get_filters.__func__ is not BaseFilterSet.get_filters.__func__:
+            return FilterIndex(cls.get_filters())
+        if '_filter_index' not in cls.__dict__:
+            cls._filter_index = FilterIndex(cls.get_filters())
+        return cls._filter_index
+
+    @classmethod
+    def _has_custom_form(cls):
+        return cls._meta.form is not forms.Form or cls.get_form_class is not django_filters.FilterSet.get_form_class
+
+    @property
+    def _lazy(self):
+        # False under legacy evaluation, or if the filters have been replaced
+        return isinstance(self.filters, FilterDict)
+
+    @property
+    def form(self):
+        form = super().form
+        if self._lazy and self.is_bound:
+            # Evaluation no longer validates the form, so validate it here for code which reads its cleaned_data
+            form.is_valid()
+        return form
+
+    @property
+    def errors(self):
+        if not self._lazy:
+            return super().errors
+        self._clean()
+        return self._errors
+
+    def is_valid(self):
+        return self.is_bound and not self.errors
+
+    @property
+    def cleaned_data(self):
+        """
+        The cleaned values of the filters for which the request supplies data. For internal use only.
+        """
+        if not self._lazy:
+            self.form.is_valid()
+            return getattr(self.form, 'cleaned_data', {})
+        self._clean()
+        return self._cleaned_data
+
+    def filter_queryset(self, queryset):
+        if not self._lazy:
+            return super().filter_queryset(queryset)
+        for name, value in self.cleaned_data.items():
+            queryset = self.filters[name].filter(queryset, value)
+            assert isinstance(queryset, models.QuerySet), (
+                f"Expected '{type(self).__name__}.{name}' to return a QuerySet, but got a "
+                f"{type(queryset).__name__} instead."
+            )
+        return queryset
+
+    def _clean(self):
+        """
+        Clean the request's value for each filter it supplies, in declaration order.
+        """
+        if hasattr(self, '_cleaned_data'):
+            return
+        self._cleaned_data = {}
+        self._errors = ErrorDict()
+        if not self.is_bound:
+            return
+        names = {name for name in self.filters.index.required if name in self.filters}
+        for key in self.data.keys():
+            names.update(self.filters.get_names(key))
+        files = MultiValueDict()
+        for name in sorted(names, key=self.filters.position):
+            field = self.filters[name].field
+            try:
+                self._cleaned_data[name] = field.clean(field.widget.value_from_datadict(self.data, files, name))
+            except ValidationError as e:
+                self._errors[name] = ErrorList(e.error_list)
 
 
     @staticmethod
     @staticmethod
     def _get_filter_lookup_dict(existing_filter):
     def _get_filter_lookup_dict(existing_filter):
@@ -351,6 +614,16 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
+        if isinstance(self.filters, FilterDict):
+            # Build custom field filters only once one is needed
+            self.filters.add_lazy('cf_', self._get_custom_field_filters)
+        else:
+            self.filters.update(self._get_custom_field_filters())
+
+    def _get_custom_field_filters(self):
+        """
+        Return filters for the model's custom fields, including their additional lookups.
+        """
         custom_field_filters = {}
         custom_field_filters = {}
         for custom_field in CustomField.objects.get_for_model(self._meta.model):
         for custom_field in CustomField.objects.get_for_model(self._meta.model):
             if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
             if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
@@ -364,7 +637,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
                 additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
                 additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
                 custom_field_filters.update(additional_lookups)
                 custom_field_filters.update(additional_lookups)
 
 
-        self.filters.update(custom_field_filters)
+        return custom_field_filters
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         """
         """

+ 203 - 0
netbox/netbox/tests/test_filtersets.py

@@ -0,0 +1,203 @@
+from copy import deepcopy
+from importlib import import_module
+
+from django import forms
+from django.conf import settings
+from django.core.exceptions import EmptyResultSet
+from django.http import QueryDict
+from django.test import TestCase
+
+from core.filtersets import ObjectChangeFilterSet
+from core.models import ObjectChange, ObjectType
+from dcim.choices import SiteStatusChoices
+from dcim.filtersets import SiteFilterSet
+from dcim.models import Region, Site
+from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
+from extras.models import CustomField
+from netbox.filtersets import FilterDict, get_data_keys
+from netbox.registry import registry
+from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueContentTypeFilter
+
+CORE_APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
+
+
+def get_sample_value(field):
+    """
+    Return a representative value for the given form field.
+    """
+    if isinstance(field, (forms.BooleanField, forms.NullBooleanField)):
+        return 'true'
+    if isinstance(field, (forms.ModelChoiceField, forms.ModelMultipleChoiceField)):
+        return settings.FILTERS_NULL_CHOICE_VALUE
+    if isinstance(field, forms.ChoiceField):
+        for value, label in field.choices:
+            # Descend into grouped choices
+            if isinstance(label, (list, tuple)):
+                value = label[0][0]
+            if value not in ('', None, settings.FILTERS_NULL_CHOICE_VALUE):
+                return str(value)
+    if isinstance(field, forms.DateTimeField):
+        return '2026-01-01 00:00:00'
+    if isinstance(field, forms.DateField):
+        return '2026-01-01'
+    if isinstance(field, forms.TimeField):
+        return '00:00:00'
+    if isinstance(field, forms.DurationField):
+        return '00:01:00'
+    if isinstance(field, forms.UUIDField):
+        return '00000000-0000-0000-0000-000000000001'
+    if isinstance(field, (forms.IntegerField, forms.DecimalField, forms.FloatField)):
+        return '1'
+    return 'x'
+
+
+def get_sample_data(filterset_class):
+    """
+    Return request data which supplies a value for every filter on the given FilterSet class.
+    """
+    data = QueryDict(mutable=True)
+    for name, filter_ in filterset_class.get_filters().items():
+        field = deepcopy(filter_).field
+        if isinstance(filter_, (ContentTypeFilter, MultiValueContentTypeFilter)):
+            data[name] = 'dcim.site'
+        elif isinstance(field, forms.MultiValueField):
+            for key, subfield in zip(get_data_keys(name, filter_), field.fields):
+                data[key] = get_sample_value(subfield)
+        elif filter_.method and isinstance(field, (forms.ModelChoiceField, forms.ModelMultipleChoiceField)):
+            # Filter methods expect model instances, which this test doesn't create
+            continue
+        else:
+            data[name] = get_sample_value(field)
+    return data
+
+
+def evaluate(filterset):
+    """
+    Return the errors and SQL resulting from evaluation of the given FilterSet.
+    """
+    try:
+        sql = str(filterset.qs.query)
+    except EmptyResultSet:
+        sql = None
+    except Exception as e:
+        sql = repr(e)
+    return list(filterset.errors), filterset.errors.get_json_data(), sql
+
+
+class FilterSetEvaluationTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.region = Region.objects.create(name='Region 1', slug='region-1')
+        Site.objects.bulk_create((
+            Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
+            Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_PLANNED),
+            Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE),
+        ))
+
+    def test_only_supplied_filters_are_copied(self):
+        filterset = SiteFilterSet({'status': ['active'], 'limit': ['10']}, Site.objects.all())
+        self.assertEqual(filterset.qs.count(), 2)
+        self.assertEqual(list(filterset.filters.filters), ['status'])
+        self.assertNotIn('_form', vars(filterset))
+
+    def test_unbound(self):
+        filterset = SiteFilterSet(queryset=Site.objects.all())
+        self.assertFalse(filterset.is_valid())
+        self.assertEqual(filterset.qs.count(), 3)
+
+    def test_invalid_value(self):
+        filterset = SiteFilterSet({'region_id': ['999999'], 'status': ['active']}, Site.objects.all())
+        self.assertFalse(filterset.is_valid())
+        self.assertEqual(list(filterset.errors), ['region_id'])
+        # Valid filters are still applied
+        self.assertEqual(filterset.qs.count(), 2)
+
+    def test_legacy_evaluation(self):
+        class LegacySiteFilterSet(SiteFilterSet):
+            legacy_evaluation = True
+
+        filterset = LegacySiteFilterSet({'status': ['active']}, Site.objects.all())
+        self.assertEqual(filterset.qs.count(), 2)
+        self.assertNotIsInstance(filterset.filters, FilterDict)
+        self.assertIn('_form', vars(filterset))
+
+    def test_form(self):
+        filterset = SiteFilterSet({'status': ['active']}, Site.objects.all())
+        self.assertEqual(list(filterset.form.fields), list(filterset.filters))
+        self.assertEqual(filterset.form.cleaned_data['status'], ['active'])
+
+    def test_filter_changes_are_per_instance(self):
+        data = {'region_id': [self.region.pk]}
+        filterset = SiteFilterSet(data, Site.objects.all())
+        filterset.filters['region_id'].queryset = Region.objects.none()
+        self.assertFalse(filterset.is_valid())
+        self.assertTrue(SiteFilterSet(data, Site.objects.all()).is_valid())
+
+    def test_added_filter(self):
+        class ExtendedSiteFilterSet(SiteFilterSet):
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+                self.filters['label'] = MultiValueCharFilter(field_name='name')
+
+        self.assertEqual(ExtendedSiteFilterSet({'label': ['Site 1']}, Site.objects.all()).qs.count(), 1)
+
+    def test_get_filters_override(self):
+        calls = []
+
+        class CustomSiteFilterSet(SiteFilterSet):
+            @classmethod
+            def get_filters(cls):
+                calls.append(cls)
+                return super().get_filters()
+
+        # A class which overrides get_filters() regenerates its filters on every instantiation
+        calls.clear()
+        CustomSiteFilterSet({}, Site.objects.all())
+        CustomSiteFilterSet({}, Site.objects.all())
+        self.assertEqual(len(calls), 2)
+
+    def test_custom_field_filters(self):
+        custom_field = CustomField.objects.create(
+            name='cf1',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+            filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
+        )
+        custom_field.object_types.set([ObjectType.objects.get_for_model(Site)])
+        Site.objects.filter(slug='site-1').update(custom_field_data={'cf1': 'foo'})
+
+        self.assertEqual(SiteFilterSet({'cf_cf1': ['foo']}, Site.objects.all()).qs.count(), 1)
+        self.assertEqual(SiteFilterSet({'cf_cf1__n': ['foo']}, Site.objects.all()).qs.count(), 2)
+        self.assertIn('cf_cf1__ic', SiteFilterSet().filters)
+
+    def test_custom_field_filters_not_loaded(self):
+        # Custom fields aren't queried unless a cf_* filter is needed
+        with self.assertNumQueries(0):
+            SiteFilterSet({'status': ['active']}, Site.objects.all()).qs
+
+    def test_widget_suffixed_data_keys(self):
+        filterset = ObjectChangeFilterSet({'time_after': '2026-01-01 00:00:00'}, ObjectChange.objects.all())
+        self.assertEqual(list(filterset.cleaned_data), ['time'])
+        self.assertIn('"time" >=', str(filterset.qs.query))
+
+
+class LegacyEvaluationParityTestCase(TestCase):
+
+    def test_parity(self):
+        """
+        Evaluate every registered FilterSet with all of its filters supplied, and check that the result matches
+        legacy evaluation.
+        """
+        # Import every core FilterSet so that it's registered
+        for app_label in CORE_APPS:
+            import_module(f'{app_label}.filtersets')
+
+        for filterset_class in registry['filtersets'].values():
+            with self.subTest(filterset=filterset_class.__name__):
+                legacy_class = type(filterset_class.__name__, (filterset_class,), {'legacy_evaluation': True})
+                data = get_sample_data(filterset_class)
+                queryset = filterset_class._meta.model.objects.all()
+                self.assertEqual(
+                    evaluate(filterset_class(data, queryset)),
+                    evaluate(legacy_class(data, queryset))
+                )