Skip to content

Commit ae42a1e

Browse files
committed
Handle SQLParseError gracefully by disabling grouping
1 parent 0809510 commit ae42a1e

File tree

4 files changed

+50
-77
lines changed

4 files changed

+50
-77
lines changed

debug_toolbar/panels/sql/utils.py

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.dispatch import receiver
66
from django.test.signals import setting_changed
77
from sqlparse import tokens as T
8+
from sqlparse.exceptions import SQLParseError
89

910
from debug_toolbar import settings as dt_settings
1011

@@ -91,42 +92,7 @@ def is_select_query(sql):
9192
return sql.lower().lstrip(" (").startswith("select")
9293

9394

94-
def _format_skipped_sql(sql, reason):
95-
"""
96-
Format SQL that was skipped from prettification.
97-
98-
Shows a notice and the first portion of raw SQL for debugging.
99-
"""
100-
preview_length = 500
101-
preview = escape(sql[:preview_length])
102-
total_length = len(sql)
103-
104-
if total_length > preview_length:
105-
suffix = f"\n\n... ({total_length - preview_length:,} more characters)"
106-
else:
107-
suffix = ""
108-
109-
return (
110-
f'<span class="djdt-sql-skipped">'
111-
f"<em>SQL formatting skipped ({reason})</em>"
112-
f"</span>"
113-
f"<pre>{preview}{suffix}</pre>"
114-
)
115-
116-
11795
def reformat_sql(sql, *, with_toggle=False):
118-
# Check length threshold to prevent slow formatting of large queries
119-
max_length = dt_settings.get_config()["SQL_PRETTIFY_MAX_LENGTH"]
120-
if max_length and len(sql) > max_length:
121-
skipped = _format_skipped_sql(
122-
sql,
123-
f"query length {len(sql):,} exceeds threshold {max_length:,}",
124-
)
125-
if with_toggle:
126-
# Return as non-collapsible since both versions would be the same
127-
return f'<span class="djDebugUncollapsed">{skipped}</span>'
128-
return skipped
129-
13096
formatted = parse_sql(sql)
13197
if not with_toggle:
13298
return formatted
@@ -140,7 +106,15 @@ def reformat_sql(sql, *, with_toggle=False):
140106
@lru_cache(maxsize=128)
141107
def parse_sql(sql, *, simplify=False):
142108
stack = get_filter_stack(simplify=simplify)
143-
return "".join(stack.run(sql))
109+
try:
110+
return "".join(stack.run(sql))
111+
except SQLParseError:
112+
# The query either exceeds the number of tokens or depth of tokens.
113+
# Recreate the FilterStack and explicitly disable the grouping to avoid
114+
# those errors.
115+
stack = get_filter_stack(simplify=simplify)
116+
stack._grouping = False
117+
return "".join(stack.run(sql))
144118

145119

146120
@cache

debug_toolbar/settings.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ def _is_running_tests():
5050
"django.utils.functional",
5151
),
5252
"PRETTIFY_SQL": True,
53-
"SQL_PRETTIFY_MAX_LENGTH": 50000, # Skip formatting for SQL longer than this
5453
"PROFILER_CAPTURE_PROJECT_CODE": True,
5554
"PROFILER_MAX_DEPTH": 10,
5655
"PROFILER_THRESHOLD_RATIO": 8,

docs/changes.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ Change log
44
Pending
55
-------
66

7-
* Added ``SQL_PRETTIFY_MAX_LENGTH`` setting to skip SQL formatting for
8-
queries exceeding the threshold. This prevents the SQL panel from freezing
9-
or crashing when queries contain large IN clauses with thousands of
10-
parameters.
7+
* Added graceful degradation for SQL queries that exceed sqlparse's token
8+
limits. When ``SQLParseError`` is raised, the SQL panel now automatically
9+
disables grouping and retries formatting, preventing crashes with large
10+
queries.
1111
* Deprecated ``RedirectsPanel`` in favor of ``HistoryPanel`` for viewing
1212
toolbar data from redirected requests.
1313
* Fixed support for generating code coverage comments in PRs.

tests/panels/test_sql.py

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -869,49 +869,49 @@ def test_explain_with_union(self):
869869
query = self.panel._queries[0]
870870
self.assertTrue(query["is_select"])
871871

872-
@override_settings(
873-
DEBUG_TOOLBAR_CONFIG={"SQL_PRETTIFY_MAX_LENGTH": 100, "PRETTIFY_SQL": True}
874-
)
875-
def test_sql_prettify_max_length(self):
872+
@override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": True})
873+
def test_sql_parse_error_graceful_degradation(self):
876874
"""
877-
Test that SQL formatting is skipped for queries exceeding the max length threshold.
875+
Test that SQLParseError is handled gracefully by disabling grouping.
878876
"""
879-
from debug_toolbar.panels.sql.utils import reformat_sql
877+
from unittest.mock import patch
880878

881-
# Short SQL should be formatted normally
882-
short_sql = "SELECT * FROM auth_user WHERE id = 1"
883-
result = reformat_sql(short_sql, with_toggle=True)
884-
self.assertNotIn("SQL formatting skipped", result)
885-
self.assertIn("SELECT", result)
879+
from sqlparse.exceptions import SQLParseError
886880

887-
# Long SQL should skip formatting and show a message
888-
long_sql = (
889-
"SELECT * FROM auth_user WHERE id IN ("
890-
+ ", ".join([f"'{i}'" for i in range(100)])
891-
+ ")"
892-
)
893-
result = reformat_sql(long_sql, with_toggle=True)
894-
self.assertIn("SQL formatting skipped", result)
895-
self.assertIn("exceeds threshold", result)
881+
from debug_toolbar.panels.sql.utils import parse_sql
896882

897-
def test_sql_prettify_max_length_disabled(self):
898-
"""
899-
Test that SQL_PRETTIFY_MAX_LENGTH=0 or None disables the length check.
900-
"""
901-
from debug_toolbar.panels.sql.utils import reformat_sql
883+
# Clear the cache to ensure our mock is used
884+
parse_sql.cache_clear()
902885

903-
long_sql = (
904-
"SELECT * FROM auth_user WHERE id IN ("
905-
+ ", ".join([f"'{i}'" for i in range(100)])
906-
+ ")"
907-
)
886+
# Mock the filter stack to raise SQLParseError on first call
887+
call_count = 0
888+
original_run = None
889+
890+
def mock_run(sql):
891+
nonlocal call_count, original_run
892+
call_count += 1
893+
if call_count == 1:
894+
raise SQLParseError("Token limit exceeded")
895+
# On retry, return a simple result
896+
return [sql]
897+
898+
with patch(
899+
"debug_toolbar.panels.sql.utils.get_filter_stack"
900+
) as mock_get_stack:
901+
mock_stack = mock_get_stack.return_value
902+
mock_stack.run = mock_run
903+
mock_stack._grouping = True
904+
905+
result = parse_sql("SELECT * FROM test")
906+
907+
# Should have been called twice (once for error, once for retry)
908+
self.assertEqual(call_count, 2)
909+
# On retry, _grouping should be set to False
910+
self.assertFalse(mock_stack._grouping)
911+
self.assertIn("SELECT", result)
908912

909-
with override_settings(
910-
DEBUG_TOOLBAR_CONFIG={"SQL_PRETTIFY_MAX_LENGTH": 0, "PRETTIFY_SQL": True}
911-
):
912-
result = reformat_sql(long_sql, with_toggle=True)
913-
# When max_length is 0 (falsy), formatting should not be skipped
914-
self.assertNotIn("SQL formatting skipped", result)
913+
# Clear the cache after the test
914+
parse_sql.cache_clear()
915915

916916

917917
class SQLPanelMultiDBTestCase(BaseMultiDBTestCase):

0 commit comments

Comments
 (0)