Capture stack trace when queries are executed
30 March, 2021
Asserting query counts in Django tests with CaptureQueriesContext
is a great way to maintain/optimize code, but it doesn't provide enough context to find what changed when tests fail during a refactor. I found it helpful to capture some lines from the stack trace when each query is being executed and have them accessible during test runs that use CaptureQueriesContext
to be logged along with the query, when count assertions fail.
You can achieve this by replacing CursorDebugWrapper
in django.db.backends.utils
with a new class. The new class can inherit from CursorDebugWrapper
and override execute
and executemany
methods. The replacement methods would wrap the call (of super()
) with a try/finally
block, and in the finally
block, call a new private method that can collect the stack trace.
Real world example
The following example assumes you have an environment variable ENABLE_QUERY_TRACEBACK_CAPTURE=1
set during test runs to override CursorDebugWrapper
.
Somewhere in the project, say a testutils.py
module:
import traceback
from typing import List
from dataclasses import dataclass
from django.db.backends.utils import CursorDebugWrapper
from django.conf import settings
@dataclass(frozen=True)
class Traceline:
path: str
lineno: int
func: str
line: str
@dataclass(frozen=True)
class QueryTrace:
sql: str
traceback: List[Traceline]
class CustomCursorDebugWrapper(CursorDebugWrapper):
def __init__(self, cursor, db):
super().__init__(cursor, db)
if not hasattr(self.db, "captured_query_traceback"):
self.db.captured_query_traceback = []
def capture_traceback(self, sql):
stack = traceback.extract_stack()
self.db.captured_query_traceback.append(
QueryTrace(
sql=sql,
traceback=[
Traceline(
path=path,
lineno=lineno,
func=func,
line=line,
)
for (path, lineno, func, line) in reversed(stack)
],
)
)
def execute(self, sql, params=None):
try:
return super().execute(sql, params)
finally:
sql = self.db.ops.last_executed_query(self.cursor, sql, params)
self.capture_traceback(sql)
def executemany(self, sql, param_list):
try:
return super().executemany(sql, param_list)
finally:
self.capture_traceback(sql)
In settings.py
:
import os
if os.environ.get("ENABLE_QUERY_TRACEBACK_CAPTURE"):
import django.db.backends.utils as backend_utils
from testutils import CustomCursorDebugWrapper
backend_utils.CursorDebugWrapper = CustomCursorDebugWrapper