@rog0x/mcp-database-tools 1.0.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,370 @@
1
+ /**
2
+ * Schema Analyzer — parse CREATE TABLE statements to extract
3
+ * tables, columns, types, constraints, foreign keys, indexes.
4
+ * Generate ER diagrams in Mermaid format.
5
+ */
6
+
7
+ interface ColumnInfo {
8
+ name: string;
9
+ type: string;
10
+ nullable: boolean;
11
+ defaultValue: string | null;
12
+ isPrimaryKey: boolean;
13
+ isUnique: boolean;
14
+ autoIncrement: boolean;
15
+ }
16
+
17
+ interface ForeignKeyInfo {
18
+ columns: string[];
19
+ referencedTable: string;
20
+ referencedColumns: string[];
21
+ onDelete: string | null;
22
+ onUpdate: string | null;
23
+ }
24
+
25
+ interface IndexInfo {
26
+ name: string | null;
27
+ columns: string[];
28
+ unique: boolean;
29
+ }
30
+
31
+ interface TableInfo {
32
+ name: string;
33
+ columns: ColumnInfo[];
34
+ primaryKey: string[];
35
+ foreignKeys: ForeignKeyInfo[];
36
+ indexes: IndexInfo[];
37
+ uniqueConstraints: string[][];
38
+ checkConstraints: string[];
39
+ }
40
+
41
+ interface SchemaAnalysis {
42
+ tables: TableInfo[];
43
+ relationships: {
44
+ from: string;
45
+ fromColumns: string[];
46
+ to: string;
47
+ toColumns: string[];
48
+ type: string;
49
+ }[];
50
+ mermaidDiagram: string;
51
+ summary: string;
52
+ }
53
+
54
+ function splitRespectingParens(text: string): string[] {
55
+ const parts: string[] = [];
56
+ let depth = 0;
57
+ let current = "";
58
+ for (const ch of text) {
59
+ if (ch === "(") depth++;
60
+ if (ch === ")") depth--;
61
+ if (ch === "," && depth === 0) {
62
+ parts.push(current.trim());
63
+ current = "";
64
+ continue;
65
+ }
66
+ current += ch;
67
+ }
68
+ if (current.trim()) parts.push(current.trim());
69
+ return parts;
70
+ }
71
+
72
+ function extractTableBody(createStmt: string): string | null {
73
+ // Find the outermost parentheses
74
+ let depth = 0;
75
+ let start = -1;
76
+ let end = -1;
77
+ for (let i = 0; i < createStmt.length; i++) {
78
+ if (createStmt[i] === "(") {
79
+ if (depth === 0) start = i + 1;
80
+ depth++;
81
+ }
82
+ if (createStmt[i] === ")") {
83
+ depth--;
84
+ if (depth === 0) {
85
+ end = i;
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ if (start === -1 || end === -1) return null;
91
+ return createStmt.substring(start, end).trim();
92
+ }
93
+
94
+ function parseColumn(def: string): ColumnInfo | null {
95
+ const trimmed = def.trim();
96
+ // Skip table-level constraints
97
+ if (/^\s*(PRIMARY KEY|FOREIGN KEY|UNIQUE|CHECK|CONSTRAINT|INDEX|KEY)\b/i.test(trimmed)) {
98
+ return null;
99
+ }
100
+
101
+ const parts = trimmed.split(/\s+/);
102
+ if (parts.length < 2) return null;
103
+
104
+ const name = parts[0].replace(/[`"[\]]/g, "");
105
+ const upper = trimmed.toUpperCase();
106
+
107
+ // Type can be multi-word, e.g. "DOUBLE PRECISION", "CHARACTER VARYING(255)"
108
+ // Find everything after the name up to the first constraint keyword
109
+ const afterName = trimmed.substring(parts[0].length).trim();
110
+ const typeMatch = afterName.match(
111
+ /^(\w+(?:\s*\([^)]*\))?(?:\s+(?:PRECISION|VARYING|WITHOUT|WITH|TIME|ZONE)(?:\s*\([^)]*\))?)*)/i
112
+ );
113
+ const type = typeMatch ? typeMatch[1].trim() : parts[1];
114
+
115
+ return {
116
+ name,
117
+ type,
118
+ nullable: !upper.includes("NOT NULL"),
119
+ defaultValue: extractDefault(trimmed),
120
+ isPrimaryKey: upper.includes("PRIMARY KEY"),
121
+ isUnique: upper.includes("UNIQUE"),
122
+ autoIncrement:
123
+ upper.includes("AUTO_INCREMENT") ||
124
+ upper.includes("AUTOINCREMENT") ||
125
+ /\bSERIAL\b|\bBIGSERIAL\b|\bSMALLSERIAL\b/i.test(type),
126
+ };
127
+ }
128
+
129
+ function extractDefault(colDef: string): string | null {
130
+ const match = colDef.match(/\bDEFAULT\s+('[^']*'|"[^"]*"|\S+)/i);
131
+ return match ? match[1] : null;
132
+ }
133
+
134
+ function parseTable(createStmt: string): TableInfo | null {
135
+ const nameMatch = createStmt.match(
136
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`?(\w+)`?\.)?`?(\w+)`?/i
137
+ );
138
+ if (!nameMatch) return null;
139
+ const tableName = nameMatch[2];
140
+
141
+ const body = extractTableBody(createStmt);
142
+ if (!body) return null;
143
+
144
+ const definitions = splitRespectingParens(body);
145
+
146
+ const columns: ColumnInfo[] = [];
147
+ const primaryKey: string[] = [];
148
+ const foreignKeys: ForeignKeyInfo[] = [];
149
+ const indexes: IndexInfo[] = [];
150
+ const uniqueConstraints: string[][] = [];
151
+ const checkConstraints: string[] = [];
152
+
153
+ for (const def of definitions) {
154
+ const trimmed = def.trim();
155
+ const upper = trimmed.toUpperCase();
156
+
157
+ // Table-level PRIMARY KEY
158
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?PRIMARY\s+KEY\s*\(/i.test(trimmed)) {
159
+ const colsMatch = trimmed.match(/PRIMARY\s+KEY\s*\(([^)]+)\)/i);
160
+ if (colsMatch) {
161
+ const cols = colsMatch[1].split(",").map((c) => c.trim().replace(/[`"[\]]/g, ""));
162
+ primaryKey.push(...cols);
163
+ }
164
+ continue;
165
+ }
166
+
167
+ // FOREIGN KEY
168
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\(/i.test(trimmed)) {
169
+ const fkMatch = trimmed.match(
170
+ /FOREIGN\s+KEY\s*\(([^)]+)\)\s*REFERENCES\s+`?(\w+)`?\s*\(([^)]+)\)/i
171
+ );
172
+ if (fkMatch) {
173
+ const fkCols = fkMatch[1].split(",").map((c) => c.trim().replace(/[`"[\]]/g, ""));
174
+ const refTable = fkMatch[2];
175
+ const refCols = fkMatch[3].split(",").map((c) => c.trim().replace(/[`"[\]]/g, ""));
176
+ const onDeleteMatch = trimmed.match(/ON\s+DELETE\s+(\w+(?:\s+\w+)?)/i);
177
+ const onUpdateMatch = trimmed.match(/ON\s+UPDATE\s+(\w+(?:\s+\w+)?)/i);
178
+ foreignKeys.push({
179
+ columns: fkCols,
180
+ referencedTable: refTable,
181
+ referencedColumns: refCols,
182
+ onDelete: onDeleteMatch ? onDeleteMatch[1] : null,
183
+ onUpdate: onUpdateMatch ? onUpdateMatch[1] : null,
184
+ });
185
+ }
186
+ continue;
187
+ }
188
+
189
+ // UNIQUE constraint
190
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?UNIQUE\s*\(/i.test(trimmed)) {
191
+ const uqMatch = trimmed.match(/UNIQUE\s*\(([^)]+)\)/i);
192
+ if (uqMatch) {
193
+ const cols = uqMatch[1].split(",").map((c) => c.trim().replace(/[`"[\]]/g, ""));
194
+ uniqueConstraints.push(cols);
195
+ }
196
+ continue;
197
+ }
198
+
199
+ // CHECK constraint
200
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?CHECK\s*\(/i.test(trimmed)) {
201
+ checkConstraints.push(trimmed);
202
+ continue;
203
+ }
204
+
205
+ // INDEX / KEY (MySQL syntax in CREATE TABLE)
206
+ if (/^\s*(?:INDEX|KEY)\s+/i.test(trimmed)) {
207
+ const idxMatch = trimmed.match(/(?:INDEX|KEY)\s+`?(\w+)`?\s*\(([^)]+)\)/i);
208
+ if (idxMatch) {
209
+ indexes.push({
210
+ name: idxMatch[1],
211
+ columns: idxMatch[2].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "")),
212
+ unique: false,
213
+ });
214
+ }
215
+ continue;
216
+ }
217
+
218
+ // Column definition
219
+ const col = parseColumn(trimmed);
220
+ if (col) {
221
+ columns.push(col);
222
+ if (col.isPrimaryKey) {
223
+ primaryKey.push(col.name);
224
+ }
225
+ }
226
+ }
227
+
228
+ return {
229
+ name: tableName,
230
+ columns,
231
+ primaryKey,
232
+ foreignKeys,
233
+ indexes,
234
+ uniqueConstraints,
235
+ checkConstraints,
236
+ };
237
+ }
238
+
239
+ function generateMermaidER(tables: TableInfo[]): string {
240
+ const lines: string[] = ["erDiagram"];
241
+
242
+ for (const table of tables) {
243
+ lines.push(` ${table.name} {`);
244
+ for (const col of table.columns) {
245
+ const pk = table.primaryKey.includes(col.name) ? " PK" : "";
246
+ const fk = table.foreignKeys.some((fk) => fk.columns.includes(col.name)) ? " FK" : "";
247
+ const type = col.type.replace(/\s+/g, "_").replace(/[()]/g, "");
248
+ lines.push(` ${type} ${col.name}${pk}${fk}`);
249
+ }
250
+ lines.push(" }");
251
+ }
252
+
253
+ // Relationships
254
+ for (const table of tables) {
255
+ for (const fk of table.foreignKeys) {
256
+ const relLabel = fk.columns.join(", ");
257
+ // Determine cardinality heuristic
258
+ const isUnique =
259
+ table.uniqueConstraints.some((uc) =>
260
+ fk.columns.every((c) => uc.includes(c))
261
+ ) ||
262
+ fk.columns.every((c) =>
263
+ table.columns.find((col) => col.name === c)?.isUnique
264
+ ) ||
265
+ fk.columns.every((c) => table.primaryKey.includes(c));
266
+
267
+ const cardinality = isUnique ? "||--||" : "}o--||";
268
+ lines.push(
269
+ ` ${table.name} ${cardinality} ${fk.referencedTable} : "${relLabel}"`
270
+ );
271
+ }
272
+ }
273
+
274
+ return lines.join("\n");
275
+ }
276
+
277
+ function parseCreateIndexes(sql: string): IndexInfo[] {
278
+ const indexes: IndexInfo[] = [];
279
+ const regex =
280
+ /CREATE\s+(UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?\s+ON\s+`?(\w+)`?\s*\(([^)]+)\)/gi;
281
+ let match: RegExpExecArray | null;
282
+ while ((match = regex.exec(sql)) !== null) {
283
+ indexes.push({
284
+ name: match[2],
285
+ columns: match[4].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "")),
286
+ unique: !!match[1],
287
+ });
288
+ }
289
+ return indexes;
290
+ }
291
+
292
+ export function schemaAnalyzer(sql: string): SchemaAnalysis {
293
+ // Split into individual statements
294
+ const statements = sql
295
+ .split(";")
296
+ .map((s) => s.trim())
297
+ .filter(Boolean);
298
+
299
+ const tables: TableInfo[] = [];
300
+ const standaloneIndexes: { tableName: string; index: IndexInfo }[] = [];
301
+
302
+ for (const stmt of statements) {
303
+ if (/^\s*CREATE\s+TABLE\b/i.test(stmt)) {
304
+ const table = parseTable(stmt);
305
+ if (table) tables.push(table);
306
+ }
307
+ if (/^\s*CREATE\s+(UNIQUE\s+)?INDEX\b/i.test(stmt)) {
308
+ const idxMatch = stmt.match(
309
+ /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?\s+ON\s+`?(\w+)`?\s*\(([^)]+)\)/i
310
+ );
311
+ if (idxMatch) {
312
+ standaloneIndexes.push({
313
+ tableName: idxMatch[2],
314
+ index: {
315
+ name: idxMatch[1],
316
+ columns: idxMatch[3].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "")),
317
+ unique: /UNIQUE/i.test(stmt),
318
+ },
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ // Attach standalone indexes to their tables
325
+ for (const si of standaloneIndexes) {
326
+ const table = tables.find(
327
+ (t) => t.name.toLowerCase() === si.tableName.toLowerCase()
328
+ );
329
+ if (table) {
330
+ table.indexes.push(si.index);
331
+ }
332
+ }
333
+
334
+ // Build relationships
335
+ const relationships = tables.flatMap((t) =>
336
+ t.foreignKeys.map((fk) => ({
337
+ from: t.name,
338
+ fromColumns: fk.columns,
339
+ to: fk.referencedTable,
340
+ toColumns: fk.referencedColumns,
341
+ type: fk.columns.every((c) => t.primaryKey.includes(c))
342
+ ? "one-to-one"
343
+ : "many-to-one",
344
+ }))
345
+ );
346
+
347
+ const mermaidDiagram = generateMermaidER(tables);
348
+
349
+ const totalColumns = tables.reduce((sum, t) => sum + t.columns.length, 0);
350
+ const totalFKs = tables.reduce((sum, t) => sum + t.foreignKeys.length, 0);
351
+ const totalIndexes = tables.reduce((sum, t) => sum + t.indexes.length, 0);
352
+
353
+ const summary = [
354
+ `Schema contains ${tables.length} table(s) with ${totalColumns} total column(s).`,
355
+ totalFKs > 0
356
+ ? `${totalFKs} foreign key relationship(s) found.`
357
+ : "No foreign keys defined.",
358
+ totalIndexes > 0
359
+ ? `${totalIndexes} index(es) defined.`
360
+ : "No standalone indexes defined.",
361
+ `Tables: ${tables.map((t) => t.name).join(", ")}`,
362
+ ].join(" ");
363
+
364
+ return {
365
+ tables,
366
+ relationships,
367
+ mermaidDiagram,
368
+ summary,
369
+ };
370
+ }