Skip to content

Commit 0f2ffbe

Browse files
committed
LITE-21319 Recent queries are now automatically cached
1 parent a314996 commit 0f2ffbe

File tree

11 files changed

+112
-55
lines changed

11 files changed

+112
-55
lines changed

.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

.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

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',

dj_rql/drf/backend.py

+39-9
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,15 +25,34 @@ 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-
)
36+
query = self.get_query(filter_instance, request, view)
37+
38+
def apply_filters_lazy():
39+
return filter_instance.apply_filters(query, request, view)
40+
41+
if filter_class.QUERIES_CACHE_BACKEND and request.method in ('GET', 'HEAD', 'OPTIONS'):
42+
query_cache = self._get_or_init_cache(filter_class, view)
43+
filters_result = query_cache.get(query)
44+
if not filters_result:
45+
filters_result = apply_filters_lazy()
46+
query_cache[query] = filters_result
47+
else:
48+
filters_result = apply_filters_lazy()
49+
50+
rql_ast, queryset = filters_result
51+
52+
request.rql_ast = rql_ast
53+
if queryset.select_data:
54+
request.rql_select = queryset.select_data
55+
3756
return queryset
3857

3958
def get_schema_operation_parameters(self, view):
@@ -59,14 +78,25 @@ def get_filter_class(view):
5978
def get_query(cls, filter_instance, request, view):
6079
return get_query(request)
6180

62-
@staticmethod
63-
def _get_filter_instance(filter_class, queryset, view):
64-
qual_name = '{0}.{1}'.format(view.basename, filter_class.__name__)
81+
@classmethod
82+
def _get_or_init_cache(cls, filter_class, view):
83+
qual_name = cls._get_filter_cls_qual_name(filter_class, view)
84+
return cls._CACHES.setdefault(
85+
qual_name, filter_class.QUERIES_CACHE_BACKEND(int(filter_class.QUERIES_CACHE_SIZE)),
86+
)
6587

66-
filter_instance = FilterCache.CACHE.get(qual_name)
88+
@classmethod
89+
def _get_filter_instance(cls, filter_class, queryset, view):
90+
qual_name = cls._get_filter_cls_qual_name(filter_class, view)
91+
92+
filter_instance = _FilterClassCache.CACHE.get(qual_name)
6793
if filter_instance:
6894
return filter_class(queryset=queryset, instance=filter_instance)
6995

7096
filter_instance = filter_class(queryset)
71-
FilterCache.CACHE[qual_name] = filter_instance
97+
_FilterClassCache.CACHE[qual_name] = filter_instance
7298
return filter_instance
99+
100+
@staticmethod
101+
def _get_filter_cls_qual_name(filter_class, view):
102+
return '{0}.{1}'.format(view.basename, filter_class.__name__)

dj_rql/filter_cls.py

+14-10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from datetime import datetime
77
from uuid import uuid4
88

9+
from cachetools import LFUCache
10+
911
from dj_rql._dataclasses import FilterArgs, OptimizationArgs
1012
from dj_rql.constants import (
1113
ComparisonOperators,
@@ -62,6 +64,12 @@ class RQLFilterClass:
6264
OPENAPI_SPECIFICATION = RQLFilterClassSpecification
6365
"""Class for OpenAPI specifications generation."""
6466

67+
QUERIES_CACHE_BACKEND = LFUCache
68+
"""Class for query caching (can be `None`)."""
69+
70+
QUERIES_CACHE_SIZE = 20
71+
"""Default number of cached queries."""
72+
6573
def __init__(self, queryset, instance=None):
6674
self.queryset = queryset
6775
self._is_distinct = self.DISTINCT
@@ -193,11 +201,11 @@ def apply_filters(self, query, request=None, view=None):
193201
self._view = view
194202

195203
rql_ast, qs, select_filters = None, self.queryset, []
204+
qs.select_data = None
196205

197206
if query:
198207
rql_ast = RQLParser.parse_query(query)
199208
rql_transformer = RQLToDjangoORMTransformer(self)
200-
201209
try:
202210
qs = rql_transformer.transform(rql_ast)
203211
except LarkError as e:
@@ -214,21 +222,17 @@ def apply_filters(self, query, request=None, view=None):
214222
if self._is_distinct:
215223
qs = qs.distinct()
216224

217-
if request:
218-
request.rql_ast = rql_ast
225+
qs.select_data = None
219226

220227
if self.SELECT:
221228
select_data = self._build_select_data(select_filters)
222229
qs = self._apply_optimizations(qs, select_data)
223-
224-
if request:
225-
request.rql_select = {
226-
'depth': 0,
227-
'select': select_data,
228-
}
230+
qs.select_data = {
231+
'depth': 0,
232+
'select': select_data,
233+
}
229234

230235
self.queryset = qs
231-
232236
self._request = None
233237
self._view = None
234238

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

tests/dj_rf/filters.py

+3
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 LRUCache
45

56
from dj_rql.constants import FilterLookups, RQL_NULL
67
from dj_rql.drf.fields import SelectField
@@ -189,3 +190,5 @@ class BooksFilterClass(RQLFilterClass):
189190

190191
class SelectBooksFilterClass(BooksFilterClass):
191192
SELECT = True
193+
QUERIES_CACHE_BACKEND = LRUCache
194+
QUERIES_CACHE_SIZE = 100

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

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 = {}

tests/test_drf/test_common_drf_backend.py

+33-9
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,50 @@ 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

104106
expected_cache_key = 'book.BooksFilterClass'
105-
assert expected_cache_key in FilterCache.CACHE
106-
cache_item_id = id(FilterCache.CACHE[expected_cache_key])
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):
122+
books = [
123+
Book.objects.create(title='F'),
124+
Book.objects.create(title='G'),
125+
]
126+
127+
for _ in range(4):
128+
response = api_client.get('{0}?{1}'.format(reverse('book-list'), 'title=F'))
129+
assert response.data == [{'id': books[0].pk}]
130+
131+
response = api_client.get(reverse('select-list') + '?select(-id)')
132+
assert response.status_code == HTTP_200_OK
133+
assert 'id' not in response.data[0]
134+
135+
caches = RQLFilterBackend._CACHES
136+
assert isinstance(caches['book.BooksFilterClass'], LFUCache)
137+
assert caches['book.BooksFilterClass']['title=F']
138+
assert isinstance(caches['select.SelectBooksFilterClass'], LRUCache)
139+
assert caches['select.SelectBooksFilterClass']['select(-id)']
116140

117141

118142
@pytest.mark.django_db

tests/test_filter_cls/test_select.py

+15-21
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,10 @@ class Cls(SelectFilterCls):
294294
)
295295

296296
request = _Request()
297-
Cls(book_qs).apply_filters('select(+hidden)', request)
298-
assert hasattr(request, 'rql_ast')
297+
_, qs = Cls(book_qs).apply_filters('select(+hidden)', request)
298+
assert not hasattr(request, 'rql_ast')
299299
assert not hasattr(request, 'rql_select')
300+
assert not hasattr(qs, 'rql_select')
300301

301302

302303
def test_apply_rql_select_applied_no_request():
@@ -305,11 +306,9 @@ def test_apply_rql_select_applied_no_request():
305306

306307

307308
def test_apply_rql_select_applied_no_query():
308-
request = _Request()
309+
_, qs = SelectFilterCls(book_qs).apply_filters('')
309310

310-
SelectFilterCls(book_qs).apply_filters('', request)
311-
assert hasattr(request, 'rql_ast')
312-
assert request.rql_select == {'depth': 0, 'select': {}}
311+
assert qs.select_data == {'depth': 0, 'select': {}}
313312

314313

315314
def test_default_exclusion_included():
@@ -326,9 +325,8 @@ class Cls(SelectFilterCls):
326325
},
327326
)
328327

329-
request = _Request()
330-
Cls(book_qs).apply_filters('select(-ft2)', request)
331-
assert request.rql_select['select'] == {'ft1': False, 'ft2': False}
328+
_, qs = Cls(book_qs).apply_filters('select(-ft2)')
329+
assert qs.select_data['select'] == {'ft1': False, 'ft2': False}
332330

333331

334332
def test_default_exclusion_overridden():
@@ -346,8 +344,8 @@ class Cls(SelectFilterCls):
346344
)
347345

348346
request = _Request()
349-
Cls(book_qs).apply_filters('select(-ft2,ft1)', request)
350-
assert request.rql_select['select'] == {'ft1': True, 'ft2': False}
347+
_, qs = Cls(book_qs).apply_filters('select(-ft2,ft1)', request)
348+
assert qs.select_data['select'] == {'ft1': True, 'ft2': False}
351349

352350

353351
def test_signs_select():
@@ -360,9 +358,8 @@ class Cls(SelectFilterCls):
360358
for i in range(1, 5)
361359
)
362360

363-
request = _Request()
364-
Cls(book_qs).apply_filters('select(ft1,+ft2,-ft3)', request)
365-
assert request.rql_select['select'] == {'ft1': True, 'ft2': True, 'ft3': False}
361+
_, qs = Cls(book_qs).apply_filters('select(ft1,+ft2,-ft3)')
362+
assert qs.select_data['select'] == {'ft1': True, 'ft2': True, 'ft3': False}
366363

367364

368365
def test_bad_select_prop_top_level_include_select():
@@ -476,10 +473,8 @@ class Cls(SelectFilterCls):
476473

477474

478475
def test_exclude_ok():
479-
request = _Request()
480-
481-
SelectFilterCls(book_qs).apply_filters('select(-id)', request)
482-
assert request.rql_select == {'depth': 0, 'select': {'id': False}}
476+
_, qs = SelectFilterCls(book_qs).apply_filters('select(-id)')
477+
assert qs.select_data == {'depth': 0, 'select': {'id': False}}
483478

484479

485480
def test_select_complex():
@@ -546,9 +541,8 @@ class Cls(SelectFilterCls):
546541
},
547542
)
548543

549-
request = _Request()
550-
Cls(book_qs).apply_filters('select(ns2.ns2.id,ft1,ns1,ns1.ft1,ns2.ft2)', request)
551-
assert request.rql_select['select'] == {
544+
_, qs = Cls(book_qs).apply_filters('select(ns2.ns2.id,ft1,ns1,ns1.ft1,ns2.ft2)')
545+
assert qs.select_data['select'] == {
552546
'ft1': True,
553547
'ns1': True,
554548
'ns1.ft1': True,

0 commit comments

Comments
 (0)