django-devbar 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-devbar
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Lightweight performance devbar for Django
5
5
  Keywords: django,debug,performance,monitoring,development
6
6
  Author: amureki
7
- Author-email: amureki <oss@amureki.com>
7
+ Author-email: amureki <oss@amureki.me>
8
8
  License-Expression: MIT
9
9
  Classifier: Programming Language :: Python
10
10
  Classifier: Programming Language :: Python :: 3
@@ -31,16 +31,25 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  # django-devbar
33
33
 
34
- Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
34
+ > [!WARNING]
35
+ > This package is experimental and may introduce breaking changes in minor versions.
35
36
 
36
- Normal operation – showing query count, duration, and application time: ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
37
+ Lightweight performance devbar for Django.
38
+ Shows DB query count, query duration, application time, and detects similar and duplicate queries with visual severity indicators.
37
39
 
38
- Duplicate queries detected – severity indicator and details shown: ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
40
+ ## Showcase
39
41
 
40
- [![Chrome DevTools extension](https://github.com/user-attachments/assets/bc06af44-a0b3-4561-a13f-8007da53cb9e)](#chrome-extension)
42
+ Normal operation – showing query count, duration, and application time:
41
43
 
42
- > [!WARNING]
43
- > This package is experimental and may introduce breaking changes in minor versions.
44
+ ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
45
+
46
+ Similar and duplicate queries detected – severity indicators and details shown:
47
+
48
+ ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
49
+
50
+ [optional] Chrome extension - showing detailed metrics in DevTools panel:
51
+
52
+ [![Chrome DevTools extension](https://github.com/user-attachments/assets/f53a4bb8-fba1-446c-8224-82eac47bd027)](#chrome-extension)
44
53
 
45
54
  ## Motivation
46
55
 
@@ -92,7 +101,7 @@ Django DevBar adds HTTP response headers with performance metrics:
92
101
 
93
102
  An additional header is included by default in DEBUG mode:
94
103
 
95
- - **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
104
+ - **DevBar-Data** - JSON header with comprehensive metrics including similar and duplicate query details
96
105
 
97
106
  This is useful for:
98
107
 
@@ -1,15 +1,24 @@
1
1
  # django-devbar
2
2
 
3
- Lightweight performance devbar for Django. Shows DB query count, query duration, application time, and detects duplicate queries with visual severity indicators.
3
+ > [!WARNING]
4
+ > This package is experimental and may introduce breaking changes in minor versions.
4
5
 
5
- Normal operation – showing query count, duration, and application time: ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
6
+ Lightweight performance devbar for Django.
7
+ Shows DB query count, query duration, application time, and detects similar and duplicate queries with visual severity indicators.
6
8
 
7
- Duplicate queries detected – severity indicator and details shown: ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
9
+ ## Showcase
8
10
 
9
- [![Chrome DevTools extension](https://github.com/user-attachments/assets/bc06af44-a0b3-4561-a13f-8007da53cb9e)](#chrome-extension)
11
+ Normal operation – showing query count, duration, and application time:
10
12
 
11
- > [!WARNING]
12
- > This package is experimental and may introduce breaking changes in minor versions.
13
+ ![devbar example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example.svg)
14
+
15
+ Similar and duplicate queries detected – severity indicators and details shown:
16
+
17
+ ![devbar warning example](https://github.com/amureki/django-devbar/raw/3c6118d4283c211a5d84510de52e1d5c3e5e46e4/docs/devbar-example-warning.svg)
18
+
19
+ [optional] Chrome extension - showing detailed metrics in DevTools panel:
20
+
21
+ [![Chrome DevTools extension](https://github.com/user-attachments/assets/f53a4bb8-fba1-446c-8224-82eac47bd027)](#chrome-extension)
13
22
 
14
23
  ## Motivation
15
24
 
@@ -61,7 +70,7 @@ Django DevBar adds HTTP response headers with performance metrics:
61
70
 
62
71
  An additional header is included by default in DEBUG mode:
63
72
 
64
- - **DevBar-Data** - JSON header with comprehensive metrics including duplicate query details
73
+ - **DevBar-Data** - JSON header with comprehensive metrics including similar and duplicate query details
65
74
 
66
75
  This is useful for:
67
76
 
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "django-devbar"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Lightweight performance devbar for Django"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  authors = [
8
- { name = "amureki", email = "oss@amureki.com" },
8
+ { name = "amureki", email = "oss@amureki.me" },
9
9
  ]
10
10
  requires-python = ">=3.10"
11
11
  dependencies = ["django>=4.2"]
@@ -13,6 +13,7 @@ from .conf import (
13
13
  get_position,
14
14
  get_show_bar,
15
15
  )
16
+ from .tracker import format_sql, truncate_sql
16
17
 
17
18
  BODY_CLOSE_RE = re.compile(rb"</body\s*>", re.IGNORECASE)
18
19
 
@@ -41,10 +42,10 @@ class DevBarMiddleware:
41
42
  stats = tracker.get_stats()
42
43
 
43
44
  db_time = stats["duration"]
44
- python_time = max(0, total_time - db_time)
45
+ python_time = round(max(0, total_time - db_time), 2)
45
46
 
46
47
  stats["python_time"] = python_time
47
- stats["total_time"] = total_time
48
+ stats["total_time"] = round(total_time, 2)
48
49
 
49
50
  if get_enable_devtools_data():
50
51
  self._add_devtools_data_header(response, stats)
@@ -57,14 +58,32 @@ class DevBarMiddleware:
57
58
  return response
58
59
 
59
60
  def _add_devtools_data_header(self, response, stats):
61
+ # Abbreviated keys used to minimize DevBar-Data header size
60
62
  extension_data = {
61
- "count": stats["count"],
62
- "db_time": stats["duration"],
63
- "app_time": stats["python_time"],
64
- "total_time": stats["total_time"],
63
+ "c": stats["count"],
64
+ "db": round(stats["duration"], 2),
65
+ "app": stats["python_time"],
66
+ "full": stats["total_time"],
65
67
  }
66
- if stats.get("duplicate_queries"):
67
- extension_data["duplicates"] = stats["duplicate_queries"]
68
+
69
+ all_queries = stats.get("queries", [])
70
+ duplicates = stats.get("duplicate_queries", [])
71
+
72
+ if all_queries:
73
+ processed_queries = [
74
+ {
75
+ "s": truncate_sql(q["sql"]),
76
+ "dur": q["duration"],
77
+ "dup": 1 if q["is_duplicate"] else 0,
78
+ "sim": 1 if q.get("is_similar") else 0,
79
+ }
80
+ for q in all_queries
81
+ ]
82
+ extension_data["q"] = processed_queries
83
+
84
+ if duplicates:
85
+ marked_duplicates = [{**d, "dup": 1} for d in duplicates]
86
+ extension_data["dup"] = marked_duplicates
68
87
 
69
88
  response["DevBar-Data"] = json.dumps(extension_data)
70
89
 
@@ -95,6 +114,7 @@ class DevBarMiddleware:
95
114
  duplicates_html = self._build_duplicates_html(
96
115
  stats.get("duplicate_queries", [])
97
116
  )
117
+ similar_html = self._build_similar_html(stats.get("similar_queries", []))
98
118
 
99
119
  template = _template_engine.get_template("django_devbar/devbar.html")
100
120
  html = template.render(
@@ -105,6 +125,7 @@ class DevBarMiddleware:
105
125
  "app_time": stats["python_time"],
106
126
  "query_count": stats["count"],
107
127
  "duplicates_html": duplicates_html,
128
+ "similar_html": similar_html,
108
129
  }
109
130
  )
110
131
  )
@@ -115,8 +136,29 @@ class DevBarMiddleware:
115
136
  response.content = content[:idx] + payload + content[idx:]
116
137
  response["Content-Length"] = str(len(response.content))
117
138
 
139
+ def _deduplicate_queries(self, queries):
140
+ seen_sqls = set()
141
+ unique = []
142
+ for q in queries:
143
+ if q["sql"] not in seen_sqls:
144
+ seen_sqls.add(q["sql"])
145
+ unique.append({**q, "sql": format_sql(q["sql"])})
146
+ return unique
147
+
118
148
  def _build_duplicates_html(self, duplicates):
119
149
  if not duplicates:
120
150
  return ""
151
+ unique = self._deduplicate_queries(duplicates)
121
152
  template = _template_engine.get_template("django_devbar/duplicates.html")
122
- return template.render(Context({"duplicates": duplicates}))
153
+ return template.render(
154
+ Context({"duplicates": unique, "total_count": len(duplicates)})
155
+ )
156
+
157
+ def _build_similar_html(self, similar):
158
+ if not similar:
159
+ return ""
160
+ unique = self._deduplicate_queries(similar)
161
+ template = _template_engine.get_template("django_devbar/similar.html")
162
+ return template.render(
163
+ Context({"similar": unique, "total_count": len(similar)})
164
+ )
@@ -13,44 +13,40 @@
13
13
  background: rgba(20, 20, 20, 0.92);
14
14
  color: #f5f5f5;
15
15
  }
16
- #django-devbar.level-warn { border-left: 3px solid #f59e0b; }
17
- #django-devbar.has-duplicates { border-left: 3px solid #f59e0b; }
16
+
18
17
  #django-devbar span { opacity: 0.7; }
19
18
  #django-devbar strong { opacity: 1; font-weight: 600; }
20
19
  #django-devbar details { display: inline; position: relative; }
21
20
  #django-devbar summary {
22
21
  all: revert;
23
- color: #f59e0b; font-weight: 600; cursor: pointer; list-style: none;
22
+ font-weight: 600; cursor: pointer; list-style: none;
24
23
  }
24
+ #django-devbar .dup-details > summary { color: #f87171; }
25
+ #django-devbar .sim-details > summary { color: #fb923c; }
25
26
  #django-devbar summary::-webkit-details-marker { display: none; }
26
- #django-devbar details[open] > .dup-list {
27
+ #django-devbar details[open] > .query-list {
27
28
  position: absolute; bottom: 100%; right: -8px; margin-bottom: 8px;
28
29
  background: rgba(20, 20, 20, 0.92); border-radius: 4px; padding: 6px;
29
30
  width: min(500px, 90vw); max-height: 200px; overflow-y: auto; overflow-x: hidden;
30
31
  box-sizing: border-box;
31
32
  box-shadow: 0 2px 8px rgba(0,0,0,0.15), 0 1px 2px rgba(0,0,0,0.2);
32
33
  }
33
- #django-devbar .dup-list details {
34
+ #django-devbar .query-list details {
34
35
  display: block;
35
36
  margin: 0 0 6px 0;
36
37
  font-size: 11px;
37
38
  color: #f5f5f5;
38
39
  }
39
- #django-devbar .dup-list details:last-child { margin-bottom: 0; }
40
- #django-devbar .dup-list summary {
40
+ #django-devbar .query-list details:last-child { margin-bottom: 0; }
41
+ #django-devbar .query-list summary {
41
42
  all: revert;
42
43
  cursor: pointer;
43
44
  list-style: none;
44
45
  padding: 6px 8px;
45
- background: rgba(251, 191, 36, 0.1);
46
46
  border-radius: 3px;
47
- border-left: 2px solid #fbbf24;
48
47
  transition: background 0.15s ease;
49
48
  }
50
- #django-devbar .dup-list summary:hover {
51
- background: rgba(251, 191, 36, 0.2);
52
- }
53
- #django-devbar .dup-list summary code {
49
+ #django-devbar .query-list summary code {
54
50
  white-space: nowrap;
55
51
  overflow: hidden;
56
52
  text-overflow: ellipsis;
@@ -58,23 +54,22 @@
58
54
  max-width: 420px;
59
55
  vertical-align: bottom;
60
56
  }
61
- #django-devbar .dup-list summary::before {
57
+ #django-devbar .query-list summary::before {
62
58
  content: '▶ ';
63
59
  display: inline-block;
64
60
  transition: transform 0.15s ease;
65
- color: #fbbf24;
66
61
  font-size: 8px;
67
62
  margin-right: 6px;
68
63
  }
69
- #django-devbar .dup-list details[open] > summary::before {
64
+ #django-devbar .query-list details[open] > summary::before {
70
65
  transform: rotate(90deg);
71
66
  }
72
- #django-devbar .dup-list code {
67
+ #django-devbar .query-list code {
73
68
  all: revert;
74
- color: #fbbf24; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
69
+ font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
75
70
  overflow-wrap: break-word;
76
71
  }
77
- #django-devbar .dup-list details[open] > code {
72
+ #django-devbar .query-list details[open] > code {
78
73
  display: block;
79
74
  padding: 8px;
80
75
  background: rgba(0, 0, 0, 0.3);
@@ -84,10 +79,16 @@
84
79
  line-height: 1.5;
85
80
  font-size: 10px;
86
81
  }
82
+ #django-devbar .dup-list summary { background: rgba(220, 38, 38, 0.1); border-left: 2px solid #dc2626; }
83
+ #django-devbar .dup-list summary:hover { background: rgba(220, 38, 38, 0.2); }
84
+ #django-devbar .dup-list summary::before, #django-devbar .dup-list code { color: #f87171; }
85
+ #django-devbar .sim-list summary { background: rgba(234, 88, 12, 0.1); border-left: 2px solid #ea580c; }
86
+ #django-devbar .sim-list summary:hover { background: rgba(234, 88, 12, 0.2); }
87
+ #django-devbar .sim-list summary::before, #django-devbar .sim-list code { color: #fb923c; }
87
88
  @media (max-width: 640px) { #django-devbar { display: none; } }
88
89
  </style>
89
- <div id="django-devbar"{% if duplicates_html %} class="has-duplicates"{% endif %}>
90
+ <div id="django-devbar" class="{% if duplicates_html %}has-duplicates{% endif %}{% if similar_html %} has-similar{% endif %}">
90
91
  <span>queries</span> <strong>{{ query_count }}</strong> <span>·</span>
91
92
  <span>db</span> <strong>{{ db_time|floatformat:0 }}ms</strong> <span>·</span>
92
- <span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>{{ duplicates_html|safe }}
93
+ <span>app</span> <strong>{{ app_time|floatformat:0 }}ms</strong>{{ duplicates_html|safe }}{{ similar_html|safe }}
93
94
  </div>
@@ -0,0 +1 @@
1
+ {% if duplicates %}<details class="dup-details"><summary>({{ total_count }}d)</summary><div class="query-list dup-list">{% for dup in duplicates %}<details><summary><code>{{ dup.sql }}</code></summary><code>{{ dup.sql }}</code></details>{% endfor %}</div></details>{% endif %}
@@ -0,0 +1 @@
1
+ {% if similar %}<details class="sim-details"><summary>({{ total_count }}s)</summary><div class="query-list sim-list">{% for q in similar %}<details><summary><code>{{ q.sql }}</code></summary><code>{{ q.sql }}</code></details>{% endfor %}</div></details>{% endif %}
@@ -0,0 +1,304 @@
1
+ import re
2
+ from contextvars import ContextVar
3
+ from time import perf_counter
4
+
5
+ SQL_KEYWORDS_RE = re.compile(
6
+ r"\b(SELECT|FROM|WHERE|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|ON|AND|OR|ORDER BY|GROUP BY|HAVING|LIMIT|OFFSET|INSERT INTO|VALUES|UPDATE|SET|DELETE|CREATE TABLE|ALTER TABLE|DROP TABLE|DISTINCT|COUNT|SUM|AVG|MAX|MIN|UNION|EXISTS|IN|IS NULL|IS NOT NULL|LIKE|BETWEEN|CASE|WHEN|THEN|ELSE|END|ASC|DESC|AS|WITH|RECURSIVE)\b",
7
+ re.IGNORECASE,
8
+ )
9
+
10
+ SQL_CLAUSES_RE = re.compile(
11
+ r"\b(FROM|WHERE|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|GROUP BY|ORDER BY|HAVING|LIMIT|OFFSET|ON|AND|OR|UNION|WITH|CASE|WHEN|THEN|ELSE)\b",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+ _query_count: ContextVar[int] = ContextVar("query_count", default=0)
16
+ _query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
17
+ _query_log: ContextVar[list] = ContextVar("query_log", default=[])
18
+
19
+
20
+ def reset():
21
+ _query_count.set(0)
22
+ _query_duration.set(0.0)
23
+ _query_log.set([])
24
+
25
+
26
+ def get_stats():
27
+ queries = _query_log.get()
28
+
29
+ # Count occurrences of each SQL template and each (SQL, params_hash) pair
30
+ sql_counts = {}
31
+ duplicate_keys = set()
32
+ for q in queries:
33
+ sql = q["sql"]
34
+ params_hash = q["params_hash"]
35
+ sql_counts[sql] = sql_counts.get(sql, 0) + 1
36
+ dup_key = (sql, params_hash)
37
+ if dup_key in duplicate_keys:
38
+ q["is_duplicate"] = True
39
+ else:
40
+ duplicate_keys.add(dup_key)
41
+
42
+ # Similar = SQL template appears 2+ times (matches DDT semantics)
43
+ similar_sqls = {sql for sql, count in sql_counts.items() if count > 1}
44
+
45
+ # Second pass: mark all queries in groups with 2+ members
46
+ dup_key_counts = {}
47
+ for q in queries:
48
+ dup_key = (q["sql"], q["params_hash"])
49
+ dup_key_counts[dup_key] = dup_key_counts.get(dup_key, 0) + 1
50
+
51
+ duplicate_keys_final = {k for k, count in dup_key_counts.items() if count > 1}
52
+
53
+ similar_queries = []
54
+ duplicate_queries = []
55
+ for q in queries:
56
+ q["is_similar"] = q["sql"] in similar_sqls
57
+ q["is_duplicate"] = (q["sql"], q["params_hash"]) in duplicate_keys_final
58
+ if q["is_similar"]:
59
+ similar_queries.append(q)
60
+ if q["is_duplicate"]:
61
+ duplicate_queries.append(q)
62
+
63
+ return {
64
+ "count": _query_count.get(),
65
+ "duration": _query_duration.get(),
66
+ "duplicate_queries": duplicate_queries,
67
+ "similar_queries": similar_queries,
68
+ "queries": queries,
69
+ }
70
+
71
+
72
+ def _hash_params(params):
73
+ try:
74
+ return hash(tuple(params)) if params else 0
75
+ except TypeError:
76
+ return hash(str(params))
77
+
78
+
79
+ def _record(sql, params, duration):
80
+ _query_count.set(_query_count.get() + 1)
81
+ _query_duration.set(_query_duration.get() + duration)
82
+
83
+ params_hash = _hash_params(params)
84
+
85
+ query_log = _query_log.get()
86
+ query_log.append(
87
+ {
88
+ "sql": sql,
89
+ "duration": round(duration, 2),
90
+ "params_hash": params_hash,
91
+ }
92
+ )
93
+ _query_log.set(query_log)
94
+
95
+
96
+ def format_sql(sql):
97
+ """Highlight SQL keywords with HTML spans for visual formatting.
98
+
99
+ Escapes HTML first for security, then wraps SQL keywords in span tags.
100
+ Used for rendering duplicates in the injected devbar HTML.
101
+
102
+ Args:
103
+ sql: SQL query string
104
+
105
+ Returns:
106
+ Safe HTML string with keywords wrapped in spans
107
+ """
108
+ from django.utils.html import escape
109
+ from django.utils.safestring import mark_safe
110
+
111
+ escaped_sql = escape(sql)
112
+
113
+ def replace_keyword(match):
114
+ return f'<span class="sql-keyword">{match.group(0)}</span>'
115
+
116
+ highlighted = SQL_KEYWORDS_RE.sub(replace_keyword, escaped_sql)
117
+ return mark_safe(highlighted)
118
+
119
+
120
+ class SQLTruncator:
121
+ """Handles intelligent SQL query truncation while preserving the structure."""
122
+
123
+ def __init__(self, max_length=150):
124
+ self.max_length = max_length
125
+
126
+ def truncate(self, sql):
127
+ """Intelligently truncate long SQL queries while preserving the structure.
128
+
129
+ Args:
130
+ sql: SQL query string
131
+
132
+ Returns:
133
+ Truncated SQL string with "..." at the end if shortened
134
+ """
135
+ if len(sql) <= self.max_length:
136
+ return sql
137
+
138
+ sql_upper = sql.upper()
139
+
140
+ if sql_upper.startswith("SELECT"):
141
+ result = self._truncate_select(sql, sql_upper)
142
+ if result:
143
+ return result
144
+
145
+ return self._fallback_truncate(sql)
146
+
147
+ def _truncate_select(self, sql, sql_upper):
148
+ """Truncate SELECT queries while preserving columns and joins."""
149
+ distinct_match = re.search(r"\bDISTINCT\b", sql_upper[:20])
150
+ has_distinct = distinct_match is not None
151
+ from_match = re.search(r"\bFROM\b", sql_upper)
152
+
153
+ if not from_match:
154
+ return None
155
+
156
+ from_pos = from_match.start()
157
+
158
+ if has_distinct:
159
+ distinct_end = distinct_match.end()
160
+ select_part = sql[distinct_end:from_pos].strip()
161
+ else:
162
+ select_part = sql[6:from_pos].strip()
163
+
164
+ first_column = self._extract_first_column(select_part)
165
+ select_clause = "SELECT DISTINCT" if has_distinct else "SELECT"
166
+
167
+ from_part = sql[from_pos:]
168
+ table_match = re.search(r'FROM\s+(["\[]?)(\w+)', from_part, re.IGNORECASE)
169
+
170
+ if not table_match:
171
+ return None
172
+
173
+ table_name = table_match.group(2)
174
+ result = f"{select_clause} {first_column} FROM {table_name}"
175
+
176
+ # Add joins if they fit
177
+ join_result = self._add_joins_if_fit(result, from_part)
178
+ if join_result:
179
+ return join_result
180
+
181
+ return result if len(result) <= self.max_length else None
182
+
183
+ def _extract_first_column(self, select_part):
184
+ """Extract the first column from SELECT clause, handling quotes and parentheses."""
185
+ if not select_part.strip():
186
+ return "..."
187
+ columns = self._parse_columns(select_part)
188
+ return columns[0] + ", ..." if columns else "..."
189
+
190
+ def _parse_columns(self, select_part):
191
+ """Parse columns from SELECT clause, handling complex expressions."""
192
+ columns = []
193
+ in_quotes = False
194
+ quote_char = None
195
+ in_parens = False
196
+ paren_depth = 0
197
+ current_col = ""
198
+
199
+ for i, char in enumerate(select_part):
200
+ if char in ('"', "'") and (i == 0 or select_part[i - 1] != "\\"):
201
+ if not in_quotes:
202
+ in_quotes = True
203
+ quote_char = char
204
+ elif char == quote_char:
205
+ in_quotes = False
206
+ quote_char = None
207
+ current_col += char
208
+ elif in_quotes:
209
+ current_col += char
210
+ elif char == "(":
211
+ in_parens = True
212
+ paren_depth += 1
213
+ current_col += char
214
+ elif char == ")":
215
+ paren_depth -= 1
216
+ if paren_depth == 0:
217
+ in_parens = False
218
+ current_col += char
219
+ elif char == "," and not in_parens and not in_quotes:
220
+ if current_col.strip():
221
+ columns.append(current_col.strip())
222
+ break
223
+ current_col = ""
224
+ elif char in " \t\n" and not in_parens and not in_quotes:
225
+ if current_col.strip():
226
+ columns.append(current_col.strip())
227
+ current_col = ""
228
+ else:
229
+ current_col += char
230
+
231
+ if not columns and current_col.strip():
232
+ columns.append(current_col.strip())
233
+
234
+ return columns
235
+
236
+ def _add_joins_if_fit(self, base_result, from_part):
237
+ """Add JOIN clauses to result if they fit within max_length."""
238
+ join_matches = re.finditer(
239
+ r'\b(LEFT\s+|RIGHT\s+|INNER\s+|OUTER\s+)?JOIN\s+(["\[]?)(\w+)',
240
+ from_part,
241
+ re.IGNORECASE,
242
+ )
243
+
244
+ result = base_result
245
+
246
+ for join_match in join_matches:
247
+ join_type = (join_match.group(1) or "").strip()
248
+ join_table = join_match.group(3)
249
+ new_result = (
250
+ f"{result} {join_type} JOIN {join_table}"
251
+ if join_type
252
+ else f"{result} JOIN {join_table}"
253
+ )
254
+ if len(new_result) <= self.max_length:
255
+ result = new_result
256
+ else:
257
+ break
258
+
259
+ return result if len(result) <= self.max_length else None
260
+
261
+ def _fallback_truncate(self, sql):
262
+ """Fallback truncation at clause boundaries or simple length limit."""
263
+ # First try simple length truncation
264
+ if len(sql) <= self.max_length:
265
+ return sql
266
+
267
+ # Try clause boundary truncation
268
+ clause_positions = [
269
+ (match.start(), match.group())
270
+ for match in SQL_CLAUSES_RE.finditer(sql[: self.max_length + 50])
271
+ ]
272
+
273
+ if clause_positions:
274
+ best_pos = self._find_best_truncate_position(clause_positions)
275
+ if best_pos > 0:
276
+ return sql[:best_pos].rstrip() + "..."
277
+
278
+ # Final fallback: simple truncation
279
+ return sql[: self.max_length].rstrip() + "..."
280
+
281
+ def _find_best_truncate_position(self, clause_positions):
282
+ """Find the best position to truncate based on clause boundaries."""
283
+ best_pos = 0
284
+ for pos, _ in clause_positions:
285
+ if self.max_length > pos > best_pos:
286
+ best_pos = pos
287
+
288
+ if best_pos > self.max_length - 30:
289
+ best_pos = 0
290
+
291
+ return best_pos
292
+
293
+
294
+ def truncate_sql(sql, max_length=150):
295
+ truncator = SQLTruncator(max_length)
296
+ return truncator.truncate(sql)
297
+
298
+
299
+ def tracking_wrapper(execute, sql, params, many, context):
300
+ start = perf_counter()
301
+ try:
302
+ return execute(sql, params, many, context)
303
+ finally:
304
+ _record(sql, params, (perf_counter() - start) * 1000)
@@ -1 +0,0 @@
1
- {% if duplicates %}<details><summary>({{ duplicates|length }}d)</summary><div class="dup-list">{% for dup in duplicates %}<details><summary><code>{{ dup.sql }}</code></summary><code>{{ dup.sql }}</code></details>{% endfor %}</div></details>{% endif %}
@@ -1,60 +0,0 @@
1
- from contextvars import ContextVar
2
- from time import perf_counter
3
-
4
- _query_count: ContextVar[int] = ContextVar("query_count", default=0)
5
- _query_duration: ContextVar[float] = ContextVar("query_duration", default=0.0)
6
- _seen_queries: ContextVar[dict] = ContextVar("seen_queries", default={})
7
- _duplicate_log: ContextVar[list] = ContextVar("duplicate_log", default=[])
8
-
9
-
10
- def reset():
11
- _query_count.set(0)
12
- _query_duration.set(0.0)
13
- _seen_queries.set({})
14
- _duplicate_log.set([])
15
-
16
-
17
- def get_stats():
18
- return {
19
- "count": _query_count.get(),
20
- "duration": _query_duration.get(),
21
- "duplicate_queries": _duplicate_log.get(),
22
- }
23
-
24
-
25
- def _hash_params(params):
26
- try:
27
- return hash(tuple(params)) if params else 0
28
- except TypeError:
29
- return hash(str(params))
30
-
31
-
32
- def _record(sql, params, duration):
33
- _query_count.set(_query_count.get() + 1)
34
- _query_duration.set(_query_duration.get() + duration)
35
-
36
- seen = _seen_queries.get()
37
- params_hash = _hash_params(params)
38
-
39
- if sql in seen:
40
- if params_hash in seen[sql]:
41
- param_str = str(params)
42
- if len(param_str) > 200:
43
- param_str = param_str[:200] + "..."
44
-
45
- duplicates = _duplicate_log.get()
46
- duplicates.append(
47
- {"sql": sql, "params": param_str, "duration": round(duration, 2)}
48
- )
49
- else:
50
- seen[sql].add(params_hash)
51
- else:
52
- seen[sql] = {params_hash}
53
-
54
-
55
- def tracking_wrapper(execute, sql, params, many, context):
56
- start = perf_counter()
57
- try:
58
- return execute(sql, params, many, context)
59
- finally:
60
- _record(sql, params, (perf_counter() - start) * 1000)