@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,497 @@
1
+ /**
2
+ * Migration Generator — compare two schemas (old vs new CREATE TABLE)
3
+ * and generate ALTER TABLE migration statements.
4
+ */
5
+
6
+ interface ColumnDef {
7
+ name: string;
8
+ type: string;
9
+ nullable: boolean;
10
+ defaultValue: string | null;
11
+ isPrimaryKey: boolean;
12
+ isUnique: boolean;
13
+ autoIncrement: boolean;
14
+ fullDefinition: string;
15
+ }
16
+
17
+ interface TableDef {
18
+ name: string;
19
+ columns: Map<string, ColumnDef>;
20
+ primaryKey: string[];
21
+ foreignKeys: FKDef[];
22
+ uniqueConstraints: string[][];
23
+ indexes: IdxDef[];
24
+ }
25
+
26
+ interface FKDef {
27
+ constraintName: string | null;
28
+ columns: string[];
29
+ referencedTable: string;
30
+ referencedColumns: string[];
31
+ onDelete: string | null;
32
+ onUpdate: string | null;
33
+ raw: string;
34
+ }
35
+
36
+ interface IdxDef {
37
+ name: string | null;
38
+ columns: string[];
39
+ unique: boolean;
40
+ }
41
+
42
+ interface MigrationResult {
43
+ upStatements: string[];
44
+ downStatements: string[];
45
+ summary: string;
46
+ changes: ChangeDescription[];
47
+ }
48
+
49
+ interface ChangeDescription {
50
+ type: "add_table" | "drop_table" | "add_column" | "drop_column" | "modify_column" | "add_fk" | "drop_fk" | "add_index" | "drop_index" | "add_unique" | "drop_unique";
51
+ table: string;
52
+ detail: string;
53
+ }
54
+
55
+ function splitRespectingParens(text: string): string[] {
56
+ const parts: string[] = [];
57
+ let depth = 0;
58
+ let current = "";
59
+ for (const ch of text) {
60
+ if (ch === "(") depth++;
61
+ if (ch === ")") depth--;
62
+ if (ch === "," && depth === 0) {
63
+ parts.push(current.trim());
64
+ current = "";
65
+ continue;
66
+ }
67
+ current += ch;
68
+ }
69
+ if (current.trim()) parts.push(current.trim());
70
+ return parts;
71
+ }
72
+
73
+ function extractTableBody(stmt: string): string | null {
74
+ let depth = 0;
75
+ let start = -1;
76
+ let end = -1;
77
+ for (let i = 0; i < stmt.length; i++) {
78
+ if (stmt[i] === "(") {
79
+ if (depth === 0) start = i + 1;
80
+ depth++;
81
+ }
82
+ if (stmt[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 stmt.substring(start, end).trim();
92
+ }
93
+
94
+ function parseColumnDef(def: string): ColumnDef | null {
95
+ const trimmed = def.trim();
96
+ if (/^\s*(PRIMARY KEY|FOREIGN KEY|UNIQUE|CHECK|CONSTRAINT|INDEX|KEY)\b/i.test(trimmed)) {
97
+ return null;
98
+ }
99
+
100
+ const parts = trimmed.split(/\s+/);
101
+ if (parts.length < 2) return null;
102
+
103
+ const name = parts[0].replace(/[`"[\]]/g, "");
104
+ const upper = trimmed.toUpperCase();
105
+
106
+ // Extract type
107
+ const afterName = trimmed.substring(parts[0].length).trim();
108
+ const typeMatch = afterName.match(
109
+ /^(\w+(?:\s*\([^)]*\))?(?:\s+(?:PRECISION|VARYING|WITHOUT|WITH|TIME|ZONE)(?:\s*\([^)]*\))?)*)/i
110
+ );
111
+ const type = typeMatch ? typeMatch[1].trim().toUpperCase() : parts[1].toUpperCase();
112
+
113
+ // Extract default
114
+ const defaultMatch = trimmed.match(/\bDEFAULT\s+('[^']*'|"[^"]*"|\S+)/i);
115
+
116
+ return {
117
+ name,
118
+ type,
119
+ nullable: !upper.includes("NOT NULL"),
120
+ defaultValue: defaultMatch ? defaultMatch[1] : null,
121
+ isPrimaryKey: upper.includes("PRIMARY KEY"),
122
+ isUnique: upper.includes("UNIQUE"),
123
+ autoIncrement:
124
+ upper.includes("AUTO_INCREMENT") ||
125
+ upper.includes("AUTOINCREMENT") ||
126
+ /\bSERIAL\b|\bBIGSERIAL\b|\bSMALLSERIAL\b/i.test(type),
127
+ fullDefinition: trimmed,
128
+ };
129
+ }
130
+
131
+ function parseSchema(sql: string): Map<string, TableDef> {
132
+ const tables = new Map<string, TableDef>();
133
+ const statements = sql.split(";").map((s) => s.trim()).filter(Boolean);
134
+
135
+ for (const stmt of statements) {
136
+ if (!/^\s*CREATE\s+TABLE\b/i.test(stmt)) continue;
137
+
138
+ const nameMatch = stmt.match(
139
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`?(\w+)`?\.)?`?(\w+)`?/i
140
+ );
141
+ if (!nameMatch) continue;
142
+ const tableName = nameMatch[2].toLowerCase();
143
+
144
+ const body = extractTableBody(stmt);
145
+ if (!body) continue;
146
+
147
+ const definitions = splitRespectingParens(body);
148
+ const columns = new Map<string, ColumnDef>();
149
+ const primaryKey: string[] = [];
150
+ const foreignKeys: FKDef[] = [];
151
+ const uniqueConstraints: string[][] = [];
152
+ const indexes: IdxDef[] = [];
153
+
154
+ for (const def of definitions) {
155
+ const trimmed = def.trim();
156
+ const upper = trimmed.toUpperCase();
157
+
158
+ // PRIMARY KEY
159
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?PRIMARY\s+KEY\s*\(/i.test(trimmed)) {
160
+ const m = trimmed.match(/PRIMARY\s+KEY\s*\(([^)]+)\)/i);
161
+ if (m) primaryKey.push(...m[1].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "").toLowerCase()));
162
+ continue;
163
+ }
164
+
165
+ // FOREIGN KEY
166
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?FOREIGN\s+KEY\s*\(/i.test(trimmed)) {
167
+ const cnMatch = trimmed.match(/CONSTRAINT\s+`?(\w+)`?/i);
168
+ const fkMatch = trimmed.match(
169
+ /FOREIGN\s+KEY\s*\(([^)]+)\)\s*REFERENCES\s+`?(\w+)`?\s*\(([^)]+)\)/i
170
+ );
171
+ if (fkMatch) {
172
+ const onDel = trimmed.match(/ON\s+DELETE\s+(\w+(?:\s+\w+)?)/i);
173
+ const onUpd = trimmed.match(/ON\s+UPDATE\s+(\w+(?:\s+\w+)?)/i);
174
+ foreignKeys.push({
175
+ constraintName: cnMatch ? cnMatch[1] : null,
176
+ columns: fkMatch[1].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "").toLowerCase()),
177
+ referencedTable: fkMatch[2].toLowerCase(),
178
+ referencedColumns: fkMatch[3].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "").toLowerCase()),
179
+ onDelete: onDel ? onDel[1].toUpperCase() : null,
180
+ onUpdate: onUpd ? onUpd[1].toUpperCase() : null,
181
+ raw: trimmed,
182
+ });
183
+ }
184
+ continue;
185
+ }
186
+
187
+ // UNIQUE
188
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?UNIQUE\s*\(/i.test(trimmed)) {
189
+ const m = trimmed.match(/UNIQUE\s*\(([^)]+)\)/i);
190
+ if (m) uniqueConstraints.push(m[1].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "").toLowerCase()));
191
+ continue;
192
+ }
193
+
194
+ // INDEX / KEY
195
+ if (/^\s*(?:INDEX|KEY)\s+/i.test(trimmed)) {
196
+ const m = trimmed.match(/(?:INDEX|KEY)\s+`?(\w+)`?\s*\(([^)]+)\)/i);
197
+ if (m) {
198
+ indexes.push({
199
+ name: m[1],
200
+ columns: m[2].split(",").map((c) => c.trim().replace(/[`"[\]]/g, "").toLowerCase()),
201
+ unique: false,
202
+ });
203
+ }
204
+ continue;
205
+ }
206
+
207
+ // CHECK / CONSTRAINT CHECK
208
+ if (/^\s*(?:CONSTRAINT\s+\w+\s+)?CHECK\s*\(/i.test(trimmed)) {
209
+ continue;
210
+ }
211
+
212
+ // Column
213
+ const col = parseColumnDef(trimmed);
214
+ if (col) {
215
+ columns.set(col.name.toLowerCase(), col);
216
+ if (col.isPrimaryKey) primaryKey.push(col.name.toLowerCase());
217
+ }
218
+ }
219
+
220
+ tables.set(tableName, {
221
+ name: tableName,
222
+ columns,
223
+ primaryKey,
224
+ foreignKeys,
225
+ uniqueConstraints,
226
+ indexes,
227
+ });
228
+ }
229
+
230
+ return tables;
231
+ }
232
+
233
+ function buildColumnDefinition(col: ColumnDef): string {
234
+ let def = `${col.name} ${col.type}`;
235
+ if (!col.nullable) def += " NOT NULL";
236
+ if (col.defaultValue) def += ` DEFAULT ${col.defaultValue}`;
237
+ if (col.isUnique) def += " UNIQUE";
238
+ return def;
239
+ }
240
+
241
+ export function migrationGenerator(
242
+ oldSchema: string,
243
+ newSchema: string,
244
+ dialect: string = "postgresql"
245
+ ): MigrationResult {
246
+ const oldTables = parseSchema(oldSchema);
247
+ const newTables = parseSchema(newSchema);
248
+
249
+ const upStatements: string[] = [];
250
+ const downStatements: string[] = [];
251
+ const changes: ChangeDescription[] = [];
252
+
253
+ // Find new tables (exist in new but not old)
254
+ for (const [name, newTable] of newTables) {
255
+ if (!oldTables.has(name)) {
256
+ // Reconstruct CREATE TABLE
257
+ const colDefs = Array.from(newTable.columns.values()).map((c) => buildColumnDefinition(c));
258
+ if (newTable.primaryKey.length > 0) {
259
+ colDefs.push(`PRIMARY KEY (${newTable.primaryKey.join(", ")})`);
260
+ }
261
+ for (const fk of newTable.foreignKeys) {
262
+ let fkDef = `FOREIGN KEY (${fk.columns.join(", ")}) REFERENCES ${fk.referencedTable} (${fk.referencedColumns.join(", ")})`;
263
+ if (fk.onDelete) fkDef += ` ON DELETE ${fk.onDelete}`;
264
+ if (fk.onUpdate) fkDef += ` ON UPDATE ${fk.onUpdate}`;
265
+ colDefs.push(fkDef);
266
+ }
267
+ for (const uc of newTable.uniqueConstraints) {
268
+ colDefs.push(`UNIQUE (${uc.join(", ")})`);
269
+ }
270
+
271
+ upStatements.push(`CREATE TABLE ${name} (\n ${colDefs.join(",\n ")}\n);`);
272
+ downStatements.push(`DROP TABLE IF EXISTS ${name};`);
273
+ changes.push({ type: "add_table", table: name, detail: `Create new table "${name}"` });
274
+ }
275
+ }
276
+
277
+ // Find dropped tables (exist in old but not new)
278
+ for (const [name, oldTable] of oldTables) {
279
+ if (!newTables.has(name)) {
280
+ const colDefs = Array.from(oldTable.columns.values()).map((c) => buildColumnDefinition(c));
281
+ if (oldTable.primaryKey.length > 0) {
282
+ colDefs.push(`PRIMARY KEY (${oldTable.primaryKey.join(", ")})`);
283
+ }
284
+
285
+ upStatements.push(`DROP TABLE IF EXISTS ${name};`);
286
+ downStatements.push(`CREATE TABLE ${name} (\n ${colDefs.join(",\n ")}\n);`);
287
+ changes.push({ type: "drop_table", table: name, detail: `Drop table "${name}"` });
288
+ }
289
+ }
290
+
291
+ // Compare tables that exist in both
292
+ for (const [name, newTable] of newTables) {
293
+ const oldTable = oldTables.get(name);
294
+ if (!oldTable) continue;
295
+
296
+ // New columns
297
+ for (const [colName, newCol] of newTable.columns) {
298
+ if (!oldTable.columns.has(colName)) {
299
+ const colDef = buildColumnDefinition(newCol);
300
+ upStatements.push(`ALTER TABLE ${name} ADD COLUMN ${colDef};`);
301
+ downStatements.push(`ALTER TABLE ${name} DROP COLUMN ${colName};`);
302
+ changes.push({
303
+ type: "add_column",
304
+ table: name,
305
+ detail: `Add column "${colName}" (${newCol.type})`,
306
+ });
307
+ }
308
+ }
309
+
310
+ // Dropped columns
311
+ for (const [colName, oldCol] of oldTable.columns) {
312
+ if (!newTable.columns.has(colName)) {
313
+ upStatements.push(`ALTER TABLE ${name} DROP COLUMN ${colName};`);
314
+ const colDef = buildColumnDefinition(oldCol);
315
+ downStatements.push(`ALTER TABLE ${name} ADD COLUMN ${colDef};`);
316
+ changes.push({
317
+ type: "drop_column",
318
+ table: name,
319
+ detail: `Drop column "${colName}"`,
320
+ });
321
+ }
322
+ }
323
+
324
+ // Modified columns
325
+ for (const [colName, newCol] of newTable.columns) {
326
+ const oldCol = oldTable.columns.get(colName);
327
+ if (!oldCol) continue;
328
+
329
+ const typeChanged = oldCol.type !== newCol.type;
330
+ const nullChanged = oldCol.nullable !== newCol.nullable;
331
+ const defaultChanged = oldCol.defaultValue !== newCol.defaultValue;
332
+
333
+ if (typeChanged || nullChanged || defaultChanged) {
334
+ const modifications: string[] = [];
335
+
336
+ if (typeChanged) {
337
+ if (dialect === "mysql") {
338
+ upStatements.push(
339
+ `ALTER TABLE ${name} MODIFY COLUMN ${buildColumnDefinition(newCol)};`
340
+ );
341
+ downStatements.push(
342
+ `ALTER TABLE ${name} MODIFY COLUMN ${buildColumnDefinition(oldCol)};`
343
+ );
344
+ } else {
345
+ upStatements.push(
346
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} TYPE ${newCol.type};`
347
+ );
348
+ downStatements.push(
349
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} TYPE ${oldCol.type};`
350
+ );
351
+ }
352
+ modifications.push(`type ${oldCol.type} -> ${newCol.type}`);
353
+ }
354
+
355
+ if (nullChanged && !typeChanged) {
356
+ if (newCol.nullable) {
357
+ upStatements.push(
358
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} DROP NOT NULL;`
359
+ );
360
+ downStatements.push(
361
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} SET NOT NULL;`
362
+ );
363
+ } else {
364
+ upStatements.push(
365
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} SET NOT NULL;`
366
+ );
367
+ downStatements.push(
368
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} DROP NOT NULL;`
369
+ );
370
+ }
371
+ modifications.push(newCol.nullable ? "make nullable" : "make NOT NULL");
372
+ }
373
+
374
+ if (defaultChanged && !typeChanged) {
375
+ if (newCol.defaultValue) {
376
+ upStatements.push(
377
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} SET DEFAULT ${newCol.defaultValue};`
378
+ );
379
+ if (oldCol.defaultValue) {
380
+ downStatements.push(
381
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} SET DEFAULT ${oldCol.defaultValue};`
382
+ );
383
+ } else {
384
+ downStatements.push(
385
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} DROP DEFAULT;`
386
+ );
387
+ }
388
+ } else {
389
+ upStatements.push(
390
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} DROP DEFAULT;`
391
+ );
392
+ downStatements.push(
393
+ `ALTER TABLE ${name} ALTER COLUMN ${colName} SET DEFAULT ${oldCol.defaultValue};`
394
+ );
395
+ }
396
+ modifications.push(`default ${oldCol.defaultValue || "none"} -> ${newCol.defaultValue || "none"}`);
397
+ }
398
+
399
+ if (modifications.length > 0) {
400
+ changes.push({
401
+ type: "modify_column",
402
+ table: name,
403
+ detail: `Modify column "${colName}": ${modifications.join(", ")}`,
404
+ });
405
+ }
406
+ }
407
+ }
408
+
409
+ // Foreign key changes
410
+ const oldFKKeys = oldTable.foreignKeys.map(
411
+ (fk) => `${fk.columns.join(",")}->${fk.referencedTable}(${fk.referencedColumns.join(",")})`
412
+ );
413
+ const newFKKeys = newTable.foreignKeys.map(
414
+ (fk) => `${fk.columns.join(",")}->${fk.referencedTable}(${fk.referencedColumns.join(",")})`
415
+ );
416
+
417
+ // New FKs
418
+ for (let i = 0; i < newTable.foreignKeys.length; i++) {
419
+ if (!oldFKKeys.includes(newFKKeys[i])) {
420
+ const fk = newTable.foreignKeys[i];
421
+ const cnName = fk.constraintName || `fk_${name}_${fk.columns.join("_")}`;
422
+ let fkDef = `ALTER TABLE ${name} ADD CONSTRAINT ${cnName} FOREIGN KEY (${fk.columns.join(", ")}) REFERENCES ${fk.referencedTable} (${fk.referencedColumns.join(", ")})`;
423
+ if (fk.onDelete) fkDef += ` ON DELETE ${fk.onDelete}`;
424
+ if (fk.onUpdate) fkDef += ` ON UPDATE ${fk.onUpdate}`;
425
+ upStatements.push(`${fkDef};`);
426
+ downStatements.push(`ALTER TABLE ${name} DROP CONSTRAINT ${cnName};`);
427
+ changes.push({
428
+ type: "add_fk",
429
+ table: name,
430
+ detail: `Add foreign key ${fk.columns.join(", ")} -> ${fk.referencedTable}(${fk.referencedColumns.join(", ")})`,
431
+ });
432
+ }
433
+ }
434
+
435
+ // Dropped FKs
436
+ for (let i = 0; i < oldTable.foreignKeys.length; i++) {
437
+ if (!newFKKeys.includes(oldFKKeys[i])) {
438
+ const fk = oldTable.foreignKeys[i];
439
+ const cnName = fk.constraintName || `fk_${name}_${fk.columns.join("_")}`;
440
+ upStatements.push(`ALTER TABLE ${name} DROP CONSTRAINT ${cnName};`);
441
+ let fkDef = `ALTER TABLE ${name} ADD CONSTRAINT ${cnName} FOREIGN KEY (${fk.columns.join(", ")}) REFERENCES ${fk.referencedTable} (${fk.referencedColumns.join(", ")})`;
442
+ if (fk.onDelete) fkDef += ` ON DELETE ${fk.onDelete}`;
443
+ if (fk.onUpdate) fkDef += ` ON UPDATE ${fk.onUpdate}`;
444
+ downStatements.push(`${fkDef};`);
445
+ changes.push({
446
+ type: "drop_fk",
447
+ table: name,
448
+ detail: `Drop foreign key ${fk.columns.join(", ")} -> ${fk.referencedTable}(${fk.referencedColumns.join(", ")})`,
449
+ });
450
+ }
451
+ }
452
+
453
+ // Unique constraint changes
454
+ const oldUQKeys = oldTable.uniqueConstraints.map((uc) => uc.sort().join(","));
455
+ const newUQKeys = newTable.uniqueConstraints.map((uc) => uc.sort().join(","));
456
+
457
+ for (const uc of newTable.uniqueConstraints) {
458
+ const key = uc.sort().join(",");
459
+ if (!oldUQKeys.includes(key)) {
460
+ const cnName = `uq_${name}_${uc.join("_")}`;
461
+ upStatements.push(`ALTER TABLE ${name} ADD CONSTRAINT ${cnName} UNIQUE (${uc.join(", ")});`);
462
+ downStatements.push(`ALTER TABLE ${name} DROP CONSTRAINT ${cnName};`);
463
+ changes.push({
464
+ type: "add_unique",
465
+ table: name,
466
+ detail: `Add unique constraint on (${uc.join(", ")})`,
467
+ });
468
+ }
469
+ }
470
+
471
+ for (const uc of oldTable.uniqueConstraints) {
472
+ const key = uc.sort().join(",");
473
+ if (!newUQKeys.includes(key)) {
474
+ const cnName = `uq_${name}_${uc.join("_")}`;
475
+ upStatements.push(`ALTER TABLE ${name} DROP CONSTRAINT ${cnName};`);
476
+ downStatements.push(`ALTER TABLE ${name} ADD CONSTRAINT ${cnName} UNIQUE (${uc.join(", ")});`);
477
+ changes.push({
478
+ type: "drop_unique",
479
+ table: name,
480
+ detail: `Drop unique constraint on (${uc.join(", ")})`,
481
+ });
482
+ }
483
+ }
484
+ }
485
+
486
+ // Summary
487
+ const summary = changes.length > 0
488
+ ? `Migration contains ${changes.length} change(s): ${changes.map((c) => c.detail).join("; ")}`
489
+ : "No differences detected between the two schemas.";
490
+
491
+ return {
492
+ upStatements,
493
+ downStatements,
494
+ summary,
495
+ changes,
496
+ };
497
+ }