• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rogerpadilla / uql / 20834566635

08 Jan 2026 10:54PM UTC coverage: 94.064% (-4.3%) from 98.408%
20834566635

Pull #81

github

web-flow
Merge 888eca30f into 097cb85cf
Pull Request #81: feat: implementation of unified ORM schema synchronization system via Schema AST

2290 of 2605 branches covered (87.91%)

Branch coverage included in aggregate %.

1766 of 1890 new or added lines in 37 files covered. (93.44%)

13 existing lines in 1 file now uncovered.

5079 of 5229 relevant lines covered (97.13%)

181.3 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.22
/packages/core/src/migrate/codegen/migrationCodeGenerator.ts
1
/**
2
 * Migration Code Generator
3
 *
4
 * Generates TypeScript migration code from SchemaDiff.
5
 * This enables auto-generating migrations from entity changes.
6
 */
7

8
import type { ColumnSchema, ForeignKeySchema, IndexSchema, SchemaDiff } from '../../type/index.js';
9

10
export interface MigrationCodeOptions {
11
  /** Indentation string (default: 2 spaces) */
12
  indent?: string;
13
  /** Whether to include comments explaining changes */
14
  includeComments?: boolean;
15
}
16

17
export interface GeneratedMigration {
18
  /** The up migration code */
19
  up: string;
20
  /** The down migration code */
21
  down: string;
22
  /** Human-readable description of changes */
23
  description: string;
24
}
25

26
/**
27
 * Generates TypeScript migration code from schema diffs.
28
 */
29
export class MigrationCodeGenerator {
30
  private readonly indent: string;
31
  private readonly includeComments: boolean;
32

33
  constructor(options: MigrationCodeOptions = {}) {
17✔
34
    this.indent = options.indent ?? '  ';
17✔
35
    this.includeComments = options.includeComments ?? true;
17✔
36
  }
37

38
  /**
39
   * Generate migration code from a single SchemaDiff.
40
   */
41
  generate(diff: SchemaDiff): GeneratedMigration {
42
    const description = this.generateDescription(diff);
17✔
43

44
    if (diff.type === 'create') {
17✔
45
      return {
7✔
46
        up: this.generateCreateTable(diff),
47
        down: this.generateDropTable(diff.tableName),
48
        description,
49
      };
50
    }
51

52
    if (diff.type === 'drop') {
10✔
53
      return {
1✔
54
        up: this.generateDropTable(diff.tableName),
55
        down: '// Cannot auto-generate: original table structure unknown',
56
        description,
57
      };
58
    }
59

60
    // alter
61
    return {
9✔
62
      up: this.generateAlterUp(diff),
63
      down: this.generateAlterDown(diff),
64
      description,
65
    };
66
  }
67

68
  /**
69
   * Generate migration code from multiple diffs.
70
   */
71
  generateAll(diffs: SchemaDiff[]): GeneratedMigration {
72
    const ups: string[] = [];
2✔
73
    const downs: string[] = [];
2✔
74
    const descriptions: string[] = [];
2✔
75

76
    for (const diff of diffs) {
2✔
77
      const result = this.generate(diff);
3✔
78
      ups.push(result.up);
3✔
79
      downs.push(result.down);
3✔
80
      descriptions.push(result.description);
3✔
81
    }
82

83
    return {
2✔
84
      up: ups.join('\n\n'),
85
      down: downs.reverse().join('\n\n'), // Reverse for correct rollback order
86
      description: descriptions.join('; '),
87
    };
88
  }
89

90
  /**
91
   * Generate a complete migration file template.
92
   */
93
  generateFile(diffs: SchemaDiff[], name?: string): string {
94
    const { up, down, description } = this.generateAll(diffs);
1✔
95
    const timestamp = new Date()
1✔
96
      .toISOString()
97
      .replace(/[-:T.Z]/g, '')
98
      .slice(0, 14);
99
    const migrationName = name ?? `migration_${timestamp}`;
1!
100

101
    return `/**
1✔
102
 * Migration: ${migrationName}
103
 * ${description}
104
 *
105
 * Generated at: ${new Date().toISOString()}
106
 */
107

108
import type { IMigrationBuilder } from '@uql/core';
109

110
export async function up(builder: IMigrationBuilder): Promise<void> {
111
${this.indentBlock(up, 1)}
112
}
113

114
export async function down(builder: IMigrationBuilder): Promise<void> {
115
${this.indentBlock(down, 1)}
116
}
117
`;
118
  }
119

120
  // ===========================================================================
121
  // Private: Code Generation
122
  // ===========================================================================
123

124
  private generateCreateTable(diff: SchemaDiff): string {
125
    const columns = diff.columnsToAdd ?? [];
7!
126
    const lines: string[] = [];
7✔
127

128
    lines.push(`await builder.createTable('${diff.tableName}', (table) => {`);
7✔
129

130
    for (const col of columns) {
7✔
131
      lines.push(`${this.indent}${this.generateColumnCall(col)};`);
16✔
132
    }
133

134
    // Add indexes if any
135
    for (const idx of diff.indexesToAdd ?? []) {
7✔
136
      lines.push(`${this.indent}${this.generateIndexCall(idx)};`);
1✔
137
    }
138

139
    lines.push('});');
7✔
140

141
    return lines.join('\n');
7✔
142
  }
143

144
  private generateDropTable(tableName: string): string {
145
    return `await builder.dropTable('${tableName}');`;
8✔
146
  }
147

148
  private generateAlterUp(diff: SchemaDiff): string {
149
    const lines: string[] = [];
9✔
150

151
    // Add columns
152
    for (const col of diff.columnsToAdd ?? []) {
9✔
153
      lines.push(this.generateAddColumn(diff.tableName, col));
8✔
154
    }
155

156
    // Alter columns
157
    for (const { to } of diff.columnsToAlter ?? []) {
9✔
158
      lines.push(this.generateAlterColumn(diff.tableName, to));
1✔
159
    }
160

161
    // Drop columns
162
    for (const colName of diff.columnsToDrop ?? []) {
9✔
163
      lines.push(`await builder.dropColumn('${diff.tableName}', '${colName}');`);
2✔
164
    }
165

166
    // Add indexes
167
    for (const idx of diff.indexesToAdd ?? []) {
9✔
168
      lines.push(this.generateCreateIndex(diff.tableName, idx));
1✔
169
    }
170

171
    // Drop indexes
172
    for (const idxName of diff.indexesToDrop ?? []) {
9✔
NEW
173
      lines.push(`await builder.dropIndex('${diff.tableName}', '${idxName}');`);
×
174
    }
175

176
    // Add foreign keys
177
    for (const fk of diff.foreignKeysToAdd ?? []) {
9✔
178
      lines.push(this.generateAddForeignKey(diff.tableName, fk));
1✔
179
    }
180

181
    // Drop foreign keys
182
    for (const fkName of diff.foreignKeysToDrop ?? []) {
9✔
NEW
183
      lines.push(`await builder.dropForeignKey('${diff.tableName}', '${fkName}');`);
×
184
    }
185

186
    return lines.join('\n');
9✔
187
  }
188

189
  private generateAlterDown(diff: SchemaDiff): string {
190
    const lines: string[] = [];
9✔
191

192
    // Reverse: drop added columns
193
    for (const col of diff.columnsToAdd ?? []) {
9✔
194
      lines.push(`await builder.dropColumn('${diff.tableName}', '${col.name}');`);
8✔
195
    }
196

197
    // Reverse: restore altered columns
198
    for (const { from } of diff.columnsToAlter ?? []) {
9✔
199
      lines.push(this.generateAlterColumn(diff.tableName, from));
1✔
200
    }
201

202
    // Reverse: re-add dropped columns (if we have the schema)
203
    // Note: This requires the original column definition
204
    if (diff.columnsToDrop?.length) {
9✔
205
      lines.push(`// TODO: Re-add dropped columns: ${diff.columnsToDrop.join(', ')}`);
2✔
206
    }
207

208
    // Reverse: drop added indexes
209
    for (const idx of diff.indexesToAdd ?? []) {
9✔
210
      lines.push(`await builder.dropIndex('${diff.tableName}', '${idx.name}');`);
1✔
211
    }
212

213
    // Reverse: re-add dropped indexes
214
    if (diff.indexesToDrop?.length) {
9!
NEW
215
      lines.push(`// TODO: Re-add dropped indexes: ${diff.indexesToDrop.join(', ')}`);
×
216
    }
217

218
    // Reverse: drop added foreign keys
219
    for (const fk of diff.foreignKeysToAdd ?? []) {
9✔
220
      if (fk.name) {
1!
221
        lines.push(`await builder.dropForeignKey('${diff.tableName}', '${fk.name}');`);
1✔
222
      }
223
    }
224

225
    // Reverse: re-add dropped foreign keys
226
    if (diff.foreignKeysToDrop?.length) {
9!
NEW
227
      lines.push(`// TODO: Re-add dropped foreign keys: ${diff.foreignKeysToDrop.join(', ')}`);
×
228
    }
229

230
    return lines.join('\n');
9✔
231
  }
232

233
  private generateAddColumn(tableName: string, col: ColumnSchema): string {
234
    const options = this.generateColumnOptions(col);
8✔
235
    return `await builder.addColumn('${tableName}', '${col.name}', (col) => col${options});`;
8✔
236
  }
237

238
  private generateAlterColumn(tableName: string, col: ColumnSchema): string {
239
    const options = this.generateColumnOptions(col);
2✔
240
    return `await builder.alterColumn('${tableName}', '${col.name}', (col) => col${options});`;
2✔
241
  }
242

243
  private generateCreateIndex(tableName: string, idx: IndexSchema): string {
244
    const cols = idx.columns.map((c) => `'${c}'`).join(', ');
1✔
245
    const opts: string[] = [];
1✔
246
    if (idx.name) opts.push(`name: '${idx.name}'`);
1!
247
    if (idx.unique) opts.push('unique: true');
1!
248
    const optsStr = opts.length ? `, { ${opts.join(', ')} }` : '';
1!
249
    return `await builder.createIndex('${tableName}', [${cols}]${optsStr});`;
1✔
250
  }
251

252
  private generateAddForeignKey(tableName: string, fk: ForeignKeySchema): string {
253
    const cols = fk.columns.map((c) => `'${c}'`).join(', ');
1✔
254
    const refCols = fk.referencedColumns.map((c: string) => `'${c}'`).join(', ');
1✔
255
    const opts: string[] = [];
1✔
256
    if (fk.name) opts.push(`name: '${fk.name}'`);
1!
257
    if (fk.onDelete && fk.onDelete !== 'NO ACTION') opts.push(`onDelete: '${fk.onDelete}'`);
1!
258
    if (fk.onUpdate && fk.onUpdate !== 'NO ACTION') opts.push(`onUpdate: '${fk.onUpdate}'`);
1!
259
    const optsStr = opts.length ? `, { ${opts.join(', ')} }` : '';
1!
260
    return `await builder.addForeignKey('${tableName}', [${cols}], { table: '${fk.referencedTable}', columns: [${refCols}] }${optsStr});`;
1✔
261
  }
262

263
  // ===========================================================================
264
  // Private: Column Call Generation
265
  // ===========================================================================
266

267
  private generateColumnCall(col: ColumnSchema): string {
268
    const method = this.getColumnMethod(col);
16✔
269
    const options = this.generateColumnOptionsObject(col);
16✔
270
    return `table.${method}('${col.name}'${options})`;
16✔
271
  }
272

273
  private generateIndexCall(idx: IndexSchema): string {
274
    const cols = idx.columns.map((c) => `'${c}'`).join(', ');
1✔
275
    if (idx.unique) {
1!
NEW
276
      return `table.unique([${cols}], '${idx.name}')`;
×
277
    }
278
    return `table.index([${cols}], '${idx.name}')`;
1✔
279
  }
280

281
  private getColumnMethod(col: ColumnSchema): string {
282
    const type = col.type as string;
16✔
283

284
    // Check for auto-increment primary key → id()
285
    if (col.isPrimaryKey && col.isAutoIncrement) {
16✔
286
      return 'id';
4✔
287
    }
288

289
    // Map type string to method name
290
    const typeMap: Record<string, string> = {
12✔
291
      integer: 'integer',
292
      int: 'integer',
293
      bigint: 'bigint',
294
      smallint: 'smallint',
295
      float: 'float',
296
      double: 'double',
297
      decimal: 'decimal',
298
      numeric: 'decimal',
299
      varchar: 'string',
300
      char: 'char',
301
      text: 'text',
302
      boolean: 'boolean',
303
      bool: 'boolean',
304
      date: 'date',
305
      time: 'time',
306
      timestamp: 'timestamp',
307
      timestamptz: 'timestamptz',
308
      json: 'json',
309
      jsonb: 'jsonb',
310
      uuid: 'uuid',
311
      blob: 'blob',
312
      bytea: 'blob',
313
      vector: 'vector',
314
    };
315

316
    const lowerType = type.toLowerCase().replace(/\(.*\)/, ''); // Remove (255) etc
12✔
317
    return typeMap[lowerType] ?? 'string';
12✔
318
  }
319

320
  private generateColumnOptionsObject(col: ColumnSchema): string {
321
    const opts: string[] = [];
16✔
322

323
    // Type-specific options
324
    if (col.length !== undefined && col.length !== 255) {
16✔
325
      opts.push(`length: ${col.length}`);
1✔
326
    }
327
    if (col.precision !== undefined) {
16✔
328
      opts.push(`precision: ${col.precision}`);
1✔
329
    }
330
    if (col.scale !== undefined) {
16✔
331
      opts.push(`scale: ${col.scale}`);
1✔
332
    }
333

334
    // Common options (only include non-defaults)
335
    if (col.nullable) {
16✔
336
      opts.push('nullable: true');
2✔
337
    }
338
    if (col.isUnique && !col.isPrimaryKey) {
16✔
339
      opts.push('unique: true');
3✔
340
    }
341
    if (col.isPrimaryKey && !col.isAutoIncrement) {
16✔
342
      opts.push('primaryKey: true');
1✔
343
    }
344
    if (col.defaultValue !== undefined) {
16✔
345
      opts.push(`defaultValue: ${this.formatValue(col.defaultValue)}`);
1✔
346
    }
347

348
    if (opts.length === 0) return '';
16✔
349
    return `, { ${opts.join(', ')} }`;
7✔
350
  }
351

352
  private generateColumnOptions(col: ColumnSchema): string {
353
    const parts: string[] = [];
10✔
354

355
    if (!col.nullable) {
10!
356
      parts.push('.notNullable()');
10✔
357
    }
358
    if (col.isUnique) {
10✔
359
      parts.push('.unique()');
1✔
360
    }
361
    if (col.defaultValue !== undefined) {
10✔
362
      parts.push(`.defaultValue(${this.formatValue(col.defaultValue)})`);
6✔
363
    }
364

365
    return parts.join('');
10✔
366
  }
367

368
  private formatValue(value: unknown): string {
369
    if (value === null) return 'null';
7!
370
    if (typeof value === 'string') {
7✔
371
      // Check for SQL expressions
372
      if (
4✔
373
        value.toUpperCase().includes('CURRENT_TIMESTAMP') ||
10✔
374
        value.includes('()') ||
375
        value.toUpperCase().includes('NOW')
376
      ) {
377
        return `t.raw('${value}')`;
1✔
378
      }
379
      return `'${value.replace(/'/g, "\\'")}'`;
3✔
380
    }
381
    if (typeof value === 'boolean') return String(value);
3✔
382
    if (typeof value === 'number') return String(value);
2✔
383
    return JSON.stringify(value);
1✔
384
  }
385

386
  // ===========================================================================
387
  // Private: Utilities
388
  // ===========================================================================
389

390
  private generateDescription(diff: SchemaDiff): string {
391
    const parts: string[] = [];
17✔
392

393
    if (diff.type === 'create') {
17✔
394
      parts.push(`Create table '${diff.tableName}'`);
7✔
395
    } else if (diff.type === 'drop') {
10✔
396
      parts.push(`Drop table '${diff.tableName}'`);
1✔
397
    } else {
398
      if (diff.columnsToAdd?.length) {
9✔
399
        parts.push(`Add ${diff.columnsToAdd.length} column(s)`);
6✔
400
      }
401
      if (diff.columnsToAlter?.length) {
9✔
402
        parts.push(`Alter ${diff.columnsToAlter.length} column(s)`);
1✔
403
      }
404
      if (diff.columnsToDrop?.length) {
9✔
405
        parts.push(`Drop ${diff.columnsToDrop.length} column(s)`);
2✔
406
      }
407
    }
408

409
    return parts.join(', ') || `Alter table '${diff.tableName}'`;
17✔
410
  }
411

412
  private indentBlock(code: string, level: number): string {
413
    const prefix = this.indent.repeat(level);
2✔
414
    return code
2✔
415
      .split('\n')
416
      .map((line) => (line.trim() ? prefix + line : line))
5!
417
      .join('\n');
418
  }
419
}
420

421
/**
422
 * Factory function to create a MigrationCodeGenerator.
423
 */
424
export function createMigrationCodeGenerator(options?: MigrationCodeOptions): MigrationCodeGenerator {
425
  return new MigrationCodeGenerator(options);
1✔
426
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc