Skip to content

Commit

Permalink
Add unknown_field_behavior feature (#1675)
Browse files Browse the repository at this point in the history
  • Loading branch information
loeeess authored Jul 19, 2024
1 parent c8889c4 commit 376d443
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 8 deletions.
2 changes: 1 addition & 1 deletion django_filters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from importlib import util as importlib_util

from .filters import *
from .filterset import FilterSet
from .filterset import FilterSet, UnknownFieldBehavior

# We make the `rest_framework` module available without an additional import.
# If DRF is not installed, no-op.
Expand Down
45 changes: 38 additions & 7 deletions django_filters/filterset.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import copy
import warnings
from collections import OrderedDict
from enum import Enum

from django import forms
from django.db import models
Expand Down Expand Up @@ -43,6 +45,12 @@ def remote_queryset(field):
return model._default_manager.complex_filter(limit_choices_to)


class UnknownFieldBehavior(Enum):
RAISE = "raise"
WARN = "warn"
IGNORE = "ignore"


class FilterSetOptions:
def __init__(self, options=None):
self.model = getattr(options, "model", None)
Expand All @@ -53,6 +61,13 @@ def __init__(self, options=None):

self.form = getattr(options, "form", forms.Form)

behavior = getattr(options, "unknown_field_behavior", UnknownFieldBehavior.RAISE)

if not isinstance(behavior, UnknownFieldBehavior):
raise ValueError(f"Invalid unknown_field_behavior: {behavior}")

self.unknown_field_behavior = behavior


class FilterSetMetaclass(type):
def __new__(cls, name, bases, attrs):
Expand Down Expand Up @@ -338,9 +353,11 @@ def get_filters(cls):
continue

if field is not None:
filters[filter_name] = cls.filter_for_field(
filter_instance = cls.filter_for_field(
field, field_name, lookup_expr
)
if filter_instance is not None:
filters[filter_name] = filter_instance

# Allow Meta.fields to contain declared filters *only* when a list/tuple
if isinstance(cls._meta.fields, (list, tuple)):
Expand All @@ -357,6 +374,18 @@ def get_filters(cls):
filters.update(cls.declared_filters)
return filters

@classmethod
def handle_unrecognized_field(cls, field_name, message):
behavior = cls._meta.unknown_field_behavior
if behavior == UnknownFieldBehavior.RAISE:
raise AssertionError(message)
elif behavior == UnknownFieldBehavior.WARN:
warnings.warn(f"Unrecognized field type for '{field_name}'. Field will be ignored.")
elif behavior == UnknownFieldBehavior.IGNORE:
pass
else:
raise ValueError(f"Invalid unknown_field_behavior: {behavior}")

@classmethod
def filter_for_field(cls, field, field_name, lookup_expr=None):
if lookup_expr is None:
Expand All @@ -371,12 +400,14 @@ def filter_for_field(cls, field, field_name, lookup_expr=None):
filter_class, params = cls.filter_for_lookup(field, lookup_type)
default.update(params)

assert filter_class is not None, (
"%s resolved field '%s' with '%s' lookup to an unrecognized field "
"type %s. Try adding an override to 'Meta.filter_overrides'. See: "
"https://django-filter.readthedocs.io/en/main/ref/filterset.html"
"#customise-filter-generation-with-filter-overrides"
) % (cls.__name__, field_name, lookup_expr, field.__class__.__name__)
if filter_class is None:
cls.handle_unrecognized_field(field_name, (
"%s resolved field '%s' with '%s' lookup to an unrecognized field "
"type %s. Try adding an override to 'Meta.filter_overrides'. See: "
"https://django-filter.readthedocs.io/en/main/ref/filterset.html"
"#customise-filter-generation-with-filter-overrides"
) % (cls.__name__, field_name, lookup_expr, field.__class__.__name__))
return None

return filter_class(**default)

Expand Down
29 changes: 29 additions & 0 deletions docs/ref/filterset.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Meta options
- :ref:`exclude <exclude>`
- :ref:`form <form>`
- :ref:`filter_overrides <filter_overrides>`
- :ref:`unknown_field_behavior <unknown_field_behavior>`


.. _model:
Expand Down Expand Up @@ -146,6 +147,34 @@ This is a map of model fields to filter classes with options::
},
}


.. _unknown_field_behavior:

Handling unknown fields with ``unknown_field_behavior``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The ``unknown_field_behavior`` option specifies how unknown fields are handled
in a ``FilterSet``. You can set this option using the values of the
``UnknownFieldBehavior`` enum:

- ``UnknownFieldBehavior.RAISE``: Raise an assertion error (default)
- ``UnknownFieldBehavior.WARN``: Issue a warning and ignore the field
- ``UnknownFieldBehavior.IGNORE``: Silently ignore the field

Note that both the ``WARN`` and ``IGNORE`` options do not include the unknown
field(s) in the list of filters.

.. code-block:: python

from django_filters import UnknownFieldBehavior

class UserFilter(django_filters.FilterSet):
class Meta:
model = User
fields = ['username', 'last_login']
unknown_field_behavior = UnknownFieldBehavior.WARN


Overriding ``FilterSet`` methods
--------------------------------

Expand Down
78 changes: 78 additions & 0 deletions tests/test_filterset.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
import warnings
from unittest import mock

from django.db import models
Expand All @@ -23,6 +24,7 @@
from django_filters.filterset import (
FILTER_FOR_DBFIELD_DEFAULTS,
FilterSet,
UnknownFieldBehavior,
filterset_factory,
)
from django_filters.widgets import BooleanWidget
Expand Down Expand Up @@ -147,6 +149,7 @@ def test_field_that_is_subclassed(self):

def test_unknown_field_type_error(self):
f = NetworkSetting._meta.get_field("mask")
FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.RAISE

with self.assertRaises(AssertionError) as excinfo:
FilterSet.filter_for_field(f, "mask")
Expand All @@ -157,6 +160,14 @@ def test_unknown_field_type_error(self):
excinfo.exception.args[0],
)

def test_return_none(self):
f = NetworkSetting._meta.get_field("mask")
# Set unknown_field_behavior to 'ignore' to avoid raising exceptions
FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.IGNORE
result = FilterSet.filter_for_field(f, "mask")

self.assertIsNone(result)

def test_symmetrical_selfref_m2m_field(self):
f = Node._meta.get_field("adjacents")
result = FilterSet.filter_for_field(f, "adjacents")
Expand Down Expand Up @@ -202,6 +213,73 @@ def test_filter_overrides(self):
pass


class HandleUnknownFieldTests(TestCase):
def setUp(self):
class NetworkSettingFilterSet(FilterSet):
class Meta:
model = NetworkSetting
fields = ["ip", "mask"]
# Initial field behavior set to 'ignore' to avoid crashing in setUp
unknown_field_behavior = UnknownFieldBehavior.IGNORE

self.FilterSet = NetworkSettingFilterSet

def test_raise_unknown_field_behavior(self):
self.FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.RAISE

with self.assertRaises(AssertionError) as excinfo:
self.FilterSet.handle_unrecognized_field("mask", "test_message")

self.assertIn(
"test_message",
excinfo.exception.args[0],
)

def test_unknown_field_warn_behavior(self):
self.FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.WARN

with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
self.FilterSet.handle_unrecognized_field("mask", "test_message")

self.assertIn(
"Unrecognized field type for 'mask'. "
"Field will be ignored.",
str(w[-1].message),
)

def test_unknown_field_ignore_behavior(self):
# No exception or warning should be raised
self.FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.IGNORE
self.FilterSet.handle_unrecognized_field("mask", "test_message")

def test_unknown_field_invalid_initial_behavior(self):
# Creation of new custom FilterSet to set initial field behavior
with self.assertRaises(ValueError) as excinfo:

class InvalidBehaviorFilterSet(FilterSet):
class Meta:
model = NetworkSetting
fields = ["ip", "mask"]
unknown_field_behavior = "invalid"

self.assertIn(
"Invalid unknown_field_behavior: invalid",
str(excinfo.exception),
)

def test_unknown_field_invalid_changed_option_behavior(self):
self.FilterSet._meta.unknown_field_behavior = "invalid"

with self.assertRaises(ValueError) as excinfo:
self.FilterSet.handle_unrecognized_field("mask", "test_message")

self.assertIn(
"Invalid unknown_field_behavior: invalid",
str(excinfo.exception),
)


class FilterSetFilterForLookupTests(TestCase):
def test_filter_for_ISNULL_lookup(self):
f = Article._meta.get_field("author")
Expand Down

0 comments on commit 376d443

Please sign in to comment.