sql_genius 0.9.0

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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +195 -0
  3. data/LICENSE.txt +65 -0
  4. data/README.md +178 -0
  5. data/Rakefile +8 -0
  6. data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
  7. data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
  8. data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
  9. data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
  10. data/app/controllers/sql_genius/base_controller.rb +29 -0
  11. data/app/controllers/sql_genius/queries_controller.rb +94 -0
  12. data/app/views/layouts/sql_genius/application.html.erb +285 -0
  13. data/config/routes.rb +34 -0
  14. data/docs/guides/ai-features.md +115 -0
  15. data/docs/guides/getting-started-rails.md +118 -0
  16. data/docs/guides/ssh-tunnel-connections.md +151 -0
  17. data/docs/screenshots/ai_tools.png +0 -0
  18. data/docs/screenshots/dashboard.png +0 -0
  19. data/docs/screenshots/duplicate_indexes.png +0 -0
  20. data/docs/screenshots/query_explore.png +0 -0
  21. data/docs/screenshots/query_stats.png +0 -0
  22. data/docs/screenshots/server.png +0 -0
  23. data/docs/screenshots/table_sizes.png +0 -0
  24. data/lib/generators/sql_genius/install/install_generator.rb +19 -0
  25. data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
  26. data/lib/sql_genius/configuration.rb +114 -0
  27. data/lib/sql_genius/core/ai/client.rb +155 -0
  28. data/lib/sql_genius/core/ai/config.rb +47 -0
  29. data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
  30. data/lib/sql_genius/core/ai/describe_query.rb +41 -0
  31. data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
  32. data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
  33. data/lib/sql_genius/core/ai/index_planner.rb +91 -0
  34. data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
  35. data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
  36. data/lib/sql_genius/core/ai/optimization.rb +81 -0
  37. data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
  38. data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
  39. data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
  40. data/lib/sql_genius/core/ai/schema_review.rb +46 -0
  41. data/lib/sql_genius/core/ai/suggestion.rb +74 -0
  42. data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
  43. data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
  44. data/lib/sql_genius/core/analysis/columns.rb +63 -0
  45. data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
  46. data/lib/sql_genius/core/analysis/query_history.rb +50 -0
  47. data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
  48. data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
  49. data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
  50. data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
  51. data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
  52. data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
  53. data/lib/sql_genius/core/column_definition.rb +30 -0
  54. data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
  55. data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
  56. data/lib/sql_genius/core/connection.rb +37 -0
  57. data/lib/sql_genius/core/execution_result.rb +27 -0
  58. data/lib/sql_genius/core/index_definition.rb +23 -0
  59. data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
  60. data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
  61. data/lib/sql_genius/core/query_builders.rb +27 -0
  62. data/lib/sql_genius/core/query_explainer.rb +113 -0
  63. data/lib/sql_genius/core/query_runner/config.rb +21 -0
  64. data/lib/sql_genius/core/query_runner.rb +123 -0
  65. data/lib/sql_genius/core/result.rb +43 -0
  66. data/lib/sql_genius/core/server_info.rb +54 -0
  67. data/lib/sql_genius/core/sql_validator.rb +149 -0
  68. data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
  69. data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
  70. data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
  71. data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
  72. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
  73. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
  74. data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
  75. data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
  76. data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
  77. data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
  78. data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
  79. data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
  80. data/lib/sql_genius/core.rb +72 -0
  81. data/lib/sql_genius/engine.rb +31 -0
  82. data/lib/sql_genius/slow_query_monitor.rb +43 -0
  83. data/lib/sql_genius/version.rb +5 -0
  84. data/lib/sql_genius.rb +29 -0
  85. data/sql_genius.gemspec +47 -0
  86. metadata +171 -0
@@ -0,0 +1,465 @@
1
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
2
+ <div style="display:flex;align-items:center;gap:12px;">
3
+ <a href="<%= path_for(:root) %>" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&larr; Dashboard</a>
4
+ <h4 style="margin:0;">Query Detail</h4>
5
+ </div>
6
+ <button class="mg-theme-toggle" id="mg-theme-btn" title="Toggle dark/light theme" onclick="(function(){var d=document.documentElement,t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);localStorage.setItem('mg-theme',t);document.getElementById('mg-theme-btn').textContent=t==='dark'?'\u2600\uFE0F':'\uD83C\uDF19';})()">
7
+ <script>document.write(document.documentElement.getAttribute('data-theme')==='dark'?'\u2600\uFE0F':'\uD83C\uDF19')</script>
8
+ </button>
9
+ </div>
10
+
11
+ <div id="qd-loading" class="mg-text-center"><span class="mg-spinner"></span> Loading query details...</div>
12
+ <div id="qd-error" class="mg-hidden"></div>
13
+
14
+ <div id="qd-content" class="mg-hidden">
15
+ <!-- SQL Block -->
16
+ <div class="mg-card mg-mb">
17
+ <div class="mg-card-header" style="display:flex;justify-content:space-between;align-items:center;">
18
+ <strong>SQL</strong>
19
+ <div style="display:flex;gap:8px;">
20
+ <button id="qd-explain-btn" class="mg-btn mg-btn-outline mg-btn-sm">EXPLAIN</button>
21
+ <button id="qd-copy-btn" class="mg-btn mg-btn-outline-secondary mg-btn-sm">Copy</button>
22
+ </div>
23
+ </div>
24
+ <div class="mg-card-body">
25
+ <code class="mg-sql-block" id="qd-sql" style="max-height:none;"></code>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Explain Results -->
30
+ <div id="qd-explain-results" class="mg-card mg-mb mg-hidden">
31
+ <div class="mg-card-header" style="display:flex;justify-content:space-between;align-items:center;">
32
+ <strong>EXPLAIN Output</strong>
33
+ <button id="qd-explain-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">Close</button>
34
+ </div>
35
+ <div class="mg-card-body">
36
+ <div id="qd-explain-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Running EXPLAIN...</div>
37
+ <div class="mg-table-wrap">
38
+ <table class="mg-table" id="qd-explain-table" class="mg-hidden">
39
+ <thead id="qd-explain-thead"></thead>
40
+ <tbody id="qd-explain-tbody"></tbody>
41
+ </table>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <!-- Stats Summary Cards -->
47
+ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px;">
48
+ <div class="mg-card">
49
+ <div class="mg-card-body mg-text-center">
50
+ <div id="qd-stat-calls" style="font-size:20px;font-weight:700;">--</div>
51
+ <div class="mg-text-muted">Calls</div>
52
+ </div>
53
+ </div>
54
+ <div class="mg-card">
55
+ <div class="mg-card-body mg-text-center">
56
+ <div id="qd-stat-total-time" style="font-size:20px;font-weight:700;">--</div>
57
+ <div class="mg-text-muted">Total Time</div>
58
+ </div>
59
+ </div>
60
+ <div class="mg-card">
61
+ <div class="mg-card-body mg-text-center">
62
+ <div id="qd-stat-avg-time" style="font-size:20px;font-weight:700;">--</div>
63
+ <div class="mg-text-muted">Avg Time</div>
64
+ </div>
65
+ </div>
66
+ <div class="mg-card">
67
+ <div class="mg-card-body mg-text-center">
68
+ <div id="qd-stat-max-time" style="font-size:20px;font-weight:700;">--</div>
69
+ <div class="mg-text-muted">Max Time</div>
70
+ </div>
71
+ </div>
72
+ <div class="mg-card">
73
+ <div class="mg-card-body mg-text-center">
74
+ <div id="qd-stat-rows-examined" style="font-size:20px;font-weight:700;">--</div>
75
+ <div class="mg-text-muted">Rows Examined</div>
76
+ </div>
77
+ </div>
78
+ <div class="mg-card">
79
+ <div class="mg-card-body mg-text-center">
80
+ <div id="qd-stat-rows-sent" style="font-size:20px;font-weight:700;">--</div>
81
+ <div class="mg-text-muted">Rows Sent</div>
82
+ </div>
83
+ </div>
84
+ <div class="mg-card">
85
+ <div class="mg-card-body mg-text-center">
86
+ <div id="qd-stat-first-seen" style="font-size:14px;font-weight:700;">--</div>
87
+ <div class="mg-text-muted">First Seen</div>
88
+ </div>
89
+ </div>
90
+ <div class="mg-card">
91
+ <div class="mg-card-body mg-text-center">
92
+ <div id="qd-stat-last-seen" style="font-size:14px;font-weight:700;">--</div>
93
+ <div class="mg-text-muted">Last Seen</div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <!-- Charts -->
99
+ <div id="qd-charts-section">
100
+ <div id="qd-no-history" class="mg-text-center mg-text-muted mg-hidden" style="padding:24px 0;">
101
+ No history data available yet. The stats collector samples every 60 seconds.
102
+ </div>
103
+ <div id="qd-chart-total-time" class="mg-card mg-mb mg-hidden">
104
+ <div class="mg-card-header"><strong>Total Time (ms)</strong></div>
105
+ <div class="mg-card-body" id="qd-chart-total-time-container" style="height:200px;"></div>
106
+ </div>
107
+ <div id="qd-chart-avg-time" class="mg-card mg-mb mg-hidden">
108
+ <div class="mg-card-header"><strong>Average Time (ms)</strong></div>
109
+ <div class="mg-card-body" id="qd-chart-avg-time-container" style="height:200px;"></div>
110
+ </div>
111
+ <div id="qd-chart-calls" class="mg-card mg-mb mg-hidden">
112
+ <div class="mg-card-header"><strong>Calls</strong></div>
113
+ <div class="mg-card-body" id="qd-chart-calls-container" style="height:200px;"></div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ <script>
119
+ (function() {
120
+ "use strict";
121
+
122
+ var DIGEST = '<%= @digest %>';
123
+ var EXPLAIN_URL = '<%= path_for(:explain) %>';
124
+ var HISTORY_URL = '<%= path_for(:query_history) %>';
125
+
126
+ var csrfMeta = document.querySelector('meta[name="csrf-token"]');
127
+ var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
128
+ var rawSql = '';
129
+
130
+ function el(id) { return document.getElementById(id); }
131
+ function show(e) { if (e) e.classList.remove('mg-hidden'); }
132
+ function hide(e) { if (e) e.classList.add('mg-hidden'); }
133
+ function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
134
+
135
+ function formatDuration(ms) {
136
+ if (ms >= 60000) return (ms / 60000).toFixed(1) + ' min';
137
+ if (ms >= 1000) return (ms / 1000).toFixed(1) + ' s';
138
+ return ms.toFixed(1) + ' ms';
139
+ }
140
+
141
+ function formatDurationStyled(ms) {
142
+ var text = formatDuration(ms);
143
+ var cls = 'mg-dur-fast';
144
+ if (ms >= 1000) cls = 'mg-dur-slow';
145
+ else if (ms >= 100) cls = 'mg-dur-moderate';
146
+ return '<span class="' + cls + '">' + text + '</span>';
147
+ }
148
+
149
+ // --- SQL Syntax Highlighting ---
150
+
151
+ var SQL_KW_SET = {};
152
+ 'SELECT FROM WHERE JOIN LEFT RIGHT INNER OUTER CROSS FULL ON AND OR NOT IN EXISTS BETWEEN LIKE IS NULL AS DISTINCT ALL UNION INTERSECT EXCEPT INSERT INTO VALUES UPDATE SET DELETE CREATE ALTER DROP TABLE INDEX VIEW TRIGGER PROCEDURE FUNCTION DATABASE SCHEMA IF THEN ELSE END CASE WHEN HAVING LIMIT OFFSET ASC DESC USING FORCE USE IGNORE WITH RECURSIVE OVER EXPLAIN ANALYZE SHOW DESCRIBE'.split(' ').forEach(function(w) { SQL_KW_SET[w] = true; });
153
+
154
+ var SQL_FN_SET = {};
155
+ 'COUNT SUM AVG MIN MAX COALESCE IFNULL NULLIF CAST CONVERT CONCAT CONCAT_WS GROUP_CONCAT SUBSTRING SUBSTR REPLACE TRIM LTRIM RTRIM UPPER LOWER UCASE LCASE LENGTH CHAR_LENGTH NOW CURDATE CURTIME DATE_FORMAT DATE_ADD DATE_SUB DATEDIFF TIMESTAMPDIFF UNIX_TIMESTAMP FROM_UNIXTIME IF ELT FIELD FIND_IN_SET FORMAT HEX UNHEX MD5 SHA1 SHA2 AES_ENCRYPT AES_DECRYPT ROUND CEIL CEILING FLOOR ABS MOD POWER SQRT LOG LOG2 LOG10 RAND GREATEST LEAST JSON_EXTRACT JSON_UNQUOTE JSON_SET JSON_OBJECT JSON_ARRAY JSON_CONTAINS JSON_LENGTH ROW_NUMBER RANK DENSE_RANK LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE'.split(' ').forEach(function(w) { SQL_FN_SET[w] = true; });
156
+
157
+ var SQL_TOKEN_RE = /'[^']*'|`[^`]*`|\b\d+(?:\.\d+)?\b|[A-Za-z_]\w*|\?\|>=|<=|<>|!=|[><=]|[(),;*]|\s+|./g;
158
+
159
+ function highlightSql(sql) {
160
+ if (!sql) return '';
161
+ var result = [];
162
+ var match;
163
+ SQL_TOKEN_RE.lastIndex = 0;
164
+ while ((match = SQL_TOKEN_RE.exec(sql)) !== null) {
165
+ var tok = match[0];
166
+ var ch = tok.charAt(0);
167
+ if (ch === "'") {
168
+ result.push('<span class="mg-sql-str">' + escHtml(tok) + '</span>');
169
+ } else if (ch === '`') {
170
+ result.push('<span class="mg-sql-tbl">' + escHtml(tok) + '</span>');
171
+ } else if (/^\d/.test(tok)) {
172
+ result.push('<span class="mg-sql-num">' + escHtml(tok) + '</span>');
173
+ } else if (/^[A-Za-z_]/.test(tok)) {
174
+ var upper = tok.toUpperCase();
175
+ var rest = sql.substring(SQL_TOKEN_RE.lastIndex);
176
+ var compoundMatch = rest.match(/^(\s+)(BY)\b/i);
177
+ if ((upper === 'GROUP' || upper === 'ORDER' || upper === 'PARTITION') && compoundMatch) {
178
+ result.push('<span class="mg-sql-kw">' + escHtml(tok) + escHtml(compoundMatch[1]) + escHtml(compoundMatch[2].toUpperCase()) + '</span>');
179
+ SQL_TOKEN_RE.lastIndex += compoundMatch[0].length;
180
+ } else if (SQL_FN_SET[upper] && rest.match(/^\s*\(/)) {
181
+ result.push('<span class="mg-sql-fn">' + escHtml(tok.toUpperCase()) + '</span>');
182
+ } else if (SQL_KW_SET[upper]) {
183
+ result.push('<span class="mg-sql-kw">' + escHtml(tok.toUpperCase()) + '</span>');
184
+ } else {
185
+ result.push(escHtml(tok));
186
+ }
187
+ } else if (tok === '?') {
188
+ result.push('<span class="mg-sql-placeholder">?</span>');
189
+ } else if (tok === '*') {
190
+ result.push('<span class="mg-sql-star">*</span>');
191
+ } else if (/^(>=|<=|<>|!=|[><=])$/.test(tok)) {
192
+ result.push('<span class="mg-sql-op">' + escHtml(tok) + '</span>');
193
+ } else if (/^[(),;]$/.test(tok)) {
194
+ result.push('<span class="mg-sql-punc">' + escHtml(tok) + '</span>');
195
+ } else {
196
+ result.push(escHtml(tok));
197
+ }
198
+ }
199
+ return result.join('');
200
+ }
201
+
202
+ // --- AJAX ---
203
+
204
+ function ajax(method, url, data, onSuccess, onError) {
205
+ var xhr = new XMLHttpRequest();
206
+ xhr.open(method, url, true);
207
+ xhr.setRequestHeader('X-CSRF-Token', csrfToken);
208
+ xhr.setRequestHeader('Accept', 'application/json');
209
+ xhr.onload = function() {
210
+ var json;
211
+ try { json = JSON.parse(xhr.responseText); } catch(e) { json = null; }
212
+ if (xhr.status >= 200 && xhr.status < 300) {
213
+ onSuccess(json);
214
+ } else {
215
+ (onError || function(){})(json, xhr.status);
216
+ }
217
+ };
218
+ xhr.onerror = function() { (onError || function(){})(null, 0); };
219
+ if (data && method !== 'GET') {
220
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
221
+ var parts = [];
222
+ for (var k in data) {
223
+ parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(data[k]));
224
+ }
225
+ xhr.send(parts.join('&'));
226
+ } else {
227
+ xhr.send();
228
+ }
229
+ }
230
+
231
+ // --- SVG Chart Drawing ---
232
+
233
+ function drawChart(containerId, data, label, color) {
234
+ var container = el(containerId);
235
+ if (!container || !data || !data.length) return;
236
+
237
+ var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
238
+ var lineColor = isDark ? '#58a6ff' : '#89CFF0';
239
+ if (color) lineColor = isDark ? color.dark : color.light;
240
+ var textColor = isDark ? '#8b949e' : '#666';
241
+ var gridColor = isDark ? '#21262d' : '#eaecef';
242
+
243
+ var padding = { top: 10, right: 16, bottom: 30, left: 60 };
244
+ var width = 800;
245
+ var height = 200;
246
+ var chartW = width - padding.left - padding.right;
247
+ var chartH = height - padding.top - padding.bottom;
248
+
249
+ var values = data.map(function(d) { return typeof d.value === 'number' ? d.value : parseFloat(d.value) || 0; });
250
+ var maxVal = Math.max.apply(null, values);
251
+ // If all values are effectively zero, show a clean 0-1 scale
252
+ if (maxVal < 0.001) maxVal = 1;
253
+
254
+ // Y axis ticks (4-5 ticks)
255
+ var tickCount = 5;
256
+ var rawStep = maxVal / (tickCount - 1);
257
+ var magnitude = Math.pow(10, Math.floor(Math.log10(rawStep || 1)));
258
+ var niceStep = Math.ceil(rawStep / magnitude) * magnitude;
259
+ if (niceStep === 0) niceStep = 1;
260
+ var yMax = niceStep * (tickCount - 1);
261
+ if (yMax < maxVal) yMax = niceStep * tickCount;
262
+
263
+ var svg = '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none" style="width:100%;height:100%;">';
264
+
265
+ // Grid lines and Y labels
266
+ for (var i = 0; i < tickCount; i++) {
267
+ var tickVal = niceStep * i;
268
+ var y = padding.top + chartH - (tickVal / yMax) * chartH;
269
+ svg += '<line x1="' + padding.left + '" y1="' + y + '" x2="' + (width - padding.right) + '" y2="' + y + '" stroke="' + gridColor + '" stroke-width="1"/>';
270
+ var tickLabel;
271
+ if (tickVal >= 1000000) tickLabel = (tickVal / 1000000).toFixed(1) + 'M';
272
+ else if (tickVal >= 1000) tickLabel = (tickVal / 1000).toFixed(1) + 'k';
273
+ else if (tickVal >= 1) tickLabel = Math.round(tickVal).toString();
274
+ else if (tickVal === 0) tickLabel = '0';
275
+ else tickLabel = tickVal.toFixed(2);
276
+ svg += '<text x="' + (padding.left - 8) + '" y="' + (y + 4) + '" text-anchor="end" fill="' + textColor + '" font-size="10" font-family="-apple-system,BlinkMacSystemFont,sans-serif">' + tickLabel + '</text>';
277
+ }
278
+
279
+ // Data points as polyline
280
+ var points = [];
281
+ for (var j = 0; j < data.length; j++) {
282
+ var x = padding.left + (j / Math.max(data.length - 1, 1)) * chartW;
283
+ var yVal = padding.top + chartH - (data[j].value / yMax) * chartH;
284
+ points.push(x.toFixed(1) + ',' + yVal.toFixed(1));
285
+ }
286
+
287
+ // Fill area below line
288
+ var fillPoints = points.slice();
289
+ fillPoints.push((padding.left + chartW).toFixed(1) + ',' + (padding.top + chartH).toFixed(1));
290
+ fillPoints.push(padding.left.toFixed(1) + ',' + (padding.top + chartH).toFixed(1));
291
+ svg += '<polygon points="' + fillPoints.join(' ') + '" fill="' + lineColor + '" fill-opacity="0.15"/>';
292
+
293
+ // Line
294
+ svg += '<polyline points="' + points.join(' ') + '" fill="none" stroke="' + lineColor + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
295
+
296
+ // X axis time labels (every ~4 hours worth of samples)
297
+ if (data.length > 1) {
298
+ var totalSpan = data.length - 1;
299
+ // Show ~6 labels max
300
+ var labelInterval = Math.max(1, Math.floor(totalSpan / 6));
301
+ for (var k = 0; k <= totalSpan; k += labelInterval) {
302
+ var xPos = padding.left + (k / totalSpan) * chartW;
303
+ var ts = data[k].timestamp;
304
+ var timeLabel = formatTimeLabel(ts);
305
+ svg += '<text x="' + xPos.toFixed(1) + '" y="' + (height - 6) + '" text-anchor="middle" fill="' + textColor + '" font-size="10" font-family="-apple-system,BlinkMacSystemFont,sans-serif">' + escHtml(timeLabel) + '</text>';
306
+ }
307
+ // Always show last label
308
+ if (totalSpan % labelInterval !== 0) {
309
+ var lastX = padding.left + chartW;
310
+ var lastTs = data[data.length - 1].timestamp;
311
+ svg += '<text x="' + lastX.toFixed(1) + '" y="' + (height - 6) + '" text-anchor="end" fill="' + textColor + '" font-size="10" font-family="-apple-system,BlinkMacSystemFont,sans-serif">' + escHtml(formatTimeLabel(lastTs)) + '</text>';
312
+ }
313
+ }
314
+
315
+ svg += '</svg>';
316
+ container.innerHTML = svg;
317
+ }
318
+
319
+ function formatTimeLabel(ts) {
320
+ if (!ts) return '';
321
+ var d = new Date(ts);
322
+ if (isNaN(d.getTime())) return ts;
323
+ var h = d.getHours();
324
+ var m = d.getMinutes();
325
+ return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
326
+ }
327
+
328
+ // --- Copy to Clipboard ---
329
+
330
+ function copyToClipboard(text, btn) {
331
+ var done = function() {
332
+ btn.textContent = 'Copied!';
333
+ setTimeout(function() { btn.textContent = 'Copy'; }, 2000);
334
+ };
335
+ if (navigator.clipboard && navigator.clipboard.writeText) {
336
+ navigator.clipboard.writeText(text).then(done);
337
+ } else {
338
+ var ta = document.createElement('textarea');
339
+ ta.value = text;
340
+ ta.style.position = 'fixed';
341
+ ta.style.opacity = '0';
342
+ document.body.appendChild(ta);
343
+ ta.select();
344
+ document.execCommand('copy');
345
+ document.body.removeChild(ta);
346
+ done();
347
+ }
348
+ }
349
+
350
+ // --- Load Data ---
351
+
352
+ function loadQueryDetail() {
353
+ show(el('qd-loading'));
354
+ hide(el('qd-content'));
355
+ hide(el('qd-error'));
356
+
357
+ ajax('GET', HISTORY_URL, null, function(data) {
358
+ hide(el('qd-loading'));
359
+
360
+ if (!data || data.error) {
361
+ el('qd-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((data && data.error) || 'Query not found.') + '</div>';
362
+ show(el('qd-error'));
363
+ return;
364
+ }
365
+
366
+ show(el('qd-content'));
367
+
368
+ var q = data.query || {};
369
+ rawSql = q.sql || '';
370
+
371
+ // SQL block
372
+ el('qd-sql').innerHTML = highlightSql(rawSql);
373
+
374
+ // Stats cards
375
+ el('qd-stat-calls').textContent = Number(q.calls || 0).toLocaleString();
376
+ el('qd-stat-total-time').innerHTML = formatDurationStyled(q.total_time_ms || 0);
377
+ el('qd-stat-avg-time').innerHTML = formatDurationStyled(q.avg_time_ms || 0);
378
+ el('qd-stat-max-time').innerHTML = formatDurationStyled(q.max_time_ms || 0);
379
+ el('qd-stat-rows-examined').textContent = Number(q.rows_examined || 0).toLocaleString();
380
+ el('qd-stat-rows-sent').textContent = Number(q.rows_sent || 0).toLocaleString();
381
+ el('qd-stat-first-seen').textContent = q.first_seen || '--';
382
+ el('qd-stat-last-seen').textContent = q.last_seen || '--';
383
+
384
+ // Charts
385
+ var history = data.history || [];
386
+ if (history.length === 0) {
387
+ show(el('qd-no-history'));
388
+ } else {
389
+ hide(el('qd-no-history'));
390
+
391
+ var totalTimeData = history.map(function(h) { return { timestamp: h.timestamp, value: h.total_time_ms || 0 }; });
392
+ var avgTimeData = history.map(function(h) { return { timestamp: h.timestamp, value: h.avg_time_ms || 0 }; });
393
+ var callsData = history.map(function(h) { return { timestamp: h.timestamp, value: h.calls || 0 }; });
394
+
395
+ show(el('qd-chart-total-time'));
396
+ drawChart('qd-chart-total-time-container', totalTimeData, 'Total Time (ms)', { light: '#89CFF0', dark: '#58a6ff' });
397
+
398
+ show(el('qd-chart-avg-time'));
399
+ drawChart('qd-chart-avg-time-container', avgTimeData, 'Average Time (ms)', { light: '#89CFF0', dark: '#58a6ff' });
400
+
401
+ show(el('qd-chart-calls'));
402
+ drawChart('qd-chart-calls-container', callsData, 'Calls', { light: '#89CFF0', dark: '#58a6ff' });
403
+ }
404
+ }, function(json) {
405
+ hide(el('qd-loading'));
406
+ el('qd-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load query details.') + '</div>';
407
+ show(el('qd-error'));
408
+ });
409
+ }
410
+
411
+ // --- Explain ---
412
+
413
+ // performance_schema DIGEST_TEXT uses normalized spacing that isn't valid SQL:
414
+ // "SELECT COUNT ( * ) FROM `riders`" -> needs to become "SELECT COUNT(*) FROM `riders`"
415
+ // "... IN ( ... )" -> "... IN (...)"
416
+ // Also replaces placeholder ? with 1 so EXPLAIN can parse it.
417
+ function normalizeDigestSql(sql) {
418
+ return sql
419
+ .replace(/\(\s+/g, '(') // "( " -> "("
420
+ .replace(/\s+\)/g, ')') // " )" -> ")"
421
+ .replace(/\s*,\s*/g, ', ') // normalize comma spacing
422
+ .replace(/\?/g, '1'); // replace ? placeholders with literal 1
423
+ }
424
+
425
+ el('qd-explain-btn').addEventListener('click', function() {
426
+ show(el('qd-explain-results'));
427
+ show(el('qd-explain-loading'));
428
+ el('qd-explain-thead').innerHTML = '';
429
+ el('qd-explain-tbody').innerHTML = '';
430
+
431
+ ajax('POST', EXPLAIN_URL, { sql: normalizeDigestSql(rawSql) }, function(data) {
432
+ hide(el('qd-explain-loading'));
433
+ if (data.error) {
434
+ el('qd-explain-tbody').innerHTML = '<tr><td class="mg-text-muted">' + escHtml(data.error) + '</td></tr>';
435
+ return;
436
+ }
437
+ if (data.columns && data.rows) {
438
+ el('qd-explain-thead').innerHTML = '<tr>' + data.columns.map(function(c) { return '<th>' + escHtml(c) + '</th>'; }).join('') + '</tr>';
439
+ el('qd-explain-tbody').innerHTML = data.rows.map(function(row) {
440
+ return '<tr>' + row.map(function(cell) {
441
+ return '<td>' + (cell === null ? '<em class="null">NULL</em>' : escHtml(String(cell))) + '</td>';
442
+ }).join('') + '</tr>';
443
+ }).join('');
444
+ }
445
+ }, function(json) {
446
+ hide(el('qd-explain-loading'));
447
+ el('qd-explain-tbody').innerHTML = '<tr><td class="mg-text-muted">' + escHtml((json && json.error) || 'EXPLAIN failed.') + '</td></tr>';
448
+ });
449
+ });
450
+
451
+ el('qd-explain-close').addEventListener('click', function() {
452
+ hide(el('qd-explain-results'));
453
+ });
454
+
455
+ // --- Copy ---
456
+
457
+ el('qd-copy-btn').addEventListener('click', function() {
458
+ copyToClipboard(rawSql, el('qd-copy-btn'));
459
+ });
460
+
461
+ // --- Init ---
462
+
463
+ loadQueryDetail();
464
+ })();
465
+ </script>
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ # Connection abstraction, SQL validator, query runner, analyses, and AI
5
+ # services. Originally extracted into a separate `sql_genius-core` gem to
6
+ # support a planned desktop app; folded back into the main gem now that
7
+ # path is no longer being pursued.
8
+ module Core
9
+ class Error < StandardError; end
10
+
11
+ # Raised by AI services / analyses that don't support the connected
12
+ # database's dialect (e.g. InnoDB-only interpreters on PostgreSQL).
13
+ # Callers surface the message verbatim to the UI.
14
+ class UnsupportedDialect < Error
15
+ class << self
16
+ def for_postgresql(feature_name)
17
+ new("#{feature_name} is MySQL/MariaDB-only and is not available on PostgreSQL.")
18
+ end
19
+ end
20
+ end
21
+
22
+ class << self
23
+ # Absolute path to the shared ERB template directory. Adapters
24
+ # register this path with their view loader:
25
+ #
26
+ # Rails: engine.config.paths["app/views"] << SqlGenius::Core.views_path
27
+ # Sinatra: set :views, SqlGenius::Core.views_path
28
+ def views_path
29
+ File.expand_path("core/views", __dir__)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ require "sql_genius/core/result"
36
+ require "sql_genius/core/server_info"
37
+ require "sql_genius/core/column_definition"
38
+ require "sql_genius/core/index_definition"
39
+ require "sql_genius/core/sql_validator"
40
+ require "sql_genius/core/query_builders"
41
+ require "sql_genius/core/connection"
42
+ require "sql_genius/core/connection/fake_adapter"
43
+ require "sql_genius/core/ai/config"
44
+ require "sql_genius/core/ai/client"
45
+ require "sql_genius/core/ai/dialect_hints"
46
+ require "sql_genius/core/ai/suggestion"
47
+ require "sql_genius/core/ai/optimization"
48
+ require "sql_genius/core/ai/schema_context_builder"
49
+ require "sql_genius/core/ai/describe_query"
50
+ require "sql_genius/core/ai/schema_review"
51
+ require "sql_genius/core/ai/rewrite_query"
52
+ require "sql_genius/core/ai/index_advisor"
53
+ require "sql_genius/core/ai/migration_risk"
54
+ require "sql_genius/core/ai/variable_reviewer"
55
+ require "sql_genius/core/ai/connection_advisor"
56
+ require "sql_genius/core/ai/workload_digest"
57
+ require "sql_genius/core/ai/innodb_interpreter"
58
+ require "sql_genius/core/ai/index_planner"
59
+ require "sql_genius/core/ai/pattern_grouper"
60
+ require "sql_genius/core/analysis/table_sizes"
61
+ require "sql_genius/core/analysis/duplicate_indexes"
62
+ require "sql_genius/core/analysis/query_stats"
63
+ require "sql_genius/core/analysis/unused_indexes"
64
+ require "sql_genius/core/analysis/server_overview"
65
+ require "sql_genius/core/analysis/columns"
66
+ require "sql_genius/core/analysis/stats_history"
67
+ require "sql_genius/core/analysis/stats_collector"
68
+ require "sql_genius/core/analysis/query_history"
69
+ require "sql_genius/core/execution_result"
70
+ require "sql_genius/core/query_runner/config"
71
+ require "sql_genius/core/query_runner"
72
+ require "sql_genius/core/query_explainer"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace SqlGenius
6
+
7
+ initializer "sql_genius.register_core_views", before: :add_view_paths do
8
+ paths["app/views"] << SqlGenius::Core.views_path
9
+ end
10
+
11
+ config.after_initialize do
12
+ if SqlGenius.configuration.redis_url.present?
13
+ require "sql_genius/slow_query_monitor"
14
+ SqlGenius::SlowQueryMonitor.subscribe!
15
+ end
16
+
17
+ if SqlGenius.configuration.stats_collection
18
+ history = SqlGenius::Core::Analysis::StatsHistory.new
19
+ connection_provider = -> { SqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection) }
20
+ collector = SqlGenius::Core::Analysis::StatsCollector.new(
21
+ connection_provider: connection_provider,
22
+ history: history,
23
+ )
24
+ SqlGenius.stats_history = history
25
+ SqlGenius.stats_collector = collector
26
+ collector.start
27
+ at_exit { collector.stop }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module SqlGenius
7
+ class SlowQueryMonitor
8
+ class << self
9
+ def redis_key
10
+ "sql_genius:slow_queries"
11
+ end
12
+
13
+ def subscribe!
14
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, start, finish, _id, payload|
15
+ duration_ms = ((finish - start) * 1000).round(1)
16
+ sql = payload[:sql].to_s
17
+ threshold = SqlGenius.configuration.slow_query_threshold_ms
18
+
19
+ next if duration_ms < threshold
20
+ next unless sql.match?(/\ASELECT\b/i)
21
+ next if sql.include?("SCHEMA")
22
+ next if sql.include?("EXPLAIN")
23
+ next if payload[:name] == "SCHEMA"
24
+
25
+ begin
26
+ redis = Redis.new(url: SqlGenius.configuration.redis_url)
27
+ entry = {
28
+ sql: sql.length > 10_000 ? sql[0, 10_000] : sql,
29
+ duration_ms: duration_ms,
30
+ timestamp: Time.now.iso8601,
31
+ name: payload[:name],
32
+ }.to_json
33
+
34
+ redis.lpush(redis_key, entry)
35
+ redis.ltrim(redis_key, 0, 199)
36
+ rescue => e
37
+ Rails.logger.debug("[sql_genius] Slow query logger error: #{e.message}") if defined?(Rails)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SqlGenius
4
+ VERSION = "0.9.0"
5
+ end
data/lib/sql_genius.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sql_genius/version"
4
+ require "sql_genius/core"
5
+ require "sql_genius/core/connection/active_record_adapter"
6
+ require "sql_genius/configuration"
7
+
8
+ module SqlGenius
9
+ class Error < StandardError; end
10
+
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ end
19
+
20
+ def reset_configuration!
21
+ @configuration = Configuration.new
22
+ end
23
+
24
+ attr_accessor :stats_history
25
+ attr_accessor :stats_collector
26
+ end
27
+ end
28
+
29
+ require "sql_genius/engine" if defined?(Rails)