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.
- {django_devbar-0.4.0 → django_devbar-0.6.0}/PKG-INFO +18 -9
- {django_devbar-0.4.0 → django_devbar-0.6.0}/README.md +16 -7
- {django_devbar-0.4.0 → django_devbar-0.6.0}/pyproject.toml +2 -2
- {django_devbar-0.4.0 → django_devbar-0.6.0}/src/django_devbar/middleware.py +51 -9
- {django_devbar-0.4.0 → django_devbar-0.6.0}/src/django_devbar/templates/django_devbar/devbar.html +22 -21
- django_devbar-0.6.0/src/django_devbar/templates/django_devbar/duplicates.html +1 -0
- django_devbar-0.6.0/src/django_devbar/templates/django_devbar/similar.html +1 -0
- django_devbar-0.6.0/src/django_devbar/tracker.py +304 -0
- django_devbar-0.4.0/src/django_devbar/templates/django_devbar/duplicates.html +0 -1
- django_devbar-0.4.0/src/django_devbar/tracker.py +0 -60
- {django_devbar-0.4.0 → django_devbar-0.6.0}/src/django_devbar/__init__.py +0 -0
- {django_devbar-0.4.0 → django_devbar-0.6.0}/src/django_devbar/conf.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-devbar
|
|
3
|
-
Version: 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.
|
|
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
|
-
|
|
34
|
+
> [!WARNING]
|
|
35
|
+
> This package is experimental and may introduce breaking changes in minor versions.
|
|
35
36
|
|
|
36
|
-
|
|
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
|
-
|
|
40
|
+
## Showcase
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
Normal operation – showing query count, duration, and application time:
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
Similar and duplicate queries detected – severity indicators and details shown:
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
[optional] Chrome extension - showing detailed metrics in DevTools panel:
|
|
51
|
+
|
|
52
|
+
[](#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
|
-
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> This package is experimental and may introduce breaking changes in minor versions.
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
9
|
+
## Showcase
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
Normal operation – showing query count, duration, and application time:
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
Similar and duplicate queries detected – severity indicators and details shown:
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
[optional] Chrome extension - showing detailed metrics in DevTools panel:
|
|
20
|
+
|
|
21
|
+
[](#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.
|
|
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.
|
|
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
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
63
|
+
"c": stats["count"],
|
|
64
|
+
"db": round(stats["duration"], 2),
|
|
65
|
+
"app": stats["python_time"],
|
|
66
|
+
"full": stats["total_time"],
|
|
65
67
|
}
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
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
|
+
)
|
{django_devbar-0.4.0 → django_devbar-0.6.0}/src/django_devbar/templates/django_devbar/devbar.html
RENAMED
|
@@ -13,44 +13,40 @@
|
|
|
13
13
|
background: rgba(20, 20, 20, 0.92);
|
|
14
14
|
color: #f5f5f5;
|
|
15
15
|
}
|
|
16
|
-
|
|
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
|
-
|
|
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] > .
|
|
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 .
|
|
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 .
|
|
40
|
-
#django-devbar .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
64
|
+
#django-devbar .query-list details[open] > summary::before {
|
|
70
65
|
transform: rotate(90deg);
|
|
71
66
|
}
|
|
72
|
-
#django-devbar .
|
|
67
|
+
#django-devbar .query-list code {
|
|
73
68
|
all: revert;
|
|
74
|
-
|
|
69
|
+
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
|
75
70
|
overflow-wrap: break-word;
|
|
76
71
|
}
|
|
77
|
-
#django-devbar .
|
|
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 %}
|
|
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)
|
|
File without changes
|
|
File without changes
|