Skip to content

Commit 3fd1d4a

Browse files
authored
Merge pull request #31 from cloudblue/feature/LITE-21319
LITE-21319 Recent queries are now automatically cached
2 parents 60c298a + 05dde9e commit 3fd1d4a

File tree

13 files changed

+163
-58
lines changed

13 files changed

+163
-58
lines changed

Diff for: .github/workflows/build.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
- name: Wait sonar to process report
5050
uses: jakejarvis/wait-action@master
5151
with:
52-
time: '60s'
52+
time: '120s'
5353
- name: SonarQube Quality Gate check
5454
uses: sonarsource/sonarqube-quality-gate-action@master
5555
timeout-minutes: 5

Diff for: .github/workflows/deploy.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
- name: Wait sonar to process report
4848
uses: jakejarvis/wait-action@master
4949
with:
50-
time: '60s'
50+
time: '120s'
5151
- name: SonarQube Quality Gate check
5252
uses: sonarsource/sonarqube-quality-gate-action@master
5353
timeout-minutes: 5

Diff for: dj_rql/drf/__init__.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
#
44

55
from dj_rql.drf._utils import get_query
6-
from dj_rql.drf.backend import FilterCache, RQLFilterBackend
6+
from dj_rql.drf.backend import RQLFilterBackend
77
from dj_rql.drf.paginations import RQLContentRangeLimitOffsetPagination, RQLLimitOffsetPagination
88

99

1010
__all__ = [
1111
'get_query',
12-
'FilterCache',
1312
'RQLContentRangeLimitOffsetPagination',
1413
'RQLFilterBackend',
1514
'RQLLimitOffsetPagination',

Diff for: dj_rql/drf/backend.py

+47-10
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from rest_framework.filters import BaseFilterBackend
88

99

10-
class FilterCache:
10+
class _FilterClassCache:
1111
CACHE = {}
1212

1313
@classmethod
@@ -25,16 +25,42 @@ class ViewSet(mixins.ListModelMixin, GenericViewSet):
2525
"""
2626
OPENAPI_RETRIEVE_SPECIFICATION = False
2727

28+
_CACHES = {}
29+
2830
def filter_queryset(self, request, queryset, view):
2931
filter_class = self.get_filter_class(view)
3032
if not filter_class:
3133
return queryset
3234

3335
filter_instance = self._get_filter_instance(filter_class, queryset, view)
34-
rql_ast, queryset = filter_instance.apply_filters(
35-
self.get_query(filter_instance, request, view), request, view,
36-
)
37-
return queryset
36+
query = self.get_query(filter_instance, request, view)
37+
38+
can_query_be_cached = all((
39+
filter_class.QUERIES_CACHE_BACKEND,
40+
filter_class.QUERIES_CACHE_SIZE,
41+
request.method in ('GET', 'HEAD', 'OPTIONS'),
42+
))
43+
if can_query_be_cached:
44+
# We must use the combination of queryset and query to make a cache key as
45+
# queryset can already contain some filters (e.x. based on authentication)
46+
cache_key = str(queryset.query) + query
47+
48+
query_cache = self._get_or_init_cache(filter_class, view)
49+
filters_result = query_cache.get(cache_key)
50+
if not filters_result:
51+
filters_result = filter_instance.apply_filters(query, request, view)
52+
query_cache[cache_key] = filters_result
53+
54+
else:
55+
filters_result = filter_instance.apply_filters(query, request, view)
56+
57+
rql_ast, queryset = filters_result
58+
59+
request.rql_ast = rql_ast
60+
if queryset.select_data:
61+
request.rql_select = queryset.select_data
62+
63+
return queryset.all()
3864

3965
def get_schema_operation_parameters(self, view):
4066
spec = []
@@ -59,14 +85,25 @@ def get_filter_class(view):
5985
def get_query(cls, filter_instance, request, view):
6086
return get_query(request)
6187

62-
@staticmethod
63-
def _get_filter_instance(filter_class, queryset, view):
64-
qual_name = '{0}.{1}'.format(view.basename, filter_class.__name__)
88+
@classmethod
89+
def _get_or_init_cache(cls, filter_class, view):
90+
qual_name = cls._get_filter_cls_qual_name(view)
91+
return cls._CACHES.setdefault(
92+
qual_name, filter_class.QUERIES_CACHE_BACKEND(int(filter_class.QUERIES_CACHE_SIZE)),
93+
)
6594

66-
filter_instance = FilterCache.CACHE.get(qual_name)
95+
@classmethod
96+
def _get_filter_instance(cls, filter_class, queryset, view):
97+
qual_name = cls._get_filter_cls_qual_name(view)
98+
99+
filter_instance = _FilterClassCache.CACHE.get(qual_name)
67100
if filter_instance:
68101
return filter_class(queryset=queryset, instance=filter_instance)
69102

70103
filter_instance = filter_class(queryset)
71-
FilterCache.CACHE[qual_name] = filter_instance
104+
_FilterClassCache.CACHE[qual_name] = filter_instance
72105
return filter_instance
106+
107+
@staticmethod
108+
def _get_filter_cls_qual_name(view):
109+
return '{0}.{1}'.format(view.__class__.__module__, view.__class__.__name__)

Diff for: dj_rql/filter_cls.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ class RQLFilterClass:
6363
OPENAPI_SPECIFICATION = RQLFilterClassSpecification
6464
"""Class for OpenAPI specifications generation."""
6565

66+
QUERIES_CACHE_BACKEND = None
67+
"""Class for query caching."""
68+
69+
QUERIES_CACHE_SIZE = 20
70+
"""Default number of cached queries."""
71+
6672
def __init__(self, queryset, instance=None):
6773
self.queryset = queryset
6874
self._is_distinct = self.DISTINCT
@@ -194,11 +200,11 @@ def apply_filters(self, query, request=None, view=None):
194200
self._view = view
195201

196202
rql_ast, qs, select_filters = None, self.queryset, []
203+
qs.select_data = None
197204

198205
if query:
199206
rql_ast = RQLParser.parse_query(query)
200207
rql_transformer = RQLToDjangoORMTransformer(self)
201-
202208
try:
203209
qs = rql_transformer.transform(rql_ast)
204210
except LarkError as e:
@@ -215,21 +221,17 @@ def apply_filters(self, query, request=None, view=None):
215221
if self._is_distinct:
216222
qs = qs.distinct()
217223

218-
if request:
219-
request.rql_ast = rql_ast
224+
qs.select_data = None
220225

221226
if self.SELECT:
222227
select_data = self._build_select_data(select_filters)
223228
qs = self._apply_optimizations(qs, select_data)
224-
225-
if request:
226-
request.rql_select = {
227-
'depth': 0,
228-
'select': select_data,
229-
}
229+
qs.select_data = {
230+
'depth': 0,
231+
'select': select_data,
232+
}
230233

231234
self.queryset = qs
232-
233235
self._request = None
234236
self._view = None
235237

Diff for: dj_rql/parser.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
33
#
44

5+
from cachetools import LFUCache
6+
57
from dj_rql.exceptions import RQLFilterParsingError
68
from dj_rql.grammar import RQL_GRAMMAR
79

@@ -10,9 +12,18 @@
1012

1113

1214
class RQLLarkParser(Lark):
15+
def __init__(self, *args, **kwargs):
16+
super(RQLLarkParser, self).__init__(*args, **kwargs)
17+
18+
self._cache = LFUCache(maxsize=1000)
19+
1320
def parse_query(self, query):
21+
cache_key = hash(query)
22+
if cache_key in self._cache:
23+
return self._cache[cache_key]
1424
try:
15-
rql_ast = RQLParser.parse(query)
25+
rql_ast = self.parse(query)
26+
self._cache[cache_key] = rql_ast
1627
return rql_ast
1728
except LarkError:
1829
raise RQLFilterParsingError()

Diff for: requirements/dev.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
lark-parser==0.11.0
22
Django>=2.2.19
3+
cachetools>=4.2.4

Diff for: tests/dj_rf/filters.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#
22
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
33
#
4+
from cachetools import LFUCache, LRUCache
45

56
from dj_rql.constants import FilterLookups, RQL_NULL
67
from dj_rql.drf.fields import SelectField
@@ -34,6 +35,7 @@
3435

3536
class BooksFilterClass(RQLFilterClass):
3637
MODEL = Book
38+
QUERIES_CACHE_BACKEND = LFUCache
3739
FILTERS = ['id', {
3840
'filter': 'title',
3941
'null_values': {RQL_NULL, 'NULL_ID'},
@@ -189,3 +191,5 @@ class BooksFilterClass(RQLFilterClass):
189191

190192
class SelectBooksFilterClass(BooksFilterClass):
191193
SELECT = True
194+
QUERIES_CACHE_BACKEND = LRUCache
195+
QUERIES_CACHE_SIZE = 100

Diff for: tests/dj_rf/view.py

+1
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,6 @@ class AutoViewSet(DRFViewSet):
6868
def rql_filter_class(self):
6969
class Cls(AutoRQLFilterClass):
7070
MODEL = Book
71+
QUERIES_CACHE_BACKEND = None
7172

7273
return Cls

Diff for: tests/test_drf/conftest.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
33
#
44

5-
from dj_rql.drf.backend import FilterCache
5+
from dj_rql.drf.backend import RQLFilterBackend, _FilterClassCache
66

77
import pytest
88

@@ -18,4 +18,5 @@ def api_client():
1818

1919
@pytest.fixture
2020
def clear_cache():
21-
FilterCache.clear()
21+
_FilterClassCache.clear()
22+
RQLFilterBackend._CACHES = {}

Diff for: tests/test_drf/test_common_drf_backend.py

+40-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#
22
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
33
#
4+
from cachetools import LFUCache, LRUCache
45

5-
from dj_rql.drf import FilterCache, RQLFilterBackend
6+
from dj_rql.drf import RQLFilterBackend
7+
from dj_rql.drf.backend import _FilterClassCache
68

79
from django.db import connection
810
from django.test.utils import CaptureQueriesContext
@@ -91,28 +93,56 @@ class View:
9193

9294

9395
@pytest.mark.django_db
94-
def test_cache(api_client, clear_cache):
96+
def test_filter_cls_cache(api_client, clear_cache):
9597
books = [
9698
Book.objects.create(title='F'),
9799
Book.objects.create(title='G'),
98100
]
99101

100-
assert FilterCache.CACHE == {}
102+
assert _FilterClassCache.CACHE == {}
101103
response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=F'))
102104
assert response.data == [{'id': books[0].pk}]
103105

104-
expected_cache_key = 'book.BooksFilterClass'
105-
assert expected_cache_key in FilterCache.CACHE
106-
cache_item_id = id(FilterCache.CACHE[expected_cache_key])
106+
expected_cache_key = 'tests.dj_rf.view.DRFViewSet'
107+
assert expected_cache_key in _FilterClassCache.CACHE
108+
cache_item_id = id(_FilterClassCache.CACHE[expected_cache_key])
107109

108110
response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=G'))
109111
assert response.data == [{'id': books[1].pk}]
110112

111-
assert expected_cache_key in FilterCache.CACHE
112-
assert id(FilterCache.CACHE[expected_cache_key]) == cache_item_id
113+
assert expected_cache_key in _FilterClassCache.CACHE
114+
assert id(_FilterClassCache.CACHE[expected_cache_key]) == cache_item_id
113115

114-
FilterCache.clear()
115-
assert FilterCache.CACHE == {}
116+
_FilterClassCache.clear()
117+
assert _FilterClassCache.CACHE == {}
118+
119+
120+
@pytest.mark.django_db
121+
def test_query_cache(api_client, clear_cache, django_assert_num_queries):
122+
books = [
123+
Book.objects.create(title='F'),
124+
Book.objects.create(title='G'),
125+
]
126+
127+
for _ in range(4):
128+
with django_assert_num_queries(2):
129+
response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=F'))
130+
assert response.data == [{'id': books[0].pk}]
131+
132+
response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=X'))
133+
assert response.data == []
134+
135+
response = api_client.get(reverse('select-list') + '?select(-id)')
136+
assert response.status_code == HTTP_200_OK
137+
assert 'id' not in response.data[0]
138+
139+
caches = RQLFilterBackend._CACHES
140+
assert isinstance(caches['tests.dj_rf.view.DRFViewSet'], LFUCache)
141+
assert caches['tests.dj_rf.view.DRFViewSet'].currsize == 2
142+
assert caches['tests.dj_rf.view.DRFViewSet'].maxsize == 20
143+
assert isinstance(caches['tests.dj_rf.view.SelectViewSet'], LRUCache)
144+
assert caches['tests.dj_rf.view.SelectViewSet'].currsize == 1
145+
assert caches['tests.dj_rf.view.SelectViewSet'].maxsize == 100
116146

117147

118148
@pytest.mark.django_db

0 commit comments

Comments
 (0)