Skip to content

Commit

Permalink
feat: add field shortcuts
Browse files Browse the repository at this point in the history
  • Loading branch information
hodossy committed Dec 18, 2020
1 parent 449a9cb commit 34cbac7
Show file tree
Hide file tree
Showing 6 changed files with 65 additions and 13 deletions.
6 changes: 3 additions & 3 deletions django_nlf/antlr/listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def sanitize_value(value, **kwargs):
return CustomFunction(function_name, args, kwargs)

# TODO: Proper error msg
raise ValueError("Invalid value")
raise ValueError(f"Unknown value type {value.type}")


def negate(output):
Expand Down Expand Up @@ -127,7 +127,7 @@ def get_current_expression(self, ctx: DjangoNLFParser.ExpressionContext):
def exitOperator(self, ctx: DjangoNLFParser.OperatorContext):
if ctx.AND() is not None:
self.operator = Operator(0, self.depth)
elif ctx.OR() is not None:
elif ctx.OR() is not None: # pragma: no branch
self.operator = Operator(1, self.depth)

# # Enter a parse tree produced by DjangoNLFParser#boolean_expr.
Expand Down Expand Up @@ -176,7 +176,7 @@ def exitLookup(self, ctx: DjangoNLFParser.LookupContext):
self.lookup = Lookup.GTE
elif ctx.LT() is not None:
self.lookup = Lookup.LT
elif ctx.LTE() is not None:
elif ctx.LTE() is not None: # pragma: no branch
self.lookup = Lookup.LTE

# # Enter a parse tree produced by DjangoNLFParser#expression.
Expand Down
5 changes: 3 additions & 2 deletions django_nlf/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@


DEFAULTS = {
"QUERY_PARAM": "q",
"PATH_SEPARATOR": ".",
"EMPTY_VALUE": "EMPTY",
"FALSE_VALUES": ("0", "f"),
"FIELD_SHORTCUTS": {},
"PATH_SEPARATOR": ".",
"QUERY_PARAM": "q",
}


Expand Down
35 changes: 28 additions & 7 deletions django_nlf/filters/django.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Union
from typing import Any, Union, Mapping

from django.db import models
from django.db.models.constants import LOOKUP_SEP
Expand All @@ -11,7 +11,8 @@


class DjangoNLFilter(NLFilterBase):
lookups: dict = {
generic_shortcuts_key = "__all__"
lookups: Mapping[Lookup, str] = {
Lookup.EQUALS: "iexact",
Lookup.CONTAINS: "icontains",
Lookup.REGEX: "iregex",
Expand All @@ -23,7 +24,7 @@ class DjangoNLFilter(NLFilterBase):
}
path_sep: str = nlf_settings.PATH_SEPARATOR
empty_val: str = nlf_settings.EMPTY_VALUE
# field_shortcuts = {}
field_shortcuts: Mapping[str, Mapping[str, str]] = nlf_settings.FIELD_SHORTCUTS

def __init__(self, request=None, view=None):
super().__init__()
Expand All @@ -35,6 +36,7 @@ def __init__(self, request=None, view=None):
self.opts = None

self.annotations = {}
self.orig_field_name = None

def follow_field_path(self, opts, path):
"""Resolves the field path on the model to get a Field instance. If any many to many
Expand Down Expand Up @@ -98,14 +100,33 @@ def normalize_expression_function(self, value):

return condition

def resolve_shortcut(self, field_name: str) -> str:
"""Resolves the final field name according to the FIELD_SHORTCUTS setting.
:param str field_name: The field name arrived in the filter expression.
:return: The resolved final field name.
:rtype: str
"""
if self.field_shortcuts:
model_shortcuts = self.field_shortcuts.get(self.opts.label, {})
if field_name in model_shortcuts:
return model_shortcuts[field_name]

generic_shortcuts = self.field_shortcuts.get(self.generic_shortcuts_key, {})
if field_name in generic_shortcuts:
return generic_shortcuts[field_name]

return field_name

def normalize_field_name(self, field_name: str) -> str:
"""Normalizes field name. By default it replaces PATH_SEPARATOR characters with Django's LOOKUP_SEP.
:param str field_name: The name of the field.
:return: Normalized field name.
:rtype: str
"""
# field_name = self.field_shortcuts.get(field_name, field_name)
self.orig_field_name = field_name
field_name = self.resolve_shortcut(field_name)
return field_name.replace(self.path_sep, LOOKUP_SEP)

def normalize_value(
Expand Down Expand Up @@ -139,7 +160,7 @@ def normalize_value(
return val

choices = ", ".join([display for _, display in field.choices])
raise ValueError(f"Invalid {field_name}! Must be one of {choices}")
raise ValueError(f"Invalid {self.orig_field_name}! Must be one of {choices}")

if isinstance(field, (models.DateField, models.DateTimeField)):
return self.coerce_datetime(value)
Expand All @@ -150,13 +171,13 @@ def normalize_value(
return value

def get_condition(self, field: str, lookup: Lookup, value: Any):
"""Constructs a :class:`Q object <>` based on parameters.
"""Constructs a :class:`Q object <django:django.db.models.Q>` based on parameters.
:param str field: The field name.
:param Lookup lookup: The lookup to be used.
:param Any value: The filtering value.
:return: The matching Q object, the filtering condition.
:rtype: :class:`Q <>`
:rtype: :class:`Q <django:django.db.models.Q>`
"""
if isinstance(value, bool):
lookup_str = None
Expand Down
2 changes: 1 addition & 1 deletion django_nlf/rest_framework/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def get_filter(self, request, queryset, view): # pylint: disable=unused-argumen
return self.filter_class(request, view)

def get_filter_expr(self, request):
return request.query_params.get(self.filter_param, '')
return request.query_params.get(self.filter_param, "")

def to_html(self, request, queryset, view): # pylint: disable=unused-argument
template = loader.get_template(self.template)
Expand Down
14 changes: 14 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ Default: ``("0", "f")``

Used in boolean coercion to determine the boolean value of a string. If the first character of the value coerced to boolean matches any listed character, the value is considered ``False``, otherwise ``True``.

NLF_FIELD_SHORTCUTS
*******************

Default: ``{}``

A simple mapping of models and field name shortcuts to full field path. The key must be a model identifier in a form of ``app.Model`` and its value is mapping of shortcut to full path. The special key ``__all__`` applies to all models, and has a lower precedence. For example if you would like to identify you users by their username in the language for your model ``Article``, and you have an ``author`` field on your model (pointing to the Primary Key of the users), you can do the following:

.. code-block:: python
NLF_FIELD_SHORTCUTS = {
"blog.Article": {"author": "author.username"}, # Do this for shortcuts for a specific model
"__all__": {} # Do this for generic shortcuts, applicable to all models
}
.. _path-separator:

NLF_PATH_SEPARATOR
Expand Down
16 changes: 16 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,19 @@ def test_backward_many_to_many_filter(self):
qs = self.nl_filter.filter(Publication.objects.all(), filter_expr)
self.assertEqual(qs.count(), 2)
self.assertListEqual(list(qs.all()), [self.p2, self.p1])


class DjangoNLFilterShortcutsTestCase(BaseTestCase):
def test_model_shortcuts(self):
self.nl_filter.field_shortcuts = {"tests.Publication": {"views": "articles.views"}}
filter_expr = "views > 10000 or views < 5000"
qs = self.nl_filter.filter(Publication.objects.all(), filter_expr)
self.assertEqual(qs.count(), 2)
self.assertListEqual(list(qs.all()), [self.p2, self.p1])

def test_generic_shortcuts(self):
self.nl_filter.field_shortcuts = {"__all__": {"views": "articles.views"}}
filter_expr = "views > 10000 or views < 5000"
qs = self.nl_filter.filter(Publication.objects.all(), filter_expr)
self.assertEqual(qs.count(), 2)
self.assertListEqual(list(qs.all()), [self.p2, self.p1])

0 comments on commit 34cbac7

Please sign in to comment.