@berthojoris/mcp-mysql-server 1.16.2 → 1.16.4
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.
- package/CHANGELOG.md +23 -0
- package/DOCUMENTATIONS.md +86 -4
- package/README.md +33 -7
- package/dist/config/featureConfig.js +8 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +27 -0
- package/dist/mcp-server.js +121 -0
- package/dist/tools/indexRecommendationTools.d.ts +50 -0
- package/dist/tools/indexRecommendationTools.js +451 -0
- package/dist/tools/schemaDesignTools.d.ts +67 -0
- package/dist/tools/schemaDesignTools.js +359 -0
- package/dist/tools/securityAuditTools.d.ts +39 -0
- package/dist/tools/securityAuditTools.js +319 -0
- package/manifest.json +81 -1
- package/package.json +2 -2
|
@@ -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,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 {};
|