Skip to content

Commit

Permalink
Merge pull request #49 from cloudblue/LITE-24280_support_get_rql_filt…
Browse files Browse the repository at this point in the history
…er_class

LITE-24280: Add support for get_rql_filter_class() in views
  • Loading branch information
maxipavlovic authored Jul 7, 2022
2 parents 283d376 + dbef712 commit 35c60e5
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 23 deletions.
31 changes: 26 additions & 5 deletions dj_rql/drf/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,26 @@ def clear(cls):
class RQLFilterBackend(BaseFilterBackend):
""" RQL filter backend for DRF GenericAPIViews.
Examples:
Set the backend filter for the ``GenericAPIView`` class-based view, and set the
``rql_filter_class`` class attribute to the ``RQLFilterClass`` to use:
.. code-block:: python
class ViewSet(mixins.ListModelMixin, GenericViewSet):
filter_backends = (RQLFilterBackend,)
rql_filter_class = ModelFilterClass
Yo can also add a ``get_rql_filter_class()`` method to the view to get the filter class:
.. code-block:: python
class ViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, GenericViewSet):
filter_backends = (RQLFilterBackend,)
def get_rql_filter_class(self):
if self.action == 'retrieve':
return ModelDetailFilterClass
return ModelFilterClass
"""
OPENAPI_RETRIEVE_SPECIFICATION = False

Expand Down Expand Up @@ -85,6 +101,8 @@ def get_schema_operation_parameters(self, view):

@staticmethod
def get_filter_class(view):
if hasattr(view, 'get_rql_filter_class') and callable(view.get_rql_filter_class):
return view.get_rql_filter_class()
return getattr(view, 'rql_filter_class', None)

@classmethod
Expand All @@ -93,14 +111,14 @@ def get_query(cls, filter_instance, request, view):

@classmethod
def _get_or_init_cache(cls, filter_class, view):
qual_name = cls._get_filter_cls_qual_name(view)
qual_name = cls._get_filter_cls_qual_name(view, filter_class)
return cls._CACHES.setdefault(
qual_name, filter_class.QUERIES_CACHE_BACKEND(int(filter_class.QUERIES_CACHE_SIZE)),
)

@classmethod
def _get_filter_instance(cls, filter_class, queryset, view):
qual_name = cls._get_filter_cls_qual_name(view)
qual_name = cls._get_filter_cls_qual_name(view, filter_class)

filter_instance = _FilterClassCache.CACHE.get(qual_name)
if filter_instance:
Expand All @@ -111,5 +129,8 @@ def _get_filter_instance(cls, filter_class, queryset, view):
return filter_instance

@staticmethod
def _get_filter_cls_qual_name(view):
return '{0}.{1}'.format(view.__class__.__module__, view.__class__.__name__)
def _get_filter_cls_qual_name(view, filter_class):
return '{0}.{1}+{2}.{3}'.format(
view.__class__.__module__, view.__class__.__name__,
filter_class.__module__, filter_class.__name__,
)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def read_file(name):
include_package_data=True,
install_requires=read_file('requirements/dev.txt').splitlines(),
tests_require=read_file('requirements/test.txt').splitlines(),
setup_requires=['setuptools_scm', 'pytest-runner', 'wheel'],
setup_requires=['setuptools_scm<7', 'pytest-runner', 'wheel'],
extras_require={
'drf': read_file('requirements/extra.txt').splitlines(),
},
Expand Down
17 changes: 17 additions & 0 deletions tests/dj_rf/filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#
# Copyright © 2022 Ingram Micro Inc. All rights reserved.
#
from copy import deepcopy

from cachetools import LFUCache, LRUCache

from dj_rql.fields import SelectField
Expand Down Expand Up @@ -48,6 +50,7 @@ class BooksFilterClass(RQLFilterClass):
'openapi': {
'required': True,
},
'hidden': True,
}, {
'filter': 'author__email',
'search': True,
Expand All @@ -67,6 +70,7 @@ class BooksFilterClass(RQLFilterClass):
'filters': AUTHOR_FILTERS,
'distinct': True,
'qs': SR('author', 'author__publisher'),
'hidden': True,
}, {
'namespace': 'page',
'source': 'pages',
Expand All @@ -89,6 +93,7 @@ class BooksFilterClass(RQLFilterClass):
'filter': 'amazon_rating',
'lookups': {FilterLookups.GE, FilterLookups.LT},
'null_values': {'random'},
'hidden': True,
}, {
'filter': 'url',
'source': 'publishing_url',
Expand Down Expand Up @@ -196,3 +201,15 @@ class SelectBooksFilterClass(BooksFilterClass):
SELECT = True
QUERIES_CACHE_BACKEND = LRUCache
QUERIES_CACHE_SIZE = 100


class SelectDetailedBooksFilterClass(SelectBooksFilterClass):

def __make_filters():
result = deepcopy(BooksFilterClass.FILTERS)
result[4]['hidden'] = False # status
result[7]['hidden'] = False # author
result[12]['hidden'] = False # amazon_rating
return result

FILTERS = __make_filters()
4 changes: 3 additions & 1 deletion tests/dj_rf/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
# Copyright © 2022 Ingram Micro Inc. All rights reserved.
#

from dj_rql.drf.serializers import RQLMixin
Expand Down Expand Up @@ -68,6 +68,8 @@ class Meta:
'author_ref', # One level reference field (FK)
'author', # Deep nested fields (FK)
'pages', # List of backrefs
'status',
'amazon_rating',
)

def get_author(self, obj):
Expand Down
6 changes: 4 additions & 2 deletions tests/dj_rf/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
# Copyright © 2022 Ingram Micro Inc. All rights reserved.
#

from django.conf.urls import include
Expand All @@ -8,7 +8,8 @@
from rest_framework.routers import SimpleRouter

from tests.dj_rf.view import (
AutoViewSet, DRFViewSet, DjangoFiltersViewSet, NoFilterClsViewSet, SelectViewSet,
AutoViewSet, DRFViewSet, DjangoFiltersViewSet, DynamicFilterClsViewSet, NoFilterClsViewSet,
SelectViewSet,
)


Expand All @@ -18,6 +19,7 @@
router.register(r'select', SelectViewSet, basename='select')
router.register(r'nofiltercls', NoFilterClsViewSet, basename='nofiltercls')
router.register(r'auto', AutoViewSet, basename='auto')
router.register(r'dynamicfiltercls', DynamicFilterClsViewSet, basename='dynamicfiltercls')

urlpatterns = [
re_path(r'^', include(router.urls)),
Expand Down
15 changes: 13 additions & 2 deletions tests/dj_rf/view.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
# Copyright © 2022 Ingram Micro Inc. All rights reserved.
#

from dj_rql.drf.backend import RQLFilterBackend
Expand All @@ -14,7 +14,9 @@
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet

from tests.dj_rf.filters import BooksFilterClass, SelectBooksFilterClass
from tests.dj_rf.filters import (
BooksFilterClass, SelectBooksFilterClass, SelectDetailedBooksFilterClass,
)
from tests.dj_rf.models import Book
from tests.dj_rf.serializers import BookSerializer, SelectBookSerializer

Expand Down Expand Up @@ -56,6 +58,15 @@ class SelectViewSet(mixins.RetrieveModelMixin, DRFViewSet):
rql_filter_class = SelectBooksFilterClass


class DynamicFilterClsViewSet(mixins.RetrieveModelMixin, DRFViewSet):
serializer_class = SelectBookSerializer

def get_rql_filter_class(self):
if self.action == 'retrieve':
return SelectDetailedBooksFilterClass
return SelectBooksFilterClass


class NoFilterClsViewSet(DRFViewSet):
rql_filter_class = None

Expand Down
86 changes: 77 additions & 9 deletions tests/test_drf/test_common_drf_backend.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
# Copyright © 2022 Ingram Micro Inc. All rights reserved.
#
from cachetools import LFUCache, LRUCache

Expand All @@ -12,7 +12,7 @@
import pytest

from rest_framework.reverse import reverse
from rest_framework.status import HTTP_200_OK
from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND

from tests.dj_rf.models import Book

Expand Down Expand Up @@ -103,7 +103,7 @@ def test_filter_cls_cache(api_client, clear_cache):
response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=F'))
assert response.data == [{'id': books[0].pk}]

expected_cache_key = 'tests.dj_rf.view.DRFViewSet'
expected_cache_key = 'tests.dj_rf.view.DRFViewSet+tests.dj_rf.filters.BooksFilterClass'
assert expected_cache_key in _FilterClassCache.CACHE
cache_item_id = id(_FilterClassCache.CACHE[expected_cache_key])

Expand All @@ -117,6 +117,42 @@ def test_filter_cls_cache(api_client, clear_cache):
assert _FilterClassCache.CACHE == {}


@pytest.mark.django_db
def test_dynamic_filter_cls_cache(api_client, clear_cache):
books = [
Book.objects.create(title='F'),
Book.objects.create(title='G'),
]

list_cache_key = '{0}+{1}'.format(
'tests.dj_rf.view.DynamicFilterClsViewSet',
'tests.dj_rf.filters.SelectBooksFilterClass',
)
detail_cache_key = '{0}+{1}'.format(
'tests.dj_rf.view.DynamicFilterClsViewSet',
'tests.dj_rf.filters.SelectDetailedBooksFilterClass',
)

assert _FilterClassCache.CACHE == {}

api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=F'))
assert list_cache_key in _FilterClassCache.CACHE

list_cache_item_id = id(_FilterClassCache.CACHE[list_cache_key])
api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=G'))
assert len(_FilterClassCache.CACHE) == 1
assert id(_FilterClassCache.CACHE[list_cache_key]) == list_cache_item_id

api_client.get(reverse('dynamicfiltercls-detail', [books[0].pk]))
assert len(_FilterClassCache.CACHE) == 2
assert detail_cache_key in _FilterClassCache.CACHE

detail_cache_item_id = id(_FilterClassCache.CACHE[detail_cache_key])
api_client.get(reverse('dynamicfiltercls-detail', [books[1].pk]))
assert len(_FilterClassCache.CACHE) == 2
assert id(_FilterClassCache.CACHE[detail_cache_key]) == detail_cache_item_id


@pytest.mark.django_db
def test_query_cache(api_client, clear_cache, django_assert_num_queries):
books = [
Expand All @@ -136,13 +172,45 @@ def test_query_cache(api_client, clear_cache, django_assert_num_queries):
assert response.status_code == HTTP_200_OK
assert 'id' not in response.data[0]

response = api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=F'))
assert response.data[0]['id'] == books[0].pk

response = api_client.get(reverse('dynamicfiltercls-list') + '?select(author)')
assert len(response.data) == 2

response = api_client.get('{0}?{1}'.format(reverse('dynamicfiltercls-list'), 'title=X'))
assert response.data == []

response = api_client.get(reverse('dynamicfiltercls-detail', [books[0].pk]))
assert response.data['id'] == books[0].pk

response = api_client.get(reverse('dynamicfiltercls-detail', ['non-exists']))
assert response.status_code == HTTP_404_NOT_FOUND

caches = RQLFilterBackend._CACHES
assert isinstance(caches['tests.dj_rf.view.DRFViewSet'], LFUCache)
assert caches['tests.dj_rf.view.DRFViewSet'].currsize == 2
assert caches['tests.dj_rf.view.DRFViewSet'].maxsize == 20
assert isinstance(caches['tests.dj_rf.view.SelectViewSet'], LRUCache)
assert caches['tests.dj_rf.view.SelectViewSet'].currsize == 1
assert caches['tests.dj_rf.view.SelectViewSet'].maxsize == 100
cache = caches['tests.dj_rf.view.DRFViewSet+tests.dj_rf.filters.BooksFilterClass']
assert isinstance(cache, LFUCache)
assert cache.currsize == 2
assert cache.maxsize == 20

cache = caches['tests.dj_rf.view.SelectViewSet+tests.dj_rf.filters.SelectBooksFilterClass']
assert isinstance(cache, LRUCache)
assert cache.currsize == 1
assert cache.maxsize == 100

cache = caches[
'tests.dj_rf.view.DynamicFilterClsViewSet'
'+tests.dj_rf.filters.SelectBooksFilterClass'
]
assert isinstance(cache, LRUCache)
assert cache.currsize == 3

cache = caches[
'tests.dj_rf.view.DynamicFilterClsViewSet'
'+tests.dj_rf.filters.SelectDetailedBooksFilterClass'
]
assert isinstance(cache, LRUCache)
assert cache.currsize == 1


@pytest.mark.django_db
Expand Down
72 changes: 72 additions & 0 deletions tests/test_drf/test_dynamic_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#
# Copyright © 2022 Ingram Micro Inc. All rights reserved.
#

import pytest

from rest_framework.reverse import reverse
from rest_framework.status import HTTP_200_OK

from tests.dj_rf.models import Author, Book, Publisher


@pytest.mark.django_db
def test_detail_default(api_client, clear_cache):
publisher = Publisher.objects.create(name='publisher')
author = Author.objects.create(name='auth', publisher=publisher)
book = Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0)

response = api_client.get(reverse('dynamicfiltercls-detail', [book.pk]))

assert response.status_code == HTTP_200_OK
assert 'author' in response.data
assert 'status' in response.data
assert 'amazon_rating' in response.data


@pytest.mark.django_db
def test_detail_exclude_fields(api_client, clear_cache):
publisher = Publisher.objects.create(name='publisher')
author = Author.objects.create(name='auth', publisher=publisher)
book = Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0)

response = api_client.get(
reverse('dynamicfiltercls-detail', [book.pk])
+ '?select(-author,-status,-amazon_rating)',
)

assert response.status_code == HTTP_200_OK
assert 'author' not in response.data
assert 'status' not in response.data
assert 'amazon_rating' not in response.data


@pytest.mark.django_db
def test_list_default(api_client, clear_cache):
publisher = Publisher.objects.create(name='publisher')
author = Author.objects.create(name='auth', publisher=publisher)
Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0)

response = api_client.get(reverse('dynamicfiltercls-list'))

assert response.status_code == HTTP_200_OK
assert 'author' not in response.data[0]
assert 'status' not in response.data[0]
assert 'amazon_rating' not in response.data[0]


@pytest.mark.django_db
def test_list_include_fields(api_client, clear_cache):
publisher = Publisher.objects.create(name='publisher')
author = Author.objects.create(name='auth', publisher=publisher)
Book.objects.create(author=author, status=Book.PLANNING, amazon_rating=5.0)

response = api_client.get(
reverse('dynamicfiltercls-list')
+ '?select(author,status,amazon_rating)',
)

assert response.status_code == HTTP_200_OK
assert 'author' in response.data[0]
assert 'status' in response.data[0]
assert 'amazon_rating' in response.data[0]
Loading

0 comments on commit 35c60e5

Please sign in to comment.