Skip to content

Commit 8ed4e6d

Browse files
author
Ignacio Avas
committed
Initial inclusion of query_count files
1 parent c113f80 commit 8ed4e6d

16 files changed

+883
-68
lines changed

django_api_query_count/models.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

django_api_query_count/query_count.py

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import json
2+
import os
3+
from sys import maxsize, stderr
4+
5+
from django.db import DEFAULT_DB_ALIAS, connections
6+
from django.test.utils import CaptureQueriesContext
7+
from django_jenkins.runner import CITestSuiteRunner
8+
from rest_framework.test import APIClient, APITestCase, APITransactionTestCase
9+
10+
11+
def skip_query_count():
12+
"""
13+
Unconditionally skip a test query count.
14+
"""
15+
def decorator(test_item):
16+
test_item.__querycount_skip__ = True
17+
return test_item
18+
19+
return decorator
20+
21+
22+
class TestResultQueryContainer(object):
23+
"""Stores all the queries from a Test Run, contained in a TestResult"""
24+
def __init__(self):
25+
self.queries_by_testcase = dict()
26+
self.total = 0
27+
28+
def add(self, test_case_id, queries):
29+
"""
30+
Merge the queries from a test case
31+
:param test_case_id: identifier for test case (This is usually the
32+
full name of the test method, including the module and class name)
33+
:param queries: TestCaseQueries for this test case
34+
"""
35+
existing_query_container = self.queries_by_testcase.get(
36+
test_case_id,
37+
TestCaseQueryContainer()
38+
)
39+
existing_query_container.merge(queries)
40+
self.queries_by_testcase[test_case_id] = existing_query_container
41+
self.total += existing_query_container.total
42+
43+
@classmethod
44+
def test_case_json(cls, test_case_id, query_container, detail):
45+
representation = query_container.get_json(detail)
46+
representation['id'] = test_case_id
47+
return representation
48+
49+
def get_json(self, detail):
50+
return {
51+
'total': self.total,
52+
'test_cases': [
53+
self.test_case_json(test_case_id, queries, detail)
54+
for test_case_id, queries in self.queries_by_testcase.items()
55+
]
56+
}
57+
58+
59+
class TestCaseQueryContainer(object):
60+
"""Stores queries by API method for a particular test case"""
61+
def __init__(self):
62+
self.queries_by_api_method = dict()
63+
self.total = 0
64+
65+
def add_by_key(self, api_method_key, queries):
66+
"""
67+
Appends queries to a certain api method
68+
:param api_method_key: tuple (method, path)
69+
:param queries: list of queries
70+
"""
71+
existing_queries = self.queries_by_api_method.get(api_method_key, [])
72+
self.queries_by_api_method[api_method_key] = queries + existing_queries
73+
self.total += len(queries)
74+
75+
def add(self, method, path, queries):
76+
"""Agregates the queries to the captured queries dict"""
77+
key = (method, path)
78+
self.add_by_key(key, queries)
79+
80+
def merge(self, test_case_container):
81+
"""
82+
Merges the queries from another test case container in this object
83+
:param test_case_container: an existing test Container
84+
"""
85+
for key, queries in test_case_container.queries_by_api_method.items():
86+
self.add_by_key(key, queries)
87+
88+
@classmethod
89+
def api_call_json(cls, api_call, queries, detail):
90+
"""
91+
Returns a json representation of a single API Call
92+
:param api_call: API call tuple (method, path)
93+
:param queries: list of queries
94+
:param detail: if True, the list of queries is returned
95+
:return: Dictionary
96+
"""
97+
method, path = api_call
98+
result = {
99+
'method': method,
100+
'path': path,
101+
'total': len(queries),
102+
}
103+
if detail:
104+
result['queries'] = queries
105+
return result
106+
107+
def get_json(self, detail):
108+
"""Returns a JSON representation of the object"""
109+
return {
110+
'total': self.total,
111+
'queries': [
112+
self.api_call_json(api_call, queries, detail)
113+
for api_call, queries in self.queries_by_api_method.items()
114+
]
115+
}
116+
117+
118+
class QueryCountAPIClient(APIClient):
119+
"""
120+
Makes the API Client to capture queries. Agregates queries for URL and Path
121+
"""
122+
123+
def __init__(self, *args, **kwargs):
124+
self.api_queries = TestCaseQueryContainer()
125+
super().__init__(*args, **kwargs)
126+
127+
@property
128+
def connection(self):
129+
"""
130+
Default connection
131+
"""
132+
return connections[DEFAULT_DB_ALIAS]
133+
134+
# Overloaded APIClient methods
135+
136+
def get(self, path, data=None, **extra):
137+
with CaptureQueriesContext(self.connection) as context:
138+
response = super().get(path, data, **extra)
139+
self.api_queries.add('get', path, context.captured_queries)
140+
return response
141+
142+
def post(self, path, data=None, **extra):
143+
with CaptureQueriesContext(self.connection) as context:
144+
response = super().post(path, data, **extra)
145+
self.api_queries.add('post', path, context.captured_queries)
146+
return response
147+
148+
def put(self, path, data=None, **extra):
149+
with CaptureQueriesContext(self.connection) as context:
150+
response = super().put(path, data, **extra)
151+
self.api_queries.add('put', path, context.captured_queries)
152+
return response
153+
154+
def patch(self, path, data=None, **extra):
155+
with CaptureQueriesContext(self.connection) as context:
156+
response = super().patch(path, data, **extra)
157+
self.api_queries.add('patch', path, context.captured_queries)
158+
return response
159+
160+
def delete(self, path, data=None, **extra):
161+
with CaptureQueriesContext(self.connection) as context:
162+
response = super().delete(path, data, **extra)
163+
self.api_queries.add('delete', path, context.captured_queries)
164+
return response
165+
166+
def options(self, path, data=None, **extra):
167+
with CaptureQueriesContext(self.connection) as context:
168+
response = super().options(path, data, **extra)
169+
self.api_queries.add('options', path, context.captured_queries)
170+
return response
171+
172+
173+
class _QueryCountTestCaseMixin(object):
174+
def count_queries_test_method(self, run_method, *args, **kwargs):
175+
test_method = getattr(self, self._testMethodName)
176+
if (getattr(self.__class__, "__querycount_skip__", False) or
177+
getattr(test_method, "__querycount_skip__", False)):
178+
# Skipped test: don't store queries
179+
return run_method(*args, **kwargs)
180+
181+
result = run_method(*args, **kwargs)
182+
183+
if result:
184+
all_queries = getattr(result, 'queries',
185+
TestResultQueryContainer())
186+
all_queries.add(self.id(), self.client.api_queries)
187+
setattr(result, 'queries', all_queries)
188+
189+
return result
190+
191+
192+
class APIQueryCountTransactionTestCase(APITransactionTestCase,
193+
_QueryCountTestCaseMixin):
194+
"""
195+
Derives APITransactionTestCase and counts Query Count per API, per test
196+
"""
197+
client_class = QueryCountAPIClient
198+
199+
def run(self, *args, **kwargs):
200+
return self.count_queries_test_method(super().run, *args, **kwargs)
201+
202+
203+
class APIQueryCountTestCase(APITestCase, _QueryCountTestCaseMixin):
204+
"""
205+
Derives APITestCase and counts Query Count per API, per test
206+
"""
207+
client_class = QueryCountAPIClient
208+
209+
def run(self, *args, **kwargs):
210+
return self.count_queries_test_method(super().run, *args, **kwargs)
211+
212+
213+
class QueryCountCITestSuiteRunner(CITestSuiteRunner):
214+
SUMMARY_FILE = 'query_count.json'
215+
DETAIL_FILE = 'query_count_detail.json'
216+
217+
def run_suite(self, *args, **kwargs):
218+
"""
219+
Same as run_suite but, dumps the JSON in the output_dir
220+
"""
221+
test_result = super().run_suite(*args, **kwargs)
222+
223+
filename = os.path.join(self.output_dir, self.SUMMARY_FILE)
224+
with open(filename, 'w') as json_file:
225+
json.dump(test_result.queries.get_json(detail=False), json_file,
226+
ensure_ascii=False, indent=4, sort_keys=True)
227+
228+
filename_detailed = os.path.join(self.output_dir, self.DETAIL_FILE)
229+
with open(filename_detailed, 'w') as json_file:
230+
json.dump(test_result.queries.get_json(detail=True), json_file,
231+
ensure_ascii=False, indent=4, sort_keys=True)
232+
233+
return test_result
234+
235+
236+
class Violation(object):
237+
def __init__(self, test_case_id, method, path, threshold, total):
238+
self.test_case_id = test_case_id
239+
self.method = method
240+
self.path = path
241+
self.threshold = threshold
242+
self.total = total
243+
244+
245+
class QueryCountEvaluator(object):
246+
def __init__(self, threshold, current_file, last_file, stream=stderr):
247+
"""
248+
Initializes the Evaluator, which writes t
249+
:param threshold: Threshold in percentage (e.g. 10)
250+
:param current_file: stream with the about-to-commit API Calls result
251+
:param last_file: stream with the last "accepted" API calls to compare
252+
:param stream: steam to write into (default: stderr)
253+
"""
254+
self.threshold = threshold
255+
self.current = json.load(current_file)
256+
self.last = json.load(last_file)
257+
self.stream = stream
258+
259+
@classmethod
260+
def default_test_case_element(cls, test_case_id):
261+
return {
262+
'id': test_case_id,
263+
'queries': [],
264+
'total': 0
265+
}
266+
267+
def list_violations(self):
268+
last_test_cases = {
269+
test_case['id']: test_case
270+
for test_case in self.last['test_cases']
271+
}
272+
273+
for element in self.current['test_cases']:
274+
test_case_id = element['id']
275+
last_test_cases_queries = last_test_cases.get(
276+
test_case_id,
277+
self.default_test_case_element(test_case_id)
278+
)
279+
for violation in self.compare_test_cases(
280+
test_case_id,
281+
element['queries'],
282+
last_test_cases_queries['queries']):
283+
yield violation
284+
285+
def run(self):
286+
"""
287+
Main method. Compares to JSON files and prints in the stream the
288+
list of API Calls (Violations) that ocurred between the current run
289+
and the last run.
290+
:return: a list of the violations ocurred
291+
"""
292+
violations = list(self.list_violations())
293+
if any(violations):
294+
self.stream.write('There are test cases with API '
295+
'calls that exceeded threshold:\n\n')
296+
297+
for violation in violations:
298+
msg = '\tIn test case {}, {} {}. Expected at most {} queries but' \
299+
' got {} queries' \
300+
'\n'.format(violation.test_case_id, violation.method,
301+
violation.path, violation.threshold,
302+
violation.total)
303+
self.stream.write(msg)
304+
305+
if not any(violations):
306+
self.stream.write('All Tests API Queries are below the allowed '
307+
'threshold.\n')
308+
309+
self.stream.flush()
310+
311+
return violations
312+
313+
def compare_test_cases(self, test_case_id, current_queries, last_queries):
314+
"""
315+
Compares the queries from a test case
316+
:param test_case_id: the name of the test case. Usually includes the
317+
class and the method name
318+
:param current_queries: API calls query list for the current run
319+
:param last_queries: API calls query for the last run
320+
:return: a list of Violation objects for any API Call that exceeded
321+
threshold
322+
"""
323+
last_queries_dict = {
324+
(element['method'], element['path']): element['total']
325+
for element in last_queries
326+
}
327+
328+
def get_last_queries(query_element):
329+
key = (query_element['method'], query_element['path'])
330+
return last_queries_dict.get(key, maxsize)
331+
332+
def get_threshold(query_element):
333+
max_factor = (self.threshold / 100.0 + 1)
334+
return round(get_last_queries(query_element) * max_factor)
335+
336+
def violates_threshold(query_element):
337+
return query_element['total'] > get_threshold(query_element)
338+
339+
return (Violation(test_case_id, element['method'], element['path'],
340+
get_threshold(element), element['total'])
341+
for element in current_queries
342+
if violates_threshold(element))

django_api_query_count/static/css/django_api_query_count.css

Whitespace-only changes.

django_api_query_count/static/img/.gitignore

Whitespace-only changes.

django_api_query_count/static/js/django_api_query_count.js

Whitespace-only changes.

django_api_query_count/templates/django_api_query_count/base.html

Lines changed: 0 additions & 21 deletions
This file was deleted.

django_api_query_count/urls.py

Lines changed: 0 additions & 9 deletions
This file was deleted.

django_api_query_count/views.py

Whitespace-only changes.

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11

22
# Additional requirements go here
3+
django-jenkins==0.110.0
4+
djangorestframework==3.6.3

requirements_dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
bumpversion==0.5.3
22
wheel==0.29.0
3-
3+
isort==4.2.15

0 commit comments

Comments
 (0)