forge-sql-orm-cli 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.
- package/README.md +202 -0
- package/dist-cli/actions/generate-models.d.ts +19 -0
- package/dist-cli/actions/migrations-create.d.ts +46 -0
- package/dist-cli/actions/migrations-drops.d.ts +6 -0
- package/dist-cli/actions/migrations-update.d.ts +6 -0
- package/dist-cli/cli.d.ts +3 -0
- package/dist-cli/cli.js +862 -0
- package/dist-cli/cli.js.map +1 -0
- package/dist-cli/cli.mjs +862 -0
- package/dist-cli/cli.mjs.map +1 -0
- package/package.json +64 -0
- package/src/.env +7 -0
- package/src/actions/generate-models.ts +267 -0
- package/src/actions/migrations-create.ts +213 -0
- package/src/actions/migrations-drops.ts +139 -0
- package/src/actions/migrations-update.ts +659 -0
- package/src/cli.ts +302 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import mysql from "mysql2/promise";
|
|
5
|
+
import { MySqlTable, TableConfig } from "drizzle-orm/mysql-core";
|
|
6
|
+
import { RowDataPacket } from "mysql2";
|
|
7
|
+
import { getTableMetadata } from "forge-sql-orm";
|
|
8
|
+
import { AnyIndexBuilder } from "drizzle-orm/mysql-core/indexes";
|
|
9
|
+
import { ForeignKeyBuilder } from "drizzle-orm/mysql-core/foreign-keys";
|
|
10
|
+
import { UniqueConstraintBuilder } from "drizzle-orm/mysql-core/unique-constraint";
|
|
11
|
+
|
|
12
|
+
interface DrizzleColumn {
|
|
13
|
+
type: string;
|
|
14
|
+
notNull: boolean;
|
|
15
|
+
autoincrement?: boolean;
|
|
16
|
+
columnType?: any;
|
|
17
|
+
name: string;
|
|
18
|
+
getSQLType: () => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DrizzleSchema {
|
|
22
|
+
[tableName: string]: {
|
|
23
|
+
[columnName: string]: DrizzleColumn;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DatabaseColumn extends RowDataPacket {
|
|
28
|
+
TABLE_NAME: string;
|
|
29
|
+
COLUMN_NAME: string;
|
|
30
|
+
COLUMN_TYPE: string;
|
|
31
|
+
IS_NULLABLE: string;
|
|
32
|
+
COLUMN_KEY: string;
|
|
33
|
+
EXTRA: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DatabaseIndex extends RowDataPacket {
|
|
37
|
+
TABLE_NAME: string;
|
|
38
|
+
INDEX_NAME: string;
|
|
39
|
+
COLUMN_NAME: string;
|
|
40
|
+
NON_UNIQUE: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface DatabaseForeignKey extends RowDataPacket {
|
|
44
|
+
TABLE_NAME: string;
|
|
45
|
+
COLUMN_NAME: string;
|
|
46
|
+
CONSTRAINT_NAME: string;
|
|
47
|
+
REFERENCED_TABLE_NAME: string;
|
|
48
|
+
REFERENCED_COLUMN_NAME: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TableSchema {
|
|
52
|
+
columns: Record<string, DatabaseColumn>;
|
|
53
|
+
indexes: Record<
|
|
54
|
+
string,
|
|
55
|
+
{
|
|
56
|
+
columns: string[];
|
|
57
|
+
unique: boolean;
|
|
58
|
+
}
|
|
59
|
+
>;
|
|
60
|
+
foreignKeys: Record<
|
|
61
|
+
string,
|
|
62
|
+
{
|
|
63
|
+
column: string;
|
|
64
|
+
referencedTable: string;
|
|
65
|
+
referencedColumn: string;
|
|
66
|
+
}
|
|
67
|
+
>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface DatabaseSchema {
|
|
71
|
+
[tableName: string]: TableSchema;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generates a migration file using the provided SQL statements.
|
|
76
|
+
* @param createStatements - Array of SQL statements.
|
|
77
|
+
* @param version - Migration version number.
|
|
78
|
+
* @returns TypeScript migration file content.
|
|
79
|
+
*/
|
|
80
|
+
function generateMigrationFile(createStatements: string[], version: number): string {
|
|
81
|
+
const versionPrefix = `v${version}_MIGRATION`;
|
|
82
|
+
|
|
83
|
+
// Clean each SQL statement and generate migration lines with .enqueue()
|
|
84
|
+
const migrationLines = createStatements
|
|
85
|
+
.map((stmt, index) => ` .enqueue("${versionPrefix}${index}", "${stmt}")`)
|
|
86
|
+
.join("\n");
|
|
87
|
+
|
|
88
|
+
// Migration template
|
|
89
|
+
return `import { MigrationRunner } from "@forge/sql/out/migration";
|
|
90
|
+
|
|
91
|
+
export default (migrationRunner: MigrationRunner): MigrationRunner => {
|
|
92
|
+
return migrationRunner
|
|
93
|
+
${migrationLines};
|
|
94
|
+
};`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Filters out SQL statements that already exist in the previous migration file
|
|
99
|
+
* @param newStatements - Array of SQL statements from new migration
|
|
100
|
+
* @param prevVersion - Previous migration version
|
|
101
|
+
* @param outputDir - Directory where migration files are stored
|
|
102
|
+
* @returns Array of SQL statements that don't exist in previous migration
|
|
103
|
+
*/
|
|
104
|
+
function filterWithPreviousMigration(
|
|
105
|
+
newStatements: string[],
|
|
106
|
+
prevVersion: number,
|
|
107
|
+
outputDir: string,
|
|
108
|
+
): string[] {
|
|
109
|
+
const prevMigrationPath = path.join(outputDir, `migrationV${prevVersion}.ts`);
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(prevMigrationPath)) {
|
|
112
|
+
return newStatements.map((s) => s.replace(/\s+/g, " "));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Read previous migration file
|
|
116
|
+
const prevContent = fs.readFileSync(prevMigrationPath, "utf-8");
|
|
117
|
+
|
|
118
|
+
// Extract SQL statements from the file
|
|
119
|
+
const prevStatements = prevContent
|
|
120
|
+
.split("\n")
|
|
121
|
+
.filter((line) => line.includes(".enqueue("))
|
|
122
|
+
.map((line) => {
|
|
123
|
+
const match = line.match(/\.enqueue\([^,]+,\s*"([^"]+)"/);
|
|
124
|
+
return match ? match[1].replace(/\s+/g, " ").trim() : "";
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Filter out statements that already exist in previous migration
|
|
128
|
+
return newStatements
|
|
129
|
+
.filter((s) => !prevStatements.includes(s.replace(/\s+/g, " ")))
|
|
130
|
+
.map((s) => s.replace(/\s+/g, " "));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Saves the generated migration file along with `migrationCount.ts` and `index.ts`.
|
|
135
|
+
* @param migrationCode - The migration code to be written to the file.
|
|
136
|
+
* @param version - Migration version number.
|
|
137
|
+
* @param outputDir - Directory where the migration files will be saved.
|
|
138
|
+
* @returns boolean indicating if migration was saved
|
|
139
|
+
*/
|
|
140
|
+
function saveMigrationFiles(migrationCode: string, version: number, outputDir: string): boolean {
|
|
141
|
+
if (!fs.existsSync(outputDir)) {
|
|
142
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const migrationFilePath = path.join(outputDir, `migrationV${version}.ts`);
|
|
146
|
+
const migrationCountPath = path.join(outputDir, `migrationCount.ts`);
|
|
147
|
+
const indexFilePath = path.join(outputDir, `index.ts`);
|
|
148
|
+
|
|
149
|
+
// Write the migration file
|
|
150
|
+
fs.writeFileSync(migrationFilePath, migrationCode);
|
|
151
|
+
|
|
152
|
+
// Write the migration count file
|
|
153
|
+
fs.writeFileSync(migrationCountPath, `export const MIGRATION_VERSION = ${version};`);
|
|
154
|
+
|
|
155
|
+
// Generate the migration index file
|
|
156
|
+
const indexFileContent = `import { MigrationRunner } from "@forge/sql/out/migration";
|
|
157
|
+
import { MIGRATION_VERSION } from "./migrationCount";
|
|
158
|
+
|
|
159
|
+
export type MigrationType = (
|
|
160
|
+
migrationRunner: MigrationRunner,
|
|
161
|
+
) => MigrationRunner;
|
|
162
|
+
|
|
163
|
+
export default async (
|
|
164
|
+
migrationRunner: MigrationRunner,
|
|
165
|
+
): Promise<MigrationRunner> => {
|
|
166
|
+
for (let i = 1; i <= MIGRATION_VERSION; i++) {
|
|
167
|
+
const migrations = (await import(\`./migrationV\${i}\`)) as {
|
|
168
|
+
default: MigrationType;
|
|
169
|
+
};
|
|
170
|
+
migrations.default(migrationRunner);
|
|
171
|
+
}
|
|
172
|
+
return migrationRunner;
|
|
173
|
+
};`;
|
|
174
|
+
|
|
175
|
+
fs.writeFileSync(indexFilePath, indexFileContent);
|
|
176
|
+
|
|
177
|
+
console.log(`✅ Migration file created: ${migrationFilePath}`);
|
|
178
|
+
console.log(`✅ Migration count file updated: ${migrationCountPath}`);
|
|
179
|
+
console.log(`✅ Migration index file created: ${indexFilePath}`);
|
|
180
|
+
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Loads the current migration version from `migrationCount.ts`.
|
|
186
|
+
* @param migrationPath - Path to the migration folder.
|
|
187
|
+
* @returns The latest migration version.
|
|
188
|
+
*/
|
|
189
|
+
const loadMigrationVersion = async (migrationPath: string): Promise<number> => {
|
|
190
|
+
try {
|
|
191
|
+
const migrationCountFilePath = path.resolve(path.join(migrationPath, "migrationCount.ts"));
|
|
192
|
+
if (!fs.existsSync(migrationCountFilePath)) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`⚠️ Warning: migrationCount.ts not found in ${migrationCountFilePath}, assuming no previous migrations.`,
|
|
195
|
+
);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { MIGRATION_VERSION } = await import(migrationCountFilePath);
|
|
200
|
+
console.log(`✅ Current migration version: ${MIGRATION_VERSION}`);
|
|
201
|
+
return MIGRATION_VERSION as number;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error(`❌ Error loading migrationCount:`, error);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Gets the current database schema from MySQL including indexes and foreign keys
|
|
210
|
+
* @param connection - MySQL connection
|
|
211
|
+
* @param dbName - Database name
|
|
212
|
+
* @returns Database schema object with indexes and foreign keys
|
|
213
|
+
*/
|
|
214
|
+
async function getDatabaseSchema(
|
|
215
|
+
connection: mysql.Connection,
|
|
216
|
+
dbName: string,
|
|
217
|
+
): Promise<DatabaseSchema> {
|
|
218
|
+
// Get columns
|
|
219
|
+
const [columns] = await connection.execute<DatabaseColumn[]>(
|
|
220
|
+
`
|
|
221
|
+
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, EXTRA
|
|
222
|
+
FROM INFORMATION_SCHEMA.COLUMNS
|
|
223
|
+
WHERE TABLE_SCHEMA = ?
|
|
224
|
+
`,
|
|
225
|
+
[dbName],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Get indexes
|
|
229
|
+
const [indexes] = await connection.execute<DatabaseIndex[]>(
|
|
230
|
+
`
|
|
231
|
+
SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME, NON_UNIQUE
|
|
232
|
+
FROM INFORMATION_SCHEMA.STATISTICS
|
|
233
|
+
WHERE TABLE_SCHEMA = ?
|
|
234
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
|
235
|
+
`,
|
|
236
|
+
[dbName],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Get foreign keys
|
|
240
|
+
const [foreignKeys] = await connection.execute<DatabaseForeignKey[]>(
|
|
241
|
+
`
|
|
242
|
+
SELECT
|
|
243
|
+
TABLE_NAME,
|
|
244
|
+
COLUMN_NAME,
|
|
245
|
+
CONSTRAINT_NAME,
|
|
246
|
+
REFERENCED_TABLE_NAME,
|
|
247
|
+
REFERENCED_COLUMN_NAME
|
|
248
|
+
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
|
249
|
+
WHERE TABLE_SCHEMA = ?
|
|
250
|
+
AND REFERENCED_TABLE_NAME IS NOT NULL
|
|
251
|
+
`,
|
|
252
|
+
[dbName],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const schema: DatabaseSchema = {};
|
|
256
|
+
|
|
257
|
+
// Process columns
|
|
258
|
+
columns.forEach((row) => {
|
|
259
|
+
if (!schema[row.TABLE_NAME]) {
|
|
260
|
+
schema[row.TABLE_NAME] = {
|
|
261
|
+
columns: {},
|
|
262
|
+
indexes: {},
|
|
263
|
+
foreignKeys: {},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
schema[row.TABLE_NAME].columns[row.COLUMN_NAME] = row;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Process indexes
|
|
270
|
+
indexes.forEach((row) => {
|
|
271
|
+
if (!schema[row.TABLE_NAME].indexes[row.INDEX_NAME]) {
|
|
272
|
+
schema[row.TABLE_NAME].indexes[row.INDEX_NAME] = {
|
|
273
|
+
columns: [],
|
|
274
|
+
unique: !row.NON_UNIQUE,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
schema[row.TABLE_NAME].indexes[row.INDEX_NAME].columns.push(row.COLUMN_NAME);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Process foreign keys
|
|
281
|
+
foreignKeys.forEach((row) => {
|
|
282
|
+
if (!schema[row.TABLE_NAME].foreignKeys[row.CONSTRAINT_NAME]) {
|
|
283
|
+
schema[row.TABLE_NAME].foreignKeys[row.CONSTRAINT_NAME] = {
|
|
284
|
+
column: row.COLUMN_NAME,
|
|
285
|
+
referencedTable: row.REFERENCED_TABLE_NAME,
|
|
286
|
+
referencedColumn: row.REFERENCED_COLUMN_NAME,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return schema;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Converts MySQL type to normalized format for comparison
|
|
296
|
+
* @param mysqlType - MySQL type from INFORMATION_SCHEMA or Drizzle type
|
|
297
|
+
* @returns Normalized type string
|
|
298
|
+
*/
|
|
299
|
+
function normalizeMySQLType(mysqlType: string): string {
|
|
300
|
+
// Remove length/precision information
|
|
301
|
+
let normalized = mysqlType.replace(/\([^)]*\)/, "").toLowerCase();
|
|
302
|
+
|
|
303
|
+
// Remove 'mysql' prefix from Drizzle types
|
|
304
|
+
normalized = normalized.replace(/^mysql/, "");
|
|
305
|
+
|
|
306
|
+
return normalized;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Gets the name of a foreign key constraint
|
|
311
|
+
* @param fk - The foreign key builder
|
|
312
|
+
* @returns The name of the foreign key constraint
|
|
313
|
+
*/
|
|
314
|
+
function getForeignKeyName(fk: ForeignKeyBuilder): string {
|
|
315
|
+
// @ts-ignore - Internal property access
|
|
316
|
+
return fk.name;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Gets the name of an index
|
|
321
|
+
* @param index - The index builder
|
|
322
|
+
* @returns The name of the index
|
|
323
|
+
*/
|
|
324
|
+
function getIndexName(index: AnyIndexBuilder): string {
|
|
325
|
+
// @ts-ignore - Internal property access
|
|
326
|
+
return index.name;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Gets the name of a unique constraint
|
|
331
|
+
* @param uc - The unique constraint builder
|
|
332
|
+
* @returns The name of the unique constraint
|
|
333
|
+
*/
|
|
334
|
+
function getUniqueConstraintName(uc: UniqueConstraintBuilder): string {
|
|
335
|
+
// @ts-ignore - Internal property access
|
|
336
|
+
return uc.name;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Gets the columns of an index
|
|
341
|
+
* @param index - The index builder
|
|
342
|
+
* @returns Array of column names
|
|
343
|
+
*/
|
|
344
|
+
function getIndexColumns(index: AnyIndexBuilder): string[] {
|
|
345
|
+
// @ts-ignore - Internal property access
|
|
346
|
+
return index.columns.map((col) => col.name);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function compareForeignKey(
|
|
350
|
+
fk: ForeignKeyBuilder,
|
|
351
|
+
{ columns }: { columns: string[]; unique: boolean },
|
|
352
|
+
) {
|
|
353
|
+
// @ts-ignore
|
|
354
|
+
const fcolumns: string[] = fk.columns.map((c) => c.name);
|
|
355
|
+
return fcolumns.sort().join(",") === columns.sort().join(",");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Generates SQL changes by comparing Drizzle schema with database schema
|
|
360
|
+
* @param drizzleSchema - Schema from Drizzle
|
|
361
|
+
* @param dbSchema - Schema from database
|
|
362
|
+
* @param schemaModule - Drizzle schema module
|
|
363
|
+
* @returns Array of SQL statements
|
|
364
|
+
*/
|
|
365
|
+
function generateSchemaChanges(
|
|
366
|
+
drizzleSchema: DrizzleSchema,
|
|
367
|
+
dbSchema: DatabaseSchema,
|
|
368
|
+
schemaModule: Record<string, any>,
|
|
369
|
+
): string[] {
|
|
370
|
+
const changes: string[] = [];
|
|
371
|
+
|
|
372
|
+
// First check existing tables in database
|
|
373
|
+
for (const [tableName, dbTable] of Object.entries(dbSchema)) {
|
|
374
|
+
const drizzleColumns = drizzleSchema[tableName];
|
|
375
|
+
|
|
376
|
+
if (!drizzleColumns) {
|
|
377
|
+
// Table exists in database but not in schema - create it
|
|
378
|
+
const columns = Object.entries(dbTable.columns)
|
|
379
|
+
.map(([colName, col]) => {
|
|
380
|
+
const type = col.COLUMN_TYPE;
|
|
381
|
+
const nullable = col.IS_NULLABLE === "YES" ? "NULL" : "NOT NULL";
|
|
382
|
+
const autoIncrement = col.EXTRA.includes("auto_increment") ? "AUTO_INCREMENT" : "";
|
|
383
|
+
return `\`${colName}\` ${type} ${nullable} ${autoIncrement}`.trim();
|
|
384
|
+
})
|
|
385
|
+
.join(",\n ");
|
|
386
|
+
|
|
387
|
+
changes.push(`CREATE TABLE if not exists \`${tableName}\` (\n ${columns}\n);`);
|
|
388
|
+
|
|
389
|
+
// Create indexes for new table
|
|
390
|
+
for (const [indexName, dbIndex] of Object.entries(dbTable.indexes)) {
|
|
391
|
+
// Skip primary key and foreign key indexes
|
|
392
|
+
if (indexName === "PRIMARY") {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check if any column in this index is a foreign key
|
|
397
|
+
const isForeignKeyIndex = dbIndex.columns.some((colName) => {
|
|
398
|
+
const column = dbTable.columns[colName];
|
|
399
|
+
return column && column.COLUMN_KEY === "MUL" && column.EXTRA.includes("foreign key");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (isForeignKeyIndex) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Create index
|
|
407
|
+
const columns = dbIndex.columns.map((col) => `\`${col}\``).join(", ");
|
|
408
|
+
const unique = dbIndex.unique ? "UNIQUE " : "";
|
|
409
|
+
changes.push(
|
|
410
|
+
`CREATE ${unique}INDEX if not exists \`${indexName}\` ON \`${tableName}\` (${columns});`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Create foreign keys for new table
|
|
415
|
+
for (const [fkName, dbFK] of Object.entries(dbTable.foreignKeys)) {
|
|
416
|
+
changes.push(
|
|
417
|
+
`ALTER TABLE \`${tableName}\` ADD CONSTRAINT \`${fkName}\` FOREIGN KEY (\`${dbFK.column}\`) REFERENCES \`${dbFK.referencedTable}\` (\`${dbFK.referencedColumn}\`);`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check for column changes in existing tables
|
|
424
|
+
for (const [colName, dbCol] of Object.entries(dbTable.columns)) {
|
|
425
|
+
const drizzleCol = Object.values(drizzleColumns).find((c) => c.name === colName);
|
|
426
|
+
|
|
427
|
+
if (!drizzleCol) {
|
|
428
|
+
// Column exists in database but not in schema - create it
|
|
429
|
+
const type = dbCol.COLUMN_TYPE;
|
|
430
|
+
const nullable = dbCol.IS_NULLABLE === "YES" ? "NULL" : "NOT NULL";
|
|
431
|
+
changes.push(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${colName}\` ${type} ${nullable};`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Check for type changes
|
|
436
|
+
const normalizedDbType = normalizeMySQLType(dbCol.COLUMN_TYPE);
|
|
437
|
+
const normalizedDrizzleType = normalizeMySQLType(drizzleCol.getSQLType());
|
|
438
|
+
|
|
439
|
+
if (normalizedDbType !== normalizedDrizzleType) {
|
|
440
|
+
const type = dbCol.COLUMN_TYPE; // Use database type as source of truth
|
|
441
|
+
const nullable = dbCol.IS_NULLABLE === "YES" ? "NULL" : "NOT NULL";
|
|
442
|
+
changes.push(
|
|
443
|
+
`ALTER TABLE \`${tableName}\` MODIFY COLUMN \`${colName}\` ${type} ${nullable};`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check for index changes
|
|
449
|
+
const table = Object.values(schemaModule).find((t) => {
|
|
450
|
+
const metadata = getTableMetadata(t);
|
|
451
|
+
return metadata.tableName === tableName;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (table) {
|
|
455
|
+
const metadata = getTableMetadata(table);
|
|
456
|
+
// First check indexes that exist in database but not in schema
|
|
457
|
+
for (const [indexName, dbIndex] of Object.entries(dbTable.indexes)) {
|
|
458
|
+
// Skip primary key and foreign key indexes
|
|
459
|
+
if (indexName === "PRIMARY") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Check if this is a foreign key index
|
|
464
|
+
const isForeignKeyIndex = metadata.foreignKeys.some(
|
|
465
|
+
(fk) => getForeignKeyName(fk) === indexName || compareForeignKey(fk, dbIndex),
|
|
466
|
+
);
|
|
467
|
+
if (isForeignKeyIndex) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Check if this is a unique constraint
|
|
472
|
+
const existsUniqIndex = metadata.uniqueConstraints.find(
|
|
473
|
+
(uc) => getUniqueConstraintName(uc) === indexName,
|
|
474
|
+
);
|
|
475
|
+
let drizzleIndex = metadata.indexes.find((i) => getIndexName(i) === indexName);
|
|
476
|
+
|
|
477
|
+
if (!drizzleIndex && existsUniqIndex) {
|
|
478
|
+
drizzleIndex = existsUniqIndex as unknown as AnyIndexBuilder;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!drizzleIndex) {
|
|
482
|
+
// Index exists in database but not in schema - create it
|
|
483
|
+
const columns = dbIndex.columns.map((col) => `\`${col}\``).join(", ");
|
|
484
|
+
const unique = dbIndex.unique ? "UNIQUE " : "";
|
|
485
|
+
changes.push(
|
|
486
|
+
`CREATE ${unique}INDEX if not exists \`${indexName}\` ON \`${tableName}\` (${columns});`,
|
|
487
|
+
);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Check if index columns changed
|
|
492
|
+
const dbColumns = dbIndex.columns.join(", ");
|
|
493
|
+
const drizzleColumns = getIndexColumns(drizzleIndex).join(", ");
|
|
494
|
+
if (
|
|
495
|
+
dbColumns !== drizzleColumns ||
|
|
496
|
+
dbIndex.unique !== drizzleIndex instanceof UniqueConstraintBuilder
|
|
497
|
+
) {
|
|
498
|
+
// Drop and recreate index using database values
|
|
499
|
+
changes.push(`DROP INDEX \`${indexName}\` ON \`${tableName}\`;`);
|
|
500
|
+
const columns = dbIndex.columns.map((col) => `\`${col}\``).join(", ");
|
|
501
|
+
const unique = dbIndex.unique ? "UNIQUE " : "";
|
|
502
|
+
changes.push(
|
|
503
|
+
`CREATE ${unique}INDEX if not exists \`${indexName}\` ON \`${tableName}\` (${columns});`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// First check foreign keys that exist in database but not in schema
|
|
509
|
+
for (const [fkName, dbFK] of Object.entries(dbTable.foreignKeys)) {
|
|
510
|
+
// Find if this column is referenced in Drizzle schema
|
|
511
|
+
const drizzleFK = metadata.foreignKeys.find(
|
|
512
|
+
(fk) =>
|
|
513
|
+
getForeignKeyName(fk) === fkName ||
|
|
514
|
+
compareForeignKey(fk, { columns: [dbFK.column], unique: false }),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
if (!drizzleFK) {
|
|
518
|
+
// Foreign key exists in database but not in schema - drop it
|
|
519
|
+
changes.push(
|
|
520
|
+
`ALTER TABLE \`${tableName}\` ADD CONSTRAINT \`${fkName}\` FOREIGN KEY (\`${dbFK.column}\`) REFERENCES \`${dbFK.referencedTable}\` (\`${dbFK.referencedColumn}\`);`,
|
|
521
|
+
);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Then check for new foreign keys that exist in schema but not in database
|
|
527
|
+
for (const drizzleForeignKey of metadata.foreignKeys) {
|
|
528
|
+
// Find if this foreign key exists in database
|
|
529
|
+
const isDbFk = Object.keys(dbTable.foreignKeys).find((fk) => {
|
|
530
|
+
let foreignKey = dbTable.foreignKeys[fk];
|
|
531
|
+
return (
|
|
532
|
+
fk === getForeignKeyName(drizzleForeignKey) ||
|
|
533
|
+
compareForeignKey(drizzleForeignKey, { columns: [foreignKey.column], unique: false })
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
if (!isDbFk) {
|
|
538
|
+
// Foreign key exists in schema but not in database - create it
|
|
539
|
+
const fkName = getForeignKeyName(drizzleForeignKey);
|
|
540
|
+
if (fkName) {
|
|
541
|
+
changes.push(`ALTER TABLE \`${tableName}\` DROP FOREIGN KEY \`${fkName}\`;`);
|
|
542
|
+
} else {
|
|
543
|
+
// @ts-ignore
|
|
544
|
+
const columns = drizzleForeignKey?.columns;
|
|
545
|
+
const columnNames = columns?.length
|
|
546
|
+
? columns.map((c: any) => c.name).join(", ")
|
|
547
|
+
: "unknown columns";
|
|
548
|
+
console.warn(
|
|
549
|
+
`⚠️ Drizzle model for table '${tableName}' does not provide a name for FOREIGN KEY constraint on columns: ${columnNames}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return changes;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Updates an existing database migration by generating schema modifications.
|
|
562
|
+
* @param options - Database connection settings and output paths.
|
|
563
|
+
*/
|
|
564
|
+
export const updateMigration = async (options: any) => {
|
|
565
|
+
try {
|
|
566
|
+
let version = await loadMigrationVersion(options.output);
|
|
567
|
+
const prevVersion = version;
|
|
568
|
+
|
|
569
|
+
if (version < 1) {
|
|
570
|
+
console.log(
|
|
571
|
+
`⚠️ Initial migration not found. Run "npx forge-sql-orm migrations:create" first.`,
|
|
572
|
+
);
|
|
573
|
+
process.exit(0);
|
|
574
|
+
}
|
|
575
|
+
version += 1;
|
|
576
|
+
|
|
577
|
+
// Create database connection
|
|
578
|
+
const connection = await mysql.createConnection({
|
|
579
|
+
host: options.host,
|
|
580
|
+
port: options.port,
|
|
581
|
+
user: options.user,
|
|
582
|
+
password: options.password,
|
|
583
|
+
database: options.dbName,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
// Get current database schema
|
|
588
|
+
const dbSchema = await getDatabaseSchema(connection, options.dbName);
|
|
589
|
+
|
|
590
|
+
// Import Drizzle schema using absolute path
|
|
591
|
+
const schemaPath = path.resolve(options.entitiesPath, "schema.ts");
|
|
592
|
+
if (!fs.existsSync(schemaPath)) {
|
|
593
|
+
throw new Error(`Schema file not found at: ${schemaPath}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const schemaModule = await import(schemaPath);
|
|
597
|
+
if (!schemaModule) {
|
|
598
|
+
throw new Error(`Invalid schema file at: ${schemaPath}. Schema must export tables.`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Process exported tables
|
|
602
|
+
const drizzleSchema: DrizzleSchema = {};
|
|
603
|
+
|
|
604
|
+
// Get all exports that are tables
|
|
605
|
+
const tables = Object.values(schemaModule) as MySqlTable<TableConfig>[];
|
|
606
|
+
|
|
607
|
+
tables.forEach((table) => {
|
|
608
|
+
const metadata = getTableMetadata(table);
|
|
609
|
+
if (metadata.tableName) {
|
|
610
|
+
// Convert AnyColumn to DrizzleColumn
|
|
611
|
+
const columns: Record<string, DrizzleColumn> = {};
|
|
612
|
+
Object.entries(metadata.columns).forEach(([name, column]) => {
|
|
613
|
+
columns[name] = {
|
|
614
|
+
type: column.dataType,
|
|
615
|
+
notNull: column.notNull,
|
|
616
|
+
autoincrement: (column as any).autoincrement,
|
|
617
|
+
columnType: column.columnType,
|
|
618
|
+
name: column.name,
|
|
619
|
+
getSQLType: () => column.getSQLType(),
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
drizzleSchema[metadata.tableName] = columns;
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
if (Object.keys(drizzleSchema).length === 0) {
|
|
627
|
+
throw new Error(`No valid tables found in schema at: ${schemaPath}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
console.log("Found tables:", Object.keys(drizzleSchema));
|
|
631
|
+
|
|
632
|
+
// Generate SQL changes
|
|
633
|
+
const createStatements = filterWithPreviousMigration(
|
|
634
|
+
generateSchemaChanges(drizzleSchema, dbSchema, schemaModule),
|
|
635
|
+
prevVersion,
|
|
636
|
+
options.output,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
if (createStatements.length) {
|
|
640
|
+
// Generate migration file content
|
|
641
|
+
const migrationFile = generateMigrationFile(createStatements, version);
|
|
642
|
+
|
|
643
|
+
// Save migration files only if there are actual changes
|
|
644
|
+
if (saveMigrationFiles(migrationFile, version, options.output)) {
|
|
645
|
+
console.log(`✅ Migration successfully updated!`);
|
|
646
|
+
}
|
|
647
|
+
process.exit(0);
|
|
648
|
+
} else {
|
|
649
|
+
console.log(`⚠️ No new migration changes detected.`);
|
|
650
|
+
process.exit(0);
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
await connection.end();
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
console.error(`❌ Error during migration update:`, error);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
};
|