Skip to content

Commit

Permalink
Chained qualifiers implementation
Browse files Browse the repository at this point in the history
ref T35707
  • Loading branch information
Maciej Lewinski committed May 30, 2022
1 parent 77eca30 commit 8c2b433
Show file tree
Hide file tree
Showing 2 changed files with 70 additions and 34 deletions.
82 changes: 62 additions & 20 deletions binder/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ class FieldFilter(object):
fields = []
# The list of allowed qualifiers
allowed_qualifiers = []
# The mapping of allowed chain qualifiers to the relevant Field
allowed_chain_qualifiers = {}

def __init__(self, field):
self.field = field
Expand Down Expand Up @@ -192,12 +194,65 @@ def check_qualifier(self, qualifier):
.format(qualifier, self.__class__.__name__, self.field_description()))


# This returns a (cached) filterclass for a field class.
def get_field_filter(self, field_class, reset=False):
f = not reset and getattr(self, '_field_filters', None)

if not f:
f = {}
for field_filter_cls in FieldFilter.__subclasses__():
for field_cls in field_filter_cls.fields:
if f.get(field_cls):
raise ValueError('Field-Filter mapping conflict: {} vs {}'.format(field_filter_cls.name, field_cls.name))
else:
f[field_cls] = field_filter_cls

self._field_filters = f

return f.get(field_class)



def get_q(self, qualifiers, value, invert, partial=''):
i = 0
field_filter = self

# First we try to handle chain qualifiers
while (
# If its not the last qualifier it has to be a chain qualifier
i < len(qualifiers) - 1 or
# For the last one we check if it is in chain qualifiers
(i < len(qualifiers) and qualifiers[i] in field_filter.allowed_chain_qualifiers)
):
chain_qualifier = qualifiers[i]
i += 1

field_cls = field_filter.allowed_chain_qualifiers[chain_qualifier]
if field_cls is None:
raise BinderRequestError(
'Qualifier {} not supported for type {} ({}).'
.format(chain_qualifier, field_filter.__class__.__name__, field_filter.field_description())
)

def get_q(self, qualifier, value, invert, partial=''):
self.check_qualifier(qualifier)
qualifier, cleaned_value = self.clean_qualifier(qualifier, value)
field = field_cls()
field.model = self.field.model
field.name = self.field.name + ':' + chain_qualifier

suffix = '__' + qualifier if qualifier else ''
field_filter_cls = self.get_field_filter(field_cls)
field_filter = field_filter_cls(field)

try:
qualifier = qualifiers[i]
except IndexError:
qualifier = None

field_filter.check_qualifier(qualifier)
qualifier, cleaned_value = field_filter.clean_qualifier(qualifier, value)

if 0 <= i < len(qualifiers):
qualifiers[i] = qualifier

suffix = ''.join('__' + qualifier for qualifier in qualifiers)
if invert:
return ~Q(**{partial + self.field.name + suffix: cleaned_value})
else:
Expand Down Expand Up @@ -254,6 +309,7 @@ class DateTimeFieldFilter(FieldFilter):
fields = [models.DateTimeField]
# Maybe allow __startswith? And __year etc?
allowed_qualifiers = [None, 'in', 'gt', 'gte', 'lt', 'lte', 'range', 'isnull']
allowed_chain_qualifiers = {'date': models.DateField}

def clean_value(self, qualifier, v):
if re.match('^[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?([A-Za-z]+|[+-][0-9]{1,4})$', v):
Expand All @@ -275,6 +331,7 @@ def clean_qualifier(self, qualifier, value):
else:
value_type = type(cleaned_value)

# [TODO] Support for chained qualifiers is added, still needed for backwards compat
if issubclass(value_type, date) and not issubclass(value_type, datetime):
if qualifier is None:
qualifier = 'date'
Expand Down Expand Up @@ -337,6 +394,7 @@ def clean_value(self, qualifier, v):
class TextFieldFilter(FieldFilter):
fields = [models.CharField, models.TextField]
allowed_qualifiers = [None, 'in', 'iexact', 'contains', 'icontains', 'startswith', 'istartswith', 'endswith', 'iendswith', 'exact', 'isnull']
allowed_chain_qualifiers = {'unaccent': models.TextField}

# Always valid(?)
def clean_value(self, qualifier, v):
Expand All @@ -357,22 +415,6 @@ class ArrayFieldFilter(FieldFilter):
fields = [ArrayField]
allowed_qualifiers = [None, 'contains', 'contained_by', 'overlap', 'isnull']

# Some copy/pasta involved....
def get_field_filter(self, field_class, reset=False):
f = not reset and getattr(self, '_field_filter', None)

if not f:
f = None
for field_filter_cls in FieldFilter.__subclasses__():
for field_cls in field_filter_cls.fields:
if field_cls == field_class:
f = field_filter_cls
break
self._field_filter = f

return f


def clean_value(self, qualifier, v):
Filter = self.get_field_filter(self.field.base_field.__class__)
filter = Filter(self.field.base_field)
Expand Down
22 changes: 8 additions & 14 deletions binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1052,18 +1052,12 @@ def _parse_filter(self, field, value, request, include_annotations, partial=''):

if not tail:
invert = False
try:
head, qualifier = head.split(':', 1)
if qualifier == 'not':
qualifier = None
invert = True
elif qualifier.startswith('not:'):
qualifier = qualifier[4:]
invert = True
except ValueError:
qualifier = None
head, *qualifiers = head.split(':')
if qualifiers and qualifiers[0] == 'not':
qualifiers = qualifiers[1:]
invert = True

q = self._filter_field(head, qualifier, value, invert, request, include_annotations, partial)
q = self._filter_field(head, qualifiers, value, invert, request, include_annotations, partial)
else:
q = Q()

Expand Down Expand Up @@ -1095,7 +1089,7 @@ def _parse_filter(self, field, value, request, include_annotations, partial=''):



def _filter_field(self, field_name, qualifier, value, invert, request, include_annotations, partial=''):
def _filter_field(self, field_name, qualifiers, value, invert, request, include_annotations, partial=''):
try:
if field_name in self.hidden_fields:
raise FieldDoesNotExist()
Expand All @@ -1108,7 +1102,7 @@ def _filter_field(self, field_name, qualifier, value, invert, request, include_a
if partial:
# NOTE: This creates a subquery; try to avoid this!
qs = annotate(self.model.objects.all(), request, annotations)
qs = qs.filter(self._filter_field(field_name, qualifier, value, invert, request, {
qs = qs.filter(self._filter_field(field_name, qualifiers, value, invert, request, {
rel_[len(rel) + 1:]: annotations
for rel_, annotations in include_annotations.items()
if rel_ == rel or rel_.startswith(rel + '.')
Expand All @@ -1121,7 +1115,7 @@ def _filter_field(self, field_name, qualifier, value, invert, request, include_a
if filter_class:
filter = filter_class(field)
try:
return filter.get_q(qualifier, value, invert, partial)
return filter.get_q(qualifiers, value, invert, partial)
except ValidationError as e:
# TODO: Maybe convert to a BinderValidationError later?
raise BinderRequestError(e.message)
Expand Down

0 comments on commit 8c2b433

Please sign in to comment.