Skip to content

Commit 4e36059

Browse files
authored
feat: cache queries in filters (#124)
* flake8 * cache the filter results of model objects * fix test failure * add test for filter caching * improve filter caching * use csv for logging * PR feedback * update changelog * bump version to 0.26.0
1 parent 40e49ae commit 4e36059

File tree

9 files changed

+157
-15
lines changed

9 files changed

+157
-15
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.25.3
2+
current_version = 0.26.0
33

44
[bumpversion:file:pyproject.toml]
55

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
# [0.26.0] - 2023-03-08
8+
- [PR 124](https://github.com/salesforce/django-declarative-apis/pull/124) Add query caching in filters
9+
710
# [0.25.3] - 2023-02-09
811
### Fixed
912
- [PR 122](https://github.com/salesforce/django-declarative-apis/pull/122) Handle malformed OAuth header

django_declarative_apis/machinery/filtering.py

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77

88
from collections import defaultdict
99
import inspect
10+
import logging
1011
import types
1112

13+
from django.conf import settings
1214
from django.db import models
1315
from django.db.models import ManyToOneRel
1416
from django.core.exceptions import FieldDoesNotExist
1517

18+
logger = logging.getLogger()
19+
1620
NEVER = 0
1721
ALWAYS = 1
1822
IF_TRUTHY = 2
@@ -63,8 +67,14 @@ def _get_unexpanded_field_value(inst, field_name, field_type):
6367
return {display_key: getattr(obj, display_key)}
6468

6569

66-
def _get_filtered_field_value(
67-
inst, field_name, field_type, filter_def, expand_this, expand_children
70+
def _get_filtered_field_value( # noqa: C901
71+
inst,
72+
field_name,
73+
field_type,
74+
filter_def,
75+
expand_this,
76+
expand_children,
77+
filter_cache,
6878
):
6979
# get the value from inst
7080
if field_type == NEVER:
@@ -80,6 +90,25 @@ def _get_filtered_field_value(
8090
val = _get_unexpanded_field_value(inst, field_name, field_type)
8191
else:
8292
try:
93+
if isinstance(inst, (models.Model)):
94+
try:
95+
field_meta = inst._meta.get_field(field_name)
96+
if field_meta.is_relation:
97+
val_pk = getattr(inst, field_meta.attname)
98+
val_cls = field_meta.related_model
99+
val_expand_children = expand_children.get(field_name, {})
100+
cache_key = _make_filter_cache_key(
101+
val_expand_children, val_cls, val_pk
102+
)
103+
if cache_key in filter_cache:
104+
logger.debug(
105+
"ev=filter_cache, status=hit, key=%s", cache_key
106+
)
107+
return filter_cache[cache_key]
108+
except FieldDoesNotExist:
109+
# this happens when you reference the special field "pk" in filters
110+
pass
111+
83112
val = getattr(inst, field_name)
84113
except (AttributeError, FieldDoesNotExist) as e: # noqa
85114
return None
@@ -93,7 +122,11 @@ def _get_filtered_field_value(
93122
val, (list, tuple, models.Model, models.query.QuerySet)
94123
):
95124
val = _apply_filters_to_object(
96-
val, filter_def, expand_children=expand_children, klass=val.__class__
125+
val,
126+
filter_def,
127+
expand_children=expand_children,
128+
klass=val.__class__,
129+
filter_cache=filter_cache,
97130
)
98131

99132
if (
@@ -107,22 +140,53 @@ def _get_filtered_field_value(
107140
return None
108141

109142

143+
def _make_filter_cache_key(expand_children, klass, pk):
144+
return (str(expand_children), klass, str(pk))
145+
146+
110147
# TODO: make this method less complex and remove the `noqa`
111148
def _apply_filters_to_object( # noqa: C901
112-
inst, filter_def, expand_children=None, klass=None
149+
inst,
150+
filter_def,
151+
expand_children=None,
152+
klass=None,
153+
filter_cache=None,
113154
):
155+
is_cacheable = False
156+
caching_enabled = getattr(settings, "DDA_FILTER_MODEL_CACHING_ENABLED", False)
157+
if (
158+
caching_enabled
159+
and isinstance(inst, (models.Model,))
160+
and filter_cache is not None
161+
):
162+
is_cacheable = True
163+
pk = getattr(inst, "pk")
164+
cache_key = _make_filter_cache_key(expand_children, klass, pk)
165+
if cache_key in filter_cache:
166+
logger.debug("ev=filter_cache, status=hit, key=%s", cache_key)
167+
return filter_cache[cache_key]
168+
else:
169+
logger.debug("ev=filter_cache, status=miss, key=%s", cache_key)
114170
if isinstance(inst, (list, tuple, models.query.QuerySet)):
115171
# if it's a tuple or list, iterate over the collection and call _apply_filters_to_object on each item
116172
return [
117173
_apply_filters_to_object(
118-
item, filter_def, expand_children=expand_children, klass=item.__class__
174+
item,
175+
filter_def,
176+
expand_children=expand_children,
177+
klass=item.__class__,
178+
filter_cache=filter_cache,
119179
)
120180
for item in inst
121181
]
122182
elif isinstance(inst, (dict,)):
123183
return {
124184
k: _apply_filters_to_object(
125-
v, filter_def, expand_children=expand_children, klass=v.__class__
185+
v,
186+
filter_def,
187+
expand_children=expand_children,
188+
klass=v.__class__,
189+
filter_cache=filter_cache,
126190
)
127191
for k, v in inst.items()
128192
}
@@ -134,16 +198,28 @@ def _apply_filters_to_object( # noqa: C901
134198
for base_class in inspect.getmro(klass):
135199
if base_class in filter_def:
136200
result = _apply_filters_to_object(
137-
inst, filter_def, expand_children=expand_children, klass=base_class
201+
inst,
202+
filter_def,
203+
expand_children=expand_children,
204+
klass=base_class,
205+
filter_cache=filter_cache,
138206
)
139207
break
208+
209+
if is_cacheable:
210+
filter_cache[cache_key] = result
211+
140212
return result
141213
else:
142214
# first, recursively populate from any ancestor classes in the inheritance hierarchy
143215
result = defaultdict(list)
144216
for base in klass.__bases__:
145217
filtered_ancestor = _apply_filters_to_object(
146-
inst, filter_def, expand_children=expand_children, klass=base
218+
inst,
219+
filter_def,
220+
expand_children=expand_children,
221+
klass=base,
222+
filter_cache=filter_cache,
147223
)
148224
if filtered_ancestor:
149225
result.update(filtered_ancestor)
@@ -166,6 +242,7 @@ def _apply_filters_to_object( # noqa: C901
166242
filter_def,
167243
expand_this=field_name in expand_children,
168244
expand_children=expand_children.get(field_name, {}),
245+
filter_cache=filter_cache,
169246
)
170247

171248
if value is not None and value != DEFAULT_UNEXPANDED_VALUE:
@@ -176,6 +253,9 @@ def _apply_filters_to_object( # noqa: C901
176253
if expandables:
177254
result[EXPANDABLE_FIELD_KEY] += expandables
178255

256+
if is_cacheable:
257+
filter_cache[cache_key] = result
258+
179259
return result
180260

181261

@@ -195,5 +275,9 @@ def apply_filters_to_object(inst, filter_def, expand_header=""):
195275
else:
196276
expand_dict = {}
197277
return _apply_filters_to_object(
198-
inst, filter_def, expand_children=expand_dict, klass=inst.__class__
278+
inst,
279+
filter_def,
280+
expand_children=expand_dict,
281+
klass=inst.__class__,
282+
filter_cache={},
199283
)

django_declarative_apis/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ def create(self, **kwargs):
7676

7777

7878
class OauthConsumer(BaseConsumer):
79-
""":code:`OAuthConsumer` inherits from :code:`BaseConsumer` and it based on :code:`OAuth1.0a`. It adds the additional
80-
properties of key, secret, and rsa_public_key_pem.
79+
""":code:`OAuthConsumer` inherits from :code:`BaseConsumer` and it based on :code:`OAuth1.0a`.
80+
It adds the additional properties of key, secret, and rsa_public_key_pem.
8181
8282
**Example**
8383

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
# built documents.
7575

7676
# The full version, including alpha/beta/rc tags.
77-
release = '0.25.3' # set by bumpversion
77+
release = '0.26.0' # set by bumpversion
7878

7979
# The short X.Y version.
8080
version = release.rsplit('.', 1)[0]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "django-declarative-apis"
7-
version = "0.25.3" # set by bumpversion
7+
version = "0.26.0" # set by bumpversion
88
description = "Simple, readable, declarative APIs for Django"
99
readme = "README.md"
1010
dependencies = [

tests/filters.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
"parent_field": expandable(model_class=models.ParentModel),
3737
"parents": expandable(model_class=models.ParentModel),
3838
},
39+
models.InefficientLeaf: {"id": ALWAYS},
40+
models.InefficientBranchA: {"leaf": ALWAYS},
41+
models.InefficientBranchB: {"leaf": ALWAYS},
42+
models.InefficientRoot: {"branch_a": ALWAYS, "branch_b": ALWAYS},
3943
}
4044

4145
RENAMED_EXPANDABLE_MODEL_FIELDS = {

tests/machinery/test_base.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import django.core.exceptions
1313
import django.test
14+
from django.test.utils import override_settings
1415
import kombu.exceptions
1516
from unittest import mock
1617
from django.core.cache import cache
@@ -21,7 +22,7 @@
2122
from django_declarative_apis.machinery import errors, filtering, tasks
2223
from django_declarative_apis.machinery.tasks import future_task_runner
2324
from django_declarative_apis.resources.utils import HttpStatusCode
24-
from tests import testutils, models
25+
from tests import testutils, models, filters
2526

2627
_TEST_RESOURCE = {"foo": "bar"}
2728

@@ -703,6 +704,36 @@ def resource(self):
703704
self.assertRaises(errors.ClientErrorForbidden, bound_endpoint.get_response)
704705

705706

707+
class FilterCachingTestCase(django.test.TestCase):
708+
def setUp(self):
709+
super().setUp()
710+
leaf = models.InefficientLeaf.objects.create(id=1)
711+
branch_a = models.InefficientBranchA.objects.create(id=1, leaf=leaf)
712+
branch_b = models.InefficientBranchB.objects.create(id=1, leaf=leaf)
713+
root = models.InefficientRoot.objects.create(
714+
id=4, branch_a=branch_a, branch_b=branch_b
715+
)
716+
self.root_id = root.id
717+
718+
def test_filter_query_cache_reduces_queries(self):
719+
root = models.InefficientRoot.objects.get(id=self.root_id)
720+
with self.assertNumQueries(4):
721+
filtering.apply_filters_to_object(
722+
root,
723+
filters.DEFAULT_FILTERS,
724+
)
725+
726+
root = models.InefficientRoot.objects.get(id=self.root_id)
727+
with self.assertNumQueries(3):
728+
with override_settings(
729+
DDA_FILTER_MODEL_CACHING_ENABLED=True, DDA_FILTER_CACHE_DEBUG_LOG=True
730+
):
731+
filtering.apply_filters_to_object(
732+
root,
733+
filters.DEFAULT_FILTERS,
734+
)
735+
736+
706737
class ResourceUpdateEndpointDefinitionTestCase(
707738
testutils.RequestCreatorMixin, django.test.TestCase
708739
):

tests/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ class RootNode(models.Model):
5353
parent_field = models.ForeignKey(ParentModel, on_delete=models.CASCADE)
5454

5555

56+
class InefficientLeaf(models.Model):
57+
id = models.IntegerField(primary_key=True)
58+
59+
60+
class InefficientBranchA(models.Model):
61+
id = models.IntegerField(primary_key=True)
62+
leaf = models.ForeignKey(InefficientLeaf, on_delete=models.CASCADE)
63+
64+
65+
class InefficientBranchB(models.Model):
66+
id = models.IntegerField(primary_key=True)
67+
leaf = models.ForeignKey(InefficientLeaf, on_delete=models.CASCADE)
68+
69+
70+
class InefficientRoot(models.Model):
71+
id = models.IntegerField(primary_key=True)
72+
branch_a = models.ForeignKey(InefficientBranchA, on_delete=models.CASCADE)
73+
branch_b = models.ForeignKey(InefficientBranchB, on_delete=models.CASCADE)
74+
75+
5676
try:
5777
import dirtyfields
5878

0 commit comments

Comments
 (0)