Skip to content

Commit 80560cf

Browse files
committed
Fix executemany() raising from the SQL panel
_record() passed the param list straight to _last_executed_query(), but the backend's last_executed_query() expects a flat sequence of scalars. On sqlite it re-quotes them with QUOTE(?) placeholders, so a sequence of param sequences raises ProgrammingError. It happens in a finally: block after the statement has already run, so the write lands and the request still 500s. Thread a many flag through _record() and render the statement as 'N times: <sql>' in that case, which is what Django's own CursorDebugWrapper does. The select/explain/profile buttons re-execute raw_sql with params through plain cursor.execute, which fails the same way, so they are hidden for executemany queries.
1 parent 49f4ef3 commit 80560cf

4 files changed

Lines changed: 67 additions & 4 deletions

File tree

debug_toolbar/panels/sql/tracking.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def _last_executed_query(self, sql, params):
187187
finally:
188188
self.db._djdt_logger = self.logger
189189

190-
def _record(self, method, sql, params):
190+
def _record(self, method, sql, params, *, many=False):
191191
alias = self.db.alias
192192
vendor = self.db.vendor
193193

@@ -219,13 +219,27 @@ def _record(self, method, sql, params):
219219
else:
220220
sql = str(sql)
221221

222+
if many:
223+
# params is a sequence of param sequences, but
224+
# last_executed_query() expects a flat sequence of scalars, so
225+
# it cannot interpolate this. Report the statement and how many
226+
# times it ran, matching Django's own CursorDebugWrapper.
227+
try:
228+
times = len(params)
229+
except TypeError:
230+
times = "?"
231+
display_sql = f"{times} times: {sql}"
232+
else:
233+
display_sql = self._last_executed_query(sql, params)
234+
222235
kwargs = {
223236
"vendor": vendor,
224237
"alias": alias,
225-
"sql": self._last_executed_query(sql, params),
238+
"sql": display_sql,
226239
"duration": duration,
227240
"raw_sql": sql,
228241
"params": _params,
242+
"many": many,
229243
"stacktrace": get_stack_trace(skip=2),
230244
"template_info": template_info,
231245
}
@@ -281,4 +295,4 @@ def execute(self, sql, params=None):
281295
return self._record(super().execute, sql, params)
282296

283297
def executemany(self, sql, param_list):
284-
return self._record(super().executemany, sql, param_list)
298+
return self._record(super().executemany, sql, param_list, many=True)

debug_toolbar/templates/debug_toolbar/panels/sql.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
{{ query.duration|floatformat:"2" }}ms
7575
</td>
7676
<td class="djdt-actions">
77-
{% if query.params is not None %}
77+
{# executemany params are a sequence of param sequences, which these views cannot re-execute. #}
78+
{% if query.params is not None and not query.many %}
7879
<form method="post">
7980
{{ query.form.as_div }}
8081
<button formaction="{% url 'djdt:sql_select' %}" class="remoteCall">Sel</button>

docs/changes.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Pending
1616
* Stopped the history panel buttons from submitting their form when clicked
1717
before the panel script has loaded, which navigated away from the page.
1818
* Added support for Django 6.1.
19+
* Fixed ``cursor.executemany()`` raising from the SQL panel instead of being
20+
recorded. The statement is now shown as ``N times: <sql>``, matching
21+
Django's own debug cursor.
1922

2023
7.0.0 (2026-06-17)
2124
------------------

tests/panels/test_sql.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,51 @@ def test_recording(self):
104104
# ensure the stacktrace is populated
105105
self.assertTrue(len(query["stacktrace"]) > 0)
106106

107+
def test_executemany(self):
108+
"""
109+
executemany() must be recorded without raising.
110+
111+
The backend's last_executed_query() expects a flat sequence of scalar
112+
params, so handing it a list of param sequences fails. It happens in a
113+
finally: block after the write has already landed, so the exception
114+
escapes into the caller.
115+
"""
116+
self.assertEqual(len(self.panel._queries), 0)
117+
118+
with connection.cursor() as cursor:
119+
cursor.executemany(
120+
"INSERT INTO tests_binary (field) VALUES (%s)",
121+
[(b"one",), (b"two",)],
122+
)
123+
124+
self.assertEqual(len(self.panel._queries), 1)
125+
query = self.panel._queries[0]
126+
self.assertEqual(
127+
query["sql"], "2 times: INSERT INTO tests_binary (field) VALUES (%s)"
128+
)
129+
self.assertTrue(query["many"])
130+
self.assertEqual(Binary.objects.count(), 2)
131+
132+
def test_executemany_with_empty_param_list(self):
133+
"""An empty param list runs no statement but must still not raise."""
134+
self.assertEqual(len(self.panel._queries), 0)
135+
136+
with connection.cursor() as cursor:
137+
cursor.executemany("INSERT INTO tests_binary (field) VALUES (%s)", [])
138+
139+
self.assertEqual(len(self.panel._queries), 1)
140+
self.assertEqual(
141+
self.panel._queries[0]["sql"],
142+
"0 times: INSERT INTO tests_binary (field) VALUES (%s)",
143+
)
144+
self.assertEqual(Binary.objects.count(), 0)
145+
146+
def test_execute_is_not_marked_as_many(self):
147+
sql_call()
148+
149+
self.assertEqual(len(self.panel._queries), 1)
150+
self.assertFalse(self.panel._queries[0]["many"])
151+
107152
def test_assert_num_queries_works(self):
108153
"""
109154
Confirm Django's assertNumQueries and CaptureQueriesContext works

0 commit comments

Comments
 (0)