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/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 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/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 e596095..f654ede 100644 --- a/test_project/one_to_one/api.py +++ b/test_project/one_to_one/api.py @@ -1,11 +1,18 @@ # -*- 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 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.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 from . import alchemy @@ -58,24 +65,98 @@ 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): + default_strict_mode = StrictMode.fail + waiter = PlaceWaiterCallableFilter(no_lookup=True) + class Meta(object): model = Place +class PlainPlaceFilterSet(PlainModelFilterSet): + default_strict_mode = StrictMode.fail + filter_backend_class = PlainFilterBackend + waiter = PlaceWaiterCallableFilter(no_lookup=True) + + 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): + default_strict_mode = StrictMode.fail filter_backend_class = SQLAlchemyFilterBackend + waiter = PlaceWaiterCallableFilter(no_lookup=True) class Meta(object): model = alchemy.Place 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): @@ -83,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): @@ -100,6 +185,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/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 2eec5f5..b3c0094 100644 --- a/test_project/urls.py +++ b/test_project/urls.py @@ -10,21 +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', 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 diff --git a/tests/backends/test_django.py b/tests/backends/test_django.py index 88273f4..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' @@ -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/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_base.py b/tests/filtersets/test_base.py index a41b118..c86395b 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' - ' foo = FooFilterSet()\n' - ' foo = Filter(form_field=CharField, lookups=ALL, default_lookup="exact", is_default=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): @@ -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) @@ -168,6 +198,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 +231,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', diff --git a/tests/filtersets/test_django.py b/tests/filtersets/test_django.py index 61343b0..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): @@ -170,13 +211,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_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/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_filters.py b/tests/test_filters.py index 28e990c..7eae1d8 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 @@ -55,7 +59,13 @@ def test_repr(self): assert repr(f) == ( 'Filter(form_field=CharField, lookups=ALL, ' - 'default_lookup="foo", is_default=True)' + '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)' ) def test_source(self): @@ -123,3 +133,102 @@ 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() + 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() + + 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 4d8be7d..cdea919 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): @@ -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) @@ -84,3 +94,32 @@ 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', + } + + 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/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 diff --git a/url_filter/backends/base.py b/url_filter/backends/base.py index f7b6367..eb5d2aa 100644 --- a/url_filter/backends/base.py +++ b/url_filter/backends/base.py @@ -7,7 +7,52 @@ 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 :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 + 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 + """ + 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 @@ -16,23 +61,106 @@ def __init__(self, queryset, context=None): @cached_property def model(self): + """ + Property for getting model on which this filter backend operates. + + 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() def bind(self, specs): + """ + Bind the given specs to the filter backend. + + 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 + @cached_property + 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] + + @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. + + See Also + -------- + regular_specs + """ + return [i for i in self.specs if i.is_callable] + @abc.abstractmethod 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 """ - @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 :class:`.Filter` definition. + + 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 + + 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..c6f6ffe 100644 --- a/url_filter/backends/django.py +++ b/url_filter/backends/django.py @@ -7,6 +7,15 @@ 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', 'day', @@ -35,38 +44,59 @@ 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.specs + self.regular_specs ) @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.specs + self.regular_specs ) - def prepare_spec(self, spec): + def _prepare_spec(self, spec): return '{}{}{}'.format( LOOKUP_SEP.join(spec.components), LOOKUP_SEP, spec.lookup, ) - def filter(self): - include = {self.prepare_spec(i): i.value for i in self.includes} - exclude = {self.prepare_spec(i): i.value for i in self.excludes} + def filter_by_specs(self, queryset): + """ + Filter queryset by applying all filter specifications - qs = self.queryset + 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: - 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 new file mode 100644 index 0000000..e0fe96c --- /dev/null +++ b/url_filter/backends/plain.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals +import re + +from ..utils import dictify +from .base import BaseFilterBackend + + +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 = { + '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): + """ + 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) + + 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): + 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)) + try: + return comparator(item, spec) + except Exception: + return True + + 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 = dictify(item) + + 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/backends/sqlalchemy.py b/url_filter/backends/sqlalchemy.py index aaeeb99..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,21 @@ 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', 'endswith', @@ -45,23 +63,50 @@ 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(self): - if not self.specs: - return self.queryset + 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 - 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): + """ + 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(self, field): + 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 b538594..d5246fb 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 @@ -17,7 +19,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), @@ -27,96 +29,69 @@ 'year': forms.IntegerField(min_value=0, max_value=9999), } +LOOKUP_CALLABLE_FROM_METHOD_REGEX = re.compile( + r'^filter_(?P[\w\d]+)_for_(?P[\w\d]+)$' +) -class Filter(object): + +class BaseFilter(six.with_metaclass(abc.ABCMeta, object)): """ - Filter class which main job is to convert leaf ``LookupConfig`` - to ``FilterSpec``. + Base class to be used for defining both filters and filtersets. - Each filter by itself is meant to be used a "field" in the - ``FilterSpec``. + 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 ``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``. - lookups : list, optional - List of strings of allowed lookups for this filter. - By default all supported lookups are allowed. - default_lookup : str, optional - If the lookup is not provided in the querystring lookup key, - this lookup will be used. By default ``exact`` lookup is used. - For example the default lookup is used when querystring key is - ``user__profile__email`` which is missing the lookup so ``exact`` - will be used. - is_default : bool, optional - Boolean specifying if this filter should be used as a default - filter in the parent ``FilterSet``. - By default it is ``False``. - Primarily this is used when querystring lookup key - refers to a nested ``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`` - will be used. At most, one default filter should be provided - in the ``FilterSet``. + as given to the :class:`.FilterSet`. Attributes ---------- - parent : FilterSet - Parent ``FilterSet`` to which this filter is bound to + 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 ``FilterSet`` + 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 self.name = None - self._init(*args, **kwargs) - - def _init(self, form_field, lookups=None, default_lookup='exact', is_default=False): - self.form_field = form_field - self._given_lookups = lookups - self.default_lookup = default_lookup or self.default_lookup - self.is_default = is_default - - def repr(self, prefix=''): - return ( - '{name}(' - 'form_field={form_field}, ' - 'lookups={lookups}, ' - 'default_lookup="{default_lookup}", ' - 'is_default={is_default}' - ')' - ''.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) - ) + self.is_bound = False 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: - return set(self._given_lookups) - if hasattr(self.root, 'filter_backend'): - return self.root.filter_backend.supported_lookups - return set() + @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 def source(self): @@ -124,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 @@ -151,12 +128,13 @@ 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. """ self.name = name self.parent = parent + self.is_bound = True @property def root(self): @@ -167,13 +145,153 @@ def root(self): return self return self.parent.root + +class Filter(BaseFilter): + """ + 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`. + + Examples + -------- + + :: + + >>> from .filtersets import FilterSet + >>> class MyFilterSet(FilterSet): + ... foo = Filter(forms.CharField()) + ... bar = Filter(forms.IntegerField()) + + Parameters + ---------- + 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 :class:`.FilterSpec`. + lookups : list, optional + List of strings of allowed lookups for this filter. + By default all supported lookups are allowed. + default_lookup : str, optional + If the lookup is not provided in the querystring lookup key, + this lookup will be used. By default ``exact`` lookup is used. + For example the default lookup is used when querystring key is + ``user__profile__email`` which is missing the lookup so ``exact`` + will be used. + is_default : bool, optional + Boolean specifying if this filter should be used as a default + filter in the parent :class:`.FilterSet`. + By default it is ``False``. + Primarily this is used when querystring lookup key + 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 :class:`.FilterSet` + will be used. At most, one default filter should be provided + 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 + ---------- + 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, + 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=''): + """ + 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}' + '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, + 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'): + return self.root.filter_backend.supported_lookups + return set() + 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. @@ -239,6 +357,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 +378,158 @@ 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): + """ + 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): + return f(self, *args, **kwargs) + + inner.form_field = form_field + + return inner + + return wrapper + + +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 + 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 + + def _get_filter_method_for_lookup(self, lookup): + name = 'filter_{}_for_{}'.format(lookup, self.root.filter_backend.name) + 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: + pass + + 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 provide form_field by using @form_field_for_filter ' + 'decorator. If the lookup is a normal lookup, then please either ' + 'provide form_field parameter or overwrite get_form_field().' + ''.format(name=self.__class__.__name__) + ) + + 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 96d13e1..4c062a7 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 @@ -14,11 +15,16 @@ from ..backends.django import DjangoFilterBackend from ..exceptions import SkipFilter -from ..filters import Filter +from ..filters import BaseFilter from ..utils import LookupConfig -__all__ = ['FilterSet', 'FilterSetOptions', 'StrictMode'] +__all__ = [ + 'FilterSet', + 'FilterSetOptions', + 'ModelFilterSetOptions', + 'StrictMode', +] class StrictMode(enum.Enum): @@ -26,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' @@ -37,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() @@ -54,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(type): +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 """ @@ -87,7 +99,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( @@ -97,66 +109,83 @@ 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. - ``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=StrictMode.drop): + def __init__(self, data=None, queryset=None, context=None, + 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__) + """ + 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 '', + ) lines = [header] + [ '{prefix}{key} = {value}'.format( prefix=prefix + ' ', @@ -170,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) @@ -179,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() @@ -210,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) @@ -246,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 @@ -281,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 = [] @@ -304,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 @@ -319,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 @@ -331,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 ------- @@ -347,7 +397,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) @@ -361,3 +417,145 @@ def _generate_lookup_configs(self): lambda a, b: {b: a}, (key.replace('!', '').split(LOOKUP_SEP) + [value])[::-1] )) + + +class ModelFilterSetOptions(FilterSetOptions): + """ + Custom options for :class:`.FilterSet` used for model-generated filtersets. + + Attributes + ---------- + model : Model + Model class from which :class:`.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 :attr:`.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', []) + self.allow_related = getattr(options, 'allow_related', True) + + +class BaseModelFilterSet(FilterSet): + """ + 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. + """ + filter_options_class = ModelFilterSetOptions + + def get_filters(self): + """ + Get all filters defined in this filterset by introspecting + 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 or name in filters: + 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 field names. + + .. 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 :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. + """ + + def _build_filterset(self, name, meta_attrs, base): + """ + Helper method for building child 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. + Useful to compute common data between all filters such as some + 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 54ac273..966eb0d 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,31 @@ }) -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 + Flag specifying whether reverse relationships should + be allowed while creating filter sets for children models. """ 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. + :class:`.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 +57,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,45 +81,55 @@ 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. + Build :class:`.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 + Build a :class:`.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], }) - 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. + Build a :class:`.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 new file mode 100644 index 0000000..8d0f4bc --- /dev/null +++ b/url_filter/filtersets/plain.py @@ -0,0 +1,69 @@ +# -*- 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 BaseModelFilterSet + + +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(BaseModelFilterSet): + """ + :class:`.FilterSet` for plain Python objects. + + The filterset can be configured via ``Meta`` class attribute, + very much like Django's ``ModelForm`` is configured. + """ + + 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) + + def _get_model_field_names(self): + 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): + return self._build_filterset( + name.title(), + {'model': field}, + PlainModelFilterSet, + ) diff --git a/url_filter/filtersets/sqlalchemy.py b/url_filter/filtersets/sqlalchemy.py index c54d25c..5d31484 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,39 @@ }) -class SQLAlchemyModelFilterSet(FilterSet): +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. """ - 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] + def _build_filter(self, name, fields): + field = fields[name] - try: - _filter = None + if isinstance(field, ColumnProperty): + return self._build_filter_from_field(field) - 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) + elif isinstance(field, RelationshipProperty): + if not self.Meta.allow_related: + raise SkipFilter + return self._build_filterset_from_related_field(field) - except SkipFilter: - continue - - else: - if _filter is not None: - filters[name] = _filter - - return filters + 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): + def _get_model_field_names(self): """ Get a list of all model fields. @@ -120,7 +99,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,33 +117,28 @@ 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. + Build :class:`.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): + """ + Build :class:`.FilterSet` for a relation SQLAlchemy model 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() diff --git a/url_filter/integrations/drf.py b/url_filter/integrations/drf.py index d3da066..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: @@ -51,7 +147,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..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``. @@ -35,20 +37,40 @@ 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 :class:`.CallableFilter`. """ - 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): + """ + Property for getting whether this filter specification is for a custom + filter callable + """ + 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): @@ -60,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. @@ -93,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 @@ -103,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. """ @@ -115,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())) @@ -128,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()} @@ -182,10 +208,36 @@ 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): + """ + 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: + 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('_') + }