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

rogerpadilla / nukak / #520

29 Dec 2025 12:41PM UTC coverage: 86.536% (-10.7%) from 97.192%
#520

push

web-flow
Merge pull request #70 from rogerpadilla/feat/migrations-issue-38

feat: add support for database migrations

669 of 915 branches covered (73.11%)

Branch coverage included in aggregate %.

135 of 276 new or added lines in 9 files covered. (48.91%)

56 existing lines in 6 files now uncovered.

2294 of 2509 relevant lines covered (91.43%)

146.06 hits per line

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

51.04
/packages/migrate/src/schemaGenerator.ts
1
import { getMeta } from 'nukak/entity';
1✔
2
import type { ColumnType, EntityMeta, FieldKey, FieldOptions, Type } from 'nukak/type';
3
import { escapeSqlId, getKeys } from 'nukak/util';
1✔
4
import type { ColumnSchema, IndexSchema, SchemaDiff, SchemaGenerator, TableSchema } from './type.js';
5

6
/**
7
 * Abstract base class for SQL schema generation
8
 */
9
export abstract class AbstractSchemaGenerator implements SchemaGenerator {
3✔
10
  /**
11
   * Primary key type for auto-increment integer IDs
12
   */
13
  protected abstract readonly serialPrimaryKeyType: string;
14

15
  constructor(protected readonly escapeIdChar: '`' | '"' = '`') {}
3✔
16

17
  /**
18
   * Escape an identifier (table name, column name, etc.)
19
   */
20
  protected escapeId(identifier: string): string {
21
    return escapeSqlId(identifier, this.escapeIdChar);
44✔
22
  }
23

24
  generateCreateTable<E>(entity: Type<E>, options: { ifNotExists?: boolean } = {}): string {
5✔
25
    const meta = getMeta(entity);
6✔
26
    const columns = this.generateColumnDefinitions(meta);
6✔
27
    const constraints = this.generateTableConstraints(meta);
6✔
28

29
    const ifNotExists = options.ifNotExists ? 'IF NOT EXISTS ' : '';
6✔
30
    let sql = `CREATE TABLE ${ifNotExists}${this.escapeId(meta.name)} (\n`;
6✔
31
    sql += columns.map((col) => `  ${col}`).join(',\n');
26✔
32

33
    if (constraints.length > 0) {
6✔
34
      sql += ',\n';
1✔
35
      sql += constraints.map((c) => `  ${c}`).join(',\n');
1✔
36
    }
37

38
    sql += '\n)';
6✔
39
    sql += this.getTableOptions(meta);
6✔
40
    sql += ';';
6✔
41

42
    return sql;
6✔
43
  }
44

45
  generateDropTable<E>(entity: Type<E>): string {
46
    const meta = getMeta(entity);
1✔
47
    return `DROP TABLE IF EXISTS ${this.escapeId(meta.name)};`;
1✔
48
  }
49

50
  generateAlterTable(diff: SchemaDiff): string[] {
51
    const statements: string[] = [];
1✔
52
    const tableName = this.escapeId(diff.tableName);
1✔
53

54
    // Add new columns
55
    if (diff.columnsToAdd?.length) {
1!
56
      for (const column of diff.columnsToAdd) {
1✔
57
        const colDef = this.generateColumnDefinitionFromSchema(column);
1✔
58
        statements.push(`ALTER TABLE ${tableName} ADD COLUMN ${colDef};`);
1✔
59
      }
60
    }
61

62
    // Alter existing columns
63
    if (diff.columnsToAlter?.length) {
1!
NEW
64
      for (const { to } of diff.columnsToAlter) {
×
NEW
65
        const colDef = this.generateColumnDefinitionFromSchema(to);
×
NEW
66
        statements.push(this.generateAlterColumnStatement(diff.tableName, to.name, colDef));
×
67
      }
68
    }
69

70
    // Drop columns
71
    if (diff.columnsToDrop?.length) {
1!
NEW
72
      for (const columnName of diff.columnsToDrop) {
×
NEW
73
        statements.push(`ALTER TABLE ${tableName} DROP COLUMN ${this.escapeId(columnName)};`);
×
74
      }
75
    }
76

77
    // Add indexes
78
    if (diff.indexesToAdd?.length) {
1!
NEW
79
      for (const index of diff.indexesToAdd) {
×
NEW
80
        statements.push(this.generateCreateIndex(diff.tableName, index));
×
81
      }
82
    }
83

84
    // Drop indexes
85
    if (diff.indexesToDrop?.length) {
1!
NEW
86
      for (const indexName of diff.indexesToDrop) {
×
NEW
87
        statements.push(this.generateDropIndex(diff.tableName, indexName));
×
88
      }
89
    }
90

91
    return statements;
1✔
92
  }
93

94
  generateCreateIndex(tableName: string, index: IndexSchema): string {
95
    const unique = index.unique ? 'UNIQUE ' : '';
1!
96
    const columns = index.columns.map((c) => this.escapeId(c)).join(', ');
1✔
97
    return `CREATE ${unique}INDEX ${this.escapeId(index.name)} ON ${this.escapeId(tableName)} (${columns});`;
1✔
98
  }
99

100
  generateDropIndex(tableName: string, indexName: string): string {
NEW
101
    return `DROP INDEX IF EXISTS ${this.escapeId(indexName)};`;
×
102
  }
103

104
  /**
105
   * Generate column definitions from entity metadata
106
   */
107
  protected generateColumnDefinitions<E>(meta: EntityMeta<E>): string[] {
108
    const columns: string[] = [];
6✔
109
    const fieldKeys = getKeys(meta.fields) as FieldKey<E>[];
6✔
110

111
    for (const key of fieldKeys) {
6✔
112
      const field = meta.fields[key];
26✔
113
      if (field?.virtual) continue; // Skip virtual fields
26!
114

115
      const colDef = this.generateColumnDefinition(key as string, field, meta);
26✔
116
      columns.push(colDef);
26✔
117
    }
118

119
    return columns;
6✔
120
  }
121

122
  /**
123
   * Generate a single column definition
124
   */
125
  protected generateColumnDefinition<E>(fieldKey: string, field: FieldOptions, meta: EntityMeta<E>): string {
126
    const columnName = this.escapeId(field.name ?? fieldKey);
26!
127
    const isId = field.isId === true;
26✔
128
    const isPrimaryKey = isId && meta.id === fieldKey;
26✔
129

130
    // Determine SQL type
131
    let sqlType: string;
132
    if (isPrimaryKey && field.autoIncrement !== false && !field.onInsert) {
26✔
133
      // Auto-increment primary key
134
      sqlType = this.serialPrimaryKeyType;
5✔
135
    } else {
136
      sqlType = this.getSqlType(field, field.type);
21✔
137
    }
138

139
    let definition = `${columnName} ${sqlType}`;
26✔
140

141
    // PRIMARY KEY constraint (for non-serial types)
142
    if (isPrimaryKey && !sqlType.includes('PRIMARY KEY')) {
26✔
143
      definition += ' PRIMARY KEY';
1✔
144
    }
145

146
    // NULL/NOT NULL
147
    if (!isPrimaryKey) {
26✔
148
      const nullable = field.nullable ?? true;
20✔
149
      if (!nullable) {
20✔
150
        definition += ' NOT NULL';
4✔
151
      }
152
    }
153

154
    // UNIQUE constraint
155
    if (field.unique && !isPrimaryKey) {
26✔
156
      definition += ' UNIQUE';
4✔
157
    }
158

159
    // DEFAULT value
160
    if (field.defaultValue !== undefined) {
26!
NEW
161
      definition += ` DEFAULT ${this.formatDefaultValue(field.defaultValue)}`;
×
162
    }
163

164
    // COMMENT (if supported)
165
    if (field.comment) {
26!
NEW
166
      definition += this.generateColumnComment(field.comment);
×
167
    }
168

169
    return definition;
26✔
170
  }
171

172
  /**
173
   * Generate column definition from a ColumnSchema object
174
   */
175
  protected generateColumnDefinitionFromSchema(column: ColumnSchema): string {
176
    const columnName = this.escapeId(column.name);
1✔
177
    let type = column.type;
1✔
178

179
    if (column.length && !type.includes('(')) {
1!
NEW
180
      type = `${type}(${column.length})`;
×
181
    } else if (column.precision !== undefined && !type.includes('(')) {
1!
NEW
182
      if (column.scale !== undefined) {
×
NEW
183
        type = `${type}(${column.precision}, ${column.scale})`;
×
184
      } else {
NEW
185
        type = `${type}(${column.precision})`;
×
186
      }
187
    }
188

189
    let definition = `${columnName} ${type}`;
1✔
190

191
    if (column.isPrimaryKey) {
1!
NEW
192
      definition += ' PRIMARY KEY';
×
193
    }
194

195
    if (!column.nullable && !column.isPrimaryKey) {
1!
NEW
196
      definition += ' NOT NULL';
×
197
    }
198

199
    if (column.isUnique && !column.isPrimaryKey) {
1!
NEW
200
      definition += ' UNIQUE';
×
201
    }
202

203
    if (column.defaultValue !== undefined) {
1!
NEW
204
      definition += ` DEFAULT ${this.formatDefaultValue(column.defaultValue)}`;
×
205
    }
206

207
    return definition;
1✔
208
  }
209

210
  /**
211
   * Generate table constraints (indexes, foreign keys, etc.)
212
   */
213
  protected generateTableConstraints<E>(meta: EntityMeta<E>): string[] {
214
    const constraints: string[] = [];
6✔
215
    const fieldKeys = getKeys(meta.fields) as FieldKey<E>[];
6✔
216

217
    // Generate indexes from field options
218
    for (const key of fieldKeys) {
6✔
219
      const field = meta.fields[key];
26✔
220
      if (field?.index) {
26!
NEW
221
        const indexName = typeof field.index === 'string' ? field.index : `idx_${meta.name}_${field.name ?? key}`;
×
NEW
222
        constraints.push(`INDEX ${this.escapeId(indexName)} (${this.escapeId(field.name ?? key)})`);
×
223
      }
224
    }
225

226
    // Generate foreign key constraints from references
227
    for (const key of fieldKeys) {
6✔
228
      const field = meta.fields[key];
26✔
229
      if (field?.reference) {
26✔
230
        const refEntity = field.reference();
1✔
231
        const refMeta = getMeta(refEntity);
1✔
232
        const refIdField = refMeta.fields[refMeta.id];
1✔
233
        const fkName = `fk_${meta.name}_${field.name ?? key}`;
1!
234

235
        constraints.push(
1✔
236
          `CONSTRAINT ${this.escapeId(fkName)} FOREIGN KEY (${this.escapeId(field.name ?? key)}) ` +
1!
237
            `REFERENCES ${this.escapeId(refMeta.name)} (${this.escapeId(refIdField?.name ?? refMeta.id)})`,
1!
238
        );
239
      }
240
    }
241

242
    return constraints;
6✔
243
  }
244

245
  getSqlType(field: FieldOptions, fieldType?: unknown): string {
246
    // Use explicit column type if specified
247
    if (field.columnType) {
28✔
248
      return this.mapColumnType(field.columnType, field);
6✔
249
    }
250

251
    // Handle special types
252
    if (field.type === 'json' || field.type === 'jsonb') {
22✔
253
      return this.mapColumnType(field.type as ColumnType, field);
1✔
254
    }
255

256
    if (field.type === 'vector') {
21!
NEW
257
      return this.mapColumnType('vector', field);
×
258
    }
259

260
    // Infer from TypeScript type
261
    const type = fieldType ?? field.type;
21!
262

263
    if (type === Number || type === 'number') {
21✔
264
      return field.precision ? this.mapColumnType('decimal', field) : 'BIGINT';
5!
265
    }
266

267
    if (type === String || type === 'string') {
16✔
268
      const length = field.length ?? 255;
14✔
269
      return `VARCHAR(${length})`;
14✔
270
    }
271

272
    if (type === Boolean || type === 'boolean') {
2!
273
      return this.getBooleanType();
2✔
274
    }
275

NEW
276
    if (type === Date || type === 'date') {
×
NEW
277
      return 'TIMESTAMP';
×
278
    }
279

NEW
280
    if (type === BigInt || type === 'bigint') {
×
NEW
281
      return 'BIGINT';
×
282
    }
283

284
    // Default to VARCHAR
NEW
285
    return `VARCHAR(${field.length ?? 255})`;
×
286
  }
287

288
  /**
289
   * Map nukak column type to database-specific SQL type
290
   */
291
  protected abstract mapColumnType(columnType: ColumnType, field: FieldOptions): string;
292

293
  /**
294
   * Get the boolean type for this database
295
   */
296
  protected abstract getBooleanType(): string;
297

298
  /**
299
   * Generate ALTER COLUMN statement (database-specific)
300
   */
301
  protected abstract generateAlterColumnStatement(tableName: string, columnName: string, newDefinition: string): string;
302

303
  /**
304
   * Get table options (e.g., ENGINE for MySQL)
305
   */
306
  protected getTableOptions<E>(meta: EntityMeta<E>): string {
307
    return '';
5✔
308
  }
309

310
  /**
311
   * Generate column comment clause (if supported)
312
   */
313
  protected generateColumnComment(comment: string): string {
NEW
314
    return '';
×
315
  }
316

317
  /**
318
   * Format a default value for SQL
319
   */
320
  protected formatDefaultValue(value: unknown): string {
NEW
321
    if (value === null) {
×
NEW
322
      return 'NULL';
×
323
    }
NEW
324
    if (typeof value === 'string') {
×
NEW
325
      return `'${value.replace(/'/g, "''")}'`;
×
326
    }
NEW
327
    if (typeof value === 'boolean') {
×
NEW
328
      return value ? 'TRUE' : 'FALSE';
×
329
    }
NEW
330
    if (typeof value === 'number' || typeof value === 'bigint') {
×
NEW
331
      return String(value);
×
332
    }
NEW
333
    if (value instanceof Date) {
×
NEW
334
      return `'${value.toISOString()}'`;
×
335
    }
NEW
336
    return String(value);
×
337
  }
338

339
  /**
340
   * Compare two schemas and return the differences
341
   */
342
  diffSchema<E>(entity: Type<E>, currentSchema: TableSchema | null): SchemaDiff | null {
NEW
343
    const meta = getMeta(entity);
×
344

NEW
345
    if (!currentSchema) {
×
346
      // Table doesn't exist, need to create
NEW
347
      return {
×
348
        tableName: meta.name,
349
        type: 'create',
350
      };
351
    }
352

NEW
353
    const columnsToAdd: ColumnSchema[] = [];
×
NEW
354
    const columnsToAlter: { from: ColumnSchema; to: ColumnSchema }[] = [];
×
NEW
355
    const columnsToDrop: string[] = [];
×
356

NEW
357
    const currentColumns = new Map(currentSchema.columns.map((c) => [c.name, c]));
×
NEW
358
    const fieldKeys = getKeys(meta.fields) as FieldKey<E>[];
×
359

360
    // Check for new or altered columns
NEW
361
    for (const key of fieldKeys) {
×
NEW
362
      const field = meta.fields[key];
×
NEW
363
      if (field?.virtual) continue;
×
364

NEW
365
      const columnName = field.name ?? key;
×
NEW
366
      const currentColumn = currentColumns.get(columnName);
×
367

NEW
368
      if (!currentColumn) {
×
369
        // Column needs to be added
NEW
370
        columnsToAdd.push(this.fieldToColumnSchema(key as string, field, meta));
×
371
      } else {
372
        // Check if column needs alteration
NEW
373
        const desiredColumn = this.fieldToColumnSchema(key as string, field, meta);
×
NEW
374
        if (this.columnsNeedAlteration(currentColumn, desiredColumn)) {
×
NEW
375
          columnsToAlter.push({ from: currentColumn, to: desiredColumn });
×
376
        }
377
      }
NEW
378
      currentColumns.delete(columnName);
×
379
    }
380

381
    // Remaining columns in currentColumns should be dropped
NEW
382
    for (const [name] of currentColumns) {
×
NEW
383
      columnsToDrop.push(name);
×
384
    }
385

NEW
386
    if (columnsToAdd.length === 0 && columnsToAlter.length === 0 && columnsToDrop.length === 0) {
×
NEW
387
      return null; // No changes needed
×
388
    }
389

NEW
390
    return {
×
391
      tableName: meta.name,
392
      type: 'alter',
393
      columnsToAdd: columnsToAdd.length > 0 ? columnsToAdd : undefined,
×
394
      columnsToAlter: columnsToAlter.length > 0 ? columnsToAlter : undefined,
×
395
      columnsToDrop: columnsToDrop.length > 0 ? columnsToDrop : undefined,
×
396
    };
397
  }
398

399
  /**
400
   * Convert field options to ColumnSchema
401
   */
402
  protected fieldToColumnSchema<E>(fieldKey: string, field: FieldOptions, meta: EntityMeta<E>): ColumnSchema {
NEW
403
    const isId = field.isId === true;
×
NEW
404
    const isPrimaryKey = isId && meta.id === fieldKey;
×
405

NEW
406
    return {
×
407
      name: field.name ?? fieldKey,
×
408
      type: this.getSqlType(field, field.type),
409
      nullable: field.nullable ?? !isPrimaryKey,
×
410
      defaultValue: field.defaultValue,
411
      isPrimaryKey,
412
      isAutoIncrement: isPrimaryKey && field.autoIncrement !== false && !field.onInsert,
×
413
      isUnique: field.unique ?? false,
×
414
      length: field.length,
415
      precision: field.precision,
416
      scale: field.scale,
417
      comment: field.comment,
418
    };
419
  }
420

421
  /**
422
   * Check if two columns differ enough to require alteration
423
   */
424
  protected columnsNeedAlteration(current: ColumnSchema, desired: ColumnSchema): boolean {
425
    // Compare relevant properties
NEW
426
    return (
×
427
      current.type.toLowerCase() !== desired.type.toLowerCase() ||
×
428
      current.nullable !== desired.nullable ||
429
      current.isUnique !== desired.isUnique ||
430
      JSON.stringify(current.defaultValue) !== JSON.stringify(desired.defaultValue)
431
    );
432
  }
433
}
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