Skip to content

Commit 9e844fd

Browse files
matthiaskEduardo Enriqueztim-schilling
authored
Fix binary parameter handling in SQL panel (#2391)
Co-authored-by: Eduardo Enriquez <eduardo.enriquez@fareharbor.com> Co-authored-by: Tim Schilling <schilling711@gmail.com>
1 parent c364770 commit 9e844fd

9 files changed

Lines changed: 189 additions & 39 deletions

File tree

debug_toolbar/panels/sql/forms.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import json
2-
31
from django import forms
42
from django.core.exceptions import ValidationError
53
from django.db import connections
@@ -21,14 +19,6 @@ class SQLSelectForm(forms.Form):
2119
request_id = forms.CharField()
2220
djdt_query_id = forms.CharField()
2321

24-
def clean_params(self):
25-
value = self.cleaned_data["params"]
26-
27-
try:
28-
return json.loads(value)
29-
except ValueError as exc:
30-
raise ValidationError("Is not valid JSON") from exc
31-
3222
def clean_alias(self):
3323
value = self.cleaned_data["alias"]
3424

@@ -61,20 +51,25 @@ def clean(self):
6151
cleaned_data["query"] = query
6252
return cleaned_data
6353

54+
def _render_row(self, row):
55+
return tuple(
56+
bytes(v).hex() if isinstance(v, (memoryview, bytes)) else v for v in row
57+
)
58+
6459
def select(self):
6560
query = self.cleaned_data["query"]
6661
sql = query["raw_sql"]
67-
params = json.loads(query["params"])
62+
params = query["params"]
6863
with self.cursor as cursor:
6964
cursor.execute(sql, params)
7065
headers = [d[0] for d in cursor.description]
71-
result = cursor.fetchall()
66+
result = [self._render_row(row) for row in cursor.fetchall()]
7267
return result, headers
7368

7469
def explain(self):
7570
query = self.cleaned_data["query"]
7671
sql = query["raw_sql"]
77-
params = json.loads(query["params"])
72+
params = query["params"]
7873
vendor = query["vendor"]
7974
with self.cursor as cursor:
8075
if vendor == "sqlite":
@@ -93,7 +88,7 @@ def explain(self):
9388
def profile(self):
9489
query = self.cleaned_data["query"]
9590
sql = query["raw_sql"]
96-
params = json.loads(query["params"])
91+
params = query["params"]
9792
with self.cursor as cursor:
9893
cursor.execute("SET PROFILING=1") # Enable profiling
9994
cursor.execute(sql, params) # Execute SELECT
@@ -113,7 +108,7 @@ def profile(self):
113108
"""
114109
)
115110
headers = [d[0] for d in cursor.description]
116-
result = cursor.fetchall()
111+
result = [self._render_row(row) for row in cursor.fetchall()]
117112
return result, headers
118113

119114
def reformat_sql(self):

debug_toolbar/panels/sql/tracking.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
1+
import base64
12
import contextlib
23
import contextvars
34
import datetime
4-
import json
55
from time import perf_counter
66

77
import django.test.testcases
88
from django.apps import apps
9+
from django.core.exceptions import ImproperlyConfigured
910

1011
from debug_toolbar import settings as dt_settings
1112
from debug_toolbar.sanitize import force_str
1213
from debug_toolbar.utils import get_stack_trace, get_template_info
1314

15+
Psycopg3Binary = None
16+
1417
try:
1518
import psycopg
1619

1720
PostgresJson = psycopg.types.json.Jsonb
1821
STATUS_IN_TRANSACTION = psycopg.pq.TransactionStatus.INTRANS
22+
Psycopg3Binary = psycopg.Binary
1923
except ImportError:
2024
try:
2125
from psycopg2._json import Json as PostgresJson
@@ -24,6 +28,19 @@
2428
PostgresJson = None
2529
STATUS_IN_TRANSACTION = None
2630

31+
try:
32+
from psycopg2.extensions import Binary as Psycopg2Binary
33+
except ImportError:
34+
Psycopg2Binary = None
35+
36+
_PostGISAdapter = None
37+
try:
38+
from django.contrib.gis.db.backends.postgis.adapter import (
39+
PostGISAdapter as _PostGISAdapter,
40+
)
41+
except (ImportError, ImproperlyConfigured):
42+
pass
43+
2744
# Prevents SQL queries from being sent to the DB. It's used
2845
# by the TemplatePanel to prevent the toolbar from issuing
2946
# additional queries.
@@ -133,6 +150,26 @@ def _decode(self, param):
133150
if isinstance(param, dict):
134151
return {key: self._decode(value) for key, value in param.items()}
135152

153+
# GeoDjango PostGIS geometry parameters: extract EWKB bytes and metadata
154+
# so the adapter can be reconstructed on the way back out for SELECT/EXPLAIN.
155+
if _PostGISAdapter is not None and isinstance(param, _PostGISAdapter):
156+
return {
157+
"__djdt_postgis__": base64.b64encode(param.ewkb).decode("ascii"),
158+
"is_geometry": param.is_geometry,
159+
"geography": param.geography,
160+
}
161+
162+
# Binary data is handled by DebugToolbarJSONEncoder in store.py.
163+
# Django's BinaryField calls connection.Database.Binary() which wraps
164+
# bytes in a driver-specific adapter: psycopg2 uses .adapted, psycopg3
165+
# uses .obj. memoryview: psycopg2/sqlite3 binary column values.
166+
if isinstance(param, (bytes, bytearray, memoryview)):
167+
return bytes(param)
168+
if Psycopg2Binary is not None and isinstance(param, Psycopg2Binary):
169+
return bytes(param.adapted)
170+
if Psycopg3Binary is not None and isinstance(param, Psycopg3Binary):
171+
return bytes(param.obj)
172+
136173
# make sure datetime, date and time are converted to string by force_str
137174
CONVERT_TYPES = (datetime.datetime, datetime.date, datetime.time)
138175
return force_str(param, strings_only=not isinstance(param, CONVERT_TYPES))
@@ -165,10 +202,11 @@ def _record(self, method, sql, params):
165202
finally:
166203
stop_time = perf_counter()
167204
duration = (stop_time - start_time) * 1000
168-
_params = ""
205+
_params = None
169206
with contextlib.suppress(TypeError):
170-
# object JSON serializable?
171-
_params = json.dumps(self._decode(params))
207+
# Decode params - binary data will be handled by DebugToolbarJSONEncoder
208+
# in store.py when the panel data is serialized
209+
_params = self._decode(params)
172210
template_info = get_template_info()
173211

174212
# Sql might be an object (such as psycopg Composed).

debug_toolbar/store.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import contextlib
23
import functools
34
import json
@@ -6,6 +7,7 @@
67
from typing import Any
78

89
from django.core.cache import caches
10+
from django.core.exceptions import ImproperlyConfigured
911
from django.core.serializers.json import DjangoJSONEncoder
1012
from django.db import transaction
1113
from django.utils.module_loading import import_string
@@ -14,15 +16,49 @@
1416
from debug_toolbar.models import HistoryEntry
1517
from debug_toolbar.sanitize import force_str
1618

19+
BINARY_SENTINEL = "__djdt_binary__"
20+
POSTGIS_SENTINEL = "__djdt_postgis__"
21+
1722

1823
class DebugToolbarJSONEncoder(DjangoJSONEncoder):
1924
def default(self, o):
25+
# Handle binary data (e.g., GeoDjango EWKB geometry data)
26+
if isinstance(o, (bytes, bytearray)):
27+
return {BINARY_SENTINEL: base64.b64encode(o).decode("ascii")}
2028
try:
2129
return super().default(o)
2230
except (TypeError, ValueError):
2331
return force_str(o)
2432

2533

34+
def _binary_object_hook(obj):
35+
if BINARY_SENTINEL in obj:
36+
return base64.b64decode(obj[BINARY_SENTINEL])
37+
if POSTGIS_SENTINEL in obj:
38+
ewkb = base64.b64decode(obj[POSTGIS_SENTINEL])
39+
try:
40+
from django.contrib.gis.db.backends.postgis.adapter import PostGISAdapter
41+
42+
adapter = PostGISAdapter.__new__(PostGISAdapter)
43+
adapter.is_geometry = obj.get("is_geometry", True)
44+
adapter.ewkb = ewkb
45+
adapter.geography = obj.get("geography", False)
46+
return adapter
47+
except (ImportError, ImproperlyConfigured):
48+
return ewkb
49+
return obj
50+
51+
52+
class DebugToolbarJSONDecoder(json.JSONDecoder):
53+
"""Custom JSON decoder that reconstructs binary data during parsing."""
54+
55+
def __init__(self, *args, **kwargs):
56+
# Set object_hook if not already provided
57+
if "object_hook" not in kwargs:
58+
kwargs["object_hook"] = _binary_object_hook
59+
super().__init__(*args, **kwargs)
60+
61+
2662
def serialize(data: Any) -> str:
2763
# If this starts throwing an exceptions, consider
2864
# Subclassing DjangoJSONEncoder and using force_str to
@@ -31,7 +67,7 @@ def serialize(data: Any) -> str:
3167

3268

3369
def deserialize(data: str) -> Any:
34-
return json.loads(data)
70+
return json.loads(data, cls=DebugToolbarJSONDecoder)
3571

3672

3773
class BaseStore:

docs/changes.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ Pending
3535
* Support Django 6.2's handling of booleans for non-PostgreSQL databases.
3636
* Changed the SQL panel to show the "Select" and "Explain" action buttons for
3737
all queries, not just ``SELECT`` statements.
38+
* Fixed SQL panel handling of binary parameters (e.g. from ``BinaryField``)
39+
and GeoDjango PostGIS geometry parameters. EWKB geometry adapters are now
40+
serialized and reconstructed so that Select and Explain work correctly on
41+
spatial queries.
3842

3943
6.3.0 (2026-04-01)
4044
------------------

tests/panels/test_sql.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -409,21 +409,21 @@ def test_param_conversion(self):
409409
# comparisons in MySQL.
410410
# Django 6.2 started passing true/false for all-non
411411
# postgres databases.
412-
expected_bools = '["Foo", true, false]'
412+
expected_bools = ["Foo", True, False]
413413
else:
414-
expected_bools = '["Foo"]'
414+
expected_bools = ["Foo"]
415415

416416
if connection.vendor == "postgresql":
417417
# PostgreSQL always includes timezone
418-
expected_datetime = '["2017-12-22 16:07:01+00:00"]'
418+
expected_datetime = ["2017-12-22 16:07:01+00:00"]
419419
else:
420-
expected_datetime = '["2017-12-22 16:07:01"]'
420+
expected_datetime = ["2017-12-22 16:07:01"]
421421

422422
self.assertEqual(
423423
tuple(query["params"] for query in self.panel._queries),
424424
(
425425
expected_bools,
426-
"[10, 1]",
426+
[10, 1],
427427
expected_datetime,
428428
),
429429
)
@@ -443,7 +443,7 @@ def test_json_param_conversion(self):
443443
self.assertEqual(len(self.panel._queries), 1)
444444
self.assertEqual(
445445
self.panel._queries[0]["params"],
446-
'["{\\"foo\\": \\"bar\\"}"]',
446+
['{"foo": "bar"}'],
447447
)
448448

449449
@unittest.skipUnless(
@@ -468,7 +468,7 @@ def test_tuple_param_conversion(self):
468468

469469
# ensure query was logged
470470
self.assertEqual(len(self.panel._queries), 1)
471-
self.assertEqual(self.panel._queries[0]["params"], '[["a", "b\'"]]')
471+
self.assertEqual(self.panel._queries[0]["params"], [["a", "b'"]])
472472

473473
def test_binary_param_force_text(self):
474474
self.assertEqual(len(self.panel._queries), 0)
@@ -539,15 +539,13 @@ def test_raw_query_param_conversion(self):
539539
self.assertEqual(
540540
tuple(query["params"] for query in self.panel._queries),
541541
(
542-
'["Foo", true, false, "2017-12-22 16:07:01"]',
543-
" ".join(
544-
[
545-
'{"first_name": "Foo",',
546-
'"is_staff": true,',
547-
'"is_superuser": false,',
548-
'"date_joined": "2017-12-22 16:07:01"}',
549-
]
550-
),
542+
["Foo", True, False, "2017-12-22 16:07:01"],
543+
{
544+
"first_name": "Foo",
545+
"is_staff": True,
546+
"is_superuser": False,
547+
"date_joined": "2017-12-22 16:07:01",
548+
},
551549
),
552550
)
553551

tests/test_integration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,29 @@ def test_sql_explain_postgres_json_field(self):
548548
)
549549
self.assertEqual(response.status_code, 404)
550550

551+
def test_sql_explain_binary_param(self):
552+
"""
553+
Confirm explain works for queries with binary parameters (e.g. GeoDjango EWKB).
554+
"""
555+
self.client.get("/execute_binary_sql/")
556+
request_ids = list(get_store().request_ids())
557+
request_id = request_ids[-1]
558+
toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id)
559+
panel = toolbar.get_panel_by_id(SQLPanel.panel_id)
560+
djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"]
561+
562+
url = "/__debug__/sql_explain/"
563+
data = {
564+
"signed": SignedDataForm.sign(
565+
{
566+
"request_id": request_id,
567+
"djdt_query_id": djdt_query_id,
568+
}
569+
)
570+
}
571+
response = self.client.post(url, data)
572+
self.assertEqual(response.status_code, 200)
573+
551574
def test_sql_profile_checks_show_toolbar(self):
552575
self.client.get("/execute_sql/")
553576
request_ids = list(get_store().request_ids())

0 commit comments

Comments
 (0)