From 972ef7263dc44aa36d5e17d309cdb347cb9f3848 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Sep 2015 20:05:18 -0400 Subject: [PATCH 01/16] added PlainFilterBackend which can filter on vanilla Python lists --- url_filter/backends/plain.py | 151 +++++++++++++++++++++++++++++++++++ url_filter/filters.py | 2 +- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 url_filter/backends/plain.py diff --git a/url_filter/backends/plain.py b/url_filter/backends/plain.py new file mode 100644 index 0000000..abce2ad --- /dev/null +++ b/url_filter/backends/plain.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals +import re + +from .base import BaseFilterBackend + + +class PlainFilterBackend(BaseFilterBackend): + supported_lookups = { + 'contains', + 'day', + 'endswith', + 'exact', + 'gt', + 'gte', + 'hour', + 'icontains', + 'iendswith', + 'iexact', + 'in', + 'iregex', + 'isnull', + 'istartswith', + 'lt', + 'lte', + 'minute', + 'month', + 'range', + 'regex', + 'second', + 'startswith', + 'week_day', + 'year', + } + + def get_model(self): + return object + + def filter(self): + if not self.specs: + return self.queryset + + return filter(self.filter_callable, self.queryset) + + def filter_callable(self, item): + return all(self.filter_by_spec(item, spec) for spec in self.specs) + + def filter_by_spec(self, item, spec): + filtered = self._filter_by_spec_and_value(item, spec.components, spec) + if spec.is_negated: + return not filtered + return filtered + + def _filter_by_spec_and_value(self, item, components, spec): + if not components: + comparator = getattr(self, '_compare_{}'.format(spec.lookup)) + return comparator(item, spec) + + if isinstance(item, (list, tuple)): + return any( + self._filter_by_spec_and_value(i, components, spec) + for i in item + ) + + if not isinstance(item, dict): + item = { + k: v + for k, v in vars(item).items() + if not k.startswith('_') + } + + return self._filter_by_spec_and_value( + item.get(components[0], {}), + components[1:], + spec, + ) + + def _compare_contains(self, value, spec): + return spec.value in value + + def _compare_day(self, value, spec): + return value.day == spec.value + + def _compare_endswith(self, value, spec): + return value.endswith(spec.value) + + def _compare_exact(self, value, spec): + return value == spec.value + + def _compare_gt(self, value, spec): + return value > spec.value + + def _compare_gte(self, value, spec): + return value >= spec.value + + def _compare_hour(self, value, spec): + return value.hour == spec.value + + def _compare_icontains(self, value, spec): + return spec.value.lower() in value.lower() + + def _compare_iendswith(self, value, spec): + return value.lower().endswith(spec.value.lower()) + + def _compare_iexact(self, value, spec): + return value.lower() == spec.value.lower() + + def _compare_in(self, value, spec): + return value in spec.value + + def _compare_iregex(self, value, spec): + return bool(re.match(spec.value, value, re.IGNORECASE)) + + def _compare_isnull(self, value, spec): + if spec.value: + return value is None + else: + return value is not None + + def _compare_istartswith(self, value, spec): + return value.lower().startswith(spec.value.lower()) + + def _compare_lt(self, value, spec): + return value < spec.value + + def _compare_lte(self, value, spec): + return value <= spec.value + + def _compare_minute(self, value, spec): + return value.minute == spec.value + + def _compare_month(self, value, spec): + return value.month == spec.value + + def _compare_range(self, value, spec): + return spec.value[0] <= value <= spec.value[1] + + def _compare_regex(self, value, spec): + return bool(re.match(spec.value, value)) + + def _compare_second(self, value, spec): + return value.second == spec.value + + def _compare_startswith(self, value, spec): + return value.startswith(spec.value) + + def _compare_week_day(self, value, spec): + return value.weekday() + 1 == spec.value + + def _compare_year(self, value, spec): + return value.year == spec.value diff --git a/url_filter/filters.py b/url_filter/filters.py index b538594..7289a64 100644 --- a/url_filter/filters.py +++ b/url_filter/filters.py @@ -17,7 +17,7 @@ } LOOKUP_FIELD_OVERWRITES = { - 'isnull': forms.BooleanField(), + 'isnull': forms.BooleanField(required=False), 'second': forms.IntegerField(min_value=0, max_value=59), 'minute': forms.IntegerField(min_value=0, max_value=59), 'hour': forms.IntegerField(min_value=0, max_value=23), From c8e3804fff40664241e9eaa63454ab2ad85d084b Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Sep 2015 23:01:49 -0400 Subject: [PATCH 02/16] added PlaneModelFilterSet which introspects objects/dicts --- url_filter/backends/base.py | 1 + url_filter/backends/plain.py | 8 +-- url_filter/filtersets/plain.py | 101 +++++++++++++++++++++++++++++++++ url_filter/integrations/drf.py | 2 +- url_filter/utils.py | 22 +++++-- 5 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 url_filter/filtersets/plain.py diff --git a/url_filter/backends/base.py b/url_filter/backends/base.py index f7b6367..a41bbcd 100644 --- a/url_filter/backends/base.py +++ b/url_filter/backends/base.py @@ -8,6 +8,7 @@ class BaseFilterBackend(six.with_metaclass(abc.ABCMeta, object)): supported_lookups = set() + enforce_same_models = True def __init__(self, queryset, context=None): self.queryset = queryset diff --git a/url_filter/backends/plain.py b/url_filter/backends/plain.py index abce2ad..73d7872 100644 --- a/url_filter/backends/plain.py +++ b/url_filter/backends/plain.py @@ -2,10 +2,12 @@ from __future__ import absolute_import, print_function, unicode_literals import re +from ..utils import dictify from .base import BaseFilterBackend class PlainFilterBackend(BaseFilterBackend): + enforce_same_models = False supported_lookups = { 'contains', 'day', @@ -63,11 +65,7 @@ def _filter_by_spec_and_value(self, item, components, spec): ) if not isinstance(item, dict): - item = { - k: v - for k, v in vars(item).items() - if not k.startswith('_') - } + item = dictify(item) return self._filter_by_spec_and_value( item.get(components[0], {}), diff --git a/url_filter/filtersets/plain.py b/url_filter/filtersets/plain.py new file mode 100644 index 0000000..0898c6a --- /dev/null +++ b/url_filter/filtersets/plain.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals +from datetime import date, datetime, time +from decimal import Decimal + +import six +from django import forms + +from ..exceptions import SkipFilter +from ..filters import Filter +from ..utils import SubClassDict, dictify +from .base import FilterSet +from .django import ModelFilterSetOptions + + +DATA_TYPES_MAPPING = SubClassDict({ + six.string_types: forms.CharField(), + six.integer_types: forms.IntegerField(), + bool: forms.BooleanField(required=False), + float: forms.FloatField(), + Decimal: forms.DecimalField(), + datetime: forms.DateTimeField(), + date: forms.DateField(), + time: forms.TimeField(), +}) + + +class PlainModelFilterSet(FilterSet): + filter_options_class = ModelFilterSetOptions + + def get_filters(self): + filters = super(PlainModelFilterSet, self).get_filters() + + assert self.Meta.model, ( + '{}.Meta.model is missing. Please specify the model ' + 'in order to use ModelFilterSet.' + ''.format(self.__class__.__name__) + ) + + if self.Meta.fields is None: + self.Meta.fields = self.get_model_field_names() + + model = dictify(self.Meta.model) + + for name in self.Meta.fields: + if name in self.Meta.exclude: + continue + + value = model.get(name) + _filter = None + primitive = DATA_TYPES_MAPPING.get(type(value)) + + try: + if primitive: + _filter = self.build_filter_from_field(value) + elif isinstance(value, (list, tuple, set)) and value: + value = list(value)[0] + if not isinstance(value, dict): + raise SkipFilter + if not self.Meta.allow_related: + raise SkipFilter + _filter = self.build_filterset_from_related_field(name, value) + elif isinstance(value, dict): + if not self.Meta.allow_related: + raise SkipFilter + _filter = self.build_filterset_from_related_field(name, value) + + except SkipFilter: + continue + + else: + if _filter is not None: + filters[name] = _filter + + return filters + + def get_model_field_names(self): + """ + Get a list of all model fields. + + This is used when ``Meta.fields`` is ``None`` + in which case this method returns all model fields. + """ + return list(dictify(self.Meta.model).keys()) + + def build_filter_from_field(self, field): + return Filter(form_field=DATA_TYPES_MAPPING.get(type(field))) + + def build_filterset_from_related_field(self, name, field): + meta = type(str('Meta'), (object,), {'model': field}) + + filterset = type( + str('{}FilterSet'.format(name.title())), + (PlainModelFilterSet,), + { + 'Meta': meta, + '__module__': self.__module__, + } + ) + + return filterset() diff --git a/url_filter/integrations/drf.py b/url_filter/integrations/drf.py index d3da066..596a17b 100644 --- a/url_filter/integrations/drf.py +++ b/url_filter/integrations/drf.py @@ -51,7 +51,7 @@ def filter_queryset(self, request, queryset, view): ) filter_model = getattr(_filter.Meta, 'model', None) - if filter_model: + if filter_model and _filter.filter_backend.enforce_same_models: model = _filter.filter_backend.model assert issubclass(model, filter_model), ( 'FilterSet model {} does not match queryset model {}' diff --git a/url_filter/utils.py b/url_filter/utils.py index bf26345..f611a79 100644 --- a/url_filter/utils.py +++ b/url_filter/utils.py @@ -182,10 +182,22 @@ def get(self, k, d=None): # try to match by value if value is d and inspect.isclass(k): - for klass, v in self.items(): - if not inspect.isclass(klass): - continue - if issubclass(k, klass): - return v + for klasses, v in self.items(): + if not isinstance(klasses, (list, tuple)): + klasses = [klasses] + for klass in klasses: + if inspect.isclass(klass) and issubclass(k, klass): + return v return value + + +def dictify(obj): + if isinstance(obj, dict): + return obj + else: + return { + k: v + for k, v in vars(obj).items() + if not k.startswith('_') + } From 21d406be825d02878af882520ccacf9a13575758 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Sep 2015 23:02:09 -0400 Subject: [PATCH 03/16] added example plain API for Place --- test_project/one_to_one/api.py | 57 ++++++++++++++++++++++++++++++++++ test_project/urls.py | 1 + 2 files changed, 58 insertions(+) diff --git a/test_project/one_to_one/api.py b/test_project/one_to_one/api.py index e596095..f71aa6b 100644 --- a/test_project/one_to_one/api.py +++ b/test_project/one_to_one/api.py @@ -1,11 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals +from django.http import Http404 +from rest_framework.response import Response from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ReadOnlyModelViewSet +from url_filter.backends.plain import PlainFilterBackend from url_filter.backends.sqlalchemy import SQLAlchemyFilterBackend from url_filter.filtersets import ModelFilterSet +from url_filter.filtersets.plain import PlainModelFilterSet from url_filter.filtersets.sqlalchemy import SQLAlchemyModelFilterSet from . import alchemy @@ -63,6 +67,34 @@ class Meta(object): model = Place +class PlainPlaceFilterSet(PlainModelFilterSet): + filter_backend_class = PlainFilterBackend + + class Meta(object): + model = { + "id": 1, + "restaurant": { + "place": 1, + "waiters": [ + { + "id": 1, + "name": "Joe", + "restaurant": 1 + }, + { + "id": 2, + "name": "Jonny", + "restaurant": 1 + } + ], + "serves_hot_dogs": True, + "serves_pizza": False + }, + "name": "Demon Dogs", + "address": "944 W. Fullerton" + } + + class SQLAlchemyPlaceFilterSet(SQLAlchemyModelFilterSet): filter_backend_class = SQLAlchemyFilterBackend @@ -100,6 +132,31 @@ class PlaceViewSet(ReadOnlyModelViewSet): filter_class = PlaceFilterSet +class PlainPlaceViewSet(ReadOnlyModelViewSet): + serializer_class = PlaceNestedSerializer + queryset = Place.objects.all() + filter_class = PlainPlaceFilterSet + + def get_queryset(self): + qs = super(PlainPlaceViewSet, self).get_queryset() + data = self.get_serializer(instance=qs.all(), many=True).data + return data + + def list(self, request): + queryset = self.filter_queryset(self.get_queryset()) + return Response(queryset) + + def retrieve(self, request, pk): + instance = next( + iter(filter(lambda i: i.get('id') == int(pk), + self.get_queryset())), + None + ) + if not instance: + raise Http404 + return Response(instance) + + class SQLAlchemyPlaceViewSet(ReadOnlyModelViewSet): serializer_class = PlaceNestedSerializer filter_class = SQLAlchemyPlaceFilterSet diff --git a/test_project/urls.py b/test_project/urls.py index 2eec5f5..ef1190b 100644 --- a/test_project/urls.py +++ b/test_project/urls.py @@ -11,6 +11,7 @@ router = DefaultRouter() router.register('one-to-one/places/alchemy', o2o_api.SQLAlchemyPlaceViewSet, 'one-to-one-alchemy:place') +router.register('one-to-one/places/plain', o2o_api.PlainPlaceViewSet, 'one-to-one-plain:place') router.register('one-to-one/places', o2o_api.PlaceViewSet, 'one-to-one:place') router.register('one-to-one/restaurants/alchemy', o2o_api.SQLAlchemyRestaurantViewSet, 'one-to-one-alchemy:restaurant') router.register('one-to-one/restaurants', o2o_api.RestaurantViewSet, 'one-to-one:restaurant') From fbd6e96f113caac88ea241bccda62a3b29d5ea8c Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Tue, 15 Sep 2015 20:43:45 -0400 Subject: [PATCH 04/16] added BaseModelFilterSet --- tests/filtersets/test_django.py | 8 +- tests/filtersets/test_sqlalchemy.py | 6 +- tests/test_utils.py | 18 +++- url_filter/filtersets/base.py | 125 +++++++++++++++++++++++++++- url_filter/filtersets/django.py | 98 +++++++--------------- url_filter/filtersets/plain.py | 89 +++++++------------- url_filter/filtersets/sqlalchemy.py | 65 ++++----------- url_filter/utils.py | 7 ++ 8 files changed, 231 insertions(+), 185 deletions(-) diff --git a/tests/filtersets/test_django.py b/tests/filtersets/test_django.py index 61343b0..5836f92 100644 --- a/tests/filtersets/test_django.py +++ b/tests/filtersets/test_django.py @@ -170,13 +170,13 @@ class Meta(object): def test_get_form_field_for_field(self): fs = ModelFilterSet() - assert isinstance(fs.get_form_field_for_field(models.CharField()), forms.CharField) - assert isinstance(fs.get_form_field_for_field(models.AutoField()), forms.IntegerField) - assert isinstance(fs.get_form_field_for_field(models.FileField()), forms.CharField) + assert isinstance(fs._get_form_field_for_field(models.CharField()), forms.CharField) + assert isinstance(fs._get_form_field_for_field(models.AutoField()), forms.IntegerField) + assert isinstance(fs._get_form_field_for_field(models.FileField()), forms.CharField) class TestField(models.Field): def formfield(self, **kwargs): return with pytest.raises(SkipFilter): - fs.get_form_field_for_field(TestField()) + fs._get_form_field_for_field(TestField()) diff --git a/tests/filtersets/test_sqlalchemy.py b/tests/filtersets/test_sqlalchemy.py index 8951db6..31505cd 100644 --- a/tests/filtersets/test_sqlalchemy.py +++ b/tests/filtersets/test_sqlalchemy.py @@ -174,13 +174,13 @@ def test_get_form_field_for_field(self): fs = SQLAlchemyModelFilterSet() assert isinstance( - fs.get_form_field_for_field(ColumnProperty(Column('name', String(50)))), + fs._get_form_field_for_field(ColumnProperty(Column('name', String(50)))), forms.CharField ) assert isinstance( - fs.get_form_field_for_field(ColumnProperty(Column('name', Integer))), + fs._get_form_field_for_field(ColumnProperty(Column('name', Integer))), forms.IntegerField ) with pytest.raises(SkipFilter): - fs.get_form_field_for_field(ColumnProperty(Column('name', TypeEngine))) + fs._get_form_field_for_field(ColumnProperty(Column('name', TypeEngine))) diff --git a/tests/test_utils.py b/tests/test_utils.py index 4d8be7d..252115c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals -from url_filter.utils import FilterSpec, LookupConfig, SubClassDict +from url_filter.utils import FilterSpec, LookupConfig, SubClassDict, dictify class TestFilterSpec(object): @@ -84,3 +84,19 @@ class Bar(Foo): assert mapping.get(Foo) == 'foo' assert mapping.get(Bar) == 'foo' assert mapping.get('not-there') is None + + +def test_dictify(): + a = {'data': 'here'} + assert dictify(a) is a + + class Foo(object): + def __init__(self): + self.a = 'a' + self.b = 'b' + self._c = 'c' + + assert dictify(Foo()) == { + 'a': 'a', + 'b': 'b', + } diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index 96d13e1..07cb4b2 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals +import abc import enum import re from collections import defaultdict @@ -18,7 +19,13 @@ from ..utils import LookupConfig -__all__ = ['FilterSet', 'FilterSetOptions', 'StrictMode'] +__all__ = [ + 'BaseModelFilterSet', + 'FilterSet', + 'FilterSetOptions', + 'ModelFilterSetOptions', + 'StrictMode', +] class StrictMode(enum.Enum): @@ -61,7 +68,7 @@ def __init__(self, options=None): pass -class FilterSetMeta(type): +class FilterSetMeta(abc.ABCMeta): """ Metaclass for creating ``FilterSet`` classes. @@ -361,3 +368,117 @@ def _generate_lookup_configs(self): lambda a, b: {b: a}, (key.replace('!', '').split(LOOKUP_SEP) + [value])[::-1] )) + + +class ModelFilterSetOptions(object): + """ + Custom options for ``FilterSet``s used for model-generated filtersets. + + Attributes + ---------- + model : Model + Model class from which ``FilterSet`` will + extract necessary filters. + fields : None, list, optional + Specific model fields for which filters + should be created for. + By default it is ``None`` in which case for all + fields filters will be created for. + exclude : list, optional + Specific model fields for which filters + should not be created for. + allow_related : bool, optional + Whether related/nested fields should be allowed + when model fields are automatically determined + (e.g. when explicit ``fields`` is not provided). + """ + def __init__(self, options=None): + self.model = getattr(options, 'model', None) + self.fields = getattr(options, 'fields', None) + self.exclude = getattr(options, 'exclude', []) + self.allow_related = getattr(options, 'allow_related', True) + + +class BaseModelFilterSet(FilterSet): + """ + Base ``FilterSet`` for model-generated filtersets. + + The filterset can be configured via ``Meta`` class attribute, + very much like how Django's ``ModelForm`` is configured. + """ + filter_options_class = ModelFilterSetOptions + + def get_filters(self): + """ + Get all filters defined in this filterset by introspecing + the given model in ``Meta.model``. + """ + filters = super(BaseModelFilterSet, self).get_filters() + + assert self.Meta.model, ( + '{name}.Meta.model is missing. Please specify the model ' + 'in order to use {name}.' + ''.format(name=self.__class__.__name__) + ) + + if self.Meta.fields is None: + self.Meta.fields = self._get_model_field_names() + + state = self._build_state() + + for name in self.Meta.fields: + if name in self.Meta.exclude: + continue + + try: + _filter = self._build_filter(name, state) + + except SkipFilter: + continue + + else: + if _filter is not None: + filters[name] = _filter + + return filters + + @abc.abstractmethod + def _get_model_field_names(self): + """ + Get a list of all model fields. + + This is used when ``Meta.fields`` is ``None`` + in which case this method returns all model fields. + + .. note:: + This method is an abstract method and must be implemented + in subclasses. + """ + + @abc.abstractmethod + def _build_filter(self, name, state): + """ + Build a filter for the field within the model by its name. + + .. note:: + This method is an abstract method and must be implemented + in subclasses. + + Parameters + ---------- + name : str + Name of the field for which to build the filter within the ``Meta.model`` + state + State of the model as returned by ``build_state``. + Since state is computed outside of the loop which builds + filters, state can be useful to store information outside + of the loop so that it can be reused for all filters. + """ + + def _build_state(self): + """ + Hook function to build state to be used while building all the filters. + Useful to compute common data between all filters such as some + data about the data so that the computation can be avoided while + building inidividual filters. + """ diff --git a/url_filter/filtersets/django.py b/url_filter/filtersets/django.py index 54ac273..07a2143 100644 --- a/url_filter/filtersets/django.py +++ b/url_filter/filtersets/django.py @@ -9,10 +9,10 @@ from ..exceptions import SkipFilter from ..filters import Filter from ..utils import SubClassDict -from .base import FilterSet +from .base import BaseModelFilterSet, ModelFilterSetOptions -__all__ = ['ModelFilterSet', 'ModelFilterSetOptions'] +__all__ = ['ModelFilterSet', 'DjangoModelFilterSetOptions'] MODEL_FIELD_OVERWRITES = SubClassDict({ @@ -21,87 +21,29 @@ }) -class ModelFilterSetOptions(object): +class DjangoModelFilterSetOptions(ModelFilterSetOptions): """ Custom options for ``FilterSet``s used for Django models. Attributes ---------- - model : Model - Django model class from which ``FilterSet`` will - extract necessary filters. - fields : None, list, optional - Specific model fields for which filters - should be created for. - By default it is ``None`` in which case for all - fields filters will be created for. - exclude : list, optional - Specific model fields for which filters - should not be created for. - allow_related : bool, optional allow_related_reverse : bool, optional """ def __init__(self, options=None): - self.model = getattr(options, 'model', None) - self.fields = getattr(options, 'fields', None) - self.exclude = getattr(options, 'exclude', []) - self.allow_related = getattr(options, 'allow_related', True) + super(DjangoModelFilterSetOptions, self).__init__(options) self.allow_related_reverse = getattr(options, 'allow_related_reverse', True) -class ModelFilterSet(FilterSet): +class ModelFilterSet(BaseModelFilterSet): """ ``FilterSet`` for Django models. The filterset can be configured via ``Meta`` class attribute, very much like Django's ``ModelForm`` is configured. """ - filter_options_class = ModelFilterSetOptions + filter_options_class = DjangoModelFilterSetOptions - def get_filters(self): - """ - Get all filters defined in this filterset including - filters corresponding to Django model fields. - """ - filters = super(ModelFilterSet, self).get_filters() - - assert self.Meta.model, ( - '{}.Meta.model is missing. Please specify the model ' - 'in order to use ModelFilterSet.' - ''.format(self.__class__.__name__) - ) - - if self.Meta.fields is None: - self.Meta.fields = self.get_model_field_names() - - for name in self.Meta.fields: - if name in self.Meta.exclude: - continue - - field = self.Meta.model._meta.get_field(name) - - try: - if isinstance(field, RelatedField): - if not self.Meta.allow_related: - raise SkipFilter - _filter = self.build_filterset_from_related_field(field) - elif isinstance(field, ForeignObjectRel): - if not self.Meta.allow_related_reverse: - raise SkipFilter - _filter = self.build_filterset_from_reverse_field(field) - else: - _filter = self.build_filter_from_field(field) - - except SkipFilter: - continue - - else: - if _filter is not None: - filters[name] = _filter - - return filters - - def get_model_field_names(self): + def _get_model_field_names(self): """ Get a list of all model fields. @@ -113,7 +55,7 @@ def get_model_field_names(self): self.Meta.model._meta.get_fields() )) - def get_form_field_for_field(self, field): + def _get_form_field_for_field(self, field): """ Get form field for the given Django model field. @@ -137,16 +79,32 @@ def get_form_field_for_field(self, field): return form_field - def build_filter_from_field(self, field): + def _build_filter(self, name, state): + field = self.Meta.model._meta.get_field(name) + + if isinstance(field, RelatedField): + if not self.Meta.allow_related: + raise SkipFilter + return self._build_filterset_from_related_field(field) + + elif isinstance(field, ForeignObjectRel): + if not self.Meta.allow_related_reverse: + raise SkipFilter + return self._build_filterset_from_reverse_field(field) + + else: + return self._build_filter_from_field(field) + + def _build_filter_from_field(self, field): """ Build ``Filter`` for a standard Django model field. """ return Filter( - form_field=self.get_form_field_for_field(field), + form_field=self._get_form_field_for_field(field), is_default=field.primary_key, ) - def build_filterset_from_related_field(self, field): + def _build_filterset_from_related_field(self, field): """ Build a ``FilterSet`` for a Django relation model field such as ``ForeignKey``. @@ -155,7 +113,7 @@ def build_filterset_from_related_field(self, field): 'exclude': [field.rel.name], }) - def build_filterset_from_reverse_field(self, field): + def _build_filterset_from_reverse_field(self, field): """ Build a ``FilterSet`` for a Django reverse relation model field. """ diff --git a/url_filter/filtersets/plain.py b/url_filter/filtersets/plain.py index 0898c6a..ba3f5fd 100644 --- a/url_filter/filtersets/plain.py +++ b/url_filter/filtersets/plain.py @@ -9,8 +9,7 @@ from ..exceptions import SkipFilter from ..filters import Filter from ..utils import SubClassDict, dictify -from .base import FilterSet -from .django import ModelFilterSetOptions +from .base import BaseModelFilterSet DATA_TYPES_MAPPING = SubClassDict({ @@ -25,68 +24,44 @@ }) -class PlainModelFilterSet(FilterSet): - filter_options_class = ModelFilterSetOptions +class PlainModelFilterSet(BaseModelFilterSet): + """ + ``FilterSet`` for plain Python objects. - def get_filters(self): - filters = super(PlainModelFilterSet, self).get_filters() + The filterset can be configured via ``Meta`` class attribute, + very much like Django's ``ModelForm`` is configured. + """ - assert self.Meta.model, ( - '{}.Meta.model is missing. Please specify the model ' - 'in order to use ModelFilterSet.' - ''.format(self.__class__.__name__) - ) + def _build_state(self): + return dictify(self.Meta.model) + + def _build_filter(self, name, model): + value = model.get(name) + primitive = DATA_TYPES_MAPPING.get(type(value)) + + if primitive: + return self.build_filter_from_field(value) + + elif isinstance(value, (list, tuple, set)) and value: + value = list(value)[0] + if not isinstance(value, dict): + raise SkipFilter + if not self.Meta.allow_related: + raise SkipFilter + return self.build_filterset_from_related_field(name, value) + + elif isinstance(value, dict): + if not self.Meta.allow_related: + raise SkipFilter + return self.build_filterset_from_related_field(name, value) - if self.Meta.fields is None: - self.Meta.fields = self.get_model_field_names() - - model = dictify(self.Meta.model) - - for name in self.Meta.fields: - if name in self.Meta.exclude: - continue - - value = model.get(name) - _filter = None - primitive = DATA_TYPES_MAPPING.get(type(value)) - - try: - if primitive: - _filter = self.build_filter_from_field(value) - elif isinstance(value, (list, tuple, set)) and value: - value = list(value)[0] - if not isinstance(value, dict): - raise SkipFilter - if not self.Meta.allow_related: - raise SkipFilter - _filter = self.build_filterset_from_related_field(name, value) - elif isinstance(value, dict): - if not self.Meta.allow_related: - raise SkipFilter - _filter = self.build_filterset_from_related_field(name, value) - - except SkipFilter: - continue - - else: - if _filter is not None: - filters[name] = _filter - - return filters - - def get_model_field_names(self): - """ - Get a list of all model fields. - - This is used when ``Meta.fields`` is ``None`` - in which case this method returns all model fields. - """ + def _get_model_field_names(self): return list(dictify(self.Meta.model).keys()) - def build_filter_from_field(self, field): + def _build_filter_from_field(self, field): return Filter(form_field=DATA_TYPES_MAPPING.get(type(field))) - def build_filterset_from_related_field(self, name, field): + def _build_filterset_from_related_field(self, name, field): meta = type(str('Meta'), (object,), {'model': field}) filterset = type( diff --git a/url_filter/filtersets/sqlalchemy.py b/url_filter/filtersets/sqlalchemy.py index c54d25c..4f66058 100644 --- a/url_filter/filtersets/sqlalchemy.py +++ b/url_filter/filtersets/sqlalchemy.py @@ -29,8 +29,7 @@ from ..exceptions import SkipFilter from ..filters import Filter from ..utils import SubClassDict -from .base import FilterSet -from .django import ModelFilterSetOptions +from .base import BaseModelFilterSet __all__ = ['SQLAlchemyModelFilterSet'] @@ -59,59 +58,29 @@ }) -class SQLAlchemyModelFilterSet(FilterSet): +class SQLAlchemyModelFilterSet(BaseModelFilterSet): """ ``FilterSet`` for SQLAlchemy models. The filterset can be configured via ``Meta`` class attribute, very much like Django's ``ModelForm`` is configured. """ - filter_options_class = ModelFilterSetOptions - def get_filters(self): - """ - Get all filters defined in this filterset including - filters corresponding to Django model fields. - """ - filters = super(SQLAlchemyModelFilterSet, self).get_filters() - - assert self.Meta.model, ( - '{}.Meta.model is missing. Please specify the model ' - 'in order to use ModelFilterSet.' - ''.format(self.__class__.__name__) - ) - - if self.Meta.fields is None: - self.Meta.fields = self.get_model_field_names() - - fields = SQLAlchemyFilterBackend._get_properties_for_model(self.Meta.model) - - for name in self.Meta.fields: - if name in self.Meta.exclude: - continue - - field = fields[name] - - try: - _filter = None - - if isinstance(field, ColumnProperty): - _filter = self.build_filter_from_field(field) - elif isinstance(field, RelationshipProperty): - if not self.Meta.allow_related: - raise SkipFilter - _filter = self.build_filterset_from_related_field(field) + def _build_filter(self, name, fields): + field = fields[name] - except SkipFilter: - continue + if isinstance(field, ColumnProperty): + return self._build_filter_from_field(field) - else: - if _filter is not None: - filters[name] = _filter + elif isinstance(field, RelationshipProperty): + if not self.Meta.allow_related: + raise SkipFilter + return self._build_filterset_from_related_field(field) - return filters + def _build_state(self): + return SQLAlchemyFilterBackend._get_properties_for_model(self.Meta.model) - def get_model_field_names(self): + def _get_model_field_names(self): """ Get a list of all model fields. @@ -120,7 +89,7 @@ def get_model_field_names(self): """ return list(SQLAlchemyFilterBackend._get_properties_for_model(self.Meta.model).keys()) - def get_form_field_for_field(self, field): + def _get_form_field_for_field(self, field): """ Get form field for the given SQLAlchemy model field. """ @@ -138,18 +107,18 @@ def get_form_field_for_field(self, field): else: return form_field(field, column) - def build_filter_from_field(self, field): + def _build_filter_from_field(self, field): """ Build ``Filter`` for a standard SQLAlchemy model field. """ column = SQLAlchemyFilterBackend._get_column_for_field(field) return Filter( - form_field=self.get_form_field_for_field(field), + form_field=self._get_form_field_for_field(field), is_default=column.primary_key, ) - def build_filterset_from_related_field(self, field): + def _build_filterset_from_related_field(self, field): m = SQLAlchemyFilterBackend._get_related_model_for_field(field) meta = { 'model': m, diff --git a/url_filter/utils.py b/url_filter/utils.py index f611a79..ba577d9 100644 --- a/url_filter/utils.py +++ b/url_filter/utils.py @@ -193,6 +193,13 @@ def get(self, k, d=None): def dictify(obj): + """ + Convert any object to a dictionary. + + If the given object is already an instance of a dict, + it is directly returned. If not, then all the public + attributes of the object are returned as a dict. + """ if isinstance(obj, dict): return obj else: From 7277631274fec3af8ddeb7ff04b38ef47822e039 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Tue, 15 Sep 2015 20:53:05 -0400 Subject: [PATCH 05/16] reduced duplication for building filtersets --- url_filter/filtersets/base.py | 27 +++++++++++++++++++++++++++ url_filter/filtersets/django.py | 24 +++++++++--------------- url_filter/filtersets/plain.py | 21 +++++++-------------- url_filter/filtersets/sqlalchemy.py | 20 ++++++-------------- 4 files changed, 49 insertions(+), 43 deletions(-) diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index 07cb4b2..efe2429 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -475,6 +475,33 @@ def _build_filter(self, name, state): of the loop so that it can be reused for all filters. """ + def _build_filterset(self, name, meta_attrs, base): + """ + Helper method for building filtersets. + + Parameters + ---------- + name : str + Name of the filterset to build. The returned class + will use the name as a prefix in the class name. + meta_attrs : dict + Attributes to use for the ``Meta``. + base : type + Class to use as a base class for the filterset. + """ + meta = type(str('Meta'), (object,), meta_attrs) + + filterset = type( + str('{}FilterSet'.format(name)), + (base,), + { + 'Meta': meta, + '__module__': self.__module__, + } + ) + + return filterset() + def _build_state(self): """ Hook function to build state to be used while building all the filters. diff --git a/url_filter/filtersets/django.py b/url_filter/filtersets/django.py index 07a2143..ac41522 100644 --- a/url_filter/filtersets/django.py +++ b/url_filter/filtersets/django.py @@ -109,7 +109,7 @@ def _build_filterset_from_related_field(self, field): Build a ``FilterSet`` for a Django relation model field such as ``ForeignKey``. """ - return self._build_filterset(field, { + return self._build_django_filterset(field, { 'exclude': [field.rel.name], }) @@ -117,23 +117,17 @@ def _build_filterset_from_reverse_field(self, field): """ Build a ``FilterSet`` for a Django reverse relation model field. """ - return self._build_filterset(field, { + return self._build_django_filterset(field, { 'exclude': [field.field.name], }) - def _build_filterset(self, field, meta): + def _build_django_filterset(self, field, meta_attrs): m = field.related_model - meta.update({'model': m}) + attrs = {'model': m} + attrs.update(meta_attrs) - meta = type(str('Meta'), (object,), meta) - - filterset = type( - str('{}FilterSet'.format(m.__name__)), - (ModelFilterSet,), - { - 'Meta': meta, - '__module__': self.__module__, - } + return self._build_filterset( + m.__name__, + attrs, + ModelFilterSet, ) - - return filterset() diff --git a/url_filter/filtersets/plain.py b/url_filter/filtersets/plain.py index ba3f5fd..56ec9bb 100644 --- a/url_filter/filtersets/plain.py +++ b/url_filter/filtersets/plain.py @@ -40,7 +40,7 @@ def _build_filter(self, name, model): primitive = DATA_TYPES_MAPPING.get(type(value)) if primitive: - return self.build_filter_from_field(value) + return self._build_filter_from_field(value) elif isinstance(value, (list, tuple, set)) and value: value = list(value)[0] @@ -48,12 +48,12 @@ def _build_filter(self, name, model): raise SkipFilter if not self.Meta.allow_related: raise SkipFilter - return self.build_filterset_from_related_field(name, value) + return self._build_filterset_from_related_field(name, value) elif isinstance(value, dict): if not self.Meta.allow_related: raise SkipFilter - return self.build_filterset_from_related_field(name, value) + return self._build_filterset_from_related_field(name, value) def _get_model_field_names(self): return list(dictify(self.Meta.model).keys()) @@ -62,15 +62,8 @@ def _build_filter_from_field(self, field): return Filter(form_field=DATA_TYPES_MAPPING.get(type(field))) def _build_filterset_from_related_field(self, name, field): - meta = type(str('Meta'), (object,), {'model': field}) - - filterset = type( - str('{}FilterSet'.format(name.title())), - (PlainModelFilterSet,), - { - 'Meta': meta, - '__module__': self.__module__, - } + return self._build_filterset( + name.title(), + {'model': field}, + PlainModelFilterSet, ) - - return filterset() diff --git a/url_filter/filtersets/sqlalchemy.py b/url_filter/filtersets/sqlalchemy.py index 4f66058..8702df3 100644 --- a/url_filter/filtersets/sqlalchemy.py +++ b/url_filter/filtersets/sqlalchemy.py @@ -120,20 +120,12 @@ def _build_filter_from_field(self, field): def _build_filterset_from_related_field(self, field): m = SQLAlchemyFilterBackend._get_related_model_for_field(field) - meta = { - 'model': m, - 'exclude': [field.back_populates] - } - meta = type(str('Meta'), (object,), meta) - - filterset = type( - str('{}FilterSet'.format(m.__name__)), - (SQLAlchemyModelFilterSet,), + return self._build_filterset( + m.__name__, { - 'Meta': meta, - '__module__': self.__module__, - } + 'model': m, + 'exclude': [field.back_populates] + }, + SQLAlchemyModelFilterSet, ) - - return filterset() From 8a2585b8154f105d600cdd243700717231e61eda Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Wed, 16 Sep 2015 21:53:08 -0400 Subject: [PATCH 06/16] added test cases for plain filtering backend --- tests/backends/test_plain.py | 238 +++++++++++++++++++++++++++++++++ tests/filtersets/test_plain.py | 98 ++++++++++++++ url_filter/backends/plain.py | 5 +- 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 tests/backends/test_plain.py create mode 100644 tests/filtersets/test_plain.py diff --git a/tests/backends/test_plain.py b/tests/backends/test_plain.py new file mode 100644 index 0000000..d510951 --- /dev/null +++ b/tests/backends/test_plain.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals +from datetime import datetime + +from url_filter.backends.plain import PlainFilterBackend +from url_filter.utils import FilterSpec + + +class Bunch(object): + def __init__(self, **kwargs): + self.__dict__ = kwargs + + +DATA = [ + { + "id": 1, + "restaurant": { + "place": 1, + "waiters": [ + { + "id": 1, + "name": "Joe", + "restaurant": 1 + }, + { + "id": 2, + "name": "Jonny", + "restaurant": 1 + } + ], + "serves_hot_dogs": True, + "serves_pizza": False + }, + "name": "Demon Dogs", + "address": "944 W. Fullerton", + "created": datetime(2015, 6, 12, 9, 30, 55), + }, + { + "id": 2, + "restaurant": { + "place": 2, + "waiters": [ + Bunch(**{ + "id": 3, + "name": "Steve", + "restaurant": 2 + }) + ], + "serves_hot_dogs": True, + "serves_pizza": False + }, + "name": "Ace Hardware", + "address": "1013 N. Ashland", + "created": datetime(2014, 5, 12, 14, 30, 37), + "nulldata": None, + } +] + + +class TestPlainFilterBackend(object): + def test_get_model(self): + backend = PlainFilterBackend([]) + + assert backend.get_model() is object + assert not backend.enforce_same_models + + def test_filter_no_specs(self): + qs = ['hello'] + backend = PlainFilterBackend(qs) + backend.bind([]) + + assert backend.filter() is qs + + def _test_filter(self, spec, expected): + backend = PlainFilterBackend(DATA) + backend.bind([spec]) + + assert list(backend.filter()) == expected + + def test_filter_contains(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'contains', 'Jo', False), + [DATA[0]] + ) + + def test_filter_endswith(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'endswith', 'e', False), + DATA + ) + + def test_filter_exact(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'exact', 'John', False), + [] + ) + + def test_filter_exact_negated(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'exact', 'John', True), + DATA + ) + + def test_filter_gt(self): + self._test_filter( + FilterSpec(['id'], 'gt', 1, False), + [DATA[1]] + ) + + def test_filter_gte(self): + self._test_filter( + FilterSpec(['id'], 'gte', 1, False), + DATA + ) + + def test_filter_icontains(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'icontains', 'jo', False), + [DATA[0]] + ) + + def test_filter_iendswith(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'iendswith', 'E', False), + DATA + ) + + def test_filter_iexact(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'iexact', 'joe', False), + [DATA[0]] + ) + + def test_filter_in(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'in', ['John', 'Steve'], False), + [DATA[1]] + ) + + def test_filter_isnull(self): + self._test_filter( + FilterSpec(['nulldata'], 'isnull', True, False), + [DATA[1]] + ) + self._test_filter( + FilterSpec(['nulldata'], 'isnull', False, False), + [DATA[0]] + ) + + def test_filter_istartswith(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'istartswith', 'j', False), + [DATA[0]] + ) + + def test_filter_lt(self): + self._test_filter( + FilterSpec(['id'], 'lt', 2, False), + [DATA[0]] + ) + + def test_filter_lte(self): + self._test_filter( + FilterSpec(['id'], 'lte', 2, False), + DATA + ) + + def test_filter_range(self): + self._test_filter( + FilterSpec(['id'], 'range', [1, 2], False), + DATA + ) + + def test_filter_startswith(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'startswith', 'J', False), + [DATA[0]] + ) + + def test_filter_regex(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'regex', r'^J.*', False), + [DATA[0]] + ) + + def test_filter_iregex(self): + self._test_filter( + FilterSpec(['restaurant', 'waiters', 'name'], 'iregex', r'^j.*', False), + [DATA[0]] + ) + + def test_filter_day(self): + self._test_filter( + FilterSpec(['created'], 'day', 12, False), + DATA + ) + + def test_filter_hour(self): + self._test_filter( + FilterSpec(['created'], 'hour', 9, False), + [DATA[0]] + ) + + def test_filter_second(self): + self._test_filter( + FilterSpec(['created'], 'second', 37, False), + [DATA[1]] + ) + + def test_filter_minute(self): + self._test_filter( + FilterSpec(['created'], 'minute', 30, False), + DATA + ) + + def test_filter_month(self): + self._test_filter( + FilterSpec(['created'], 'month', 5, False), + [DATA[1]] + ) + + def test_filter_year(self): + self._test_filter( + FilterSpec(['created'], 'year', 2015, False), + [DATA[0]] + ) + + def test_filter_week_day(self): + self._test_filter( + FilterSpec(['created'], 'week_day', 1, False), + [DATA[1]] + ) + + def test_filter_exception_handling(self): + self._test_filter( + FilterSpec(['id'], 'week_day', 1, False), + DATA + ) diff --git a/tests/filtersets/test_plain.py b/tests/filtersets/test_plain.py new file mode 100644 index 0000000..99edf70 --- /dev/null +++ b/tests/filtersets/test_plain.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function, unicode_literals + +from django import forms + +from url_filter.filters import Filter +from url_filter.filtersets.plain import PlainModelFilterSet + + +class TestPlainModelFilterSet(object): + def test_get_filters_no_relations_place(self): + class PlaceFilterSet(PlainModelFilterSet): + class Meta(object): + model = { + "id": 1, + "restaurant": { + "place": 1, + "waiters": [ + { + "id": 1, + "name": "Joe", + "restaurant": 1 + }, + { + "id": 2, + "name": "Jonny", + "restaurant": 1 + } + ], + "serves_hot_dogs": True, + "serves_pizza": False, + }, + "name": "Demon Dogs", + "address": "944 W. Fullerton", + "ignored": [{}], + } + allow_related = False + + filters = PlaceFilterSet().get_filters() + + assert set(filters.keys()) == { + 'id', 'name', 'address', + } + + assert isinstance(filters['id'], Filter) + assert isinstance(filters['id'].form_field, forms.IntegerField) + assert isinstance(filters['name'], Filter) + assert isinstance(filters['name'].form_field, forms.CharField) + assert isinstance(filters['address'], Filter) + assert isinstance(filters['address'].form_field, forms.CharField) + + def test_get_filters_with_both_reverse_and_direct_relations(self): + class PlaceFilterSet(PlainModelFilterSet): + class Meta(object): + model = { + "id": 1, + "restaurant": { + "place": 1, + "waiters": [ + { + "id": 1, + "name": "Joe", + "restaurant": 1 + }, + { + "id": 2, + "name": "Jonny", + "restaurant": 1 + }, + ], + "serves_hot_dogs": True, + "serves_pizza": False, + "ignored": [5], + }, + "name": "Demon Dogs", + "address": "944 W. Fullerton" + } + + filters = PlaceFilterSet().get_filters() + + assert set(filters.keys()) == { + 'id', 'address', 'name', 'restaurant', + } + assert set(filters['restaurant'].filters.keys()) == { + 'place', 'serves_hot_dogs', 'serves_pizza', 'waiters' + } + assert set(filters['restaurant'].filters['waiters'].filters.keys()) == { + 'id', 'name', 'restaurant', + } + + assert isinstance(filters['id'], Filter) + assert isinstance(filters['id'].form_field, forms.IntegerField) + assert isinstance(filters['address'], Filter) + assert isinstance(filters['address'].form_field, forms.CharField) + assert isinstance(filters['name'], Filter) + assert isinstance(filters['name'].form_field, forms.CharField) + assert isinstance(filters['restaurant'], PlainModelFilterSet) + assert isinstance(filters['restaurant'].filters['waiters'], PlainModelFilterSet) diff --git a/url_filter/backends/plain.py b/url_filter/backends/plain.py index 73d7872..7d87201 100644 --- a/url_filter/backends/plain.py +++ b/url_filter/backends/plain.py @@ -56,7 +56,10 @@ def filter_by_spec(self, item, spec): def _filter_by_spec_and_value(self, item, components, spec): if not components: comparator = getattr(self, '_compare_{}'.format(spec.lookup)) - return comparator(item, spec) + try: + return comparator(item, spec) + except Exception: + return True if isinstance(item, (list, tuple)): return any( From 03fcfae019a58784e1d7d7adca210f1b85abaccc Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Wed, 7 Oct 2015 22:41:22 -0400 Subject: [PATCH 07/16] added support for custom callable filters also added base FilterBase class which allows for all subclasses to utilize __init__ as a standard initialization method added example of using custom CallableFilter in one_to_one.Place API --- test_project/one_to_one/api.py | 42 ++++++ tests/filtersets/test_base.py | 4 +- tests/test_filters.py | 2 +- url_filter/backends/base.py | 81 ++++++++++- url_filter/backends/django.py | 15 +-- url_filter/backends/plain.py | 11 +- url_filter/backends/sqlalchemy.py | 14 +- url_filter/filters.py | 215 +++++++++++++++++++++--------- url_filter/filtersets/base.py | 12 +- url_filter/utils.py | 26 +++- 10 files changed, 320 insertions(+), 102 deletions(-) diff --git a/test_project/one_to_one/api.py b/test_project/one_to_one/api.py index f71aa6b..ea8fa18 100644 --- a/test_project/one_to_one/api.py +++ b/test_project/one_to_one/api.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import print_function, unicode_literals +import operator +from django import forms from django.http import Http404 from rest_framework.response import Response from rest_framework.serializers import ModelSerializer @@ -8,6 +10,7 @@ from url_filter.backends.plain import PlainFilterBackend from url_filter.backends.sqlalchemy import SQLAlchemyFilterBackend +from url_filter.filters import CallableFilter, form_field_for_filter from url_filter.filtersets import ModelFilterSet from url_filter.filtersets.plain import PlainModelFilterSet from url_filter.filtersets.sqlalchemy import SQLAlchemyModelFilterSet @@ -62,13 +65,51 @@ class Meta(object): model = Place +class PlaceWaiterCallableFilter(CallableFilter): + @form_field_for_filter(forms.CharField()) + def filter_exact_for_django(self, queryset, spec): + f = queryset.filter if not spec.is_negated else queryset.exclude + return f(restaurant__waiter__name=spec.value) + + @form_field_for_filter(forms.CharField()) + def filter_exact_for_sqlalchemy(self, queryset, spec): + op = operator.eq if not spec.is_negated else operator.ne + return ( + queryset + .join(alchemy.Place.restaurant) + .join(alchemy.Restaurant.waiter_set) + .filter(op(alchemy.Waiter.name, spec.value)) + ) + + @form_field_for_filter(forms.CharField()) + def filter_exact_for_plain(self, queryset, spec): + def identity(x): + return x + + def negate(x): + return not x + + op = identity if not spec.is_negated else negate + return filter( + lambda i: op(self.root.filter_backend._filter_by_spec_and_value( + item=i, + components=['restaurant', 'waiters', 'name'], + spec=spec, + )), + queryset + ) + + class PlaceFilterSet(ModelFilterSet): + waiter = PlaceWaiterCallableFilter(no_lookup=True) + class Meta(object): model = Place class PlainPlaceFilterSet(PlainModelFilterSet): filter_backend_class = PlainFilterBackend + waiter = PlaceWaiterCallableFilter(no_lookup=True) class Meta(object): model = { @@ -97,6 +138,7 @@ class Meta(object): class SQLAlchemyPlaceFilterSet(SQLAlchemyModelFilterSet): filter_backend_class = SQLAlchemyFilterBackend + waiter = PlaceWaiterCallableFilter(no_lookup=True) class Meta(object): model = alchemy.Place diff --git a/tests/filtersets/test_base.py b/tests/filtersets/test_base.py index a41b118..36c3d19 100644 --- a/tests/filtersets/test_base.py +++ b/tests/filtersets/test_base.py @@ -36,9 +36,9 @@ class BarFilterSet(FilterSet): assert repr(BarFilterSet()) == ( 'BarFilterSet()\n' - ' bar = Filter(form_field=IntegerField, lookups=ALL, default_lookup="exact", is_default=False)\n' + ' bar = Filter(form_field=IntegerField, lookups=ALL, default_lookup="exact", is_default=False, no_lookup=False)\n' ' foo = FooFilterSet()\n' - ' foo = Filter(form_field=CharField, lookups=ALL, default_lookup="exact", is_default=False)' + ' foo = Filter(form_field=CharField, lookups=ALL, default_lookup="exact", is_default=False, no_lookup=False)' ) def test_get_filters(self): diff --git a/tests/test_filters.py b/tests/test_filters.py index 28e990c..a3b6cec 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -55,7 +55,7 @@ def test_repr(self): assert repr(f) == ( 'Filter(form_field=CharField, lookups=ALL, ' - 'default_lookup="foo", is_default=True)' + 'default_lookup="foo", is_default=True, no_lookup=False)' ) def test_source(self): diff --git a/url_filter/backends/base.py b/url_filter/backends/base.py index a41bbcd..bd6926a 100644 --- a/url_filter/backends/base.py +++ b/url_filter/backends/base.py @@ -7,6 +7,25 @@ class BaseFilterBackend(six.with_metaclass(abc.ABCMeta, object)): + """ + Base filter backend from which all other backends must subclass. + + Parameters + ---------- + queryset + Iterable which this filter backend will eventually filter. + The type of the iterable depends on the filter backend. + For example for ``DjangoFilterBackend``, ``QuerySet`` needs + to be passed. + context: dict + Context dictionary. It could contain any information + which potentially could be useful to filter given + queryset. That can include, request, view, view kwargs, etc. + The idea is similar to DRF serializers. By passing the context, + it allows custom filters to reference all the information + they need to be able to effectively filter data. + """ + name = None supported_lookups = set() enforce_same_models = True @@ -17,11 +36,42 @@ def __init__(self, queryset, context=None): @cached_property def model(self): + """ + Property for getting model on which this filter backend operates. + + The main use for this is being able to validate that correct + filterset is being used to filter some data. + """ return self.get_model() def bind(self, specs): + """ + Bind the given specs to the filter backend. + + This allows the filterset to be instantiated first before + filter specs are constructed and later, specs can be binded. + """ self.specs = specs + @cached_property + def regular_specs(self): + """ + Property for getting standard filter specifications + which can be used directly by the filter backend + to filter queryset. + """ + return [i for i in self.specs if not i.is_callable] + + @cached_property + def callable_specs(self): + """ + Property for getting custom filter specifications + which have a filter callable for filtering querysets. + These specifications cannot be directly used by filter + backend and have to be called manually to filter data. + """ + return [i for i in self.specs if i.is_callable] + @abc.abstractmethod def get_model(self): """ @@ -30,10 +80,37 @@ def get_model(self): .. note:: **MUST** be implemented by subclasses """ - @abc.abstractmethod def filter(self): """ - Main method for filtering queryset. + Main public method for filtering querysets. + """ + qs = self.filter_by_specs(self.queryset) + qs = self.filter_by_callables(qs) + return qs + + @abc.abstractmethod + def filter_by_specs(self, queryset): + """ + Method for filtering queryset by using standard filter specs. .. note:: **MUST** be implemented by subclasses """ + + def filter_by_callables(self, queryset): + """ + Method for filtering queryset by using custom filter callables + as given in the ``Filter`` definition. + + This is really meant to accomodate filtering with simple + filter keys having complex filtering logic behind them. + """ + if not self.callable_specs: + return queryset + + for spec in self.callable_specs: + queryset = spec.filter_callable( + queryset=queryset, + spec=spec, + ) + + return queryset diff --git a/url_filter/backends/django.py b/url_filter/backends/django.py index d900c25..8bd8809 100644 --- a/url_filter/backends/django.py +++ b/url_filter/backends/django.py @@ -7,6 +7,7 @@ class DjangoFilterBackend(BaseFilterBackend): + name = 'django' supported_lookups = { 'contains', 'day', @@ -41,14 +42,14 @@ def get_model(self): def includes(self): return filter( lambda i: not i.is_negated, - self.specs + self.regular_specs ) @property def excludes(self): return filter( lambda i: i.is_negated, - self.specs + self.regular_specs ) def prepare_spec(self, spec): @@ -58,15 +59,13 @@ def prepare_spec(self, spec): spec.lookup, ) - def filter(self): + def filter_by_specs(self, queryset): include = {self.prepare_spec(i): i.value for i in self.includes} exclude = {self.prepare_spec(i): i.value for i in self.excludes} - qs = self.queryset - if include: - qs = qs.filter(**include) + queryset = queryset.filter(**include) if exclude: - qs = qs.exclude(**exclude) + queryset = queryset.exclude(**exclude) - return qs + return queryset diff --git a/url_filter/backends/plain.py b/url_filter/backends/plain.py index 7d87201..5e762db 100644 --- a/url_filter/backends/plain.py +++ b/url_filter/backends/plain.py @@ -7,6 +7,7 @@ class PlainFilterBackend(BaseFilterBackend): + name = 'plain' enforce_same_models = False supported_lookups = { 'contains', @@ -38,14 +39,14 @@ class PlainFilterBackend(BaseFilterBackend): def get_model(self): return object - def filter(self): - if not self.specs: - return self.queryset + def filter_by_specs(self, queryset): + if not self.regular_specs: + return queryset - return filter(self.filter_callable, self.queryset) + return filter(self.filter_callable, queryset) def filter_callable(self, item): - return all(self.filter_by_spec(item, spec) for spec in self.specs) + return all(self.filter_by_spec(item, spec) for spec in self.regular_specs) def filter_by_spec(self, item, spec): filtered = self._filter_by_spec_and_value(item, spec.components, spec) diff --git a/url_filter/backends/sqlalchemy.py b/url_filter/backends/sqlalchemy.py index aaeeb99..0db46fa 100644 --- a/url_filter/backends/sqlalchemy.py +++ b/url_filter/backends/sqlalchemy.py @@ -17,6 +17,7 @@ def lower(value): class SQLAlchemyFilterBackend(BaseFilterBackend): + name = 'sqlalchemy' supported_lookups = { 'contains', 'endswith', @@ -47,19 +48,18 @@ def __init__(self, *args, **kwargs): def get_model(self): return self.queryset._primary_entity.entities[0] - def filter(self): - if not self.specs: - return self.queryset + def filter_by_specs(self, queryset): + if not self.regular_specs: + return queryset - clauses = [self.build_clause(spec) for spec in self.specs] + clauses = [self.build_clause(spec) for spec in self.regular_specs] conditions, joins = zip(*clauses) joins = list(itertools.chain(*joins)) - qs = self.queryset if joins: - qs = qs.join(*joins) + queryset = queryset.join(*joins) - return qs.filter(*conditions) + return queryset.filter(*conditions) def build_clause(self, spec): to_join = [] diff --git a/url_filter/filters.py b/url_filter/filters.py index 7289a64..3ef282b 100644 --- a/url_filter/filters.py +++ b/url_filter/filters.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals -from functools import partial +import abc +import re +from functools import partial, wraps import six from cached_property import cached_property @@ -27,8 +29,76 @@ 'year': forms.IntegerField(min_value=0, max_value=9999), } +LOOKUP_CALLABLE_FROM_METHOD_REGEX = re.compile(r'^filter_([\w\d]+)_for_[\w\d]+$') -class Filter(object): + +class BaseFilter(six.with_metaclass(abc.ABCMeta, object)): + def __init__(self, source=None, *args, **kwargs): + self._source = source + self.parent = None + self.name = None + + def __repr__(self): + data = self.repr() + data = data if six.PY3 else data.encode('utf-8') + return data + + @abc.abstractmethod + def repr(self, prefix=''): + """ + """ + + @property + def source(self): + """ + Source field/attribute in queryset model to be used for filtering. + + This property is helpful when ``source`` parameter is not provided + when instantiating ``Filter`` since it will use the filter name + as it is defined in the ``FilterSet``. For example:: + + >>> class MyFilterSet(FilterSet): + ... foo = Filter(form_field=CharField()) + ... bar = Filter(source='stuff', form_field=CharField()) + >>> fs = MyFilterSet() + >>> print(fs.fields['foo'].source) + foo + >>> print(fs.fields['bar'].source) + stuff + """ + return self._source or self.name + + @property + def components(self): + """ + List of all components (source names) of all parent filtersets. + """ + if self.parent is None: + return [] + return self.parent.components + [self.source] + + def bind(self, name, parent): + """ + Bind the filter to the filterset. + + This method should be used by the parent ``FilterSet`` + since it allows to specify the parent and name of each + filter within the filterset. + """ + self.name = name + self.parent = parent + + @property + def root(self): + """ + This gets the root filterset. + """ + if self.parent is None: + return self + return self.parent.root + + +class Filter(BaseFilter): """ Filter class which main job is to convert leaf ``LookupConfig`` to ``FilterSpec``. @@ -78,17 +148,16 @@ class Filter(object): Name of the field as it is defined in parent ``FilterSet`` """ - def __init__(self, source=None, *args, **kwargs): - self._source = source - self.parent = None - self.name = None - self._init(*args, **kwargs) - - def _init(self, form_field, lookups=None, default_lookup='exact', is_default=False): + def __init__(self, form_field, + lookups=None, default_lookup='exact', + is_default=False, no_lookup=False, + *args, **kwargs): + super(Filter, self).__init__(*args, **kwargs) self.form_field = form_field self._given_lookups = lookups self.default_lookup = default_lookup or self.default_lookup self.is_default = is_default + self.no_lookup = no_lookup def repr(self, prefix=''): return ( @@ -96,20 +165,17 @@ def repr(self, prefix=''): 'form_field={form_field}, ' 'lookups={lookups}, ' 'default_lookup="{default_lookup}", ' - 'is_default={is_default}' + 'is_default={is_default}, ' + 'no_lookup={no_lookup}' ')' ''.format(name=self.__class__.__name__, form_field=self.form_field.__class__.__name__, lookups=self._given_lookups or 'ALL', default_lookup=self.default_lookup, - is_default=self.is_default) + is_default=self.is_default, + no_lookup=self.no_lookup) ) - def __repr__(self): - data = self.repr() - data = data if six.PY3 else data.encode('utf-8') - return data - @cached_property def lookups(self): if self._given_lookups: @@ -118,55 +184,6 @@ def lookups(self): return self.root.filter_backend.supported_lookups return set() - @property - def source(self): - """ - Source field/attribute in queryset model to be used for filtering. - - This property is helpful when ``source`` parameter is not provided - when instantiating ``Filter`` since it will use the filter name - as it is defined in the ``FilterSet``. For example:: - - >>> class MyFilterSet(FilterSet): - ... foo = Filter(form_field=CharField()) - ... bar = Filter(source='stuff', form_field=CharField()) - >>> fs = MyFilterSet() - >>> print(fs.fields['foo'].source) - foo - >>> print(fs.fields['bar'].source) - stuff - """ - return self._source or self.name - - @property - def components(self): - """ - List of all components (source names) of all parent filtersets. - """ - if self.parent is None: - return [] - return self.parent.components + [self.source] - - def bind(self, name, parent): - """ - Bind the filter to the filterset. - - This method should be used by the parent ``FilterSet`` - since it allows to specify the parent and name of each - filter within the filterset. - """ - self.name = name - self.parent = parent - - @property - def root(self): - """ - This gets the root filterset. - """ - if self.parent is None: - return self - return self.parent.root - def get_form_field(self, lookup): """ Get the form field for a particular lookup. @@ -239,6 +256,12 @@ def get_spec(self, config): 'after the final lookup (e.g. field__in__equal=value).' ) + if self.no_lookup: + raise ValidationError( + 'Lookup was explicit used in filter specification. ' + 'This filter does not allow to specify lookup.' + ) + lookup = config.name value = config.value.data @@ -254,3 +277,65 @@ def get_spec(self, config): value = self.clean_value(value, lookup) return FilterSpec(self.components, lookup, value, is_negated) + + +def form_field_for_filter(form_field): + def wrapper(f): + @wraps(f) + def inner(self, *args, **kwargs): + return f(self, *args, **kwargs) + + inner.form_field = form_field + + return inner + + return wrapper + + +class CallableFilter(Filter): + def __init__(self, form_field=None, *args, **kwargs): + # need to overwrite to make form_field optional + super(CallableFilter, self).__init__(form_field, *args, **kwargs) + + @cached_property + def lookups(self): + lookups = super(CallableFilter, self).lookups + + r = LOOKUP_CALLABLE_FROM_METHOD_REGEX + custom_lookups = {m.group(0) for m in (r.match(i) for i in dir(self)) if m} + + return lookups | custom_lookups + + def _get_filter_method_for_lookup(self, lookup): + name = 'filter_{}_for_{}'.format(lookup, self.root.filter_backend.name) + return getattr(self, name, None) + + def _is_callable_filter(self, lookup): + return bool(self._get_filter_method_for_lookup(lookup)) + + def get_form_field(self, lookup): + if self._is_callable_filter(lookup): + filter_method = self._get_filter_method_for_lookup(lookup) + + form_field = getattr(filter_method, 'form_field', None) + if form_field is not None: + return form_field + + form_field = super(CallableFilter, self).get_form_field(lookup) + + assert form_field is not None, ( + '{name} was not provided form_field parameter in initialization ' + '(e.g. {name}(form_field=CharField)) and form_field was not ' + 'provided for the lookup. If the lookup is a custom filter callable ' + 'you should profile form_field by using @form_field_for_filter ' + 'decorator. If the lookup is a normal lookup, then please either ' + 'provide form_field paramer or overwrite get_form_field().' + ''.format(name=self.__class__.__name__) + ) + + return form_field + + def get_spec(self, config): + spec = super(CallableFilter, self).get_spec(config) + spec.filter_callable = self._get_filter_method_for_lookup(spec.lookup) + return spec diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index efe2429..a29a18d 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -15,7 +15,7 @@ from ..backends.django import DjangoFilterBackend from ..exceptions import SkipFilter -from ..filters import Filter +from ..filters import BaseFilter from ..utils import LookupConfig @@ -94,7 +94,7 @@ def __new__(cls, name, bases, attrs): filters = {} for base in [vars(base) for base in bases] + [attrs]: - filters.update({k: v for k, v in base.items() if isinstance(v, Filter)}) + filters.update({k: v for k, v in base.items() if isinstance(v, BaseFilter)}) new_class._declared_filters = filters new_class.Meta = new_class.filter_options_class( @@ -104,7 +104,7 @@ def __new__(cls, name, bases, attrs): return new_class -class FilterSet(six.with_metaclass(FilterSetMeta, Filter)): +class FilterSet(six.with_metaclass(FilterSetMeta, BaseFilter)): """ Main user-facing classes to use filtersets. @@ -155,8 +155,10 @@ class FilterSet(six.with_metaclass(FilterSetMeta, Filter)): filter_backend_class = DjangoFilterBackend filter_options_class = FilterSetOptions - def _init(self, data=None, queryset=None, context=None, - strict_mode=StrictMode.drop): + def __init__(self, data=None, queryset=None, context=None, + strict_mode=StrictMode.drop, + *args, **kwargs): + super(FilterSet, self).__init__(*args, **kwargs) self.data = data self.queryset = queryset self.context = context or {} diff --git a/url_filter/utils.py b/url_filter/utils.py index ba577d9..0f9c9b2 100644 --- a/url_filter/utils.py +++ b/url_filter/utils.py @@ -36,19 +36,31 @@ class FilterSpec(object): Whether this filter should be negated. By default its ``False``. """ - def __init__(self, components, lookup, value, is_negated=False): + def __init__(self, components, lookup, value, is_negated=False, filter_callable=None): self.components = components self.lookup = lookup self.value = value self.is_negated = is_negated + self.filter_callable = filter_callable + + @property + def is_callable(self): + return self.filter_callable is not None def __repr__(self): - return '<{} {} {}{} {}>'.format( - self.__class__.__name__, - '.'.join(self.components), - 'NOT ' if self.is_negated else '', - self.lookup, - repr(self.value), + if self.is_callable: + callable_repr = ' via {}.{}'.format(self.filter_callable.__self__.__class__.__name__, + self.filter_callable.__name__) + else: + callable_repr = '' + + return '<{name} {components} {negated}{lookup} {value!r}{callable}>'.format( + name=self.__class__.__name__, + components='.'.join(self.components), + negated='NOT ' if self.is_negated else '', + lookup=self.lookup, + value=self.value, + callable=callable_repr, ) def __eq__(self, other): From baba5ac629d38d1e779901f85e7ed65cf6f4487e Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Dec 2015 18:44:03 -0500 Subject: [PATCH 08/16] improved test coverage to reflect plain Python filtering features --- tests/backends/test_django.py | 15 +++++ tests/filtersets/test_django.py | 41 ++++++++++++++ tests/test_filters.py | 92 ++++++++++++++++++++++++++++++- tests/test_utils.py | 23 ++++++++ url_filter/backends/sqlalchemy.py | 2 +- url_filter/filters.py | 25 ++++----- url_filter/filtersets/base.py | 5 +- url_filter/utils.py | 21 +++++-- 8 files changed, 201 insertions(+), 23 deletions(-) diff --git a/tests/backends/test_django.py b/tests/backends/test_django.py index 88273f4..dd728fc 100644 --- a/tests/backends/test_django.py +++ b/tests/backends/test_django.py @@ -72,3 +72,18 @@ def test_filter(self): assert result == qs.filter.return_value.exclude.return_value qs.filter.assert_called_once_with(name__exact='value') qs.filter.return_value.exclude.assert_called_once_with(address__contains='value') + + def test_filter_callable_specs(self): + qs = mock.Mock() + + def foo(queryset, spec): + return queryset.filter(spec) + + spec = FilterSpec(['name'], 'exact', 'value', False, foo) + backend = DjangoFilterBackend(qs) + backend.bind([spec]) + + result = backend.filter() + + assert result == qs.filter.return_value + qs.filter.assert_called_once_with(spec) diff --git a/tests/filtersets/test_django.py b/tests/filtersets/test_django.py index 5836f92..92b8d07 100644 --- a/tests/filtersets/test_django.py +++ b/tests/filtersets/test_django.py @@ -41,6 +41,47 @@ class Meta(object): assert isinstance(filters['address'], Filter) assert isinstance(filters['address'].form_field, forms.CharField) + def test_get_filters_no_relations_place_exclude_address(self): + class PlaceFilterSet(ModelFilterSet): + class Meta(object): + model = Place + exclude = ['address'] + allow_related = False + allow_related_reverse = False + + filters = PlaceFilterSet().get_filters() + + assert set(filters.keys()) == { + 'id', 'name', + } + + assert isinstance(filters['id'], Filter) + assert isinstance(filters['id'].form_field, forms.IntegerField) + assert isinstance(filters['name'], Filter) + assert isinstance(filters['name'].form_field, forms.CharField) + + def test_get_filters_no_relations_place_address_overwrite(self): + class PlaceFilterSet(ModelFilterSet): + address = Filter(forms.IntegerField()) + + class Meta(object): + model = Place + allow_related = False + allow_related_reverse = False + + filters = PlaceFilterSet().get_filters() + + assert set(filters.keys()) == { + 'id', 'name', 'address', + } + + assert isinstance(filters['id'], Filter) + assert isinstance(filters['id'].form_field, forms.IntegerField) + assert isinstance(filters['name'], Filter) + assert isinstance(filters['name'].form_field, forms.CharField) + assert isinstance(filters['address'], Filter) + assert isinstance(filters['address'].form_field, forms.IntegerField) + def test_get_filters_no_relations_restaurant(self): class RestaurantFilterSet(ModelFilterSet): class Meta(object): diff --git a/tests/test_filters.py b/tests/test_filters.py index a3b6cec..57c3323 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -8,7 +8,11 @@ from url_filter.backends.django import DjangoFilterBackend from url_filter.fields import MultipleValuesField -from url_filter.filters import Filter as _Filter +from url_filter.filters import ( + CallableFilter, + Filter as _Filter, + form_field_for_filter, +) from url_filter.utils import FilterSpec, LookupConfig @@ -123,3 +127,89 @@ def test_get_spec(self): assert f.get_spec(LookupConfig('key', { 'foo': 'value', 'happy': 'rainbows', })) + with pytest.raises(forms.ValidationError): + f.no_lookup = True + assert f.get_spec(LookupConfig('key', {'exact': 'value'})) + + +def test_form_field_for_filter(): + field = forms.CharField() + + class Foo(object): + def foo(self): + """foo""" + return 5 + + bar = form_field_for_filter(field)(foo) + + f = Foo() + assert f.bar.__doc__ == f.foo.__doc__ + assert f.bar() == 5 + assert f.bar.form_field is field + + +class TestCallableFilter(object): + def test_init(self): + f = CallableFilter() + + assert f.form_field is None + + def test_lookups(self): + class Foo(CallableFilter): + def filter_foo_for_django(self): + pass + + f = Foo() + + assert f.lookups == {'foo'} + + def test_get_form_field(self): + field = forms.CharField() + + class Foo(CallableFilter): + @form_field_for_filter(field) + def filter_foo_for_django(self): + pass + + f = Foo() + f.filter_backend = DjangoFilterBackend(queryset=[]) + + assert f.get_form_field('foo') is field + + def test_get_form_field_default_form_field(self): + field = forms.CharField() + + class Foo(CallableFilter): + def filter_foo_for_django(self): + pass + + f = Foo(form_field=field, lookups=['exact']) + f.filter_backend = DjangoFilterBackend(queryset=[]) + + assert f.get_form_field('foo') is field + + def test_get_form_field_no_form_field(self): + class Foo(CallableFilter): + def filter_foo_for_django(self): + pass + + f = Foo() + f.filter_backend = DjangoFilterBackend(queryset=[]) + + with pytest.raises(AssertionError): + f.get_form_field('foo') + + def test_get_spec(self): + class Foo(CallableFilter): + @form_field_for_filter(forms.CharField()) + def filter_foo_for_django(self): + pass + + p = Filter(source='parent', form_field=forms.CharField()) + p.filter_backend = DjangoFilterBackend(queryset=[]) + f = Foo(source='child', default_lookup='foo') + f.parent = p + + assert f.get_spec(LookupConfig('key', 'value')) == FilterSpec( + ['child'], 'foo', 'value', False, f.filter_foo_for_django + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 252115c..cdea919 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,6 +13,16 @@ def test_repr(self): ''.format(repr('value')) ) + class Foo(object): + def foo(self): + pass + + f = Foo() + + assert repr(FilterSpec(['a', 'b'], 'exact', 'value', False, filter_callable=f.foo)) == ( + ''.format(repr('value')) + ) + def test_equality(self): a = FilterSpec(['a', 'b'], 'exact', 'value', False) b = FilterSpec(['a', 'b'], 'exact', 'value', False) @@ -100,3 +110,16 @@ def __init__(self): 'a': 'a', 'b': 'b', } + + class Bar(object): + __slots__ = ['a', 'b', '_c'] + + def __init__(self): + self.a = 'a' + self.b = 'b' + self._c = 'c' + + assert dictify(Bar()) == { + 'a': 'a', + 'b': 'b', + } diff --git a/url_filter/backends/sqlalchemy.py b/url_filter/backends/sqlalchemy.py index 0db46fa..5022c95 100644 --- a/url_filter/backends/sqlalchemy.py +++ b/url_filter/backends/sqlalchemy.py @@ -149,5 +149,5 @@ def _get_attribute_for_field(cls, field): return field.class_attribute @classmethod - def _get_related_model_for_field(self, field): + def _get_related_model_for_field(cls, field): return field._dependency_processor.mapper.class_ diff --git a/url_filter/filters.py b/url_filter/filters.py index 3ef282b..075f80f 100644 --- a/url_filter/filters.py +++ b/url_filter/filters.py @@ -29,7 +29,9 @@ 'year': forms.IntegerField(min_value=0, max_value=9999), } -LOOKUP_CALLABLE_FROM_METHOD_REGEX = re.compile(r'^filter_([\w\d]+)_for_[\w\d]+$') +LOOKUP_CALLABLE_FROM_METHOD_REGEX = re.compile( + r'^filter_(?P[\w\d]+)_for_(?P[\w\d])+$' +) class BaseFilter(six.with_metaclass(abc.ABCMeta, object)): @@ -302,24 +304,19 @@ def lookups(self): lookups = super(CallableFilter, self).lookups r = LOOKUP_CALLABLE_FROM_METHOD_REGEX - custom_lookups = {m.group(0) for m in (r.match(i) for i in dir(self)) if m} + custom_lookups = {m.groupdict()['filter'] for m in (r.match(i) for i in dir(self)) if m} return lookups | custom_lookups def _get_filter_method_for_lookup(self, lookup): name = 'filter_{}_for_{}'.format(lookup, self.root.filter_backend.name) - return getattr(self, name, None) - - def _is_callable_filter(self, lookup): - return bool(self._get_filter_method_for_lookup(lookup)) + return getattr(self, name) def get_form_field(self, lookup): - if self._is_callable_filter(lookup): - filter_method = self._get_filter_method_for_lookup(lookup) - - form_field = getattr(filter_method, 'form_field', None) - if form_field is not None: - return form_field + try: + return self._get_filter_method_for_lookup(lookup).form_field + except AttributeError: + pass form_field = super(CallableFilter, self).get_form_field(lookup) @@ -327,9 +324,9 @@ def get_form_field(self, lookup): '{name} was not provided form_field parameter in initialization ' '(e.g. {name}(form_field=CharField)) and form_field was not ' 'provided for the lookup. If the lookup is a custom filter callable ' - 'you should profile form_field by using @form_field_for_filter ' + 'you should provide form_field by using @form_field_for_filter ' 'decorator. If the lookup is a normal lookup, then please either ' - 'provide form_field paramer or overwrite get_form_field().' + 'provide form_field parameter or overwrite get_form_field().' ''.format(name=self.__class__.__name__) ) diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index a29a18d..6a312bf 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -372,7 +372,7 @@ def _generate_lookup_configs(self): )) -class ModelFilterSetOptions(object): +class ModelFilterSetOptions(FilterSetOptions): """ Custom options for ``FilterSet``s used for model-generated filtersets. @@ -395,6 +395,7 @@ class ModelFilterSetOptions(object): (e.g. when explicit ``fields`` is not provided). """ def __init__(self, options=None): + super(ModelFilterSetOptions, self).__init__(options) self.model = getattr(options, 'model', None) self.fields = getattr(options, 'fields', None) self.exclude = getattr(options, 'exclude', []) @@ -429,7 +430,7 @@ def get_filters(self): state = self._build_state() for name in self.Meta.fields: - if name in self.Meta.exclude: + if name in self.Meta.exclude or name in filters: continue try: diff --git a/url_filter/utils.py b/url_filter/utils.py index 0f9c9b2..60f83fe 100644 --- a/url_filter/utils.py +++ b/url_filter/utils.py @@ -35,6 +35,10 @@ class FilterSpec(object): is_negated : bool, optional Whether this filter should be negated. By default its ``False``. + filter_callable : func, optional + Callable which should be used for filtering this + filter spec. This is primaliry meant to be used + by ``CallableFilter``. """ def __init__(self, components, lookup, value, is_negated=False, filter_callable=None): self.components = components @@ -215,8 +219,15 @@ def dictify(obj): if isinstance(obj, dict): return obj else: - return { - k: v - for k, v in vars(obj).items() - if not k.startswith('_') - } + if hasattr(obj, '__slots__'): + return { + k: getattr(obj, k) + for k in obj.__slots__ + if not k.startswith('_') and hasattr(obj, k) + } + else: + return { + k: v + for k, v in vars(obj).items() + if not k.startswith('_') + } From 50bd4981eea318da780d340cfc911e4c028b01dd Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Dec 2015 19:00:37 -0500 Subject: [PATCH 09/16] added Python 2.5 to testing environment in travis and tox configs --- .gitignore | 1 + .travis.yml | 2 +- tox.ini | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0dbdc8e..c416b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ lib64 pip-log.txt # Unit test / coverage reports +.cache .coverage .tox nosetests.xml diff --git a/.travis.yml b/.travis.yml index 88a9987..24c9525 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: python python: - - "3.4" + - "3.5" - "2.7" - "pypy" diff --git a/tox.ini b/tox.ini index 9d18a95..eef306f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,14 @@ [tox] envlist = - {py27,py34,pypy,pypy3}-django{18} + {py27,py34,py35,pypy}-django{18,19} + # 1.9 breaks pypy3 hence only testing with 18 + {pypy3}-django{18} [testenv] basepython = py27: python2.7 py34: python3.4 + py35: python3.5 pypy: pypy pypy3: pypy3 setenv = @@ -18,6 +21,7 @@ deps = django16: django<1.7 django17: django<1.8 django18: django<1.9 + django19: django<1.10 whitelist_externals = make From 6b2625b40d1bffd26df63fca38f2d54880054b7d Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Dec 2015 20:32:55 -0500 Subject: [PATCH 10/16] Django 1.9 support to pass manage.py check --- test_project/many_to_many/api.py | 8 +++++++- test_project/many_to_one/api.py | 8 +++++++- test_project/one_to_one/api.py | 15 ++++++++++++-- test_project/settings.py | 4 +++- test_project/urls.py | 34 ++++++++++++++++---------------- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/test_project/many_to_many/api.py b/test_project/many_to_many/api.py index b958db6..c34d137 100644 --- a/test_project/many_to_many/api.py +++ b/test_project/many_to_many/api.py @@ -5,7 +5,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from url_filter.backends.sqlalchemy import SQLAlchemyFilterBackend -from url_filter.filtersets import ModelFilterSet +from url_filter.filtersets import ModelFilterSet, StrictMode from url_filter.filtersets.sqlalchemy import SQLAlchemyModelFilterSet from . import alchemy @@ -38,11 +38,14 @@ class Meta(object): class PublicationFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail + class Meta(object): model = Publication class SQLAlchemyPublicationFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend class Meta(object): @@ -50,11 +53,14 @@ class Meta(object): class ArticleFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail + class Meta(object): model = Article class SQLAlchemyArticleFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend class Meta(object): diff --git a/test_project/many_to_one/api.py b/test_project/many_to_one/api.py index 429b757..ebf6dc4 100644 --- a/test_project/many_to_one/api.py +++ b/test_project/many_to_one/api.py @@ -5,7 +5,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from url_filter.backends.sqlalchemy import SQLAlchemyFilterBackend -from url_filter.filtersets import ModelFilterSet +from url_filter.filtersets import ModelFilterSet, StrictMode from url_filter.filtersets.sqlalchemy import SQLAlchemyModelFilterSet from . import alchemy @@ -38,11 +38,14 @@ class Meta(object): class ReporterFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail + class Meta(object): model = Reporter class SQLAlchemyReporterFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend class Meta(object): @@ -50,11 +53,14 @@ class Meta(object): class ArticleFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail + class Meta(object): model = Article class SQLAlchemyArticleFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend class Meta(object): diff --git a/test_project/one_to_one/api.py b/test_project/one_to_one/api.py index ea8fa18..f654ede 100644 --- a/test_project/one_to_one/api.py +++ b/test_project/one_to_one/api.py @@ -10,8 +10,8 @@ from url_filter.backends.plain import PlainFilterBackend from url_filter.backends.sqlalchemy import SQLAlchemyFilterBackend -from url_filter.filters import CallableFilter, form_field_for_filter -from url_filter.filtersets import ModelFilterSet +from url_filter.filters import CallableFilter, Filter, form_field_for_filter +from url_filter.filtersets import ModelFilterSet, StrictMode from url_filter.filtersets.plain import PlainModelFilterSet from url_filter.filtersets.sqlalchemy import SQLAlchemyModelFilterSet @@ -101,6 +101,7 @@ def negate(x): class PlaceFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail waiter = PlaceWaiterCallableFilter(no_lookup=True) class Meta(object): @@ -108,6 +109,7 @@ class Meta(object): class PlainPlaceFilterSet(PlainModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = PlainFilterBackend waiter = PlaceWaiterCallableFilter(no_lookup=True) @@ -137,6 +139,7 @@ class Meta(object): class SQLAlchemyPlaceFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend waiter = PlaceWaiterCallableFilter(no_lookup=True) @@ -145,11 +148,15 @@ class Meta(object): class RestaurantFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail + place_id = Filter(forms.IntegerField(min_value=0)) + class Meta(object): model = Restaurant class SQLAlchemyRestaurantFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend class Meta(object): @@ -157,11 +164,15 @@ class Meta(object): class WaiterFilterSet(ModelFilterSet): + default_strict_mode = StrictMode.fail + class Meta(object): model = Waiter class SQLAlchemyWaiterFilterSet(SQLAlchemyModelFilterSet): + default_strict_mode = StrictMode.fail + filter_backend_class = SQLAlchemyFilterBackend class Meta(object): diff --git a/test_project/settings.py b/test_project/settings.py index 3c73bf7..51537e8 100644 --- a/test_project/settings.py +++ b/test_project/settings.py @@ -22,6 +22,8 @@ 'django_extensions', 'rest_framework', + 'django.contrib.auth', + 'django.contrib.contenttypes', 'django.contrib.staticfiles', ) @@ -37,5 +39,5 @@ REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': [ 'url_filter.integrations.drf.DjangoFilterBackend', - ] + ], } diff --git a/test_project/urls.py b/test_project/urls.py index ef1190b..b3c0094 100644 --- a/test_project/urls.py +++ b/test_project/urls.py @@ -10,22 +10,22 @@ router = DefaultRouter() -router.register('one-to-one/places/alchemy', o2o_api.SQLAlchemyPlaceViewSet, 'one-to-one-alchemy:place') -router.register('one-to-one/places/plain', o2o_api.PlainPlaceViewSet, 'one-to-one-plain:place') -router.register('one-to-one/places', o2o_api.PlaceViewSet, 'one-to-one:place') -router.register('one-to-one/restaurants/alchemy', o2o_api.SQLAlchemyRestaurantViewSet, 'one-to-one-alchemy:restaurant') -router.register('one-to-one/restaurants', o2o_api.RestaurantViewSet, 'one-to-one:restaurant') -router.register('one-to-one/waiters/alchemy', o2o_api.SQLAlchemyWaiterViewSet, 'one-to-one-alchemy:waiter') -router.register('one-to-one/waiters', o2o_api.WaiterViewSet, 'one-to-one:waiter') - -router.register('many-to-one/reporters/alchemy', m2o_api.SQLAlchemyReporterViewSet, 'many-to-one-alchemy:reporter') -router.register('many-to-one/reporters', m2o_api.ReporterViewSet, 'many-to-one:reporter') -router.register('many-to-one/articles/alchemy', m2o_api.SQLAlchemyArticleViewSet, 'many-to-one-alchemy:article') -router.register('many-to-one/articles', m2o_api.ArticleViewSet, 'many-to-one:article') - -router.register('many-to-many/publications/alchemy', m2m_api.SQLAlchemyPublicationViewSet, 'many-to-many-alchemy:publication') -router.register('many-to-many/publications', m2m_api.PublicationViewSet, 'many-to-many:publication') -router.register('many-to-many/articles/alchemy', m2m_api.SQLAlchemyArticleViewSet, 'many-to-many-alchemy:article') -router.register('many-to-many/articles', m2m_api.ArticleViewSet, 'many-to-many:article') +router.register('one-to-one/places/alchemy', o2o_api.SQLAlchemyPlaceViewSet, 'one-to-one-alchemy|place') +router.register('one-to-one/places/plain', o2o_api.PlainPlaceViewSet, 'one-to-one-plain|place') +router.register('one-to-one/places', o2o_api.PlaceViewSet, 'one-to-one|place') +router.register('one-to-one/restaurants/alchemy', o2o_api.SQLAlchemyRestaurantViewSet, 'one-to-one-alchemy|restaurant') +router.register('one-to-one/restaurants', o2o_api.RestaurantViewSet, 'one-to-one|restaurant') +router.register('one-to-one/waiters/alchemy', o2o_api.SQLAlchemyWaiterViewSet, 'one-to-one-alchemy|waiter') +router.register('one-to-one/waiters', o2o_api.WaiterViewSet, 'one-to-one|waiter') + +router.register('many-to-one/reporters/alchemy', m2o_api.SQLAlchemyReporterViewSet, 'many-to-one-alchemy|reporter') +router.register('many-to-one/reporters', m2o_api.ReporterViewSet, 'many-to-one|reporter') +router.register('many-to-one/articles/alchemy', m2o_api.SQLAlchemyArticleViewSet, 'many-to-one-alchemy|article') +router.register('many-to-one/articles', m2o_api.ArticleViewSet, 'many-to-one|article') + +router.register('many-to-many/publications/alchemy', m2m_api.SQLAlchemyPublicationViewSet, 'many-to-many-alchemy|publication') +router.register('many-to-many/publications', m2m_api.PublicationViewSet, 'many-to-many|publication') +router.register('many-to-many/articles/alchemy', m2m_api.SQLAlchemyArticleViewSet, 'many-to-many-alchemy|article') +router.register('many-to-many/articles', m2m_api.ArticleViewSet, 'many-to-many|article') urlpatterns = router.urls From 843a0163417d0ce3fd266f574e032bd6dc88c2a7 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Dec 2015 20:33:12 -0500 Subject: [PATCH 11/16] added source to the representation of both Filters and FilterSets --- tests/filtersets/test_base.py | 6 +++--- url_filter/filters.py | 4 ++++ url_filter/filtersets/base.py | 10 +++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/filtersets/test_base.py b/tests/filtersets/test_base.py index 36c3d19..bcb1695 100644 --- a/tests/filtersets/test_base.py +++ b/tests/filtersets/test_base.py @@ -36,9 +36,9 @@ class BarFilterSet(FilterSet): assert repr(BarFilterSet()) == ( 'BarFilterSet()\n' - ' bar = Filter(form_field=IntegerField, lookups=ALL, default_lookup="exact", is_default=False, no_lookup=False)\n' - ' foo = FooFilterSet()\n' - ' foo = Filter(form_field=CharField, lookups=ALL, default_lookup="exact", is_default=False, no_lookup=False)' + ' bar = Filter(source="bar", form_field=IntegerField, lookups=ALL, default_lookup="exact", is_default=False, no_lookup=False)\n' + ' foo = FooFilterSet(source="foo")\n' + ' foo = Filter(source="foo", form_field=CharField, lookups=ALL, default_lookup="exact", is_default=False, no_lookup=False)' ) def test_get_filters(self): diff --git a/url_filter/filters.py b/url_filter/filters.py index 075f80f..b995f41 100644 --- a/url_filter/filters.py +++ b/url_filter/filters.py @@ -39,6 +39,7 @@ def __init__(self, source=None, *args, **kwargs): self._source = source self.parent = None self.name = None + self.is_bound = False def __repr__(self): data = self.repr() @@ -89,6 +90,7 @@ def bind(self, name, parent): """ self.name = name self.parent = parent + self.is_bound = True @property def root(self): @@ -164,6 +166,7 @@ def __init__(self, form_field, def repr(self, prefix=''): return ( '{name}(' + '{source}' 'form_field={form_field}, ' 'lookups={lookups}, ' 'default_lookup="{default_lookup}", ' @@ -171,6 +174,7 @@ def repr(self, prefix=''): 'no_lookup={no_lookup}' ')' ''.format(name=self.__class__.__name__, + source='source="{}", '.format(self.source) if self.is_bound else '', form_field=self.form_field.__class__.__name__, lookups=self._given_lookups or 'ALL', default_lookup=self.default_lookup, diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index 6a312bf..8f4aa80 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -154,18 +154,22 @@ class FilterSet(six.with_metaclass(FilterSetMeta, BaseFilter)): """ filter_backend_class = DjangoFilterBackend filter_options_class = FilterSetOptions + default_strict_mode = StrictMode.drop def __init__(self, data=None, queryset=None, context=None, - strict_mode=StrictMode.drop, + strict_mode=None, *args, **kwargs): super(FilterSet, self).__init__(*args, **kwargs) self.data = data self.queryset = queryset self.context = context or {} - self.strict_mode = strict_mode + self.strict_mode = strict_mode or self.default_strict_mode def repr(self, prefix=''): - header = '{name}()'.format(name=self.__class__.__name__) + header = '{name}({source})'.format( + name=self.__class__.__name__, + source='source="{}"'.format(self.source) if self.is_bound else '', + ) lines = [header] + [ '{prefix}{key} = {value}'.format( prefix=prefix + ' ', From da65e93de7a2152357def83e2afe57a9e55e9074 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Dec 2015 20:38:09 -0500 Subject: [PATCH 12/16] added tests for filtering by isnull on IntegerField --- tests/filtersets/test_base.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/filtersets/test_base.py b/tests/filtersets/test_base.py index bcb1695..51fa0f0 100644 --- a/tests/filtersets/test_base.py +++ b/tests/filtersets/test_base.py @@ -168,6 +168,7 @@ class PlaceFilterSet(FilterSet): class RestaurantFilterSet(FilterSet): pk = Filter(form_field=forms.IntegerField(min_value=0), is_default=True) place = PlaceFilterSet() + place_id = Filter(form_field=forms.IntegerField(min_value=0)) serves_hot_dogs = Filter(form_field=forms.BooleanField(required=False)) serves_pizza = Filter(form_field=forms.BooleanField(required=False)) @@ -200,6 +201,20 @@ def _test(fs, data, qs, expected, count): Restaurant.objects.exclude(place__address__contains='Ashland'), 1 ) + _test( + RestaurantFilterSet, + 'place_id__isnull=True', + Restaurant.objects.all(), + Restaurant.objects.filter(place_id__isnull=True), + 0 + ) + _test( + RestaurantFilterSet, + 'place_id__isnull=False', + Restaurant.objects.all(), + Restaurant.objects.filter(place_id__isnull=False), + 2 + ) _test( WaiterFilterSet, 'restaurant__place__pk=1', From 6330135c33edd15b86ecf481a44444cc96a964cb Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Sun, 13 Dec 2015 21:13:54 -0500 Subject: [PATCH 13/16] added ability to filter directly on default filter of FilterSet --- tests/filtersets/test_base.py | 30 ++++++++++++++++++++++++++++++ url_filter/filtersets/base.py | 8 +++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/filtersets/test_base.py b/tests/filtersets/test_base.py index 51fa0f0..c86395b 100644 --- a/tests/filtersets/test_base.py +++ b/tests/filtersets/test_base.py @@ -159,6 +159,36 @@ def _test(data, expected, **kwargs): with pytest.raises(forms.ValidationError): _test('bar__thing__in=100,5', [], strict_mode=StrictMode.fail) + def test_get_specs_using_default_filter(self): + class BarFilterSet(FilterSet): + id = Filter(form_field=forms.IntegerField(), + is_default=True) + other = Filter(source='stuff', + form_field=forms.CharField(), + default_lookup='contains') + thing = Filter(form_field=forms.IntegerField(min_value=0, max_value=15)) + + class FooFilterSet(FilterSet): + field = Filter(form_field=forms.CharField()) + bar = BarFilterSet() + + def _test(data, expected, **kwargs): + fs = FooFilterSet( + data=QueryDict(data), + queryset=[], + **kwargs + ) + + assert set(fs.get_specs()) == set(expected) + + _test('bar=5', [ + FilterSpec(['bar', 'id'], 'exact', 5, False), + ]) + _test('bar__isnull=True', [ + FilterSpec(['bar', 'id'], 'isnull', True, False), + ]) + _test('bar__gt=foo', []) + def test_filter_one_to_one(self, one_to_one): class PlaceFilterSet(FilterSet): pk = Filter(form_field=forms.IntegerField(min_value=0), is_default=True) diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index 8f4aa80..5cd4d2e 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -360,7 +360,13 @@ def get_spec(self, config): value = LookupConfig(config.key, config.data) if name not in self.filters: - raise SkipFilter + if self.default_filter: + # if name is not found as a filter, there is a possibility + # it is a lookup made on the default filter of this filterset + # in which case we try to get that spec directly from the child + return self.default_filter.get_spec(config) + else: + raise SkipFilter return self.filters[name].get_spec(value) From af5b9934f08668d0976f8674d128419616b95cd2 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Tue, 15 Dec 2015 23:58:25 -0500 Subject: [PATCH 14/16] documentation improvements in both API docs and other generic docs --- docs/api/url_filter.backends.plain.rst | 7 + docs/api/url_filter.backends.rst | 1 + docs/api/url_filter.filtersets.plain.rst | 7 + docs/api/url_filter.filtersets.rst | 1 + docs/big_picture.rst | 87 ++++---- docs/usage.rst | 85 +++++--- tests/backends/test_django.py | 2 +- tests/test_filters.py | 6 + url_filter/backends/base.py | 68 ++++++- url_filter/backends/django.py | 37 +++- url_filter/backends/plain.py | 31 ++- url_filter/backends/sqlalchemy.py | 62 ++++++ url_filter/exceptions.py | 2 +- url_filter/fields.py | 2 +- url_filter/filters.py | 246 ++++++++++++++++++++--- url_filter/filtersets/base.py | 163 +++++++++------ url_filter/filtersets/django.py | 10 +- url_filter/filtersets/plain.py | 2 +- url_filter/filtersets/sqlalchemy.py | 17 +- url_filter/integrations/drf.py | 96 +++++++++ url_filter/utils.py | 40 ++-- 21 files changed, 772 insertions(+), 200 deletions(-) create mode 100644 docs/api/url_filter.backends.plain.rst create mode 100644 docs/api/url_filter.filtersets.plain.rst diff --git a/docs/api/url_filter.backends.plain.rst b/docs/api/url_filter.backends.plain.rst new file mode 100644 index 0000000..e39dfd9 --- /dev/null +++ b/docs/api/url_filter.backends.plain.rst @@ -0,0 +1,7 @@ +url_filter.backends.plain module +================================ + +.. automodule:: url_filter.backends.plain + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/url_filter.backends.rst b/docs/api/url_filter.backends.rst index 0f9fccd..7a6c3a8 100644 --- a/docs/api/url_filter.backends.rst +++ b/docs/api/url_filter.backends.rst @@ -13,5 +13,6 @@ Submodules url_filter.backends.base url_filter.backends.django + url_filter.backends.plain url_filter.backends.sqlalchemy diff --git a/docs/api/url_filter.filtersets.plain.rst b/docs/api/url_filter.filtersets.plain.rst new file mode 100644 index 0000000..fd4b362 --- /dev/null +++ b/docs/api/url_filter.filtersets.plain.rst @@ -0,0 +1,7 @@ +url_filter.filtersets.plain module +================================== + +.. automodule:: url_filter.filtersets.plain + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/url_filter.filtersets.rst b/docs/api/url_filter.filtersets.rst index 0c4a7a5..942d62b 100644 --- a/docs/api/url_filter.filtersets.rst +++ b/docs/api/url_filter.filtersets.rst @@ -13,5 +13,6 @@ Submodules url_filter.filtersets.base url_filter.filtersets.django + url_filter.filtersets.plain url_filter.filtersets.sqlalchemy diff --git a/docs/big_picture.rst b/docs/big_picture.rst index 33e8977..4ab98f7 100644 --- a/docs/big_picture.rst +++ b/docs/big_picture.rst @@ -10,8 +10,8 @@ Basics In order to filter any data, this library breaks the process to 3 phases: -1. Parse the URL querystring into ``LookupConfig`` -2. Loop throught all the configs and generate ``FilterSpec`` when possible +1. Parse the URL querystring into :class:`.LookupConfig` +2. Loop throught all the configs and generate :class:`.FilterSpec` when possible 3. Use the list of specs to actually filter data And here is a bit more information about each phase. @@ -20,7 +20,7 @@ Parsing +++++++ Fundamentally a querystring is a collection of key-pairs. -As such, this data is natually flat and is usually represented +As such, this data is naturally flat and is usually represented as a simple dictionary:: ?foo=bar&happy=rainbows => { @@ -35,7 +35,7 @@ as a simple dictionary:: are present. The filtering however is not flat. Each querystring key can be nested -when using nested ``FilterSet`` and in addition it can optionally +when using nested :class:`.FilterSet` and in addition it can optionally contain lookup. For example:: ?foo=bar @@ -66,10 +66,10 @@ nested dictionaries. For example:: } } -That is essentially what ``LookupConfig`` stores. Since these dictionaries +That is essentially what :class:`.LookupConfig` stores. Since these dictionaries are flat (each dictionaty has at most one key), it also provides some utility properties for dealing with such data. You can refer to the -:py:class:`url_filter.utils.LookupConfig` API documentation for more +:class:`.LookupConfig` API documentation for more information. Filter Specification @@ -78,31 +78,32 @@ Filter Specification As mentioned in :doc:`README `, Django URL Filter decouples parsing of querystring and filtering. It achieves that by constructing filter specifications which have all necessary information to filter data -without actually filtering data. Thats what ``FilterSpec`` is. +without actually filtering data. Thats what :class:`.FilterSpec` is. It stores 3 required pieces of information on how to filter data: * Which attribute to filter on. Since models can be related by attributes - related models, this actually ends up being a list of attributes which - we call components. + of related models, this actually ends up being a list of attributes which + we call ``components``. * Lookup to use to filter data. This specifies how the value should be compared while doing filtering. Example is ``exact``, ``contains``. - Currenlty only lookups from Django ORM are supported. + By default only lookups from Django ORM are supported however custom + :class:`.CallableFilter` can be used to define custom lookups. * If the filter is negated. For example to filter when username is ``'foo'`` - to filter when username is not ``'foo'``. + or filter when username is not ``'foo'``. Filtering +++++++++ -Since filtering is decoupled from the ``FilterSet``, the filtering honors +Since filtering is decoupled from the :class:`.FilterSet`, the filtering honors all go to a specified filter backend. The backend is very simple. It takes a list of filter specifications and a data to filter and its job is to filter that data as specified in the specifications. .. note:: - Currently we only support Django ORM and SQLAlchemy filter backends + Currently we only support a handful of backends such as Django ORM, + SQLAlchemy and plain Python interables filter backends but you can imagine that any backend can be implemented. - Eventually filter backends can be added for flat data-structures - like filtering a vanilla Python lists or even more exotic sources + Eventually filter backends can be added for more exotic sources like Mongo, Redis, etc. Steps @@ -111,62 +112,62 @@ Steps Above information hopefully puts things in perspective and here is more detailed step-by-step guide what Django URL Filter does behind the scenes: -#. ``FilterSet`` is instantiated with querystring data as well as - querystring to filter. -#. ``FilterSet`` is asked to filter given data via - :py:meth:`filter ` method +#. :class:`.FilterSet` is instantiated with querystring data as well as + queryset to filter. +#. :class:`.FilterSet` is asked to filter given data via + :meth:`filter ` method which kicks in all the steps below. -#. ``FilterSet`` finds all filters it is capable of Filtering - via :py:meth:`get_filters `. +#. :class:`.FilterSet` finds all filters it is capable of Filtering + via :meth:`get_filters `. This is where custom filtersets can hook into to do custom stuff like extracting filters from a Django model. -#. ``FilterSet`` binds all child filters to itself via - :py:meth:`bind `. - This practically sets :py:attr:`parent ` - and :py:attr:`name `. -#. Root ``FilterSet`` loops through all querystring pairs and generates - ``LookupConfig`` for all of them. -#. Root ``FilterSet`` loops through all generated configs and attemps to +#. :class:`.FilterSet` binds all child filters to itself via + :meth:`bind `. + This practically sets :attr:`parent ` + and :attr:`name ` attributes. +#. Root :class:`.FilterSet` loops through all querystring pairs and generates + :class:`.LookupConfig` for all of them. +#. Root :class:`.FilterSet` loops through all generated configs and attemps to find appropriate filter to use to generate a spec fo the given config. - The matching happens by the first key in the ``LookupConfig`` dict. - If that key is found in available filters, that filer is used and + The matching happens by the first key in the :class:`.LookupConfig` dict. + If that key is found in available filters, that filter is used and otherwise that config is skipped. This is among the reasons why - ``LookupConfig`` is used since it allows this step to be very simple. + :class:`.LookupConfig` is used since it allows this step to be very simple. #. If appropriate filter is found, it is passed nested config to the child filter which then goes through very similar process as in previous step until it gets to a leaf filter. -#. Leaf ``Filter`` gets the config. In then checks if the config is still +#. Leaf :class:`.Filter` gets the config. In then checks if the config is still nested. For example if the config is simply a value (e.g. ``'bar'``) or is still a dictionary (e.g. ``{'contains': 'bar'}``). If the config is just a value, it then uses a default lookup for that filter as provided in ``default_lookup`` parameter when instantiating - ``Filter``. If the config is a dictionary, it makes sure that it is a + :class:`.Filter`. If the config is a dictionary, it makes sure that it is a valid lookup config (e.g. its not ``{'rainbows': {'contains': 'bar'}}`` since it would not know what to do with ``rainbows`` since it is not a valid lookup value). -#. Now that ``Filter`` validated the lookup itself, it cleans the actual +#. Now that :class:`.Filter` validated the lookup itself, it cleans the actual filter value by using either ``form_field`` as passed as parameter - when instantiating ``Filter`` or by using loopup overwrite. + when instantiating :class:`.Filter` or by using lookup overwrite. Overwrites are necessary for more exotic lookups like ``in`` or ``year`` since they need to validate data in a different way. -#. If the value is valid, then the leaf filter constructs a ``FilterSpec`` +#. If the value is valid, then the leaf filter constructs a :class:`.FilterSpec` since it has all the necessary information to do that - 1) all filter component names from all ancestors (e.g. all attributes which should be accessed on the queryset to get to the value to be filtered on); 2) the actual filter value and 3) if the filter is negated. -#. At this point, root ``FilterSpec`` will get the ``FilterSpec`` as +#. At this point, root :class:`.FilterSet` will get the :class:`.FilterSpec` as bubbled up from the leaf filter. If any ``ValidationError`` exceptions - are raised, then depending on ``strict_mode``, it will either ignores + are raised, then depending on ``strict_mode``, it will either ignore errors or will propagate them up to the caller of the filterset. #. Once all specs are collected from all the querystring key-value-pairs, - root ``FilterSet`` instantiates a filter backend and passes it + root :class:`.FilterSet` instantiates a filter backend and passes it all the specs. -#. Finally root ``FilterSet`` uses the filter backend to filter +#. Finally root :class:`.FilterSet` uses the filter backend to filter given queryset and returns the results to the user. Some important things to note: -* Root ``FilterSet`` does all the looping over querystring data and +* Root :class:`.FilterSet` does all the looping over querystring data and generated configurations. -* Children filters of a root ``FilterSet`` are only responsible for - generating ``FilterSpec`` and in the process validating the data. +* Children filters of a root :class:`.FilterSet` are only responsible for + generating :class:`.FilterSpec` and in the process validating the data. diff --git a/docs/usage.rst b/docs/usage.rst index f079124..8398685 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -4,7 +4,7 @@ Usage Vanilla ------- -In its simplest form, Django URL Filter usage resolves around ``FilterSet``. +In its simplest form, Django URL Filter usage revolves around :class:`.FilterSet`. They can be used manually:: from django import forms @@ -30,19 +30,19 @@ They can be used manually:: Notable things to mention from above: -* ``FilterSet`` can be used as a ``Filter`` within another ``FilterSet`` +* :class:`.FilterSet` can be used as a :class:`.Filter` within another :class:`.FilterSet` hence allowing filtering by related models. * ``form_field`` is used to validate the filter value. Each lookup however can overwrite validation. For example ``year`` lookup will use ``IntegerField`` rather then ``DateField``. -* ``Filter`` can restrict allowed lookups for that field by - using ``lookup`` parameter +* :class:`.Filter` can restrict allowed lookups for that field by + using ``lookups`` parameter Django ------ -Instead of manually creating ``FilterSet``, Django URL Filter comes with -``ModelFilterSet`` which greatly simplifies the task:: +Instead of manually creating :class:`.FilterSet`, Django URL Filter comes with +:class:`.ModelFilterSet` which greatly simplifies the task:: from django import forms @@ -56,7 +56,7 @@ Instead of manually creating ``FilterSet``, Django URL Filter comes with Notable things: * ``fields`` can actually be completely omitted. In that case - ``FilterSet`` will use all fields available in the model, including + :class:`.FilterSet` will use all fields available in the model, including related fields. * filters can be manually overwritten when custom behavior is required:: @@ -71,7 +71,8 @@ SQLAlchemy ---------- `SQLAlchemy `_ works very similar to how Django -backend works. For example:: +backend works. Additionally :class:`.SQLAlchemyModelFilterSet` is available to be able +to easily create filter sets from SQLAlchemy models. For example:: from django import forms from url_filter.backend.sqlalchemy import SQLAlchemyFilterBackend @@ -89,25 +90,70 @@ backend works. For example:: Notable things: -* this works exactly same as ``ModelFitlerSet`` so refer above for some of +* this works exactly same as :class:`.ModelFilterSet` so refer above for some of general options. * ``filter_backend_class`` **must** be provided since otherwise - ``DjangoFilterBackend`` will be used which will obviously not work + :class:`.DjangoFilterBackend` will be used which will obviously not work with SQLAlchemy models. * ``queryset`` given to the queryset should be SQLAlchemy query object. +Plain Filtering +--------------- + +In addition to supporting regular ORMs ``django-url-filter`` also allows to +filter plain Python lists of either objects or dictionaries. This feature +is primarily meant to filter data-sources without direct filtering support +such as lists of data in redis. For example:: + + from django import forms + from url_filter.backend.plain import PlainFilterBackend + from url_filter.filtersets.plain import PlainModelFilterSet + + class UserFilterSet(PlainModelFilterSet): + filter_backend_class = PlainFilterBackend + + class Meta(object): + # the filterset will generate fields from the + # primitive Python data-types + model = { + 'username': 'foo', + 'password': bar, + 'joined': date(2015, 1, 2), + 'profile': { + 'preferred_name': 'rainbow', + } + } + + fs = UserFilterSet(data=QueryDict(), queryset=[{...}, {...}, ...]) + fs.filter() + Integrations ------------ Django URL Filters tries to be usage-agnostic and does not assume -how ``FilterSet`` is being used in the application. It does however +how :class:`.FilterSet` is being used in the application. It does however ship with some common integrations to simplify common workflows. +Django Class Based Views +++++++++++++++++++++++++ + +:class:`.FilterSet` or related classes can directly be used within Django class-based-views:: + + class MyFilterSet(ModelFilterSet): + class Meta(object): + model = MyModel + + class MyListView(ListView): + queryset = MyModel.objects.all() + def get_queryset(self): + qs = super(MyListView, self).get_queryset() + return MyFilterSet(data=self.request.GET, queryset=qs).filter() + Django REST Framework +++++++++++++++++++++ Django URL Filter can rather easily be integrated with DRF. -For that, a DRF filter backend is implemented and can be used in settings:: +For that, a DRF-specific filter backend :class:`.DjangoFilterBackend` is implemented and can be used in settings:: # settings.py REST_FRAMEWORK = { @@ -126,7 +172,7 @@ or manually set in the viewset:: Note in the example above, fields to be filtered on are explicitly specified in the ``filter_fields`` attribute. Alternatively if more -control over ``FilterSet`` is required, it can be set explicitly:: +control over :class:`.FilterSet` is required, it can be set explicitly:: class MyFilterSet(FilterSet): pass @@ -137,15 +183,4 @@ control over ``FilterSet`` is required, it can be set explicitly:: filter_backends = [DjangoFilterBackend] filter_class = MyFilterSet -Backends --------- - -``FilterSet`` by itself is decoupled from the actual filtering -of the queryset. Backend can be swapped by using ``filter_backend_class``:: - - class FooFilterSet(FilterSet): - filter_backend_class = MyFilterBackend - -.. note:: - Currently only ``DjangoFilterBackend`` is implemented which uses - Django ORM however more backends are planned for. +For more available options, please refer to :class:`.DjangoFilterBackend` documentation. diff --git a/tests/backends/test_django.py b/tests/backends/test_django.py index dd728fc..bcf93a1 100644 --- a/tests/backends/test_django.py +++ b/tests/backends/test_django.py @@ -54,7 +54,7 @@ def test_excludes(self): def test_prepare_spec(self): backend = DjangoFilterBackend(Place.objects.all()) - spec = backend.prepare_spec(FilterSpec(['name'], 'exact', 'value')) + spec = backend._prepare_spec(FilterSpec(['name'], 'exact', 'value')) assert spec == 'name__exact' diff --git a/tests/test_filters.py b/tests/test_filters.py index 57c3323..311c1fa 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -59,6 +59,12 @@ def test_repr(self): assert repr(f) == ( 'Filter(form_field=CharField, lookups=ALL, ' + 'default_lookup="foo", no_lookup=False)' + ) + + f.is_bound = True + assert repr(f) == ( + 'Filter(source="foo", form_field=CharField, lookups=ALL, ' 'default_lookup="foo", is_default=True, no_lookup=False)' ) diff --git a/url_filter/backends/base.py b/url_filter/backends/base.py index bd6926a..eb5d2aa 100644 --- a/url_filter/backends/base.py +++ b/url_filter/backends/base.py @@ -15,8 +15,8 @@ class BaseFilterBackend(six.with_metaclass(abc.ABCMeta, object)): queryset Iterable which this filter backend will eventually filter. The type of the iterable depends on the filter backend. - For example for ``DjangoFilterBackend``, ``QuerySet`` needs - to be passed. + For example for :class:`.DjangoFilterBackend`, Django's + ``QuerySet`` needs to be passed. context: dict Context dictionary. It could contain any information which potentially could be useful to filter given @@ -26,8 +26,33 @@ class BaseFilterBackend(six.with_metaclass(abc.ABCMeta, object)): they need to be able to effectively filter data. """ name = None + """ + Name of the filter backend. + + This is used by custom callable filters to define callables + for each supported backend. More at :class:`.CallableFilter` + """ supported_lookups = set() + """ + Set of supported lookups this filter backend supports. + + This is used by leaf :class:`.Filter` to determine whether + it should construct :class:`.FilterSpec` for a particular + key-value pair from querystring since it if constructs + specification but then filter backend will not be able to + filter it, things will blow up. By explicitly checking + if filter backend supports particular lookup it can + short-circuit the logic and avoid errors down the road. + This is pretty much the only coupling between filters + and filter backends. + """ enforce_same_models = True + """ + Whether same models should be enforced when trying to use + this filter backend. + + More can be found in :meth:`BaseFilterBackend.model` + """ def __init__(self, queryset, context=None): self.queryset = queryset @@ -39,8 +64,12 @@ def model(self): """ Property for getting model on which this filter backend operates. - The main use for this is being able to validate that correct - filterset is being used to filter some data. + This is meant to be used by the integrations directly shipped with + django-url-filter which need to be able to validate that the filterset + will be able to filter given queryset. They can do that by comparing + the model they are trying to filter matches the model the filterbackend + got. This primarily will have misconfigurations such as using + SQLAlchemy filterset to filter Django's ``QuerySet``. """ return self.get_model() @@ -48,8 +77,15 @@ def bind(self, specs): """ Bind the given specs to the filter backend. - This allows the filterset to be instantiated first before - filter specs are constructed and later, specs can be binded. + This allows the filter backend to be instantiated first before + filter specs are constructed and later, specs can be binded + to the backend. + + Parameters + ---------- + specs : list + List of :class:`.FilterSpec` to be binded for the filter + backend for filtering """ self.specs = specs @@ -59,6 +95,10 @@ def regular_specs(self): Property for getting standard filter specifications which can be used directly by the filter backend to filter queryset. + + See Also + -------- + callable_specs """ return [i for i in self.specs if not i.is_callable] @@ -69,6 +109,10 @@ def callable_specs(self): which have a filter callable for filtering querysets. These specifications cannot be directly used by filter backend and have to be called manually to filter data. + + See Also + -------- + regular_specs """ return [i for i in self.specs if i.is_callable] @@ -77,7 +121,12 @@ def get_model(self): """ Get the queryset model. - .. note:: **MUST** be implemented by subclasses + .. note:: **MUST** be implemented by subclasses. + :meth:`.model` property uses this method to get the model. + + See Also + -------- + model """ def filter(self): @@ -99,10 +148,11 @@ def filter_by_specs(self, queryset): def filter_by_callables(self, queryset): """ Method for filtering queryset by using custom filter callables - as given in the ``Filter`` definition. + as given in the :class:`.Filter` definition. - This is really meant to accomodate filtering with simple + This is really meant to accommodate filtering with simple filter keys having complex filtering logic behind them. + More about custom callables can be found at :class:`.CallableFilter` """ if not self.callable_specs: return queryset diff --git a/url_filter/backends/django.py b/url_filter/backends/django.py index 8bd8809..c6f6ffe 100644 --- a/url_filter/backends/django.py +++ b/url_filter/backends/django.py @@ -7,6 +7,14 @@ class DjangoFilterBackend(BaseFilterBackend): + """ + Filter backend for filtering Django querysets. + + .. warning:: + The filter backend can **ONLY** filter Django's ``QuerySet``. + Passing any other datatype for filtering will kill happy bunnies + under rainbow. + """ name = 'django' supported_lookups = { 'contains', @@ -36,10 +44,20 @@ class DjangoFilterBackend(BaseFilterBackend): } def get_model(self): + """ + Get the model from the given queryset + """ return self.queryset.model @property def includes(self): + """ + Property which gets list of non-negated filters + + By combining all non-negated filters we can optimize filtering by + calling ``QuerySet.filter`` once rather then calling it for each + filter specification. + """ return filter( lambda i: not i.is_negated, self.regular_specs @@ -47,12 +65,19 @@ def includes(self): @property def excludes(self): + """ + Property which gets list of negated filters + + By combining all negated filters we can optimize filtering by + calling ``QuerySet.exclude`` once rather then calling it for each + filter specification. + """ return filter( lambda i: i.is_negated, self.regular_specs ) - def prepare_spec(self, spec): + def _prepare_spec(self, spec): return '{}{}{}'.format( LOOKUP_SEP.join(spec.components), LOOKUP_SEP, @@ -60,8 +85,14 @@ def prepare_spec(self, spec): ) def filter_by_specs(self, queryset): - include = {self.prepare_spec(i): i.value for i in self.includes} - exclude = {self.prepare_spec(i): i.value for i in self.excludes} + """ + Filter queryset by applying all filter specifications + + The filtering is done by calling ``QuerySet.filter`` and + ``QuerySet.exclude`` as appropriate. + """ + include = {self._prepare_spec(i): i.value for i in self.includes} + exclude = {self._prepare_spec(i): i.value for i in self.excludes} if include: queryset = queryset.filter(**include) diff --git a/url_filter/backends/plain.py b/url_filter/backends/plain.py index 5e762db..e0fe96c 100644 --- a/url_filter/backends/plain.py +++ b/url_filter/backends/plain.py @@ -7,6 +7,16 @@ class PlainFilterBackend(BaseFilterBackend): + """ + Filter backend for filtering plain Python iterables. + + .. warning:: + The filter backend does filtering inside a regular loop + by comparing attributes of individual objects within iterable. + As a result, this is **NOT** efficient method for filtering + any large amounts of data. In those cases, it would probably + be better to find more appropriate and efficient way to filter data. + """ name = 'plain' enforce_same_models = False supported_lookups = { @@ -37,18 +47,31 @@ class PlainFilterBackend(BaseFilterBackend): } def get_model(self): + """ + Get the model from the given queryset + + Since there is no specific model for filtering Python lists, + this simply returns ``object`` + """ return object def filter_by_specs(self, queryset): + """ + Filter queryset by applying all filter specifications + + The filtering is done by calling manually loping over all + items in the iterable and comparing inner attributes with the + filter specification. + """ if not self.regular_specs: return queryset - return filter(self.filter_callable, queryset) + return filter(self._filter_callable, queryset) - def filter_callable(self, item): - return all(self.filter_by_spec(item, spec) for spec in self.regular_specs) + def _filter_callable(self, item): + return all(self._filter_by_spec(item, spec) for spec in self.regular_specs) - def filter_by_spec(self, item, spec): + def _filter_by_spec(self, item, spec): filtered = self._filter_by_spec_and_value(item, spec.components, spec) if spec.is_negated: return not filtered diff --git a/url_filter/backends/sqlalchemy.py b/url_filter/backends/sqlalchemy.py index 5022c95..09c3a82 100644 --- a/url_filter/backends/sqlalchemy.py +++ b/url_filter/backends/sqlalchemy.py @@ -9,6 +9,9 @@ from .base import BaseFilterBackend +__all__ = ['SQLAlchemyFilterBackend'] + + def lower(value): try: return value.lower() @@ -17,6 +20,20 @@ def lower(value): class SQLAlchemyFilterBackend(BaseFilterBackend): + """ + Filter backend for filtering SQLAlchemy query objects. + + .. warning:: + The filter backend can **ONLY** filter SQLAlchemy's query objects. + Passing any other datatype for filtering will kill happy bunnies + under rainbow. + + .. warning:: + The filter backend can **ONLY** filter query objects which query a single + entity (e.g. query a single model or model column). + If query object queries multiple entities, ``AssertionError`` + will be raised. + """ name = 'sqlalchemy' supported_lookups = { 'contains', @@ -46,9 +63,19 @@ def __init__(self, *args, **kwargs): ) def get_model(self): + """ + Get the model from the given queryset + """ return self.queryset._primary_entity.entities[0] def filter_by_specs(self, queryset): + """ + Filter SQLAlchemy query object by applying all filter specifications + + The filtering is done by calling ``filter`` with all appropriate + filter clauses. Additionally if any filter specifications filter + by related models, those models are joined as necessary. + """ if not self.regular_specs: return queryset @@ -62,6 +89,24 @@ def filter_by_specs(self, queryset): return queryset.filter(*conditions) def build_clause(self, spec): + """ + Construct SQLAlchemy binary expression filter clause from the + given filter specification. + + Parameters + ---------- + spec : FilterSpec + Filter specification for which to construct filter clause + + Returns + ------- + tuple + Tuple of filter binary expression clause and and a list of + model attributes/descriptors which should be joined when + doing filtering. If these attributes are not joined, + SQLAlchemy will not join appropriate tables hence + wont be able to correctly filter data. + """ to_join = [] model = self.model @@ -134,6 +179,11 @@ def _build_clause_startswith(self, spec, column): @classmethod def _get_properties_for_model(cls, model): + """ + Get column properties dict for the given model where + keys are field names and values are column properties + (e.g. ``ColumnProperty``) or related classes. + """ mapper = class_mapper(model) return { i.key: i @@ -142,12 +192,24 @@ def _get_properties_for_model(cls, model): @classmethod def _get_column_for_field(cls, field): + """ + Get a ``Column`` instance from the model property instance + (e.g. ``ColumnProperty`` class or related) + """ return field.columns[0] @classmethod def _get_attribute_for_field(cls, field): + """ + Get the model class attribute/descriptor from property instance + (e.g. ``ColumnProperty`` class or related) + """ return field.class_attribute @classmethod def _get_related_model_for_field(cls, field): + """ + Get related model to which field has relationship to + from property instance (e.g. ``ColumnProperty`` class or related) + """ return field._dependency_processor.mapper.class_ diff --git a/url_filter/exceptions.py b/url_filter/exceptions.py index cea84f0..b8123cc 100644 --- a/url_filter/exceptions.py +++ b/url_filter/exceptions.py @@ -5,7 +5,7 @@ class SkipFilter(Exception): """ Exception to be used when any particular filter - within the ``FilterSet`` should be skipped. + within the :class:`url_filter.filtersets.base.FilterSet` should be skipped. Possible reasons for skipping the field: diff --git a/url_filter/fields.py b/url_filter/fields.py index 6a3d16d..66ab2d9 100644 --- a/url_filter/fields.py +++ b/url_filter/fields.py @@ -23,7 +23,7 @@ class MultipleValuesField(forms.CharField): By default no maximum is enforced. max_validators : list, optional Additional validators which should be used to validate - all values once wplit by the delimiter. + all values once split by the delimiter. delimiter : str, optional The delimiter by which the value will be split into multiple values. diff --git a/url_filter/filters.py b/url_filter/filters.py index b995f41..d734e72 100644 --- a/url_filter/filters.py +++ b/url_filter/filters.py @@ -35,6 +35,33 @@ class BaseFilter(six.with_metaclass(abc.ABCMeta, object)): + """ + Base class to be used for defining both filters and filtersets. + + This class implements the bare-minimum functions which are used across + both filters and filtersets however all other functionality must be + implemented in subclasses. Additionally by using a single base class, + both filters and filtersets inherit from the same base class hence + instance checks can be easily done by filteset's metaclass in order + to find all declared filters defined in it. + + Parameters + ---------- + source : str + Name of the attribute for which which filter applies to + within the model of the queryset to be filtered + as given to the :class:`.FilterSet`. + + Attributes + ---------- + parent : :class:`.FilterSet` + Parent :class:`.FilterSet` to which this filter is bound to + name : str + Name of the field as it is defined in parent :class:`.FilterSet` + is_bound : bool + If this filter has been bound to a parent yet + """ + def __init__(self, source=None, *args, **kwargs): self._source = source self.parent = None @@ -49,6 +76,21 @@ def __repr__(self): @abc.abstractmethod def repr(self, prefix=''): """ + Get the representation of the filter or its subclasses. + + Subclasses **must** overwrite this method. + + .. note:: + This class should return unicode text data + + Parameters + ---------- + prefix : str + All nested filtersets provide useful representation of the complete + filterset including all descendants however in that case descendants + need to be indented in order for the representation to get structure. + This parameter is used to do just that. It specifies the prefix + the representation must use before returning any of its representations. """ @property @@ -57,16 +99,18 @@ def source(self): Source field/attribute in queryset model to be used for filtering. This property is helpful when ``source`` parameter is not provided - when instantiating ``Filter`` since it will use the filter name - as it is defined in the ``FilterSet``. For example:: + when instantiating :class:`.BaseFilter` or its subclasses since it will + use the filter name as it is defined in the :class:`.FilterSet`. + For example:: + >>> from .filtersets import FilterSet >>> class MyFilterSet(FilterSet): - ... foo = Filter(form_field=CharField()) - ... bar = Filter(source='stuff', form_field=CharField()) + ... foo = Filter(form_field=forms.CharField()) + ... bar = Filter(source='stuff', form_field=forms.CharField()) >>> fs = MyFilterSet() - >>> print(fs.fields['foo'].source) + >>> print(fs.filters['foo'].source) foo - >>> print(fs.fields['bar'].source) + >>> print(fs.filters['bar'].source) stuff """ return self._source or self.name @@ -84,7 +128,7 @@ def bind(self, name, parent): """ Bind the filter to the filterset. - This method should be used by the parent ``FilterSet`` + This method should be used by the parent :class:`.FilterSet` since it allows to specify the parent and name of each filter within the filterset. """ @@ -104,24 +148,30 @@ def root(self): class Filter(BaseFilter): """ - Filter class which main job is to convert leaf ``LookupConfig`` - to ``FilterSpec``. + Class which job is to convert leaf :class:`.LookupConfig` to + :class:`.FilterSpec` + + Each filter by itself is meant to be used a "filter" (field) in the + :class:`.FilterSet`. - Each filter by itself is meant to be used a "field" in the - ``FilterSpec``. + Examples + -------- + + :: + + >>> from .filtersets import FilterSet + >>> class MyFilterSet(FilterSet): + ... foo = Filter(forms.CharField()) + ... bar = Filter(forms.IntegerField()) Parameters ---------- - source : str - Name of the attribute for which which filter applies to - within the model of the queryset to be filtered - as given to the ``FilterSet``. form_field : Field Instance of Django's ``forms.Field`` which will be used to clean the filter value as provided in the queryset. For example if field is ``IntegerField``, this filter will make sure to convert the filtering value to integer - before creating a ``FilterSpec``. + before creating a :class:`.FilterSpec`. lookups : list, optional List of strings of allowed lookups for this filter. By default all supported lookups are allowed. @@ -133,23 +183,35 @@ class Filter(BaseFilter): will be used. is_default : bool, optional Boolean specifying if this filter should be used as a default - filter in the parent ``FilterSet``. + filter in the parent :class:`.FilterSet`. By default it is ``False``. Primarily this is used when querystring lookup key - refers to a nested ``FilterSet`` however it does not specify + refers to a nested :class:`.FilterSet` however it does not specify which filter to use. For example lookup key ``user__profile`` intends to filter something in the user's profile however it does not specify by which field to filter on. - In that case the default filter within profile ``FilterSet`` + In that case the default filter within profile :class:`.FilterSet` will be used. At most, one default filter should be provided - in the ``FilterSet``. + in the :class:`.FilterSet`. + no_lookup : bool, optional + When ``True``, this filter does not allow to explicitly specify + lookups in the URL. For example ``id__gt`` will not be allowed. + This is useful when a given filter should only support a single + lookup and that lookup name should not be exposed in the URL. + This is of particular use when defining custom callable filters. + By default it is ``False``. Attributes ---------- - parent : FilterSet - Parent ``FilterSet`` to which this filter is bound to - name : str - Name of the field as it is defined in parent ``FilterSet`` + form_field : Field + Django form field which is provided in initialization which + should be used to validate data as provided in the querystring + default_lookup : str + Default lookup to be used as provided in initialization + is_default : bool + If this filter should be a default filter as provided in initialization + no_lookup : str + If this filter should not support explicit lookups as provided in initialization """ def __init__(self, form_field, @@ -164,13 +226,27 @@ def __init__(self, form_field, self.no_lookup = no_lookup def repr(self, prefix=''): + """ + Get custom representation of the filter + + The representation includes the following information: + + * filter class name + * source name (same as :attr:`.source`) when filter is bound to parent + * primary form field (same as :attr:`.form_field`) + * which lookups this filter supports + * default lookup (same as :attr:`.default_lookup`) + * if the filter is a default filter (same as :attr:`.is_default`) when + filter is bound to parent + * if this filter does not support explicit lookups (same as :attr:`.no_lookup`) + """ return ( '{name}(' '{source}' 'form_field={form_field}, ' 'lookups={lookups}, ' 'default_lookup="{default_lookup}", ' - 'is_default={is_default}, ' + '{is_default}' 'no_lookup={no_lookup}' ')' ''.format(name=self.__class__.__name__, @@ -178,12 +254,31 @@ def repr(self, prefix=''): form_field=self.form_field.__class__.__name__, lookups=self._given_lookups or 'ALL', default_lookup=self.default_lookup, - is_default=self.is_default, + is_default='is_default={}, '.format(self.is_default) if self.is_bound else '', no_lookup=self.no_lookup) ) @cached_property def lookups(self): + """ + Cached property for getting lookups this filter supports + + The reason why we need as a property is because lookups + cant be hardcoded. There are 3 main distinct possibilities + which drive which lookups are supported: + + * lookups were explicitly provided in the filter instantiation + in which case we use those lookups. For example:: + + >>> f = Filter(forms.CharField(), lookups=['exact', 'contains']) + * when filter is already bound to a parent filterset and root + filterset has a defined ``filter_backend`` we use supported + lookups as explicitly defined by the backend. This is necessary + since different backends support different sets of lookups. + * when nether lookups are explicitly provided and filter is not bound + yet we have no choice but not support any lookups and so we + use empty set as supported lookups + """ if self._given_lookups: return set(self._given_lookups) if hasattr(self.root, 'filter_backend'): @@ -194,9 +289,9 @@ def get_form_field(self, lookup): """ Get the form field for a particular lookup. - This method does not blindly return ``form_field`` attribute + This method does not blindly return :attr:`.form_field` attribute since some lookups require to use different validations. - For example for if the ``form_field`` is ``CharField`` but + For example if the :attr:`.form_field` is ``CharField`` but the lookup is ``isnull``, it makes more sense to use ``BooleanField`` as form field. @@ -286,6 +381,30 @@ def get_spec(self, config): def form_field_for_filter(form_field): + """ + Decorator for specifying form field for a custom callable filter + on the filter callable method + + Examples + -------- + :: + + >>> class MyFilterCallable(CallableFilter): + ... @form_field_for_filter(forms.CharField()) + ... def filter_foo_for_django(self, queryset, spec): + ... return queryset + + Parameters + ---------- + form_field : Field + Django form field which should be used for the decorated + custom callable filter + + Returns + ------- + func + Function which can be used to decorate a class method + """ def wrapper(f): @wraps(f) def inner(self, *args, **kwargs): @@ -299,12 +418,68 @@ def inner(self, *args, **kwargs): class CallableFilter(Filter): + """ + Custom filter class meant to be subclassed in order to add + support for custom lookups via custom callables + + The goal of this filter is to provide: + + * support for custom callbacks (or overwrite existing ones) + * support different filtering backends + + Custom callable functions for lookups and different backends + are defined via class methods by using the following method + name pattern:: + + filter__for_ + + Obviously multiple methods can be used to implement functionality + for multiple lookups and/or backends. This makes callable filters + pretty flexible and ideal for implementing custom reusable filtering + filters which follow DRY. + + Examples + -------- + :: + + >>> from django.http import QueryDict + >>> from .filtersets import FilterSet + + >>> class MyCallableFilter(CallableFilter): + ... @form_field_for_filter(forms.CharField()) + ... def filter_foo_for_django(self, queryset, spec): + ... f = queryset.filter if not spec.is_negated else queryset.exclude + ... return f(foo=spec.value) + ... def filter_foo_for_sqlalchemy(self, queryset, spec): + ... op = operator.eq if not spec.is_negated else operator.ne + ... return queryset.filter(op(Foo.foo, spec.value)) + + >>> class MyFilterSet(FilterSet): + ... field = MyCallableFilter() + + >>> f = MyFilterSet(data=QueryDict('field__foo=bar'), queryset=[]) + + .. note:: + Unlike base class :class:`.Filter` this filter makes + ``form_field`` parameter optional. Please note however that + when ``form_field`` parameter is not provided, all custom + filter callables should define their own appropriate form fields + by using :func:`.form_field_for_filter`. + """ + def __init__(self, form_field=None, *args, **kwargs): # need to overwrite to make form_field optional super(CallableFilter, self).__init__(form_field, *args, **kwargs) @cached_property def lookups(self): + """ + Get all supported lookups for the filter + + This property is identical to the super implementation except it also + finds all custom lookups from the class methods and adds them to the + set of supported lookups as returned by the super implementation. + """ lookups = super(CallableFilter, self).lookups r = LOOKUP_CALLABLE_FROM_METHOD_REGEX @@ -317,6 +492,16 @@ def _get_filter_method_for_lookup(self, lookup): return getattr(self, name) def get_form_field(self, lookup): + """ + Get the form field for a particular lookup. + + This method attempts to return form field for custom callables + as set by :func:`.form_field_for_filter`. When either custom + lookup is not set or its form field is not set, super implementation + is used to get the form field. If form field at that point is not + found, this method raises ``AssertionError``. That can only happen + when `form_field` parameter is not provided during initialization. + """ try: return self._get_filter_method_for_lookup(lookup).form_field except AttributeError: @@ -337,6 +522,11 @@ def get_form_field(self, lookup): return form_field def get_spec(self, config): + """ + Get the :class:`.FilterSpec` for the given :class:`.LookupConfig` + with appropriately set :attr:`.FilterSpec.filter_callable` + when the lookup is a custom lookup + """ spec = super(CallableFilter, self).get_spec(config) spec.filter_callable = self._get_filter_method_for_lookup(spec.lookup) return spec diff --git a/url_filter/filtersets/base.py b/url_filter/filtersets/base.py index 5cd4d2e..4c062a7 100644 --- a/url_filter/filtersets/base.py +++ b/url_filter/filtersets/base.py @@ -20,7 +20,6 @@ __all__ = [ - 'BaseModelFilterSet', 'FilterSet', 'FilterSetOptions', 'ModelFilterSetOptions', @@ -33,10 +32,10 @@ class StrictMode(enum.Enum): Strictness mode enum. :``drop`` (default): - ignores all filter failures. when any occur, ``FilterSet`` + ignores all filter failures. when any occur, :class:`.FilterSet` simply then does not filter provided queryset. :``fail``: - when validation fails for any filter within ``FilterSet``, + when validation fails for any filter within :class:`.FilterSet`, all error are compiled and cumulative ``ValidationError`` is raised. """ drop = 'drop' @@ -44,16 +43,22 @@ class StrictMode(enum.Enum): LOOKUP_RE = re.compile( - r'^(?:[^\d\W]\w*)(?:{}?[^\d\W]\w*)*(?:\!)?$' + r'^(?:[^\d\W]\w*)(?:{}?[^\d\W]\w*)*(?:!)?$' r''.format(LOOKUP_SEP), re.IGNORECASE ) class FilterKeyValidator(RegexValidator): + """ + Custom regex validator for validating the querystring filter + is of correct syntax:: + + name[__]*[__][!] + """ regex = LOOKUP_RE message = ( 'Filter key is of invalid format. ' - 'It must be `name[__]*[__][!]`.' + 'It must be `name[__]*[__][!]`.' ) filter_key_validator = FilterKeyValidator() @@ -61,22 +66,22 @@ class FilterKeyValidator(RegexValidator): class FilterSetOptions(object): """ - Base class for handling options passed to ``FilterSet`` + Base class for handling options passed to :class:`.FilterSet` via ``Meta`` attribute. """ def __init__(self, options=None): pass -class FilterSetMeta(abc.ABCMeta): +class FilterSetMeta(type(BaseFilter)): """ - Metaclass for creating ``FilterSet`` classes. + Metaclass for creating :class:`.FilterSet` classes. Its primary job is to do: * collect all declared filters in all bases and set them as ``_declared_filters`` on created - ``FilterSet`` class. + :class:`.FilterSet` class. * instantiate ``Meta`` by using ``filter_options_class`` attribute """ @@ -108,53 +113,52 @@ class FilterSet(six.with_metaclass(FilterSetMeta, BaseFilter)): """ Main user-facing classes to use filtersets. - ``FilterSet`` primarily does: + It primarily does: * takes queryset to filter * takes querystring data which will be used to filter given queryset - * from the querystring, it constructs a list of ``LookupConfig`` - * loops over the created configs and attemps to get - ``FilterSpec`` for each - * in the process, if delegates the job of constructing spec + * from the querystring, it constructs a list of :class:`.LookupConfig` + * loops over the created configs and attempts to get + :class:`.FilterSpec` for each + * in the process, it delegates the job of constructing spec to child filters when any match is found between filter - defined on the filter and name in the config + defined on the filter and lookup name in the config Parameters ---------- - source : str - Name of the attribute for which which filter applies to - within the model of the queryset to be filtered - as given to the ``FilterSet``. data : QueryDict, optional - QueryDict of querystring data. - Only optional when ``FilterSet`` is used a nested filter - within another ``FilterSet``. + ``QueryDict`` of querystring data. + Only optional when :class:`.FilterSet` is used as a nested filter + within another :class:`.FilterSet`. queryset : iterable, optional Can be any iterable as supported by the filter backend. - Only optional when ``FilterSet`` is used a nested filter - within another ``FilterSet``. + Only optional when :class:`.FilterSet` is used as a nested filter + within another :class:`.FilterSet`. context : dict, optional Context for filtering. This is passed to filtering backend. Usually this would consist of passing ``request`` and ``view`` object from the Django view. strict_mode : str, optional - Strict mode how ``FilterSet`` should behave when any validation - fails. See ``StrictMode`` doc for more information. + Strict mode how :class:`.FilterSet` should behave when any validation + fails. See :class:`.StrictMode` doc for more information. Default is ``drop``. - - Attributes - ---------- - filter_backend_class - Class to be used as filter backend. By default - ``DjangoFilterBackend`` is used. - filter_options_class - Class to be used to construct ``Meta`` during - ``FilterSet`` class creation time in its metalclass. """ filter_backend_class = DjangoFilterBackend + """ + Class to be used as filter backend. By default + :class:`.DjangoFilterBackend` is used. + """ filter_options_class = FilterSetOptions + """ + Class to be used to construct ``Meta`` during + :class:`.FilterSet` class creation time in its metaclass. + """ default_strict_mode = StrictMode.drop + """ + Default strict mode which should be used when one is not + provided in initialization. + """ def __init__(self, data=None, queryset=None, context=None, strict_mode=None, @@ -166,6 +170,18 @@ def __init__(self, data=None, queryset=None, context=None, self.strict_mode = strict_mode or self.default_strict_mode def repr(self, prefix=''): + """ + Custom representation of the filterset + + + Parameters + ---------- + prefix : str + Prefix with which each line of the representation should + be prefixed with. This allows to recursively get the + representation of all descendants with correct indentation + (children are indented compared to parent) + """ header = '{name}({source})'.format( name=self.__class__.__name__, source='source="{}"'.format(self.source) if self.is_bound else '', @@ -183,8 +199,11 @@ def repr(self, prefix=''): def get_filters(self): """ Get all filters defined in this filterset. + By default only declared filters are returned however - this methoc can be used a hook to customize that. + this method is meant to be used as a hook in subclasses + in order to enhance functionality such as automatically + adding filters from model fields. """ return deepcopy(self._declared_filters) @@ -192,13 +211,14 @@ def get_filters(self): def filters(self): """ Cached property for accessing filters available in this filteset. - In addition to getting filters via ``get_filters``, - this property binds all filters to the filtset by using ``bind``. + In addition to getting filters via :meth:`.get_filters`, + this property binds all filters to the filterset by using + :meth:`.BaseFilter.bind`. See Also -------- get_filters - bind + :meth:`.BaseFilter.bind` """ filters = self.get_filters() @@ -223,10 +243,23 @@ def default_filter(self): def validate_key(self, key): """ - Validate that ``LookupConfig`` key is correct. + Validate that :class:`.LookupConfig` key is correct. This is the key as provided in the querystring. Currently key is validated against a regex expression. + + Useful to filter out invalid filter querystring pairs + since not whole querystring is not dedicated for filter + purposes but could contain other information such as + pagination information. In that case if the key is + invalid key for filtering, we can simply ignore it + without wasting time trying to get filter specification + for it. + + Parameters + ---------- + key : str + Key as provided in the querystring """ filter_key_validator(key) @@ -259,15 +292,15 @@ def filter_backend(self): def filter(self): """ - Main method which should be used on root ``FilterSet`` + Main method which should be used on root :class:`.FilterSet` to filter queryset. This method: - * asserts that filtering is being done on root ``FilterSet`` + * asserts that filtering is being done on root :class:`.FilterSet` and that all necessary data is provided - * creates ``LookupConfig``s from the provided data (querystring) - * loops over all configs and attemps to get ``FilterSpec`` + * creates :class:`.LookupConfig` from the provided data (querystring) + * loops over all configs and attempts to get :class:`.FilterSet` for all of them * instantiates filter backend * uses the created filter specs to filter queryset by using specs @@ -294,21 +327,21 @@ def filter(self): def get_specs(self): """ - Get ``FilterSpecs`` for the given querystring data. + Get list of :class:`.FilterSpec` for the given querystring data. This function does: - * unpacks the querystring data to ``LookupConfig``s - * loops throught all configs and uses appropriate children - filters to generate ``FilterSpec``s + * unpacks the querystring data to a list of :class:`.LookupConfig` + * loops through all configs and uses appropriate children + filters to generate list of :class:`.FilterSpec` * if any validations fails while generating specs, all errors are collected and depending on ``strict_mode`` - it reraises the errors or ignores them. + it re-raises the errors or ignores them. Returns ------- list - List of ``FilterSpec``s + List of :class:`.FilterSpec` """ configs = self._generate_lookup_configs() specs = [] @@ -317,6 +350,10 @@ def get_specs(self): for data in configs: try: self.validate_key(data.key) + except ValidationError: + continue + + try: specs.append(self.get_spec(data)) except SkipFilter: pass @@ -332,7 +369,7 @@ def get_specs(self): def get_spec(self, config): """ - Get ``FilterSpec`` for the given ``LookupConfig``. + Get :class:`.FilterSpec` for the given :class:`.LookupConfig`. If the config is non leaf config (it has more nested fields), then the appropriate matching child filter is used @@ -344,7 +381,7 @@ def get_spec(self, config): Parameters ---------- config : LookupConfig - Config for which to generate ``FilterSpec`` + Config for which to generate :class:`.FilterSpec` Returns ------- @@ -384,12 +421,12 @@ def _generate_lookup_configs(self): class ModelFilterSetOptions(FilterSetOptions): """ - Custom options for ``FilterSet``s used for model-generated filtersets. + Custom options for :class:`.FilterSet` used for model-generated filtersets. Attributes ---------- model : Model - Model class from which ``FilterSet`` will + Model class from which :class:`.FilterSet` will extract necessary filters. fields : None, list, optional Specific model fields for which filters @@ -398,11 +435,11 @@ class ModelFilterSetOptions(FilterSetOptions): fields filters will be created for. exclude : list, optional Specific model fields for which filters - should not be created for. + should **not** be created for. allow_related : bool, optional Whether related/nested fields should be allowed when model fields are automatically determined - (e.g. when explicit ``fields`` is not provided). + (e.g. when explicit :attr:`.fields` is not provided). """ def __init__(self, options=None): super(ModelFilterSetOptions, self).__init__(options) @@ -414,7 +451,7 @@ def __init__(self, options=None): class BaseModelFilterSet(FilterSet): """ - Base ``FilterSet`` for model-generated filtersets. + Base :class:`.FilterSet` for model-generated filtersets. The filterset can be configured via ``Meta`` class attribute, very much like how Django's ``ModelForm`` is configured. @@ -423,7 +460,7 @@ class BaseModelFilterSet(FilterSet): def get_filters(self): """ - Get all filters defined in this filterset by introspecing + Get all filters defined in this filterset by introspecting the given model in ``Meta.model``. """ filters = super(BaseModelFilterSet, self).get_filters() @@ -461,7 +498,7 @@ def _get_model_field_names(self): Get a list of all model fields. This is used when ``Meta.fields`` is ``None`` - in which case this method returns all model fields. + in which case this method returns all model field names. .. note:: This method is an abstract method and must be implemented @@ -482,7 +519,7 @@ def _build_filter(self, name, state): name : str Name of the field for which to build the filter within the ``Meta.model`` state - State of the model as returned by ``build_state``. + State of the model as returned by :meth:`._build_state`. Since state is computed outside of the loop which builds filters, state can be useful to store information outside of the loop so that it can be reused for all filters. @@ -490,7 +527,7 @@ def _build_filter(self, name, state): def _build_filterset(self, name, meta_attrs, base): """ - Helper method for building filtersets. + Helper method for building child filtersets. Parameters ---------- @@ -519,6 +556,6 @@ def _build_state(self): """ Hook function to build state to be used while building all the filters. Useful to compute common data between all filters such as some - data about the data so that the computation can be avoided while - building inidividual filters. + data about model so that the computation can be avoided while + building individual filters. """ diff --git a/url_filter/filtersets/django.py b/url_filter/filtersets/django.py index ac41522..966eb0d 100644 --- a/url_filter/filtersets/django.py +++ b/url_filter/filtersets/django.py @@ -28,6 +28,8 @@ class DjangoModelFilterSetOptions(ModelFilterSetOptions): Attributes ---------- allow_related_reverse : bool, optional + Flag specifying whether reverse relationships should + be allowed while creating filter sets for children models. """ def __init__(self, options=None): super(DjangoModelFilterSetOptions, self).__init__(options) @@ -36,7 +38,7 @@ def __init__(self, options=None): class ModelFilterSet(BaseModelFilterSet): """ - ``FilterSet`` for Django models. + :class:`.FilterSet` for Django models. The filterset can be configured via ``Meta`` class attribute, very much like Django's ``ModelForm`` is configured. @@ -97,7 +99,7 @@ def _build_filter(self, name, state): def _build_filter_from_field(self, field): """ - Build ``Filter`` for a standard Django model field. + Build :class:`.Filter` for a standard Django model field. """ return Filter( form_field=self._get_form_field_for_field(field), @@ -106,7 +108,7 @@ def _build_filter_from_field(self, field): def _build_filterset_from_related_field(self, field): """ - Build a ``FilterSet`` for a Django relation model field + Build a :class:`.FilterSet` for a Django relation model field such as ``ForeignKey``. """ return self._build_django_filterset(field, { @@ -115,7 +117,7 @@ def _build_filterset_from_related_field(self, field): def _build_filterset_from_reverse_field(self, field): """ - Build a ``FilterSet`` for a Django reverse relation model field. + Build a :class:`.FilterSet` for a Django reverse relation model field. """ return self._build_django_filterset(field, { 'exclude': [field.field.name], diff --git a/url_filter/filtersets/plain.py b/url_filter/filtersets/plain.py index 56ec9bb..8d0f4bc 100644 --- a/url_filter/filtersets/plain.py +++ b/url_filter/filtersets/plain.py @@ -26,7 +26,7 @@ class PlainModelFilterSet(BaseModelFilterSet): """ - ``FilterSet`` for plain Python objects. + :class:`.FilterSet` for plain Python objects. The filterset can be configured via ``Meta`` class attribute, very much like Django's ``ModelForm`` is configured. diff --git a/url_filter/filtersets/sqlalchemy.py b/url_filter/filtersets/sqlalchemy.py index 8702df3..5d31484 100644 --- a/url_filter/filtersets/sqlalchemy.py +++ b/url_filter/filtersets/sqlalchemy.py @@ -60,7 +60,7 @@ class SQLAlchemyModelFilterSet(BaseModelFilterSet): """ - ``FilterSet`` for SQLAlchemy models. + :class:`.FilterSet` for SQLAlchemy models. The filterset can be configured via ``Meta`` class attribute, very much like Django's ``ModelForm`` is configured. @@ -78,6 +78,16 @@ def _build_filter(self, name, fields): return self._build_filterset_from_related_field(field) def _build_state(self): + """ + Build state of all column properties for the SQLAlchemy model + which normalizes to a dict where keys are field names and values + are column property instances. + This state is computed before main loop which builds all filters + for all fields. As a result all helper builder methods + can use this state to get column property instances for necessary + fields by simply doing a dictionary lookup instead of requiring + search operations to find appropriate properties. + """ return SQLAlchemyFilterBackend._get_properties_for_model(self.Meta.model) def _get_model_field_names(self): @@ -109,7 +119,7 @@ def _get_form_field_for_field(self, field): def _build_filter_from_field(self, field): """ - Build ``Filter`` for a standard SQLAlchemy model field. + Build :class:`.Filter` for a standard SQLAlchemy model field. """ column = SQLAlchemyFilterBackend._get_column_for_field(field) @@ -119,6 +129,9 @@ def _build_filter_from_field(self, field): ) def _build_filterset_from_related_field(self, field): + """ + Build :class:`.FilterSet` for a relation SQLAlchemy model field. + """ m = SQLAlchemyFilterBackend._get_related_model_for_field(field) return self._build_filterset( diff --git a/url_filter/integrations/drf.py b/url_filter/integrations/drf.py index 596a17b..fc9a85f 100644 --- a/url_filter/integrations/drf.py +++ b/url_filter/integrations/drf.py @@ -7,9 +7,72 @@ class DjangoFilterBackend(BaseFilterBackend): + """ + DRF filter backend which integrates with ``django-url-filter`` + + This integration backend can be specified in global DRF settings:: + + # settings.py + REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': [ + 'url_filter.integrations.drf.DjangoFilterBackend', + ] + } + + Alternatively filter backend can be specified per view/viewset bases:: + + class MyViewSet(ModelViewSet): + queryset = MyModel.objects.all() + filter_backends = [DjangoFilterBackend] + filter_fields = ['field1', 'field2'] + + The following attributes can be specified on the view: + + * ``filter_class`` - explicit filter (:class:`.FilterSet` to be specific) class + which should be used for filtering. When this attribute is supplied, this + filterset will be used and all other attributes described below will are ignored. + * ``filter_fields`` - list of strings which should be names + of fields which should be included in the generated :class:`.FilterSet`. + This is equivalent:: + + class MyFilterSet(ModelFilterSet): + class Meta(object): + model = MyModel + fields = ['fields1', ...] + * ``filter_class_meta_kwargs`` - additional kwargs which should be passed + in ``Meta`` for the generated :class:`.FilterSet`. + * ``filter_class_default`` - base class to use while creating new :class:`.FilterSet`. + This is primarily useful when using non-Django data-sources. + By default :attr:`.default_filter_set` is used. + """ default_filter_set = ModelFilterSet + """ + Default base class which will be used while dynamically creating :class:`.FilterSet` + """ def get_filter_class(self, view, queryset=None): + """ + Get filter class which will be used for filtering. + + Parameters + ---------- + view : View + DRF view/viewset where this filter backend is being used. + Please refer to :class:`.DjangoFilterBackend` documentation + for list of attributes which can be supplied in view + to customize how filterset will be determined. + queryset + Query object for filtering + + Returns + ------- + :class:`.FilterSet` + :class:`.FilterSet` class either directly specified in the view or + dynamically constructed for the queryset model. + None + When appropriate :class:`.FilterSet` cannot be determined + for filtering + """ filter_class_default = getattr(view, 'filter_class_default', self.default_filter_set) filter_class = getattr(view, 'filter_class', None) filter_class_meta_kwargs = getattr(view, 'filter_class_meta_kwargs', {}) @@ -35,12 +98,45 @@ def get_filter_class(self, view, queryset=None): ) def get_filter_context(self, request, view): + """ + Get context to be passed to :class:`.FilterSet` during initialization + + Parameters + ---------- + request : HttpRequest + Request object from the view + view : View + View where this filter backend is being used + + Returns + ------- + dict + Context to be passed to :class:`.FilterSet` + """ return { 'request': request, 'view': view, } def filter_queryset(self, request, queryset, view): + """ + Main method for filtering query object + + Parameters + ---------- + request : HttpRequest + Request object from the view + queryset + Query object for filtering + view : View + View where this filter backend is being used + + Returns + ------- + object + Filtered query object if filtering class was determined by + :meth:`.get_filter_class`. If not given ``queryset`` is returned. + """ filter_class = self.get_filter_class(view, queryset) if filter_class: diff --git a/url_filter/utils.py b/url_filter/utils.py index 60f83fe..99aa07c 100644 --- a/url_filter/utils.py +++ b/url_filter/utils.py @@ -7,12 +7,14 @@ class FilterSpec(object): """ Class for describing filter specification. - The main job of the ``FilterSet`` is to parse + The main job of the :class:`.FilterSet` is to parse the submitted lookups into a list of filter specs. A list of these specs is then used by the filter backend - to actually filter given queryset. + to actually filter given queryset. That's what :class:`.FilterSpec` + provides - a way to portably define filter specification + to be used by a filter backend. - The reason why filtering is decoupled from the ``FilterSet`` + The reason why filtering is decoupled from the :class:`.FilterSet` is because this allows to implement filter backends not related to Django. @@ -22,11 +24,11 @@ class FilterSpec(object): A list of strings which are names of the keys/attributes to be used in filtering of the queryset. For example lookup config with key - ``user__profile__email`` will be components of + ``user__profile__email`` will have components of ``['user', 'profile', 'email']. lookup : str Name of the lookup how final key/attribute from - ``components`` should be compared. + :attr:`.components` should be compared. For example lookup config with key ``user__profile__email__contains`` will have a lookup ``contains``. @@ -38,7 +40,7 @@ class FilterSpec(object): filter_callable : func, optional Callable which should be used for filtering this filter spec. This is primaliry meant to be used - by ``CallableFilter``. + by :class:`.CallableFilter`. """ def __init__(self, components, lookup, value, is_negated=False, filter_callable=None): self.components = components @@ -49,6 +51,10 @@ def __init__(self, components, lookup, value, is_negated=False, filter_callable= @property def is_callable(self): + """ + Property for getting whether this filter specification is for a custom + filter callable + """ return self.filter_callable is not None def __repr__(self): @@ -76,11 +82,11 @@ def __hash__(self): class LookupConfig(object): """ - Lookup configuration which is used by ``FilterSet`` - to create a ``FilterSpec``. + Lookup configuration which is used by :class:`.FilterSet` + to create a :class:`.FilterSpec`. The main purpose of this config is to allow the use - if recursion in ``FilterSet``. Each lookup key + if recursion in :class:`.FilterSet`. Each lookup key (the keys in the querystring) is parsed into a nested one-key dictionary which lookup config stores. @@ -109,7 +115,7 @@ class LookupConfig(object): Either: * nested dictionary where the key is the next key within - the lookup chain and value is another ``LookupConfig`` + the lookup chain and value is another :class:`.LookupConfig` * the filtering value as provided in the querystring value Parameters @@ -119,7 +125,7 @@ class LookupConfig(object): data : dict, str A regular vanilla Python dictionary. This class automatically converts nested - dictionaries to instances of LookupConfig. + dictionaries to instances of :class:`.LookupConfig`. Alternatively a filtering value as provided in the querystring. """ @@ -131,12 +137,16 @@ def __init__(self, key, data): self.data = data def is_key_value(self): + """ + Check if this :class:`.LookupConfig` is not a nested :class:`.LookupConfig` + but instead the value is a non-dict value. + """ return len(self.data) == 1 and not isinstance(self.value, dict) @property def name(self): """ - If the ``data`` is nested ``LookupConfig``, + If the ``data`` is nested :class:`.LookupConfig`, this gets its first lookup key. """ return next(iter(self.data.keys())) @@ -144,15 +154,15 @@ def name(self): @property def value(self): """ - If the ``data`` is nested ``LookupConfig``, + If the ``data`` is nested :class:`.LookupConfig`, this gets its first lookup value which could either - be another ``LookupConfig`` or actual filtering value. + be another :class:`.LookupConfig` or actual filtering value. """ return next(iter(self.data.values())) def as_dict(self): """ - Converts the nested ``LookupConfig``s to a regular ``dict``. + Converts the nested :class:`.LookupConfig` to a regular ``dict``. """ if isinstance(self.data, dict): return {k: v.as_dict() for k, v in self.data.items()} From 906ccb4fbabf6ced7d8380070f1d587138a453f8 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Tue, 15 Dec 2015 23:58:52 -0500 Subject: [PATCH 15/16] running doctests in make test target --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ece4e1f..48f003b 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ lint: flake8 . test: - py.test -sv --cov=url_filter --cov-report=term-missing tests/ + py.test -sv --cov=url_filter --cov-report=term-missing --doctest-modules tests/ url_filter/ test-all: tox From 2253a2e19c4c9d1c30329c7de1a5d7fe6ad3dc65 Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Wed, 16 Dec 2015 00:10:42 -0500 Subject: [PATCH 16/16] only enabling custom lookups when they are implemented for used backend --- tests/test_filters.py | 13 +++++++++++++ url_filter/filters.py | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_filters.py b/tests/test_filters.py index 311c1fa..7eae1d8 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -166,9 +166,22 @@ def filter_foo_for_django(self): pass f = Foo() + f.filter_backend = mock.Mock(supported_lookups=set()) + f.filter_backend.name = 'django' assert f.lookups == {'foo'} + def test_lookups_not_all_backends(self): + class Foo(CallableFilter): + def filter_foo_for_django(self): + pass + + f = Foo() + f.filter_backend = mock.Mock(supported_lookups=set()) + f.filter_backend.name = 'sqlalchemy' + + assert f.lookups == set() + def test_get_form_field(self): field = forms.CharField() diff --git a/url_filter/filters.py b/url_filter/filters.py index d734e72..d5246fb 100644 --- a/url_filter/filters.py +++ b/url_filter/filters.py @@ -30,7 +30,7 @@ } LOOKUP_CALLABLE_FROM_METHOD_REGEX = re.compile( - r'^filter_(?P[\w\d]+)_for_(?P[\w\d])+$' + r'^filter_(?P[\w\d]+)_for_(?P[\w\d]+)$' ) @@ -483,7 +483,10 @@ def lookups(self): lookups = super(CallableFilter, self).lookups r = LOOKUP_CALLABLE_FROM_METHOD_REGEX - custom_lookups = {m.groupdict()['filter'] for m in (r.match(i) for i in dir(self)) if m} + custom_lookups = { + m.groupdict()['filter'] for m in (r.match(i) for i in dir(self)) + if m and m.groupdict()['backend'] == self.root.filter_backend.name + } return lookups | custom_lookups