Repository: carltongibson/django-filter

Issue:
Wrap FieldError to provide more useful debugging info
- add field/expression info. 
- add tests that exhibit the error as expected behavior. Eg, a 'foo' lookup should fail. Although that may be 1.9 only. 

Lookup types
It seems as though Django 1.9 has become more particular about query lookups because I was trying to implement a FilterSet such that it would enable all the lookup types for the field. Looking through the issue/pr tracker [using the `LOOKUP_TYPES` variable](https://github.com/carltongibson/django-filter/pull/301) was suggested. I tried to do that and got some errors.

I ended up adding a test that implements the example code @carltongibson has in that PR to verify that it wasn't caused by some crazy DRF wrapping (it wasn't). Then I figured I'd make the test pass.

I don't know if my fix is the best, but I think the test should be okay and it should pass in any case.



Discussion:

I should have seen those failures coming based on my description of django 1.9 being more strict about lookup types.

As such, the test now allows django < 1.9 to have more `base_filters` while 1.9+ has fewer.

Hi @mjschultz. If you're interested in exposing all lookups, I recommend looking at [drf-filters](https://github.com/philipn/django-rest-framework-filters). There is an `AllLookupsFilter` and an `'__all__'` shorthand for the dict-style syntax that does this correctly.

Using `LOOKUP_TYPES` ends up actually have the wrong behavior. In django 1.8, the additional lookups are invalid. Django used to register lookups for all of the default query terms. This mostly worked, but allowed you to do nonsensical things like perform date/time lookups on an integer field. Django 1.9 has a smaller set of default, but universally applicable lookups. Specific lookups, such as the date & time lookups are only registered on the date & time fields. Using `LOOKUP_TYPES` sort of works here again in Django 1.9, however you're missing out on the new transforms. In 1.8, you could only perform exact queries on the date/time lookups. eg, `published_on__year=2000`. In 1.9, transforms allow you to do `published_on__year__gt=2000`. `LOOKUP_TYPES` is unaware of the transforms. It's also not aware of any custom transforms and lookups.

As to the PR, I don't think hiding FieldError is the right thing to do. The error let's users know at class load time that something has been configured incorrectly. eg, a misspelled `excat` lookup. That said, the error is missing useful field/lookup information, and could be enhanced. Hope that helps!


Resolving patch:
diff --git a/django_filters/exceptions.py b/django_filters/exceptions.py
new file mode 100644
--- /dev/null
+++ b/django_filters/exceptions.py
@@ -0,0 +1,9 @@
+
+from django.core.exceptions import FieldError
+
+
+class FieldLookupError(FieldError):
+    def __init__(self, model_field, lookup_expr):
+        super(FieldLookupError, self).__init__(
+            "Unsupported lookup '%s' for field '%s'." % (lookup_expr, model_field)
+        )
diff --git a/django_filters/utils.py b/django_filters/utils.py
--- a/django_filters/utils.py
+++ b/django_filters/utils.py
@@ -1,12 +1,14 @@
 from django.conf import settings
+from django.core.exceptions import FieldError
 from django.db import models
 from django.db.models.constants import LOOKUP_SEP
 from django.db.models.expressions import Expression
 from django.db.models.fields import FieldDoesNotExist
 from django.db.models.fields.related import ForeignObjectRel
-from django.utils import timezone
+from django.utils import six, timezone
 
 from .compat import remote_model
+from .exceptions import FieldLookupError
 
 
 def try_dbfield(fn, field_class):
@@ -78,22 +80,25 @@ def resolve_field(model_field, lookup_expr):
 
     assert len(lookups) > 0
 
-    while lookups:
-        name = lookups[0]
-        # If there is just one part left, try first get_lookup() so
-        # that if the lhs supports both transform and lookup for the
-        # name, then lookup will be picked.
-        if len(lookups) == 1:
-            final_lookup = lhs.get_lookup(name)
-            if not final_lookup:
-                # We didn't find a lookup. We are going to interpret
-                # the name as transform, and do an Exact lookup against
-                # it.
-                lhs = query.try_transform(lhs, name, lookups)
-                final_lookup = lhs.get_lookup('exact')
-            return lhs.output_field, final_lookup.lookup_name
-        lhs = query.try_transform(lhs, name, lookups)
-        lookups = lookups[1:]
+    try:
+        while lookups:
+            name = lookups[0]
+            # If there is just one part left, try first get_lookup() so
+            # that if the lhs supports both transform and lookup for the
+            # name, then lookup will be picked.
+            if len(lookups) == 1:
+                final_lookup = lhs.get_lookup(name)
+                if not final_lookup:
+                    # We didn't find a lookup. We are going to interpret
+                    # the name as transform, and do an Exact lookup against
+                    # it.
+                    lhs = query.try_transform(lhs, name, lookups)
+                    final_lookup = lhs.get_lookup('exact')
+                return lhs.output_field, final_lookup.lookup_name
+            lhs = query.try_transform(lhs, name, lookups)
+            lookups = lookups[1:]
+    except FieldError as e:
+        six.raise_from(FieldLookupError(model_field, lookup_expr), e)
 
 
 def handle_timezone(value):


Test patch:
diff --git a/tests/test_filtering.py b/tests/test_filtering.py
--- a/tests/test_filtering.py
+++ b/tests/test_filtering.py
@@ -28,6 +28,7 @@
 from django_filters.filters import RangeFilter
 from django_filters.filters import TimeRangeFilter
 # from django_filters.widgets import LinkWidget
+from django_filters.exceptions import FieldLookupError
 
 from .models import User
 from .models import Comment
@@ -1718,3 +1719,16 @@ class Meta:
         f = F({'status': '2'}, queryset=qs)
         self.assertEqual(len(f.qs), 2)
         self.assertEqual(f.count(), 2)
+
+    def test_invalid_field_lookup(self):
+        # We want to ensure that non existent lookups (or just simple misspellings)
+        # throw a useful exception containg the field and lookup expr.
+        with self.assertRaises(FieldLookupError) as context:
+            class F(FilterSet):
+                class Meta:
+                    model = User
+                    fields = {'username': ['flub']}
+
+        exc = str(context.exception)
+        self.assertIn('tests.User.username', exc)
+        self.assertIn('flub', exc)
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -5,9 +5,9 @@
 from django.test import TestCase
 from django.db import models
 from django.db.models.constants import LOOKUP_SEP
-from django.core.exceptions import FieldError
 
 from django_filters.utils import get_model_field, resolve_field
+from django_filters.exceptions import FieldLookupError
 
 from .models import User
 from .models import Article
@@ -156,11 +156,19 @@ def test_resolve_implicit_exact_lookup(self):
     def test_invalid_lookup_expression(self):
         model_field = Article._meta.get_field('published')
 
-        with self.assertRaises(FieldError):
-            field, lookup = resolve_field(model_field, 'invalid_lookup')
+        with self.assertRaises(FieldLookupError) as context:
+            resolve_field(model_field, 'invalid_lookup')
+
+        exc = str(context.exception)
+        self.assertIn(str(model_field), exc)
+        self.assertIn('invalid_lookup', exc)
 
     def test_invalid_transformed_lookup_expression(self):
         model_field = Article._meta.get_field('published')
 
-        with self.assertRaises(FieldError):
-            field, lookup = resolve_field(model_field, 'date__invalid_lookup')
+        with self.assertRaises(FieldLookupError) as context:
+            resolve_field(model_field, 'date__invalid_lookup')
+
+        exc = str(context.exception)
+        self.assertIn(str(model_field), exc)
+        self.assertIn('date__invalid_lookup', exc)