Skip to content

Commit 7dd9caa

Browse files
committed
Redesigned architecture, added the ability to add your own output handlers
1 parent e27974c commit 7dd9caa

File tree

14 files changed

+350
-278
lines changed

14 files changed

+350
-278
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,23 @@ for _ in CaptureQueries(advanced_verb=True, queries=True, explain=True):
104104
>>>
105105
>>> Tests count: 1 | Total queries count: 2 | Total execution time: 0.22s | Median time one test is: 0.109s | Vendor: sqlite
106106
```
107+
108+
### Customization of the display
109+
> To customize the display of SQL queries, you can import a list with handlers and remove handlers from it or expand it with your own handlers.
110+
111+
```python
112+
from capture_db_queries import settings, IHandler
113+
114+
# NOTE: The handler must comply with the specified interface.
115+
class SomeHandler(IHandler):
116+
def handle(self, queries_log):
117+
for query in queries_log:
118+
query.sql = "Hello World!"
119+
return queries_log
120+
121+
settings.PRINTER_HANDLERS.remove("capture_db_queries.handlers.ColorizeSqlHandler")
122+
settings.PRINTER_HANDLERS.append("path.to.your.handler.SomeHandler")
123+
```
124+
125+
## TODO:
126+
1. Add support for other ORM's, SQLAlchemy, etc.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ disable_error_code = [
114114
"operator",
115115
"assignment",
116116
"union-attr",
117+
"comparison-overlap",
117118
]
118119

119120

@@ -204,6 +205,7 @@ ignore = [
204205
"F403", # (не ругаться на использование from ... import *)
205206
# https://docs.astral.sh/ruff/rules/#pyupgrade-up
206207
"UP031", # (не ругаться на форматирование с помощью %s)
208+
"UP035", # (не ругаться на импорт из typing_extensions)
207209
"UP036", # (не ругаться на использование sys.version_info если текущая версия не подпадает под условие)
208210
# https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
209211
"G004", # (не ругаться на использование f-строк для сообщения лога)

src/capture_db_queries/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# ruff: noqa: F401, E402
2+
13
import sys
24
import warnings
35

@@ -8,7 +10,8 @@
810
)
911

1012
if sys.version_info >= (3, 12):
11-
from . import _sqlite3_adapters_and_converters # noqa: F401
13+
from . import _sqlite3_adapters_and_converters
1214

13-
from ._logging import switch_logger, switch_trace # noqa: F401, E402
14-
from .decorators import CaptureQueries, ExtCaptureQueriesContext, capture_queries # noqa: F401, E402
15+
from ._logging import switch_logger, switch_trace
16+
from .decorators import CaptureQueries, ExtCaptureQueriesContext, capture_queries
17+
from .handlers import IHandler

src/capture_db_queries/decorators.py

Lines changed: 104 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
from __future__ import annotations
22

3-
import abc
43
import functools
54
import statistics
65
import time
76
import warnings
8-
from collections import deque
9-
from contextlib import contextmanager
107
from functools import wraps
118
from typing import TYPE_CHECKING, Any
129
from unittest.util import safe_repr
@@ -17,26 +14,119 @@
1714
reset_queries,
1815
)
1916
from django.test.utils import CaptureQueriesContext
20-
from typing_extensions import Self # noqa: UP035
17+
from typing_extensions import Self
2118

2219
from ._logging import log
2320
from .dtos import IterationPrintDTO, SeveralPrintDTO, SinglePrintDTO
2421
from .printers import AbcPrinter, PrinterSql
2522
from .wrappers import BaseExecutionWrapper, ExplainExecutionWrapper
2623

2724
if TYPE_CHECKING:
28-
from collections.abc import Callable, Generator
25+
from collections.abc import Callable
2926
from types import TracebackType
3027

3128
from django.db.backends.base.base import BaseDatabaseWrapper
32-
from django.db.backends.utils import CursorWrapper
3329

34-
from .types import DecoratedCallable, QueriesLog, Query, T
30+
from .dtos import Query
31+
from .types import DecoratedCallable, T
3532

3633
__all__ = ('CaptureQueries', 'capture_queries', 'ExtCaptureQueriesContext')
3734

3835

39-
class AbcCapture(abc.ABC):
36+
class CaptureQueries:
37+
"""
38+
#### Class to simplify the search for slow and suboptimal sql queries\
39+
to the database in django projects.
40+
41+
---
42+
43+
#### About class
44+
- Class allows you to track any sql queries that are executed inside the body of a loop,
45+
a decorated function, or a context manager,
46+
the body can be executed a specified number of times to get the average query execution time.
47+
48+
- The class allows you to display formatted output data
49+
containing brief information about the current iteration of the measurement,
50+
display sql queries and explain information on them,
51+
as well as summary information containing data on all measurements.
52+
53+
`Do not use the class inside the business logic of your application,
54+
this will greatly slow down the execution of the queries,
55+
the class is intended only for the test environment.`
56+
57+
---
58+
59+
#### - Optional parameters:
60+
- `assert_q_count`: The expected number of database queries during all `number_runs`, otherwise an exception will be raised: "AssertionError: `N` not less than or equal to `N` queries".
61+
- `number_runs`: The number of runs of the decorated function or test for loop.
62+
- `verbose`: Displaying the final results of test measurements within all `number_runs`.
63+
- `advanced_verb`: Displaying the result of each test measurement.
64+
- `auto_call_func`: Autorun of the decorated function. (without passing arguments to the function, since the launch takes place inside the class).
65+
- `queries`: Displaying colored and formatted SQL queries to the database.
66+
- `explain`: Displaying explain information about each query. (has no effect on the original query).
67+
- `explain_opts`: Parameters for explain. (for more information about the parameters for explain, see the documentation for your DBMS).
68+
- `connection`: Connecting to your database, by default: django.db.connection
69+
70+
---
71+
72+
#### Usage examples::
73+
74+
for ctx in CaptureQueries(number_runs=2, advanced_verb=True):
75+
response = self.client.get(url)
76+
77+
>>> Test №1 | Queries count: 10 | Execution time: 0.04s
78+
>>> Test №2 | Queries count: 10 | Execution time: 0.04s
79+
>>> Tests count: 2 | Total queries count: 20 | Total execution time: 0.08s | Median time one test is: 0.041s | Vendor: sqlite
80+
81+
# OR
82+
83+
@CaptureQueries(number_runs=2, advanced_verb=True)
84+
def test_request():
85+
response = self.client.get(url)
86+
87+
>>> Test №1 | Queries count: 10 | Execution time: 0.04s
88+
>>> Test №2 | Queries count: 10 | Execution time: 0.04s
89+
>>> Tests count: 2 | Total queries count: 20 | Total execution time: 0.08s | Median time one test is: 0.041s | Vendor: sqlite
90+
91+
# OR
92+
93+
# NOTE: The with context manager does not support multi-launch number_runs > 1
94+
with CaptureQueries(number_runs=1, advanced_verb=True) as ctx:
95+
response = self.client.get(url)
96+
97+
>>> Queries count: 10 | Execution time: 0.04s | Vendor: sqlite
98+
99+
---
100+
101+
#### Example of output when using queries and explain::
102+
103+
for _ in CaptureQueries(advanced_verb=True, queries=True, explain=True):
104+
list(Reporter.objects.filter(pk=1))
105+
list(Article.objects.filter(pk=1))
106+
107+
>>> Test №1 | Queries count: 2 | Execution time: 0.22s
108+
>>>
109+
>>>
110+
>>> №[1] time=[0.109] explain=['2 0 0 SEARCH TABLE tests_reporter USING INTEGER PRIMARY KEY (rowid=?)']
111+
>>> SELECT "tests_reporter"."id",
112+
>>> "tests_reporter"."full_name"
113+
>>> FROM "tests_reporter"
114+
>>> WHERE "tests_reporter"."id" = 1
115+
>>>
116+
>>>
117+
>>> №[2] time=[0.109] explain=['2 0 0 SEARCH TABLE tests_article USING INTEGER PRIMARY KEY (rowid=?)']
118+
>>> SELECT "tests_article"."id",
119+
>>> "tests_article"."pub_date",
120+
>>> "tests_article"."headline",
121+
>>> "tests_article"."content",
122+
>>> "tests_article"."reporter_id"
123+
>>> FROM "tests_article"
124+
>>> WHERE "tests_article"."id" = 1
125+
>>>
126+
>>>
127+
>>> Tests count: 1 | Total queries count: 2 | Total execution time: 0.22s | Median time one test is: 0.109s | Vendor: sqlite
128+
""" # noqa: E501
129+
40130
def __init__(
41131
self,
42132
assert_q_count: int | None = None,
@@ -73,12 +163,8 @@ def __init__(
73163
self.connection.vendor, assert_q_count, verbose, advanced_verb, queries
74164
)
75165

76-
self.queries_log: QueriesLog = deque(maxlen=self.connection.queries_limit)
77-
78-
self.wrapper = self.wrapper_cls(
79-
connection=self.connection, queries_log=self.queries_log, explain_opts=explain_opts or {}
80-
)
81-
self.wrapper_ctx_manager = self.__wrap_reqs_in_wrapper()
166+
self.wrapper = self.wrapper_cls(connection=self.connection, explain_opts=explain_opts or {})
167+
self.wrapper_ctx_manager = self.wrapper.wrap_reqs_in_wrapper()
82168

83169
@property
84170
def printer_cls(self) -> type[AbcPrinter]:
@@ -92,15 +178,6 @@ def wrapper_cls(self) -> type[BaseExecutionWrapper]:
92178
return ExplainExecutionWrapper
93179
return BaseExecutionWrapper
94180

95-
@contextmanager
96-
def __wrap_reqs_in_wrapper(self) -> Generator[None, None, CursorWrapper]:
97-
"""Wraps all database requests in a wrapper."""
98-
log.debug('')
99-
100-
# https://docs.djangoproject.com/en/5.1/topics/db/instrumentation/#connection-execute-wrapper
101-
with self.connection.execute_wrapper(self.wrapper):
102-
yield
103-
104181
def __enter__(self) -> Self:
105182
log.debug('')
106183

@@ -133,7 +210,7 @@ def __exit__(
133210
self.printer.print_single_sql(
134211
SinglePrintDTO(
135212
queries_count=queries_count,
136-
queries_log=self.queries_log,
213+
queries_log=self.wrapper.queries_log,
137214
execution_time_per_iter=self.wrapper.timer.execution_time_per_iter,
138215
)
139216
)
@@ -169,7 +246,7 @@ def __next__(self) -> Self:
169246
self.printer.print_several_sql(
170247
SeveralPrintDTO(
171248
queries_count=queries_count,
172-
queries_log=self.queries_log,
249+
queries_log=self.wrapper.queries_log,
173250
current_iteration=self.current_iteration,
174251
all_execution_times=self.wrapper.timer.all_execution_times,
175252
)
@@ -197,115 +274,16 @@ def wrapped(*args: Any, **kwargs: Any) -> Any:
197274
def __len__(self) -> int:
198275
log.debug('')
199276

200-
return len(self.queries_log)
277+
return len(self.wrapper.queries_log)
201278

202279
def __getitem__(self, index: int) -> Query | None:
203280
log.debug('')
204281

205282
try:
206-
return self.queries_log[index]
283+
return self.wrapper.queries_log[index]
207284
except IndexError:
208285
return None
209286

210-
@abc.abstractmethod
211-
def _assert_queries_count(self, queries_count: int) -> None:
212-
raise NotImplementedError
213-
214-
215-
class CaptureQueries(AbcCapture):
216-
"""
217-
#### Class to simplify the search for slow and suboptimal sql queries\
218-
to the database in django projects.
219-
220-
---
221-
222-
#### About class
223-
- Class allows you to track any sql queries that are executed inside the body of a loop,
224-
a decorated function, or a context manager,
225-
the body can be executed a specified number of times to get the average query execution time.
226-
227-
- The class allows you to display formatted output data
228-
containing brief information about the current iteration of the measurement,
229-
display sql queries and explain information on them,
230-
as well as summary information containing data on all measurements.
231-
232-
`Do not use the class inside the business logic of your application,
233-
this will greatly slow down the execution of the queries,
234-
the class is intended only for the test environment.`
235-
236-
---
237-
238-
#### - Optional parameters:
239-
- `assert_q_count`: The expected number of database queries during all `number_runs`, otherwise an exception will be raised: "AssertionError: `N` not less than or equal to `N` queries".
240-
- `number_runs`: The number of runs of the decorated function or test for loop.
241-
- `verbose`: Displaying the final results of test measurements within all `number_runs`.
242-
- `advanced_verb`: Displaying the result of each test measurement.
243-
- `auto_call_func`: Autorun of the decorated function. (without passing arguments to the function, since the launch takes place inside the class).
244-
- `queries`: Displaying colored and formatted SQL queries to the database.
245-
- `explain`: Displaying explain information about each query. (has no effect on the original query).
246-
- `explain_opts`: Parameters for explain. (for more information about the parameters for explain, see the documentation for your DBMS).
247-
- `connection`: Connecting to your database, by default: django.db.connection
248-
249-
---
250-
251-
#### Usage examples::
252-
253-
for ctx in CaptureQueries(number_runs=2, advanced_verb=True):
254-
response = self.client.get(url)
255-
256-
>>> Test №1 | Queries count: 10 | Execution time: 0.04s
257-
>>> Test №2 | Queries count: 10 | Execution time: 0.04s
258-
>>> Tests count: 2 | Total queries count: 20 | Total execution time: 0.08s | Median time one test is: 0.041s | Vendor: sqlite
259-
260-
# OR
261-
262-
@CaptureQueries(number_runs=2, advanced_verb=True)
263-
def test_request():
264-
response = self.client.get(url)
265-
266-
>>> Test №1 | Queries count: 10 | Execution time: 0.04s
267-
>>> Test №2 | Queries count: 10 | Execution time: 0.04s
268-
>>> Tests count: 2 | Total queries count: 20 | Total execution time: 0.08s | Median time one test is: 0.041s | Vendor: sqlite
269-
270-
# OR
271-
272-
# NOTE: The with context manager does not support multi-launch number_runs > 1
273-
with CaptureQueries(number_runs=1, advanced_verb=True) as ctx:
274-
response = self.client.get(url)
275-
276-
>>> Queries count: 10 | Execution time: 0.04s | Vendor: sqlite
277-
278-
---
279-
280-
#### Example of output when using queries and explain::
281-
282-
for _ in CaptureQueries(advanced_verb=True, queries=True, explain=True):
283-
list(Reporter.objects.filter(pk=1))
284-
list(Article.objects.filter(pk=1))
285-
286-
>>> Test №1 | Queries count: 2 | Execution time: 0.22s
287-
>>>
288-
>>>
289-
>>> №[1] time=[0.109] explain=['2 0 0 SEARCH TABLE tests_reporter USING INTEGER PRIMARY KEY (rowid=?)']
290-
>>> SELECT "tests_reporter"."id",
291-
>>> "tests_reporter"."full_name"
292-
>>> FROM "tests_reporter"
293-
>>> WHERE "tests_reporter"."id" = 1
294-
>>>
295-
>>>
296-
>>> №[2] time=[0.109] explain=['2 0 0 SEARCH TABLE tests_article USING INTEGER PRIMARY KEY (rowid=?)']
297-
>>> SELECT "tests_article"."id",
298-
>>> "tests_article"."pub_date",
299-
>>> "tests_article"."headline",
300-
>>> "tests_article"."content",
301-
>>> "tests_article"."reporter_id"
302-
>>> FROM "tests_article"
303-
>>> WHERE "tests_article"."id" = 1
304-
>>>
305-
>>>
306-
>>> Tests count: 1 | Total queries count: 2 | Total execution time: 0.22s | Median time one test is: 0.109s | Vendor: sqlite
307-
""" # noqa: E501
308-
309287
def _assert_queries_count(self, queries_count: int) -> None:
310288
log.debug('')
311289

src/capture_db_queries/dtos.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@
88
from .types import QueriesLog
99

1010

11+
@dataclass
12+
class Query:
13+
sql: str
14+
time: float
15+
16+
17+
@dataclass
18+
class ExpQuery(Query):
19+
explain: str
20+
21+
1122
@dataclass(frozen=True)
1223
class BasePrintDTO:
1324
queries_count: int

0 commit comments

Comments
 (0)