Skip to content

Commit db28e81

Browse files
author
Eduardo Enriquez
committed
Binary parameter handling for GeoDjango
1 parent 4e47b94 commit db28e81

File tree

3 files changed

+203
-3
lines changed

3 files changed

+203
-3
lines changed

debug_toolbar/panels/sql/forms.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import base64
23

34
from django import forms
45
from django.core.exceptions import ValidationError
@@ -10,6 +11,23 @@
1011
from debug_toolbar.toolbar import DebugToolbar
1112

1213

14+
def _reconstruct_params(params):
15+
"""
16+
Reconstruct parameters that were encoded for JSON storage,
17+
especially binary data that was base64 encoded.
18+
"""
19+
if isinstance(params, list):
20+
return [_reconstruct_params(param) for param in params]
21+
elif isinstance(params, dict):
22+
if "__djdt_binary__" in params:
23+
# Reconstruct binary data from base64
24+
return base64.b64decode(params["__djdt_binary__"])
25+
else:
26+
return {key: _reconstruct_params(value) for key, value in params.items()}
27+
else:
28+
return params
29+
30+
1331
class SQLSelectForm(forms.Form):
1432
"""
1533
Validate params
@@ -69,10 +87,15 @@ def clean(self):
6987
cleaned_data["query"] = query
7088
return cleaned_data
7189

90+
def _get_query_params(self):
91+
"""Get reconstructed parameters for the current query"""
92+
query = self.cleaned_data["query"]
93+
return _reconstruct_params(json.loads(query["params"]))
94+
7295
def select(self):
7396
query = self.cleaned_data["query"]
7497
sql = query["raw_sql"]
75-
params = json.loads(query["params"])
98+
params = self._get_query_params()
7699
with self.cursor as cursor:
77100
cursor.execute(sql, params)
78101
headers = [d[0] for d in cursor.description]
@@ -82,7 +105,7 @@ def select(self):
82105
def explain(self):
83106
query = self.cleaned_data["query"]
84107
sql = query["raw_sql"]
85-
params = json.loads(query["params"])
108+
params = self._get_query_params()
86109
vendor = query["vendor"]
87110
with self.cursor as cursor:
88111
if vendor == "sqlite":
@@ -101,7 +124,7 @@ def explain(self):
101124
def profile(self):
102125
query = self.cleaned_data["query"]
103126
sql = query["raw_sql"]
104-
params = json.loads(query["params"])
127+
params = self._get_query_params()
105128
with self.cursor as cursor:
106129
cursor.execute("SET PROFILING=1") # Enable profiling
107130
cursor.execute(sql, params) # Execute SELECT

debug_toolbar/panels/sql/tracking.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import contextvars
33
import datetime
44
import json
5+
import base64
56
from time import perf_counter
67

78
import django.test.testcases
@@ -126,6 +127,11 @@ def _decode(self, param):
126127
if isinstance(param, dict):
127128
return {key: self._decode(value) for key, value in param.items()}
128129

130+
# Handle binary data (e.g., GeoDjango EWKB geometry data)
131+
if isinstance(param, (bytes, bytearray)):
132+
# Mark as binary data for later reconstruction
133+
return {"__djdt_binary__": base64.b64encode(param).decode('ascii')}
134+
129135
# make sure datetime, date and time are converted to string by force_str
130136
CONVERT_TYPES = (datetime.datetime, datetime.date, datetime.time)
131137
try:
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Tests for GeoDjango binary parameter handling fix
3+
"""
4+
import json
5+
import base64
6+
import unittest
7+
8+
from debug_toolbar.panels.sql.forms import _reconstruct_params
9+
from debug_toolbar.panels.sql.tracking import NormalCursorMixin
10+
11+
from ..base import BaseTestCase
12+
13+
14+
class MockCursor:
15+
"""Mock cursor for testing"""
16+
pass
17+
18+
19+
class MockConnection:
20+
"""Mock database connection for testing"""
21+
vendor = "postgresql"
22+
alias = "default"
23+
24+
25+
class MockLogger:
26+
"""Mock logger for testing"""
27+
def record(self, **kwargs):
28+
pass
29+
30+
31+
class TestCursor(NormalCursorMixin):
32+
"""Test cursor that can be instantiated"""
33+
def __init__(self):
34+
# Initialize with mock objects
35+
self.cursor = MockCursor()
36+
self.db = MockConnection()
37+
self.logger = MockLogger()
38+
39+
40+
class GeoDjangoBinaryParameterTest(BaseTestCase):
41+
"""Test cases for GeoDjango binary parameter handling"""
42+
43+
def test_binary_parameter_encoding_decoding(self):
44+
"""Test that binary parameters are properly encoded and decoded"""
45+
# Create a test cursor with the _decode method
46+
cursor = TestCursor()
47+
48+
# Test binary data similar to GeoDjango EWKB geometry
49+
binary_data = b'\x01\x01\x00\x00\x20\xe6\x10\x00\x00\xff\xfe\xfd'
50+
51+
# Test encoding (what happens when query is logged)
52+
encoded = cursor._decode(binary_data)
53+
54+
# Should be marked as binary data
55+
self.assertIsInstance(encoded, dict)
56+
self.assertIn("__djdt_binary__", encoded)
57+
58+
# Should be base64 encoded
59+
expected_b64 = base64.b64encode(binary_data).decode('ascii')
60+
self.assertEqual(encoded["__djdt_binary__"], expected_b64)
61+
62+
# Test JSON serialization (what happens in tracking.py)
63+
json_params = json.dumps([encoded])
64+
65+
# Test parsing back from JSON
66+
parsed = json.loads(json_params)
67+
68+
# Test reconstruction (what happens in forms.py)
69+
reconstructed = _reconstruct_params(parsed)
70+
71+
# Should recover original binary data
72+
self.assertEqual(len(reconstructed), 1)
73+
self.assertEqual(reconstructed[0], binary_data)
74+
self.assertIsInstance(reconstructed[0], bytes)
75+
76+
def test_mixed_parameter_types(self):
77+
"""Test that mixed parameter types are handled correctly"""
78+
cursor = TestCursor()
79+
80+
# Test with mixed types including binary data
81+
params = [
82+
"string_param",
83+
42,
84+
b'\x01\x02\x03', # binary data
85+
None,
86+
["nested", "list"],
87+
]
88+
89+
# Encode each parameter
90+
encoded_params = [cursor._decode(p) for p in params]
91+
92+
# Serialize to JSON
93+
json_str = json.dumps(encoded_params)
94+
95+
# Parse and reconstruct
96+
parsed = json.loads(json_str)
97+
reconstructed = _reconstruct_params(parsed)
98+
99+
# Check each parameter
100+
self.assertEqual(reconstructed[0], "string_param") # string unchanged
101+
self.assertEqual(reconstructed[1], 42) # int unchanged
102+
self.assertEqual(reconstructed[2], b'\x01\x02\x03') # binary restored
103+
self.assertIsNone(reconstructed[3]) # None unchanged
104+
self.assertEqual(reconstructed[4], ["nested", "list"]) # list unchanged
105+
106+
def test_nested_binary_data(self):
107+
"""Test binary data nested in lists and dicts"""
108+
cursor = TestCursor()
109+
110+
# Test nested structures with binary data
111+
nested_params = [
112+
[b'\x01\x02', "string", b'\x03\x04'],
113+
{"key": b'\x05\x06', "other": "value"},
114+
]
115+
116+
# Encode
117+
encoded = [cursor._decode(p) for p in nested_params]
118+
119+
# Serialize and parse
120+
json_str = json.dumps(encoded)
121+
parsed = json.loads(json_str)
122+
reconstructed = _reconstruct_params(parsed)
123+
124+
# Check nested list
125+
self.assertEqual(reconstructed[0][0], b'\x01\x02')
126+
self.assertEqual(reconstructed[0][1], "string")
127+
self.assertEqual(reconstructed[0][2], b'\x03\x04')
128+
129+
# Check nested dict
130+
self.assertEqual(reconstructed[1]["key"], b'\x05\x06')
131+
self.assertEqual(reconstructed[1]["other"], "value")
132+
133+
def test_empty_binary_data(self):
134+
"""Test handling of empty binary data"""
135+
cursor = TestCursor()
136+
137+
# Test empty bytes
138+
empty_bytes = b''
139+
encoded = cursor._decode(empty_bytes)
140+
141+
# Should still be marked as binary
142+
self.assertIsInstance(encoded, dict)
143+
self.assertIn("__djdt_binary__", encoded)
144+
145+
# Reconstruct
146+
json_str = json.dumps([encoded])
147+
parsed = json.loads(json_str)
148+
reconstructed = _reconstruct_params(parsed)
149+
150+
self.assertEqual(reconstructed[0], empty_bytes)
151+
152+
def test_bytearray_support(self):
153+
"""Test that bytearray is also handled as binary data"""
154+
cursor = TestCursor()
155+
156+
# Test bytearray
157+
byte_array = bytearray(b'\x01\x02\x03\x04')
158+
encoded = cursor._decode(byte_array)
159+
160+
# Should be marked as binary
161+
self.assertIn("__djdt_binary__", encoded)
162+
163+
# Reconstruct (should become bytes, not bytearray)
164+
json_str = json.dumps([encoded])
165+
parsed = json.loads(json_str)
166+
reconstructed = _reconstruct_params(parsed)
167+
168+
# Should be equal in content (bytes vs bytearray comparison works)
169+
self.assertEqual(reconstructed[0], byte_array)
170+
# Should be bytes type after reconstruction
171+
self.assertIsInstance(reconstructed[0], bytes)

0 commit comments

Comments
 (0)