@berthojoris/mcp-mysql-server 1.16.3 → 1.17.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.
@@ -0,0 +1,451 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.IndexRecommendationTools = void 0;
7
+ const connection_1 = __importDefault(require("../db/connection"));
8
+ const config_1 = require("../config/config");
9
+ class IndexRecommendationTools {
10
+ constructor(security) {
11
+ this.db = connection_1.default.getInstance();
12
+ this.security = security;
13
+ }
14
+ validateDatabaseAccess(requestedDatabase) {
15
+ const connectedDatabase = config_1.dbConfig.database;
16
+ if (!connectedDatabase) {
17
+ return {
18
+ valid: false,
19
+ database: "",
20
+ error: "No database specified in connection string. Cannot access any database.",
21
+ };
22
+ }
23
+ if (!requestedDatabase) {
24
+ return {
25
+ valid: true,
26
+ database: connectedDatabase,
27
+ };
28
+ }
29
+ if (requestedDatabase !== connectedDatabase) {
30
+ return {
31
+ valid: false,
32
+ database: "",
33
+ error: `Access denied. You can only access the connected database '${connectedDatabase}'. Requested database '${requestedDatabase}' is not allowed.`,
34
+ };
35
+ }
36
+ return {
37
+ valid: true,
38
+ database: connectedDatabase,
39
+ };
40
+ }
41
+ async recommendIndexes(params) {
42
+ try {
43
+ const dbValidation = this.validateDatabaseAccess(params?.database);
44
+ if (!dbValidation.valid) {
45
+ return { status: "error", error: dbValidation.error };
46
+ }
47
+ const database = dbValidation.database;
48
+ const maxQueryPatterns = Math.min(Math.max(params?.max_query_patterns ?? 25, 1), 200);
49
+ const maxRecommendations = Math.min(Math.max(params?.max_recommendations ?? 25, 1), 200);
50
+ const minExecCount = Math.max(params?.min_execution_count ?? 5, 1);
51
+ const minAvgMs = Math.max(params?.min_avg_time_ms ?? 5, 0);
52
+ const includeUnused = params?.include_unused_index_warnings ?? false;
53
+ const notes = [
54
+ "Recommendations are heuristic-based and should be validated with EXPLAIN and real workload testing.",
55
+ "Composite index order matters: equality predicates first, then range/order/group columns.",
56
+ ];
57
+ // 1) Collect query patterns from performance_schema digests
58
+ let digests = [];
59
+ try {
60
+ digests = await this.getTopSelectDigests(maxQueryPatterns);
61
+ }
62
+ catch (e) {
63
+ return {
64
+ status: "error",
65
+ error: "Unable to read performance_schema digests. Ensure performance_schema is enabled and the MySQL user has access.",
66
+ };
67
+ }
68
+ const filteredDigests = digests.filter((d) => (d.execution_count || 0) >= minExecCount &&
69
+ (d.avg_execution_time_sec || 0) * 1000 >= minAvgMs);
70
+ if (filteredDigests.length === 0) {
71
+ return {
72
+ status: "success",
73
+ data: {
74
+ database,
75
+ analyzed_query_patterns: 0,
76
+ recommendations: [],
77
+ notes: [
78
+ ...notes,
79
+ "No qualifying SELECT query patterns found (consider lowering min_execution_count/min_avg_time_ms or generating workload).",
80
+ ],
81
+ },
82
+ };
83
+ }
84
+ // 2) Load schema columns + existing indexes
85
+ const existingIndexes = await this.getExistingIndexMap(database);
86
+ // 3) Parse query patterns, build per-table column usage signals
87
+ const usageByTable = new Map();
88
+ for (const d of filteredDigests) {
89
+ const parsed = this.parseQueryPattern(d.query_pattern);
90
+ for (const [table, signals] of Object.entries(parsed.byTable)) {
91
+ const tableLower = table;
92
+ if (!usageByTable.has(tableLower)) {
93
+ usageByTable.set(tableLower, {
94
+ equalityCols: new Map(),
95
+ rangeCols: new Map(),
96
+ orderCols: new Map(),
97
+ groupCols: new Map(),
98
+ supporting: [],
99
+ });
100
+ }
101
+ const agg = usageByTable.get(tableLower);
102
+ agg.supporting.push(d);
103
+ this.bumpCounts(agg.equalityCols, signals.equalityCols);
104
+ this.bumpCounts(agg.rangeCols, signals.rangeCols);
105
+ this.bumpCounts(agg.orderCols, signals.orderCols);
106
+ this.bumpCounts(agg.groupCols, signals.groupCols);
107
+ }
108
+ }
109
+ // 4) Generate candidate indexes
110
+ const recommendations = [];
111
+ for (const [table, agg] of usageByTable.entries()) {
112
+ // Only recommend for tables in the connected schema
113
+ if (!existingIndexes.has(table)) {
114
+ continue;
115
+ }
116
+ const columns = this.pickIndexColumns(agg);
117
+ if (columns.length === 0)
118
+ continue;
119
+ // Validate identifier safety
120
+ if (!this.security.validateIdentifier(table).valid)
121
+ continue;
122
+ const safeCols = columns.filter((c) => this.security.validateIdentifier(c).valid);
123
+ if (safeCols.length === 0)
124
+ continue;
125
+ // Skip if an existing index already covers the prefix
126
+ const existing = existingIndexes.get(table);
127
+ if (this.isCoveredByExistingIndex(existing, safeCols)) {
128
+ continue;
129
+ }
130
+ const proposedName = this.makeIndexName(table, safeCols);
131
+ const createSql = "CREATE INDEX `" +
132
+ proposedName +
133
+ "` ON `" +
134
+ database +
135
+ "`.`" +
136
+ table +
137
+ "` (" +
138
+ safeCols.map((c) => "`" + c + "`").join(", ") +
139
+ ");";
140
+ const reason = this.buildReasonString(agg, safeCols);
141
+ const supporting = agg.supporting
142
+ .slice(0, 5)
143
+ .map((s) => ({
144
+ query_pattern: s.query_pattern,
145
+ execution_count: s.execution_count,
146
+ avg_execution_time_ms: Math.round((s.avg_execution_time_sec || 0) * 1000 * 1000) / 1000,
147
+ }));
148
+ recommendations.push({
149
+ table_name: table,
150
+ columns: safeCols,
151
+ proposed_index_name: proposedName,
152
+ create_index_sql: createSql,
153
+ reason,
154
+ supporting_query_patterns: supporting,
155
+ });
156
+ }
157
+ // Simple prioritization: tables with more supporting patterns first
158
+ recommendations.sort((a, b) => b.supporting_query_patterns.length - a.supporting_query_patterns.length);
159
+ const sliced = recommendations.slice(0, maxRecommendations);
160
+ let unusedIndexWarnings;
161
+ if (includeUnused) {
162
+ try {
163
+ const unused = await this.getUnusedIndexes();
164
+ unusedIndexWarnings = unused.map((u) => ({
165
+ table_schema: u.table_schema,
166
+ table_name: u.table_name,
167
+ index_name: u.index_name,
168
+ note: "Index appears unused per performance_schema. Validate before dropping; stats reset will affect this.",
169
+ }));
170
+ }
171
+ catch (e) {
172
+ notes.push(`Unused index warnings unavailable: ${e.message || "failed to query"}`);
173
+ }
174
+ }
175
+ return {
176
+ status: "success",
177
+ data: {
178
+ database,
179
+ analyzed_query_patterns: filteredDigests.length,
180
+ recommendations: sliced,
181
+ ...(unusedIndexWarnings ? { unused_index_warnings: unusedIndexWarnings } : {}),
182
+ notes,
183
+ },
184
+ };
185
+ }
186
+ catch (error) {
187
+ return { status: "error", error: error.message };
188
+ }
189
+ }
190
+ bumpCounts(target, cols) {
191
+ for (const c of cols) {
192
+ target.set(c, (target.get(c) || 0) + 1);
193
+ }
194
+ }
195
+ pickIndexColumns(agg) {
196
+ const pickTop = (m, n) => Array.from(m.entries())
197
+ .sort((a, b) => b[1] - a[1])
198
+ .slice(0, n)
199
+ .map(([k]) => k);
200
+ const eq = pickTop(agg.equalityCols, 3);
201
+ const range = pickTop(agg.rangeCols, 1);
202
+ const order = pickTop(agg.orderCols, 1);
203
+ const group = pickTop(agg.groupCols, 1);
204
+ const out = [];
205
+ for (const c of [...eq, ...range, ...group, ...order]) {
206
+ if (!out.includes(c))
207
+ out.push(c);
208
+ if (out.length >= 4)
209
+ break;
210
+ }
211
+ return out;
212
+ }
213
+ buildReasonString(agg, cols) {
214
+ const parts = [];
215
+ const colSet = new Set(cols);
216
+ const hits = (m) => Array.from(m.entries())
217
+ .filter(([c]) => colSet.has(c))
218
+ .sort((a, b) => b[1] - a[1])
219
+ .slice(0, 3)
220
+ .map(([c, n]) => `${c}(${n})`);
221
+ const eqHits = hits(agg.equalityCols);
222
+ if (eqHits.length)
223
+ parts.push(`frequent equality filters: ${eqHits.join(", ")}`);
224
+ const rangeHits = hits(agg.rangeCols);
225
+ if (rangeHits.length)
226
+ parts.push(`range filters: ${rangeHits.join(", ")}`);
227
+ const groupHits = hits(agg.groupCols);
228
+ if (groupHits.length)
229
+ parts.push(`GROUP BY: ${groupHits.join(", ")}`);
230
+ const orderHits = hits(agg.orderCols);
231
+ if (orderHits.length)
232
+ parts.push(`ORDER BY: ${orderHits.join(", ")}`);
233
+ return parts.length
234
+ ? parts.join("; ")
235
+ : "Based on observed query patterns in performance_schema";
236
+ }
237
+ makeIndexName(table, cols) {
238
+ const base = `idx_${table}_${cols.join("_")}`;
239
+ const name = base.replace(/[^a-zA-Z0-9_]/g, "_").slice(0, 64);
240
+ return name;
241
+ }
242
+ isCoveredByExistingIndex(existing, proposedCols) {
243
+ for (const idx of existing) {
244
+ const prefix = idx.columns.slice(0, proposedCols.length);
245
+ if (prefix.length === proposedCols.length &&
246
+ prefix.every((c, i) => c === proposedCols[i])) {
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+ async getExistingIndexMap(database) {
253
+ const rows = await this.db.query(`
254
+ SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME
255
+ FROM INFORMATION_SCHEMA.STATISTICS
256
+ WHERE TABLE_SCHEMA = ?
257
+ ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
258
+ `, [database]);
259
+ const byTable = new Map();
260
+ for (const r of rows) {
261
+ const table = r.TABLE_NAME;
262
+ const idx = r.INDEX_NAME;
263
+ const col = r.COLUMN_NAME;
264
+ if (!table || !idx || !col)
265
+ continue;
266
+ if (!byTable.has(table))
267
+ byTable.set(table, new Map());
268
+ const byIndex = byTable.get(table);
269
+ if (!byIndex.has(idx))
270
+ byIndex.set(idx, []);
271
+ byIndex.get(idx).push(col);
272
+ }
273
+ const out = new Map();
274
+ for (const [table, idxMap] of byTable.entries()) {
275
+ out.set(table, Array.from(idxMap.entries()).map(([index_name, columns]) => ({
276
+ index_name,
277
+ columns,
278
+ })));
279
+ }
280
+ return out;
281
+ }
282
+ async getTopSelectDigests(limit) {
283
+ // Note: performance_schema access may require extra privileges.
284
+ const query = `
285
+ SELECT
286
+ DIGEST_TEXT as query_pattern,
287
+ COUNT_STAR as execution_count,
288
+ ROUND(AVG_TIMER_WAIT / 1000000000000, 6) as avg_execution_time_sec,
289
+ ROUND(SUM_TIMER_WAIT / 1000000000000, 6) as total_execution_time_sec,
290
+ SUM_ROWS_EXAMINED as rows_examined,
291
+ SUM_ROWS_SENT as rows_sent,
292
+ FIRST_SEEN as first_seen,
293
+ LAST_SEEN as last_seen
294
+ FROM performance_schema.events_statements_summary_by_digest
295
+ WHERE DIGEST_TEXT IS NOT NULL
296
+ AND DIGEST_TEXT LIKE 'SELECT %'
297
+ ORDER BY SUM_TIMER_WAIT DESC
298
+ LIMIT ${limit}
299
+ `;
300
+ const rows = await this.db.query(query);
301
+ return rows.map((r) => ({
302
+ query_pattern: r.query_pattern,
303
+ execution_count: parseInt(r.execution_count || "0", 10) || 0,
304
+ avg_execution_time_sec: parseFloat(r.avg_execution_time_sec || "0") || 0,
305
+ total_execution_time_sec: parseFloat(r.total_execution_time_sec || "0") || 0,
306
+ rows_examined: parseInt(r.rows_examined || "0", 10) || 0,
307
+ rows_sent: parseInt(r.rows_sent || "0", 10) || 0,
308
+ first_seen: r.first_seen,
309
+ last_seen: r.last_seen,
310
+ }));
311
+ }
312
+ async getUnusedIndexes() {
313
+ const query = `
314
+ SELECT
315
+ t.TABLE_SCHEMA as table_schema,
316
+ t.TABLE_NAME as table_name,
317
+ s.INDEX_NAME as index_name
318
+ FROM information_schema.STATISTICS s
319
+ LEFT JOIN performance_schema.table_io_waits_summary_by_index_usage p
320
+ ON s.TABLE_SCHEMA = p.OBJECT_SCHEMA
321
+ AND s.TABLE_NAME = p.OBJECT_NAME
322
+ AND s.INDEX_NAME = p.INDEX_NAME
323
+ JOIN information_schema.TABLES t
324
+ ON s.TABLE_SCHEMA = t.TABLE_SCHEMA
325
+ AND s.TABLE_NAME = t.TABLE_NAME
326
+ WHERE s.TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')
327
+ AND s.INDEX_NAME != 'PRIMARY'
328
+ AND (p.INDEX_NAME IS NULL OR p.COUNT_STAR = 0)
329
+ AND t.TABLE_TYPE = 'BASE TABLE'
330
+ GROUP BY t.TABLE_SCHEMA, t.TABLE_NAME, s.INDEX_NAME
331
+ ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME, s.INDEX_NAME
332
+ `;
333
+ const rows = await this.db.query(query);
334
+ return rows.map((r) => ({
335
+ table_schema: r.table_schema,
336
+ table_name: r.table_name,
337
+ index_name: r.index_name,
338
+ }));
339
+ }
340
+ parseQueryPattern(queryPattern) {
341
+ const qp = (queryPattern || "").replace(/\s+/g, " ").trim();
342
+ const upper = qp.toUpperCase();
343
+ const byTable = {};
344
+ // Very lightweight SQL-ish parsing (digest text is normalized already)
345
+ const tableAliases = new Map(); // alias -> table
346
+ const addTable = (table, alias) => {
347
+ const t = table.replace(/[`"\[\]]/g, "");
348
+ if (!t)
349
+ return;
350
+ if (!byTable[t]) {
351
+ byTable[t] = { equalityCols: [], rangeCols: [], orderCols: [], groupCols: [] };
352
+ }
353
+ if (alias)
354
+ tableAliases.set(alias, t);
355
+ tableAliases.set(t, t);
356
+ };
357
+ const fromMatch = qp.match(/\bFROM\s+([a-zA-Z0-9_\.]+)(?:\s+(?:AS\s+)?([a-zA-Z0-9_]+))?/i);
358
+ if (fromMatch)
359
+ addTable(fromMatch[1], fromMatch[2]);
360
+ for (const m of qp.matchAll(/\bJOIN\s+([a-zA-Z0-9_\.]+)(?:\s+(?:AS\s+)?([a-zA-Z0-9_]+))?/gi)) {
361
+ addTable(m[1], m[2]);
362
+ }
363
+ const segment = (keyword, stop) => {
364
+ const start = upper.indexOf(keyword);
365
+ if (start === -1)
366
+ return "";
367
+ const after = start + keyword.length;
368
+ const rest = qp.slice(after);
369
+ const restUpper = rest.toUpperCase();
370
+ let end = rest.length;
371
+ for (const s of stop) {
372
+ const idx = restUpper.indexOf(s);
373
+ if (idx !== -1 && idx < end)
374
+ end = idx;
375
+ }
376
+ return rest.slice(0, end).trim();
377
+ };
378
+ const whereSeg = segment("WHERE", [" GROUP BY ", " ORDER BY ", " LIMIT "]);
379
+ const groupSeg = segment("GROUP BY", [" ORDER BY ", " LIMIT "]);
380
+ const orderSeg = segment("ORDER BY", [" LIMIT "]);
381
+ const extractCols = (seg) => {
382
+ if (!seg)
383
+ return [];
384
+ const out = [];
385
+ // Try alias.column patterns first
386
+ for (const m of seg.matchAll(/\b([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\b/g)) {
387
+ const alias = m[1];
388
+ const col = m[2];
389
+ out.push({ table: tableAliases.get(alias) || alias, column: col });
390
+ }
391
+ // Fallback: bare columns in GROUP/ORDER lists (best-effort)
392
+ if (out.length === 0) {
393
+ for (const token of seg.split(/\s*,\s*/)) {
394
+ const c = token
395
+ .trim()
396
+ .replace(/\bASC\b|\bDESC\b/gi, "")
397
+ .replace(/[^a-zA-Z0-9_]/g, "")
398
+ .trim();
399
+ if (c)
400
+ out.push({ column: c });
401
+ }
402
+ }
403
+ return out;
404
+ };
405
+ // WHERE: equality vs range-ish heuristics
406
+ if (whereSeg) {
407
+ // equality patterns
408
+ for (const m of whereSeg.matchAll(/\b([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*(=|IN\b|IS\b)\s*/gi)) {
409
+ const [alias, col] = m[1].split(".");
410
+ const table = tableAliases.get(alias) || alias;
411
+ if (byTable[table])
412
+ byTable[table].equalityCols.push(col);
413
+ }
414
+ for (const m of whereSeg.matchAll(/\b([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*(<|>|<=|>=|BETWEEN\b|LIKE\b)\s*/gi)) {
415
+ const [alias, col] = m[1].split(".");
416
+ const table = tableAliases.get(alias) || alias;
417
+ if (byTable[table])
418
+ byTable[table].rangeCols.push(col);
419
+ }
420
+ }
421
+ // JOIN ON clauses
422
+ for (const m of qp.matchAll(/\bON\s+([^\n]+?)(?=\bJOIN\b|\bWHERE\b|\bGROUP\s+BY\b|\bORDER\s+BY\b|\bLIMIT\b|$)/gi)) {
423
+ const onSeg = m[1];
424
+ for (const colRef of extractCols(onSeg)) {
425
+ if (colRef.table && byTable[colRef.table]) {
426
+ byTable[colRef.table].equalityCols.push(colRef.column);
427
+ }
428
+ }
429
+ }
430
+ // GROUP BY / ORDER BY
431
+ for (const colRef of extractCols(groupSeg)) {
432
+ if (colRef.table && byTable[colRef.table])
433
+ byTable[colRef.table].groupCols.push(colRef.column);
434
+ }
435
+ for (const colRef of extractCols(orderSeg)) {
436
+ if (colRef.table && byTable[colRef.table])
437
+ byTable[colRef.table].orderCols.push(colRef.column);
438
+ }
439
+ // De-dup per table
440
+ for (const t of Object.keys(byTable)) {
441
+ byTable[t] = {
442
+ equalityCols: Array.from(new Set(byTable[t].equalityCols)),
443
+ rangeCols: Array.from(new Set(byTable[t].rangeCols)),
444
+ orderCols: Array.from(new Set(byTable[t].orderCols)),
445
+ groupCols: Array.from(new Set(byTable[t].groupCols)),
446
+ };
447
+ }
448
+ return { byTable };
449
+ }
450
+ }
451
+ exports.IndexRecommendationTools = IndexRecommendationTools;
@@ -0,0 +1,22 @@
1
+ import { SecurityLayer } from "../security/securityLayer";
2
+ export declare class QueryVisualizationTools {
3
+ private db;
4
+ private security;
5
+ constructor(security: SecurityLayer);
6
+ private extractExplainNodes;
7
+ private sqlJoinEdges;
8
+ private buildMermaid;
9
+ /**
10
+ * Create a lightweight visual representation of a SQL query.
11
+ * Returns Mermaid flowchart + EXPLAIN FORMAT=JSON summary.
12
+ */
13
+ visualizeQuery(params: {
14
+ query: string;
15
+ include_explain_json?: boolean;
16
+ format?: "mermaid" | "json" | "both";
17
+ }): Promise<{
18
+ status: string;
19
+ data?: any;
20
+ error?: string;
21
+ }>;
22
+ }
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.QueryVisualizationTools = void 0;
7
+ const connection_1 = __importDefault(require("../db/connection"));
8
+ class QueryVisualizationTools {
9
+ constructor(security) {
10
+ this.db = connection_1.default.getInstance();
11
+ this.security = security;
12
+ }
13
+ extractExplainNodes(explainJson) {
14
+ const nodes = [];
15
+ const visit = (obj) => {
16
+ if (!obj || typeof obj !== "object")
17
+ return;
18
+ if (obj.table && typeof obj.table === "object") {
19
+ const t = obj.table;
20
+ nodes.push({
21
+ table_name: t.table_name,
22
+ access_type: t.access_type,
23
+ possible_keys: t.possible_keys,
24
+ key: t.key,
25
+ rows_examined_per_scan: t.rows_examined_per_scan,
26
+ rows_produced_per_join: t.rows_produced_per_join,
27
+ filtered: t.filtered,
28
+ attached_condition: t.attached_condition,
29
+ });
30
+ }
31
+ for (const v of Object.values(obj)) {
32
+ if (Array.isArray(v))
33
+ v.forEach(visit);
34
+ else if (v && typeof v === "object")
35
+ visit(v);
36
+ }
37
+ };
38
+ visit(explainJson);
39
+ return nodes;
40
+ }
41
+ sqlJoinEdges(query) {
42
+ const edges = [];
43
+ const q = query.replace(/\s+/g, " ");
44
+ // Very lightweight parser: FROM <t> ... JOIN <t2> ON <cond>
45
+ const fromMatch = q.match(/\bFROM\s+([`"']?\w+[`"']?)(?:\s+AS\s+\w+|\s+\w+)?/i);
46
+ const base = fromMatch ? fromMatch[1].replace(/[`"']/g, "") : undefined;
47
+ if (!base)
48
+ return edges;
49
+ const joinRegex = /\bJOIN\s+([`"']?\w+[`"']?)(?:\s+AS\s+\w+|\s+\w+)?\s+ON\s+(.+?)(?=\bJOIN\b|\bWHERE\b|\bGROUP\b|\bORDER\b|\bLIMIT\b|$)/gi;
50
+ let m;
51
+ let prev = base;
52
+ while ((m = joinRegex.exec(q))) {
53
+ const table = m[1].replace(/[`"']/g, "");
54
+ const on = m[2].trim();
55
+ edges.push({ from: prev, to: table, on });
56
+ prev = table;
57
+ }
58
+ return edges;
59
+ }
60
+ buildMermaid(nodes, edges) {
61
+ const lines = [];
62
+ lines.push("graph TD");
63
+ const uniqTables = Array.from(new Set(nodes.map((n) => n.table_name).filter(Boolean)));
64
+ const nodeId = (t) => `t_${t.replace(/[^a-zA-Z0-9_]/g, "_")}`;
65
+ for (const t of uniqTables) {
66
+ const n = nodes.find((x) => x.table_name === t);
67
+ const labelParts = [t];
68
+ if (n?.access_type)
69
+ labelParts.push(`access=${n.access_type}`);
70
+ if (n?.key)
71
+ labelParts.push(`key=${n.key}`);
72
+ if (typeof n?.rows_examined_per_scan === "number") {
73
+ labelParts.push(`rows≈${n.rows_examined_per_scan}`);
74
+ }
75
+ lines.push(`${nodeId(t)}["${labelParts.join("\\n")}"]`);
76
+ }
77
+ if (edges.length) {
78
+ for (const e of edges) {
79
+ lines.push(`${nodeId(e.from)} -->|"${(e.on || "").replace(/"/g, "'").slice(0, 60)}"| ${nodeId(e.to)}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+ // Fallback: join order from EXPLAIN
84
+ for (let i = 0; i < uniqTables.length - 1; i++) {
85
+ lines.push(`${nodeId(uniqTables[i])} --> ${nodeId(uniqTables[i + 1])}`);
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+ /**
90
+ * Create a lightweight visual representation of a SQL query.
91
+ * Returns Mermaid flowchart + EXPLAIN FORMAT=JSON summary.
92
+ */
93
+ async visualizeQuery(params) {
94
+ try {
95
+ const query = params.query;
96
+ if (!query || typeof query !== "string") {
97
+ return { status: "error", error: "query is required" };
98
+ }
99
+ if (!this.security.isReadOnlyQuery(query)) {
100
+ return {
101
+ status: "error",
102
+ error: "Only read-only queries (SELECT/SHOW/DESCRIBE/EXPLAIN) are supported for visualization.",
103
+ };
104
+ }
105
+ const includeExplain = params.include_explain_json ?? true;
106
+ const format = params.format ?? "both";
107
+ const explainQuery = `EXPLAIN FORMAT=JSON ${query}`;
108
+ const explainRows = await this.db.query(explainQuery);
109
+ let explainJson = null;
110
+ let queryCost = null;
111
+ if (explainRows[0] && explainRows[0].EXPLAIN) {
112
+ try {
113
+ explainJson = JSON.parse(explainRows[0].EXPLAIN);
114
+ queryCost =
115
+ explainJson?.query_block?.cost_info?.query_cost?.toString?.() ?? null;
116
+ }
117
+ catch {
118
+ explainJson = explainRows;
119
+ }
120
+ }
121
+ const nodes = explainJson ? this.extractExplainNodes(explainJson) : [];
122
+ const edges = this.sqlJoinEdges(query);
123
+ const mermaid = this.buildMermaid(nodes, edges);
124
+ const data = {
125
+ mermaid,
126
+ explain_summary: {
127
+ query_cost: queryCost,
128
+ tables: nodes.map((n) => ({
129
+ table_name: n.table_name,
130
+ access_type: n.access_type,
131
+ key: n.key,
132
+ rows_examined_per_scan: n.rows_examined_per_scan,
133
+ filtered: n.filtered,
134
+ })),
135
+ },
136
+ };
137
+ if (includeExplain)
138
+ data.explain_json = explainJson;
139
+ if (format === "mermaid") {
140
+ return { status: "success", data: { mermaid } };
141
+ }
142
+ if (format === "json") {
143
+ return {
144
+ status: "success",
145
+ data: { explain_summary: data.explain_summary, explain_json: explainJson },
146
+ };
147
+ }
148
+ return { status: "success", data };
149
+ }
150
+ catch (error) {
151
+ return { status: "error", error: error.message };
152
+ }
153
+ }
154
+ }
155
+ exports.QueryVisualizationTools = QueryVisualizationTools;
@@ -0,0 +1,67 @@
1
+ import { SecurityLayer } from "../security/securityLayer";
2
+ type NamingConvention = "snake_case" | "camelCase";
3
+ export declare class SchemaDesignTools {
4
+ private security;
5
+ constructor(security: SecurityLayer);
6
+ designSchemaFromRequirements(params: {
7
+ requirements_text: string;
8
+ entities?: Array<{
9
+ name: string;
10
+ fields?: string[];
11
+ }>;
12
+ naming_convention?: NamingConvention;
13
+ include_audit_columns?: boolean;
14
+ id_type?: "BIGINT" | "UUID";
15
+ engine?: string;
16
+ charset?: string;
17
+ collation?: string;
18
+ }): Promise<{
19
+ status: string;
20
+ data?: {
21
+ input: {
22
+ requirements_text: string;
23
+ inferred_entities_count: number;
24
+ };
25
+ tables: Array<{
26
+ table_name: string;
27
+ columns: Array<{
28
+ name: string;
29
+ type: string;
30
+ nullable: boolean;
31
+ primary_key?: boolean;
32
+ unique?: boolean;
33
+ references?: {
34
+ table: string;
35
+ column: string;
36
+ };
37
+ }>;
38
+ indexes: Array<{
39
+ name: string;
40
+ columns: string[];
41
+ unique?: boolean;
42
+ }>;
43
+ }>;
44
+ relationships: Array<{
45
+ from_table: string;
46
+ from_column: string;
47
+ to_table: string;
48
+ to_column: string;
49
+ type: "one_to_many" | "many_to_one";
50
+ }>;
51
+ ddl_statements: string[];
52
+ notes: string[];
53
+ };
54
+ error?: string;
55
+ }>;
56
+ private normalizeLoose;
57
+ private toSnakeCase;
58
+ private toCamelCase;
59
+ private normalizeIdentifier;
60
+ private inferColumnType;
61
+ private extractEntitiesAndRelations;
62
+ private findTableNameForRaw;
63
+ private makeIndexName;
64
+ private generateCreateTableDDL;
65
+ private generateCreateIndexDDL;
66
+ }
67
+ export {};