Explorar el Código

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

Jeremy Stretch hace 2 días
padre
commit
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.
 
+### 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
 
 ### TagFilter

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

@@ -3,7 +3,7 @@
   "bookmark:list_objects_with_permission": 16,
   "configcontext:api_list_objects": 22,
   "configcontext:list_objects_with_permission": 15,
-  "configcontextprofile:api_list_objects": 12,
+  "configcontextprofile:api_list_objects": 11,
   "configcontextprofile:list_objects_with_permission": 15,
   "configtemplate:api_list_objects": 10,
   "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)
 
-        cleaned_data = self.form.cleaned_data
+        cleaned_data = self.cleaned_data
         protocols = cleaned_data.get('protocol') or []
         port_tests = [
             (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).
         if not value:
             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)]))
 
 

+ 4 - 9
netbox/ipam/views.py

@@ -573,11 +573,8 @@ class ChildAvailabilityMixin:
     def _get_detection_filterset(self, request):
         """
         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:
             return self.filterset_instance
@@ -600,7 +597,7 @@ class ChildAvailabilityMixin:
 
         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:
             return False
 
@@ -610,9 +607,7 @@ class ChildAvailabilityMixin:
 
         # 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.
-        # 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 value:  # mirrors MultipleChoiceFilter.filter()
                     self._active_child_filters = True

+ 281 - 8
netbox/netbox/filtersets.py

@@ -1,14 +1,21 @@
 import json
+from collections.abc import MutableMapping
 from copy import deepcopy
 
 import django_filters
+from django import forms
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
 from django.db import models
 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_filters.exceptions import FieldLookupError
 from django_filters.utils import get_model_field, resolve_field
+from django_filters.widgets import SuffixedMultiWidget
 
 from core.choices import ObjectChangeActionChoices
 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
 #
@@ -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
         if data and ('filter' in data or 'filter_id' in data):
             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
             # anonymous visibility (shared filters only) when no request is available.
-            request = kwargs.get('request')
             user = request.user if request else AnonymousUser()
             saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
                 Q(slug__in=data.pop('filter', [])) |
@@ -149,7 +314,105 @@ class BaseFilterSet(django_filters.FilterSet):
                     else:
                         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
     def _get_filter_lookup_dict(existing_filter):
@@ -351,6 +614,16 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
     def __init__(self, *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 = {}
         for custom_field in CustomField.objects.get_for_model(self._meta.model):
             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)
                 custom_field_filters.update(additional_lookups)
 
-        self.filters.update(custom_field_filters)
+        return custom_field_filters
 
     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))
+                )