Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions debug_toolbar/panels/sql/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import django.test.testcases
from django.apps import apps
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext as _, ngettext

from debug_toolbar import settings as dt_settings
from debug_toolbar.sanitize import force_str
Expand Down Expand Up @@ -187,7 +188,7 @@ def _last_executed_query(self, sql, params):
finally:
self.db._djdt_logger = self.logger

def _record(self, method, sql, params):
def _record(self, method, sql, params, *, many=False):
alias = self.db.alias
vendor = self.db.vendor

Expand Down Expand Up @@ -219,13 +220,32 @@ def _record(self, method, sql, params):
else:
sql = str(sql)

if many:
# params is a sequence of param sequences, but
# last_executed_query() expects a flat sequence of scalars, so
# it cannot interpolate this. Report the statement and how many
# times it ran, matching Django's own CursorDebugWrapper.
try:
times = len(params)
except TypeError:
display_sql = _("? times: %(sql)s") % {"sql": sql}
else:
display_sql = ngettext(
"%(count)d time: %(sql)s",
"%(count)d times: %(sql)s",
times,
) % {"count": times, "sql": sql}
else:
display_sql = self._last_executed_query(sql, params)

kwargs = {
"vendor": vendor,
"alias": alias,
"sql": self._last_executed_query(sql, params),
"sql": display_sql,
"duration": duration,
"raw_sql": sql,
"params": _params,
"many": many,
"stacktrace": get_stack_trace(skip=2),
"template_info": template_info,
}
Expand Down Expand Up @@ -281,4 +301,4 @@ def execute(self, sql, params=None):
return self._record(super().execute, sql, params)

def executemany(self, sql, param_list):
return self._record(super().executemany, sql, param_list)
return self._record(super().executemany, sql, param_list, many=True)
3 changes: 2 additions & 1 deletion debug_toolbar/templates/debug_toolbar/panels/sql.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
{{ query.duration|floatformat:"2" }}ms
</td>
<td class="djdt-actions">
{% if query.params is not None %}
{# executemany params are a sequence of param sequences, which these views cannot re-execute. #}
{% if query.params is not None and not query.many %}
<form method="post">
{{ query.form.as_div }}
<button formaction="{% url 'djdt:sql_select' %}" class="remoteCall">Sel</button>
Expand Down
3 changes: 3 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ Pending
* Updated the example screenshot.
* Updated the screenshot capture logic to find the toolbar elements in the
shadow DOM.
* Fixed ``cursor.executemany()`` raising from the SQL panel instead of being
recorded. The statement is now shown as ``N times: <sql>``, matching
Django's own debug cursor.

7.1.1 (2026-08-14)
------------------
Expand Down
61 changes: 61 additions & 0 deletions tests/panels/test_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,67 @@ def test_recording(self):
# ensure the stacktrace is populated
self.assertTrue(len(query["stacktrace"]) > 0)

def test_executemany(self):
"""
executemany() must be recorded without raising.

The backend's last_executed_query() expects a flat sequence of scalar
params, so handing it a list of param sequences fails. It happens in a
finally: block after the write has already landed, so the exception
escapes into the caller.
"""
self.assertEqual(len(self.panel._queries), 0)

with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO tests_binary (field) VALUES (%s)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just asking: Is there a specific reason you chose tests_binary for this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the only backend-agnostic model in tests/models.py. The other two are Postgres-only (PostgresJSON) and GeoDjango. Binary also has a single field, so the INSERT stays one column. Happy to switch if you prefer another.

[(b"one",), (b"two",)],
)

self.assertEqual(len(self.panel._queries), 1)
query = self.panel._queries[0]
self.assertEqual(
query["sql"], "2 times: INSERT INTO tests_binary (field) VALUES (%s)"
)
self.assertTrue(query["many"])
self.assertEqual(Binary.objects.count(), 2)

def test_executemany_singular(self):
"""A single param set uses the singular form."""
self.assertEqual(len(self.panel._queries), 0)

with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO tests_binary (field) VALUES (%s)", [(b"one",)]
)

self.assertEqual(len(self.panel._queries), 1)
self.assertEqual(
self.panel._queries[0]["sql"],
"1 time: INSERT INTO tests_binary (field) VALUES (%s)",
)
self.assertEqual(Binary.objects.count(), 1)

def test_executemany_with_empty_param_list(self):
"""An empty param list runs no statement but must still not raise."""
self.assertEqual(len(self.panel._queries), 0)

with connection.cursor() as cursor:
cursor.executemany("INSERT INTO tests_binary (field) VALUES (%s)", [])

self.assertEqual(len(self.panel._queries), 1)
self.assertEqual(
self.panel._queries[0]["sql"],
"0 times: INSERT INTO tests_binary (field) VALUES (%s)",
)
self.assertEqual(Binary.objects.count(), 0)

def test_execute_is_not_marked_as_many(self):
sql_call()

self.assertEqual(len(self.panel._queries), 1)
self.assertFalse(self.panel._queries[0]["many"])

def test_assert_num_queries_works(self):
"""
Confirm Django's assertNumQueries and CaptureQueriesContext works
Expand Down