Skip to content

Commit 64ebc40

Browse files
committed
Separate SQL truncation into a class
1 parent 4ae8ef5 commit 64ebc40

File tree

3 files changed

+322
-118
lines changed

3 files changed

+322
-118
lines changed

src/django_devbar/middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def _add_devtools_data_header(self, response, stats):
7272
if all_queries:
7373
processed_queries = [
7474
{
75-
"s": q["sql"] if q["is_duplicate"] else truncate_sql(q["sql"]),
75+
"s": truncate_sql(q["sql"]),
7676
"dur": q["duration"],
7777
"dup": 1 if q["is_duplicate"] else 0,
7878
}

src/django_devbar/tracker.py

Lines changed: 158 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -100,142 +100,183 @@ def replace_keyword(match):
100100
return mark_safe(highlighted)
101101

102102

103-
def truncate_sql(sql, max_length=150):
104-
"""Intelligently truncate long SQL queries while preserving structure.
103+
class SQLTruncator:
104+
"""Handles intelligent SQL query truncation while preserving the structure."""
105105

106-
Attempts to preserve the most important parts of the query:
107-
- Extracts first column from SELECT clause
108-
- Includes primary table from FROM clause
109-
- Preserves as many JOINs as fit within max_length
110-
- Respects max_length limit (default 150 chars)
106+
def __init__(self, max_length=150):
107+
self.max_length = max_length
111108

112-
Falls back to clause-boundary truncation if structure parsing fails.
109+
def truncate(self, sql):
110+
"""Intelligently truncate long SQL queries while preserving the structure.
113111
114-
Args:
115-
sql: SQL query string
116-
max_length: Maximum length of truncated output (default 150)
112+
Args:
113+
sql: SQL query string
117114
118-
Returns:
119-
Truncated SQL string with "..." at the end if shortened
120-
"""
121-
if len(sql) <= max_length:
122-
return sql
115+
Returns:
116+
Truncated SQL string with "..." at the end if shortened
117+
"""
118+
if len(sql) <= self.max_length:
119+
return sql
123120

124-
sql_upper = sql.upper()
121+
sql_upper = sql.upper()
125122

126-
if sql_upper.startswith("SELECT"):
123+
if sql_upper.startswith("SELECT"):
124+
result = self._truncate_select(sql, sql_upper)
125+
if result:
126+
return result
127+
128+
return self._fallback_truncate(sql)
129+
130+
def _truncate_select(self, sql, sql_upper):
131+
"""Truncate SELECT queries while preserving columns and joins."""
127132
distinct_match = re.search(r"\bDISTINCT\b", sql_upper[:20])
128133
has_distinct = distinct_match is not None
129134
from_match = re.search(r"\bFROM\b", sql_upper)
130-
if from_match:
131-
from_pos = from_match.start()
132135

133-
if has_distinct:
134-
distinct_end = distinct_match.end()
135-
select_part = sql[distinct_end:from_pos].strip()
136+
if not from_match:
137+
return None
138+
139+
from_pos = from_match.start()
140+
141+
if has_distinct:
142+
distinct_end = distinct_match.end()
143+
select_part = sql[distinct_end:from_pos].strip()
144+
else:
145+
select_part = sql[6:from_pos].strip()
146+
147+
first_column = self._extract_first_column(select_part)
148+
select_clause = "SELECT DISTINCT" if has_distinct else "SELECT"
149+
150+
from_part = sql[from_pos:]
151+
table_match = re.search(r'FROM\s+(["\[]?)(\w+)', from_part, re.IGNORECASE)
152+
153+
if not table_match:
154+
return None
155+
156+
table_name = table_match.group(2)
157+
result = f"{select_clause} {first_column} FROM {table_name}"
158+
159+
# Add joins if they fit
160+
join_result = self._add_joins_if_fit(result, from_part)
161+
if join_result:
162+
return join_result
163+
164+
return result if len(result) <= self.max_length else None
165+
166+
def _extract_first_column(self, select_part):
167+
"""Extract the first column from SELECT clause, handling quotes and parentheses."""
168+
if not select_part.strip():
169+
return "..."
170+
columns = self._parse_columns(select_part)
171+
return columns[0] + ", ..." if columns else "..."
172+
173+
def _parse_columns(self, select_part):
174+
"""Parse columns from SELECT clause, handling complex expressions."""
175+
columns = []
176+
in_quotes = False
177+
quote_char = None
178+
in_parens = False
179+
paren_depth = 0
180+
current_col = ""
181+
182+
for i, char in enumerate(select_part):
183+
if char in ('"', "'") and (i == 0 or select_part[i - 1] != "\\"):
184+
if not in_quotes:
185+
in_quotes = True
186+
quote_char = char
187+
elif char == quote_char:
188+
in_quotes = False
189+
quote_char = None
190+
current_col += char
191+
elif in_quotes:
192+
current_col += char
193+
elif char == "(":
194+
in_parens = True
195+
paren_depth += 1
196+
current_col += char
197+
elif char == ")":
198+
paren_depth -= 1
199+
if paren_depth == 0:
200+
in_parens = False
201+
current_col += char
202+
elif char == "," and not in_parens and not in_quotes:
203+
if current_col.strip():
204+
columns.append(current_col.strip())
205+
break
206+
current_col = ""
207+
elif char in " \t\n" and not in_parens and not in_quotes:
208+
if current_col.strip():
209+
columns.append(current_col.strip())
210+
current_col = ""
136211
else:
137-
select_part = sql[6:from_pos].strip()
138-
139-
columns = []
140-
in_quotes = False
141-
quote_char = None
142-
in_parens = False
143-
paren_depth = 0
144-
current_col = ""
145-
146-
for i, char in enumerate(select_part):
147-
if char in ('"', "'") and (i == 0 or select_part[i - 1] != "\\"):
148-
if not in_quotes:
149-
in_quotes = True
150-
quote_char = char
151-
elif char == quote_char:
152-
in_quotes = False
153-
quote_char = None
154-
current_col += char
155-
elif in_quotes:
156-
current_col += char
157-
elif char == "(":
158-
in_parens = True
159-
paren_depth += 1
160-
current_col += char
161-
elif char == ")":
162-
paren_depth -= 1
163-
if paren_depth == 0:
164-
in_parens = False
165-
current_col += char
166-
elif char == "," and not in_parens and not in_quotes:
167-
if current_col.strip():
168-
columns.append(current_col.strip())
169-
break
170-
current_col = ""
171-
elif char in " \t\n" and not in_parens and not in_quotes:
172-
if current_col.strip():
173-
columns.append(current_col.strip())
174-
current_col = ""
175-
else:
176-
current_col += char
177-
178-
if not columns and current_col.strip():
179-
columns.append(current_col.strip())
180-
181-
if columns:
182-
columns_str = columns[0] + ", ..."
212+
current_col += char
213+
214+
if not columns and current_col.strip():
215+
columns.append(current_col.strip())
216+
217+
return columns
218+
219+
def _add_joins_if_fit(self, base_result, from_part):
220+
"""Add JOIN clauses to result if they fit within max_length."""
221+
join_matches = re.finditer(
222+
r'\b(LEFT\s+|RIGHT\s+|INNER\s+|OUTER\s+)?JOIN\s+(["\[]?)(\w+)',
223+
from_part,
224+
re.IGNORECASE,
225+
)
226+
227+
result = base_result
228+
229+
for join_match in join_matches:
230+
join_type = (join_match.group(1) or "").strip()
231+
join_table = join_match.group(3)
232+
new_result = (
233+
f"{result} {join_type} JOIN {join_table}"
234+
if join_type
235+
else f"{result} JOIN {join_table}"
236+
)
237+
if len(new_result) <= self.max_length:
238+
result = new_result
183239
else:
184-
columns_str = "..."
185-
186-
select_clause = "SELECT DISTINCT" if has_distinct else "SELECT"
187-
188-
from_part = sql[from_pos:]
189-
table_match = re.search(r'FROM\s+(["\[]?)(\w+)', from_part, re.IGNORECASE)
190-
if table_match:
191-
table_name = table_match.group(2)
192-
193-
join_matches = re.finditer(
194-
r'\b(LEFT\s+|RIGHT\s+|INNER\s+|OUTER\s+)?JOIN\s+(["\[]?)(\w+)',
195-
from_part,
196-
re.IGNORECASE,
197-
)
198-
199-
result = f"{select_clause} {columns_str} FROM {table_name}"
200-
201-
for join_match in join_matches:
202-
join_type = (join_match.group(1) or "").strip()
203-
join_table = join_match.group(3)
204-
new_result = (
205-
f"{result} {join_type} JOIN {join_table}"
206-
if join_type
207-
else f"{result} JOIN {join_table}"
208-
)
209-
if len(new_result) <= max_length:
210-
result = new_result
211-
else:
212-
break
213-
214-
if len(result) <= max_length:
215-
return result
216-
217-
result = f"{select_clause} {columns_str} FROM {table_name}"
218-
if len(result) <= max_length:
219-
return result
220-
221-
clause_positions = [
222-
(match.start(), match.group())
223-
for match in SQL_CLAUSES_RE.finditer(sql[: max_length + 50])
224-
]
225-
226-
if clause_positions:
240+
break
241+
242+
return result if len(result) <= self.max_length else None
243+
244+
def _fallback_truncate(self, sql):
245+
"""Fallback truncation at clause boundaries or simple length limit."""
246+
# First try simple length truncation
247+
if len(sql) <= self.max_length:
248+
return sql
249+
250+
# Try clause boundary truncation
251+
clause_positions = [
252+
(match.start(), match.group())
253+
for match in SQL_CLAUSES_RE.finditer(sql[: self.max_length + 50])
254+
]
255+
256+
if clause_positions:
257+
best_pos = self._find_best_truncate_position(clause_positions)
258+
if best_pos > 0:
259+
return sql[:best_pos].rstrip() + "..."
260+
261+
# Final fallback: simple truncation
262+
return sql[: self.max_length].rstrip() + "..."
263+
264+
def _find_best_truncate_position(self, clause_positions):
265+
"""Find the best position to truncate based on clause boundaries."""
227266
best_pos = 0
228267
for pos, _ in clause_positions:
229-
if max_length > pos > best_pos:
268+
if self.max_length > pos > best_pos:
230269
best_pos = pos
231270

232-
if best_pos > max_length - 30:
271+
if best_pos > self.max_length - 30:
233272
best_pos = 0
234273

235-
if best_pos > 0:
236-
return sql[:best_pos].rstrip() + "..."
274+
return best_pos
237275

238-
return sql[:max_length].rstrip() + "..."
276+
277+
def truncate_sql(sql, max_length=150):
278+
truncator = SQLTruncator(max_length)
279+
return truncator.truncate(sql)
239280

240281

241282
def tracking_wrapper(execute, sql, params, many, context):

0 commit comments

Comments
 (0)