diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47388752c..f652544fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: - id: biome-check verbose: true - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.12.4' + rev: 'v0.12.5' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/debug_toolbar/panels/sql/decoders.py b/debug_toolbar/panels/sql/decoders.py new file mode 100644 index 000000000..deb84c05a --- /dev/null +++ b/debug_toolbar/panels/sql/decoders.py @@ -0,0 +1,26 @@ +import base64 +import json + + +class DebugToolbarJSONDecoder(json.JSONDecoder): + """Custom JSON decoder that reconstructs binary data during parsing.""" + + def decode(self, s): + """Override decode to apply reconstruction after parsing.""" + obj = super().decode(s) + return self._reconstruct_params(obj) + + def _reconstruct_params(self, params): + """Reconstruct parameters, handling lists and dicts recursively.""" + if isinstance(params, list): + return [self._reconstruct_params(param) for param in params] + elif isinstance(params, dict): + if "__djdt_binary__" in params: + return base64.b64decode(params["__djdt_binary__"]) + else: + return { + key: self._reconstruct_params(value) + for key, value in params.items() + } + else: + return params diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index 44906924d..0fe26e078 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -6,6 +6,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from debug_toolbar.panels.sql.decoders import DebugToolbarJSONDecoder from debug_toolbar.panels.sql.utils import is_select_query, reformat_sql from debug_toolbar.toolbar import DebugToolbar @@ -69,10 +70,15 @@ def clean(self): cleaned_data["query"] = query return cleaned_data + def _get_query_params(self): + """Get reconstructed parameters for the current query""" + query = self.cleaned_data["query"] + return json.loads(query["params"], cls=DebugToolbarJSONDecoder) + def select(self): query = self.cleaned_data["query"] sql = query["raw_sql"] - params = json.loads(query["params"]) + params = self._get_query_params() with self.cursor as cursor: cursor.execute(sql, params) headers = [d[0] for d in cursor.description] @@ -82,7 +88,7 @@ def select(self): def explain(self): query = self.cleaned_data["query"] sql = query["raw_sql"] - params = json.loads(query["params"]) + params = self._get_query_params() vendor = query["vendor"] with self.cursor as cursor: if vendor == "sqlite": @@ -101,7 +107,7 @@ def explain(self): def profile(self): query = self.cleaned_data["query"] sql = query["raw_sql"] - params = json.loads(query["params"]) + params = self._get_query_params() with self.cursor as cursor: cursor.execute("SET PROFILING=1") # Enable profiling cursor.execute(sql, params) # Execute SELECT diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index 45e0c0c17..c6474bb8e 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -1,3 +1,4 @@ +import base64 import contextlib import contextvars import datetime @@ -126,6 +127,11 @@ def _decode(self, param): if isinstance(param, dict): return {key: self._decode(value) for key, value in param.items()} + # Handle binary data (e.g., GeoDjango EWKB geometry data) + if isinstance(param, (bytes, bytearray)): + # Mark as binary data for later reconstruction + return {"__djdt_binary__": base64.b64encode(param).decode("ascii")} + # make sure datetime, date and time are converted to string by force_str CONVERT_TYPES = (datetime.datetime, datetime.date, datetime.time) try: diff --git a/docs/changes.rst b/docs/changes.rst index 5c7c0844b..62dd555a1 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,6 +1,14 @@ Change log ========== +Pending +------- + +* Fixed SQL Explain functionality for GeoDjango queries with binary parameters. + Binary data (such as EWKB geometry) is now properly handled through base64 + encoding, preventing "parse error - invalid geometry" errors when using + Explain on spatial queries. + 6.0.0 (2025-07-22) ------------------ diff --git a/tests/panels/test_sql_geodjango_fix.py b/tests/panels/test_sql_geodjango_fix.py new file mode 100644 index 000000000..b29fab22b --- /dev/null +++ b/tests/panels/test_sql_geodjango_fix.py @@ -0,0 +1,137 @@ +""" +Tests for GeoDjango binary parameter handling fix +""" + +import base64 +import json + +from debug_toolbar.panels.sql.decoders import DebugToolbarJSONDecoder +from debug_toolbar.panels.sql.tracking import NormalCursorMixin + +from ..base import BaseTestCase + + +class MockCursor: + """Mock cursor for testing""" + + +class MockConnection: + """Mock database connection for testing""" + + vendor = "postgresql" + alias = "default" + + +class MockLogger: + """Mock logger for testing""" + + def record(self, **kwargs): + pass + + +class TestCursor(NormalCursorMixin): + """Test cursor that can be instantiated""" + + def __init__(self): + self.cursor = MockCursor() + self.db = MockConnection() + self.logger = MockLogger() + + +class GeoDjangoBinaryParameterTest(BaseTestCase): + """Test cases for GeoDjango binary parameter handling""" + + def test_binary_parameter_encoding_decoding(self): + """Test that binary parameters are properly encoded and decoded""" + cursor = TestCursor() + + # Test binary data similar to GeoDjango EWKB geometry + binary_data = b"\x01\x01\x00\x00\x20\xe6\x10\x00\x00\xff\xfe\xfd" + encoded = cursor._decode(binary_data) + + self.assertIsInstance(encoded, dict) + self.assertIn("__djdt_binary__", encoded) + + expected_b64 = base64.b64encode(binary_data).decode("ascii") + self.assertEqual(encoded["__djdt_binary__"], expected_b64) + + json_params = json.dumps([encoded]) + reconstructed = json.loads(json_params, cls=DebugToolbarJSONDecoder) + + self.assertEqual(len(reconstructed), 1) + self.assertEqual(reconstructed[0], binary_data) + self.assertIsInstance(reconstructed[0], bytes) + + def test_mixed_parameter_types(self): + """Test that mixed parameter types are handled correctly""" + cursor = TestCursor() + + params = [ + "string_param", + 42, + b"\x01\x02\x03", + None, + ["nested", "list"], + ] + + encoded_params = [cursor._decode(p) for p in params] + + json_str = json.dumps(encoded_params) + reconstructed = json.loads(json_str, cls=DebugToolbarJSONDecoder) + + self.assertEqual(reconstructed[0], "string_param") # string unchanged + self.assertEqual(reconstructed[1], 42) # int unchanged + self.assertEqual(reconstructed[2], b"\x01\x02\x03") # binary restored + self.assertIsNone(reconstructed[3]) # None unchanged + self.assertEqual(reconstructed[4], ["nested", "list"]) # list unchanged + + def test_nested_binary_data(self): + """Test binary data nested in lists and dicts""" + cursor = TestCursor() + + nested_params = [ + [b"\x01\x02", "string", b"\x03\x04"], + {"key": b"\x05\x06", "other": "value"}, + ] + + encoded = [cursor._decode(p) for p in nested_params] + + json_str = json.dumps(encoded) + reconstructed = json.loads(json_str, cls=DebugToolbarJSONDecoder) + + self.assertEqual(reconstructed[0][0], b"\x01\x02") + self.assertEqual(reconstructed[0][1], "string") + self.assertEqual(reconstructed[0][2], b"\x03\x04") + + self.assertEqual(reconstructed[1]["key"], b"\x05\x06") + self.assertEqual(reconstructed[1]["other"], "value") + + def test_empty_binary_data(self): + """Test handling of empty binary data""" + cursor = TestCursor() + + empty_bytes = b"" + encoded = cursor._decode(empty_bytes) + + self.assertIsInstance(encoded, dict) + self.assertIn("__djdt_binary__", encoded) + + json_str = json.dumps([encoded]) + reconstructed = json.loads(json_str, cls=DebugToolbarJSONDecoder) + + self.assertEqual(reconstructed[0], empty_bytes) + + def test_bytearray_support(self): + """Test that bytearray is also handled as binary data""" + cursor = TestCursor() + + byte_array = bytearray(b"\x01\x02\x03\x04") + encoded = cursor._decode(byte_array) + + self.assertIn("__djdt_binary__", encoded) + + json_str = json.dumps([encoded]) + reconstructed = json.loads(json_str, cls=DebugToolbarJSONDecoder) + + self.assertEqual(reconstructed[0], byte_array) + self.assertIsInstance(reconstructed[0], bytes)