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

rogerpadilla / uql / 23053878235

13 Mar 2026 01:49PM UTC coverage: 95.243% (-0.03%) from 95.273%
23053878235

push

github

rogerpadilla
chore(release): publish

 - uql-orm@0.4.0

2815 of 3114 branches covered (90.4%)

Branch coverage included in aggregate %.

5054 of 5148 relevant lines covered (98.17%)

313.79 hits per line

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

88.02
/packages/uql-orm/src/migrate/schemaGenerator.ts
1
import { AbstractDialect } from '../dialect/index.js';
2
import { getMeta } from '../entity/index.js';
3
import {
4
  areTypesEqual,
5
  canonicalToSql,
6
  fieldOptionsToCanonical,
7
  isVectorCategory,
8
  sqlToCanonical,
9
} from '../schema/canonicalType.js';
10
import { SchemaASTBuilder } from '../schema/schemaASTBuilder.js';
11
import type {
12
  CanonicalType,
13
  ColumnNode,
14
  ForeignKeyAction,
15
  IndexNode,
16
  RelationshipNode,
17
  TableNode,
18
} from '../schema/types.js';
19
import type {
20
  ColumnSchema,
21
  Dialect,
22
  EntityMeta,
23
  FieldKey,
24
  FieldOptions,
25
  IndexSchema,
26
  NamingStrategy,
27
  SchemaDiff,
28
  SchemaGenerator,
29
  Type,
30
  VectorDistance,
31
} from '../type/index.js';
32
import { escapeSqlId, getKeys, isAutoIncrement } from '../util/index.js';
33
import { formatDefaultValue } from './builder/expressions.js';
34
import type { FullColumnDefinition, TableDefinition, TableForeignKeyDefinition } from './builder/types.js';
35

36
/** Maps UQL distance metric names to inline DDL keywords (e.g. MariaDB `DISTANCE=euclidean`). */
37
const INLINE_VECTOR_DISTANCE_MAP: Partial<Record<VectorDistance, string>> = { cosine: 'cosine', l2: 'euclidean' };
20✔
38

39
/**
40
 * Unified SQL schema generator.
41
 * Parameterized by dialect to handle Postgres, MySQL, MariaDB, and SQLite.
42
 */
20✔
43
export class SqlSchemaGenerator extends AbstractDialect implements SchemaGenerator {
44
  /**
45
   * Escape an identifier (table name, column name, etc.)
46
   */
47
  protected escapeId(identifier: string): string {
48
    return escapeSqlId(identifier, this.config.quoteChar);
815✔
49
  }
50

51
  /**
52
   * Primary key type for auto-increment integer IDs
53
   */
54
  protected get serialPrimaryKeyType(): string {
55
    return this.config.serialPrimaryKey;
127✔
56
  }
57

58
  // ============================================================================
59
  // CanonicalType Integration (Unified Type System)
60
  // ============================================================================
61

62
  /**
63
   * Convert FieldOptions to CanonicalType using the unified type system.
64
   */
65
  protected getCanonicalType(field: FieldOptions, fieldType?: unknown): CanonicalType {
66
    return fieldOptionsToCanonical(field, fieldType);
176✔
67
  }
68

69
  /**
70
   * Convert CanonicalType to SQL type string for this dialect.
71
   * Also handles legacy string types for backward compatibility.
72
   */
73
  protected canonicalTypeToSql(type: CanonicalType | string): string {
74
    // Handle legacy string types
75
    if (typeof type === 'string') {
551✔
76
      return type;
7✔
77
    }
78
    return canonicalToSql(type, this.dialect);
544✔
79
  }
80

81
  // ============================================================================
82
  // SchemaGenerator Implementation
83
  // ============================================================================
84

85
  generateCreateTable<E>(entity: Type<E>, options: { ifNotExists?: boolean } = {}): string {
41✔
86
    const builder = new SchemaASTBuilder(this.namingStrategy);
41✔
87
    const ast = builder.fromEntities([entity], {
41✔
88
      resolveTableName: this.resolveTableName.bind(this),
89
      resolveColumnName: this.resolveColumnName.bind(this),
90
    });
91
    const tableNode = ast.getTables()[0];
41✔
92
    return this.generateCreateTableFromNode(tableNode, options);
41✔
93
  }
94

95
  generateDropTable<E>(entity: Type<E>): string {
96
    const meta = getMeta(entity);
7✔
97
    const tableName = this.resolveTableName(entity, meta);
7✔
98
    return `DROP TABLE IF EXISTS ${this.escapeId(tableName)};`;
7✔
99
  }
100

101
  generateAlterTable(diff: SchemaDiff): string[] {
102
    const statements: string[] = [];
47✔
103
    const tableName = this.escapeId(diff.tableName);
47✔
104

105
    // Add new columns
106
    if (diff.columnsToAdd?.length) {
47✔
107
      for (const column of diff.columnsToAdd) {
29✔
108
        const colDef = this.generateColumnDefinitionFromSchema(column);
39✔
109
        statements.push(`ALTER TABLE ${tableName} ADD COLUMN ${colDef};`);
39✔
110
      }
111
    }
112

113
    // Alter existing columns
114
    if (diff.columnsToAlter?.length) {
47✔
115
      for (const { to } of diff.columnsToAlter) {
5✔
116
        const colDef = this.generateColumnDefinitionFromSchema(to);
5✔
117
        const colStatements = this.generateAlterColumnStatements(diff.tableName, to, colDef);
5✔
118
        statements.push(...colStatements);
5✔
119
      }
120
    }
121

122
    // Drop columns
123
    if (diff.columnsToDrop?.length) {
46✔
124
      for (const columnName of diff.columnsToDrop) {
6✔
125
        statements.push(`ALTER TABLE ${tableName} DROP COLUMN ${this.escapeId(columnName)};`);
6✔
126
      }
127
    }
128

129
    // Add indexes
130
    if (diff.indexesToAdd?.length) {
46✔
131
      for (const index of diff.indexesToAdd) {
2✔
132
        statements.push(this.generateCreateIndex(diff.tableName, index));
2✔
133
      }
134
    }
135

136
    // Drop indexes
137
    if (diff.indexesToDrop?.length) {
46✔
138
      for (const indexName of diff.indexesToDrop) {
2✔
139
        statements.push(this.generateDropIndex(diff.tableName, indexName));
2✔
140
      }
141
    }
142

143
    return statements;
46✔
144
  }
145

146
  generateAlterTableDown(diff: SchemaDiff): string[] {
147
    const statements: string[] = [];
5✔
148
    const tableName = this.escapeId(diff.tableName);
5✔
149

150
    // Reverse column additions by dropping them
151
    if (diff.columnsToAdd?.length) {
5✔
152
      for (const column of diff.columnsToAdd) {
1✔
153
        statements.push(`ALTER TABLE ${tableName} DROP COLUMN ${this.escapeId(column.name)};`);
1✔
154
      }
155
    }
156

157
    // Reverse column alterations by restoring original schema
158
    if (diff.columnsToAlter?.length) {
5✔
159
      for (const { from } of diff.columnsToAlter) {
1✔
160
        const colDef = this.generateColumnDefinitionFromSchema(from, { includePrimaryKey: false });
1✔
161
        const colStatements = this.generateAlterColumnStatements(diff.tableName, from, colDef);
1✔
162
        statements.push(...colStatements);
1✔
163
      }
164
    }
165

166
    // Reverse index additions by dropping them
167
    if (diff.indexesToAdd?.length) {
5✔
168
      for (const index of diff.indexesToAdd) {
1✔
169
        statements.push(this.generateDropIndex(diff.tableName, index.name));
1✔
170
      }
171
    }
172

173
    if (diff.columnsToDrop?.length || diff.indexesToDrop?.length) {
5✔
174
      statements.push(`-- TODO: Manual reversal needed for dropped columns/indexes`);
2✔
175
    }
176

177
    return statements;
5✔
178
  }
179

180
  generateCreateIndex(tableName: string, index: IndexSchema, options: { ifNotExists?: boolean } = {}): string {
21✔
181
    const unique = index.unique ? 'UNIQUE ' : '';
21✔
182
    const ifNotExists = (options.ifNotExists ?? this.config.features.indexIfNotExists) ? 'IF NOT EXISTS ' : '';
21✔
183
    const opsClassMap = this.config.vectorOpsClass;
21✔
184
    const hasOpsClass = opsClassMap && (index.type === 'hnsw' || index.type === 'ivfflat');
21✔
185

186
    // For pgvector indexes: append operator class to the column
187
    const columns = index.columns
21✔
188
      .map((c) => {
189
        const escaped = this.escapeId(c);
29✔
190
        if (hasOpsClass && index.distance) {
29✔
191
          const opsClass = opsClassMap[index.distance];
3✔
192
          return opsClass ? `${escaped} ${opsClass}` : escaped;
3!
193
        }
194
        return escaped;
26✔
195
      })
196
      .join(', ');
197

198
    const using = index.type ? ` USING ${index.type}` : '';
21✔
199

200
    // Build WITH params for vector indexes with operator class support
201
    let withClause = '';
21✔
202
    if (hasOpsClass) {
21✔
203
      const withParams: string[] = [];
3✔
204
      if (index.m !== undefined) withParams.push(`m = ${index.m}`);
3✔
205
      if (index.efConstruction !== undefined) withParams.push(`ef_construction = ${index.efConstruction}`);
3✔
206
      if (index.lists !== undefined) withParams.push(`lists = ${index.lists}`);
3✔
207
      withClause = withParams.length > 0 ? ` WITH (${withParams.join(', ')})` : '';
3✔
208
    }
209

210
    return `CREATE ${unique}INDEX ${ifNotExists}${this.escapeId(index.name)} ON ${this.escapeId(tableName)}${using} (${columns})${withClause};`;
21✔
211
  }
212

213
  generateDropIndex(tableName: string, indexName: string): string {
214
    if (this.config.dropIndexSyntax === 'on-table') {
7✔
215
      return `DROP INDEX ${this.escapeId(indexName)} ON ${this.escapeId(tableName)};`;
2✔
216
    }
217
    return `DROP INDEX IF EXISTS ${this.escapeId(indexName)};`;
5✔
218
  }
219

220
  /**
221
   * Generate column definition from a ColumnSchema object
222
   */
223
  public generateColumnDefinitionFromSchema(
224
    column: ColumnSchema,
225
    options: { includePrimaryKey?: boolean; includeUnique?: boolean } = {},
45✔
226
  ): string {
227
    const { includePrimaryKey = true, includeUnique = true } = options;
45✔
228

229
    let type = column.type;
45✔
230

231
    if (!type.includes('(')) {
45✔
232
      if (column.precision !== undefined) {
30!
233
        if (column.scale !== undefined) {
×
234
          type += `(${column.precision}, ${column.scale})`;
×
235
        } else {
236
          type += `(${column.precision})`;
×
237
        }
238
      } else if (column.length !== undefined) {
30!
239
        type += `(${column.length})`;
×
240
      }
241
    }
242

243
    if (!includePrimaryKey) {
45✔
244
      type = type.replace(/\s+PRIMARY\s+KEY/i, '');
1✔
245
    }
246

247
    let definition = `${this.escapeId(column.name)} ${type}`;
45✔
248

249
    if (includePrimaryKey && column.isPrimaryKey && !type.includes('PRIMARY KEY')) {
45!
250
      definition += ' PRIMARY KEY';
×
251
    }
252

253
    if (!column.nullable && !column.isPrimaryKey) {
45!
254
      definition += ' NOT NULL';
×
255
    }
256

257
    if (includeUnique && column.isUnique && !column.isPrimaryKey) {
45!
258
      definition += ' UNIQUE';
×
259
    }
260

261
    if (column.defaultValue !== undefined) {
45!
262
      definition += ` DEFAULT ${this.formatDefaultValue(column.defaultValue)}`;
×
263
    }
264

265
    if (column.comment) {
45!
266
      definition += this.generateColumnComment(column.name, column.comment);
×
267
    }
268

269
    return definition;
45✔
270
  }
271

272
  public getSqlType(field: FieldOptions, fieldType?: unknown): string {
273
    // If field has a reference, inherit type from the target primary key
274
    if (field.references) {
177✔
275
      const refEntity = field.references();
1✔
276
      const refMeta = getMeta(refEntity);
1✔
277
      const refIdField = refMeta.fields[refMeta.id!];
1✔
278
      return this.getSqlType(
1✔
279
        { ...refIdField!, references: undefined, isId: undefined, autoIncrement: false },
280
        refIdField!.type,
281
      );
282
    }
283

284
    // Get canonical type and convert to SQL
285
    const canonical = this.getCanonicalType(field, fieldType);
176✔
286

287
    // Special case for serial primary keys
288
    if (isAutoIncrement(field, field.isId === true)) {
176✔
289
      return this.serialPrimaryKeyType;
53✔
290
    }
291

292
    return this.canonicalTypeToSql(canonical);
123✔
293
  }
294

295
  /**
296
   * Get the boolean type for this database
297
   */
298
  public getBooleanType(): string {
299
    return this.canonicalTypeToSql({ category: 'boolean' });
3✔
300
  }
301

302
  /**
303
   * Generate ALTER COLUMN statements (database-specific)
304
   */
305
  public generateAlterColumnStatements(tableName: string, column: ColumnSchema, newDefinition: string): string[] {
306
    const table = this.escapeId(tableName);
10✔
307
    const colName = this.escapeId(column.name);
10✔
308

309
    if (this.config.alterColumnSyntax === 'none') {
10✔
310
      throw new Error(
2✔
311
        `${this.dialect}: Cannot alter column "${column.name}" - you must recreate the table. ` +
312
          `This database does not support ALTER COLUMN.`,
313
      );
314
    }
315

316
    if (this.config.alterColumnStrategy === 'separate-clauses') {
8✔
317
      const statements: string[] = [];
4✔
318
      // Separate ALTER COLUMN clauses for different changes (Postgres)
319
      statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} TYPE ${column.type};`);
4✔
320

321
      if (column.nullable) {
4✔
322
        statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} DROP NOT NULL;`);
2✔
323
      } else {
324
        statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} SET NOT NULL;`);
2✔
325
      }
326

327
      if (column.defaultValue !== undefined) {
4✔
328
        statements.push(
1✔
329
          `ALTER TABLE ${table} ALTER COLUMN ${colName} SET DEFAULT ${this.formatDefaultValue(column.defaultValue)};`,
330
        );
331
      } else {
332
        statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} DROP DEFAULT;`);
3✔
333
      }
334
      return statements;
4✔
335
    }
336

337
    return [`ALTER TABLE ${table} ${this.config.alterColumnSyntax} ${newDefinition};`];
4✔
338
  }
339

340
  /**
341
   * Get table options (e.g., ENGINE for MySQL)
342
   */
343
  public getTableOptions<E>(_meta: EntityMeta<E>): string {
344
    return this.config.tableOptions ? ` ${this.config.tableOptions}` : '';
2✔
345
  }
346

347
  /**
348
   * Generate column comment clause (if supported)
349
   */
350
  public generateColumnComment(columnName: string, comment: string): string {
351
    if (this.config.features.columnComment) {
7✔
352
      const escapedComment = comment.replace(/'/g, "''");
3✔
353
      return ` COMMENT '${escapedComment}'`;
3✔
354
    }
355
    return '';
4✔
356
  }
357

358
  /**
359
   * Format a default value for SQL
360
   */
361
  public formatDefaultValue(value: unknown): string {
362
    if (this.config.booleanLiteral === 'integer' && typeof value === 'boolean') {
26✔
363
      return value ? '1' : '0';
3✔
364
    }
365
    return formatDefaultValue(value);
23✔
366
  }
367

368
  /**
369
   * Compare an entity with a database table node and return the differences.
370
   */
371
  diffSchema<E>(entity: Type<E>, currentTable: TableNode | undefined): SchemaDiff | undefined {
372
    const meta = getMeta(entity);
81✔
373

374
    if (!currentTable) {
81✔
375
      return {
30✔
376
        tableName: this.resolveTableName(entity, meta),
377
        type: 'create',
378
      };
379
    }
380

381
    const columnsToAdd: ColumnSchema[] = [];
51✔
382
    const columnsToAlter: { from: ColumnSchema; to: ColumnSchema }[] = [];
51✔
383
    const columnsToDrop: string[] = [];
51✔
384

385
    const currentColumns = new Map<string, ColumnNode>(currentTable.columns);
51✔
386
    const fieldKeys = getKeys(meta.fields) as FieldKey<E>[];
51✔
387

388
    for (const key of fieldKeys) {
51✔
389
      const field = meta.fields[key];
132✔
390
      if (!field || field.virtual) continue;
132!
391

392
      const columnName = this.resolveColumnName(key, field);
132✔
393
      const currentColumn = currentColumns.get(columnName);
132✔
394

395
      if (!currentColumn) {
132✔
396
        columnsToAdd.push(this.fieldToColumnSchema(key, field, meta));
39✔
397
      } else {
398
        const desiredColumn = this.fieldToColumnSchema(key, field, meta);
93✔
399
        const currentColumnSchema = this.columnNodeToSchema(currentColumn);
93✔
400
        if (this.columnsNeedAlteration(currentColumnSchema, desiredColumn)) {
93✔
401
          columnsToAlter.push({ from: currentColumnSchema, to: desiredColumn });
16✔
402
        }
403
      }
404
      currentColumns.delete(columnName);
132✔
405
    }
406

407
    for (const [name] of currentColumns) {
51✔
408
      columnsToDrop.push(name);
17✔
409
    }
410

411
    if (columnsToAdd.length === 0 && columnsToAlter.length === 0 && columnsToDrop.length === 0) {
51✔
412
      return undefined;
4✔
413
    }
414

415
    return {
47✔
416
      tableName: this.resolveTableName(entity, meta),
417
      type: 'alter',
418
      columnsToAdd: columnsToAdd.length > 0 ? columnsToAdd : undefined,
47✔
419
      columnsToAlter: columnsToAlter.length > 0 ? columnsToAlter : undefined,
47✔
420
      columnsToDrop: columnsToDrop.length > 0 ? columnsToDrop : undefined,
47✔
421
    };
422
  }
423

424
  private columnNodeToSchema(col: ColumnNode): ColumnSchema {
425
    return {
93✔
426
      name: col.name,
427
      type: this.canonicalTypeToSql(col.type),
428
      nullable: col.nullable,
429
      defaultValue: col.defaultValue,
430
      isPrimaryKey: col.isPrimaryKey,
431
      isAutoIncrement: col.isAutoIncrement,
432
      isUnique: col.isUnique,
433
      comment: col.comment,
434
    };
435
  }
436

437
  /**
438
   * Convert field options to ColumnSchema
439
   */
440
  protected fieldToColumnSchema<E>(fieldKey: string, field: FieldOptions, meta: EntityMeta<E>): ColumnSchema {
441
    const isPrimaryKey = field.isId === true && meta.id === fieldKey;
132✔
442

443
    return {
132✔
444
      name: this.resolveColumnName(fieldKey, field),
445
      type: this.getSqlType(field, field.type),
446
      nullable: field.nullable ?? !isPrimaryKey,
264✔
447
      defaultValue: field.defaultValue,
448
      isPrimaryKey,
449
      isAutoIncrement: isAutoIncrement(field, isPrimaryKey),
450
      isUnique: field.unique ?? false,
264✔
451
      length: field.length,
452
      precision: field.precision,
453
      scale: field.scale,
454
      comment: field.comment,
455
    };
456
  }
457

458
  /**
459
   * Check if two columns differ enough to require alteration
460
   */
461
  protected columnsNeedAlteration(current: ColumnSchema, desired: ColumnSchema): boolean {
462
    if (current.isPrimaryKey && desired.isPrimaryKey) {
93✔
463
      return false;
51✔
464
    }
465

466
    if (current.isPrimaryKey !== desired.isPrimaryKey) return true;
42!
467
    if (current.nullable !== desired.nullable) return true;
42!
468
    if (current.isUnique !== desired.isUnique) return true;
42!
469

470
    if (!this.isTypeEqual(current, desired)) return true;
42✔
471
    if (!this.isDefaultValueEqual(current.defaultValue, desired.defaultValue)) return true;
32✔
472

473
    return false;
26✔
474
  }
475

476
  /**
477
   * Compare two column types for equality using the canonical type system
478
   */
479
  protected isTypeEqual(current: ColumnSchema, desired: ColumnSchema): boolean {
480
    const typeA = sqlToCanonical(current.type);
42✔
481
    const typeB = sqlToCanonical(desired.type);
42✔
482
    return areTypesEqual(typeA, typeB);
42✔
483
  }
484

485
  /**
486
   * Compare two default values for equality
487
   */
488
  protected isDefaultValueEqual(current: unknown, desired: unknown): boolean {
489
    if (current === desired) return true;
32✔
490
    if (current === undefined || desired === undefined) return current === desired;
6!
491

492
    const normalize = (val: unknown): string => {
×
493
      if (val === null) return 'null';
×
494
      if (typeof val === 'string') {
×
495
        let s = val.replace(/::[a-z_]+(\s+[a-z_]+)*(\[\])?$/i, '');
×
496
        s = s.replace(/^'(.*)'$/, '$1');
×
497
        if (s.toLowerCase() === 'null') return 'null';
×
498
        return s;
×
499
      }
500
      return typeof val === 'object' ? JSON.stringify(val) : String(val);
×
501
    };
502

503
    return normalize(current) === normalize(desired);
×
504
  }
505

506
  // ============================================================================
507
  // SchemaAST Support Methods
508
  // ============================================================================
509

510
  /**
511
   * Generate CREATE TABLE SQL from a TableNode.
512
   */
513
  generateCreateTableFromNode(table: TableNode, options: { ifNotExists?: boolean } = {}): string {
80✔
514
    const columns: string[] = [];
80✔
515
    const constraints: string[] = [];
80✔
516

517
    for (const col of table.columns.values()) {
80✔
518
      const colDef = this.generateColumnFromNode(col);
330✔
519
      columns.push(colDef);
330✔
520
    }
521

522
    if (table.primaryKey.length > 1) {
80✔
523
      const pkCols = table.primaryKey.map((c) => this.escapeId(c.name)).join(', ');
8✔
524
      constraints.push(`PRIMARY KEY (${pkCols})`);
4✔
525
    }
526

527
    // Add table-level foreign keys if any
528
    for (const rel of table.outgoingRelations) {
80✔
529
      if (rel.from.columns.length > 0) {
25!
530
        const fromCols = rel.from.columns.map((c) => this.escapeId(c.name)).join(', ');
25✔
531
        const toCols = rel.to.columns.map((c) => this.escapeId(c.name)).join(', ');
25✔
532
        const constraintName = rel.name ? `CONSTRAINT ${this.escapeId(rel.name)} ` : '';
25!
533
        constraints.push(
25✔
534
          `${constraintName}FOREIGN KEY (${fromCols}) REFERENCES ${this.escapeId(rel.to.table.name)} (${toCols})` +
535
            ` ON DELETE ${rel.onDelete ?? this.defaultForeignKeyAction} ON UPDATE ${rel.onUpdate ?? this.defaultForeignKeyAction}`,
50!
536
        );
537
      }
538
    }
539

540
    const ifNotExists = options.ifNotExists && this.config.features.ifNotExists ? 'IF NOT EXISTS ' : '';
80✔
541
    let sql = `CREATE TABLE ${ifNotExists}${this.escapeId(table.name)} (\n`;
80✔
542
    sql += columns.map((col) => `  ${col}`).join(',\n');
330✔
543

544
    if (constraints.length > 0) {
80✔
545
      sql += ',\n';
25✔
546
      sql += constraints.map((c) => `  ${c}`).join(',\n');
29✔
547
    }
548

549
    // Inline vector indexes (MariaDB requires them inside CREATE TABLE)
550
    const isInlineVectorIdx = this.config.features.vectorIndexStyle === 'inline';
80✔
551
    const vectorIndexes = isInlineVectorIdx ? table.indexes.filter((idx) => idx.type === 'vector') : [];
80✔
552
    const regularIndexes = isInlineVectorIdx ? table.indexes.filter((idx) => idx.type !== 'vector') : table.indexes;
80✔
553

554
    if (vectorIndexes.length > 0) {
80✔
555
      sql += ',\n';
3✔
556
      sql += vectorIndexes.map((idx) => `  ${this.generateInlineVectorIndex(idx)}`).join(',\n');
3✔
557
    }
558

559
    sql += '\n)';
80✔
560

561
    if (this.config.tableOptions) {
80✔
562
      sql += ` ${this.config.tableOptions}`;
24✔
563
    }
564

565
    sql += ';';
80✔
566

567
    // Generate regular indexes as separate statements
568
    const indexStatements = regularIndexes.map((idx) => this.generateCreateIndexFromNode(idx)).join('\n');
80✔
569

570
    if (indexStatements) {
80!
571
      sql += `\n${indexStatements}`;
×
572
    }
573

574
    // Prepend CREATE EXTENSION for dialects that require it (e.g. pgvector)
575
    if (this.config.vectorExtension) {
80✔
576
      const hasVectorCol = [...table.columns.values()].some((c) => isVectorCategory(c.type.category));
182✔
577
      if (hasVectorCol) {
41✔
578
        sql = `CREATE EXTENSION IF NOT EXISTS ${this.config.vectorExtension};\n${sql}`;
1✔
579
      }
580
    }
581

582
    return sql;
80✔
583
  }
584

585
  /**
586
   * Generate a column definition from a ColumnNode.
587
   */
588
  protected generateColumnFromNode(col: ColumnNode): string {
589
    const colName = this.escapeId(col.name);
332✔
590
    let sqlType = this.canonicalTypeToSql(col.type);
332✔
591

592
    if (col.isPrimaryKey && col.isAutoIncrement) {
332✔
593
      sqlType = this.serialPrimaryKeyType;
74✔
594
    }
595

596
    let def = `${colName} ${sqlType}`;
332✔
597

598
    if (!col.nullable && !col.isPrimaryKey) {
332✔
599
      def += ' NOT NULL';
44✔
600
    }
601

602
    if (col.isPrimaryKey && col.table.primaryKey.length === 1 && !sqlType.includes('PRIMARY KEY')) {
332✔
603
      def += ' PRIMARY KEY';
2✔
604
    }
605

606
    if (col.isUnique && !col.isPrimaryKey) {
332✔
607
      def += ' UNIQUE';
8✔
608
    }
609

610
    if (col.defaultValue !== undefined) {
332✔
611
      def += ` DEFAULT ${this.formatDefaultValue(col.defaultValue)}`;
16✔
612
    }
613

614
    if (col.comment) {
332!
615
      def += this.generateColumnComment(col.name, col.comment);
×
616
    }
617

618
    return def;
332✔
619
  }
620

621
  /**
622
   * Generate an inline VECTOR INDEX clause for use inside CREATE TABLE.
623
   * Syntax: `VECTOR INDEX (col) M=n DISTANCE=metric`
624
   */
625
  private generateInlineVectorIndex(index: IndexNode): string {
626
    const columns = index.columns.map((c) => this.escapeId(c.name)).join(', ');
3✔
627
    let clause = `VECTOR INDEX (${columns})`;
3✔
628

629
    if (index.m !== undefined) {
3✔
630
      clause += ` M=${index.m}`;
1✔
631
    }
632

633
    if (index.distance) {
3✔
634
      const metric = INLINE_VECTOR_DISTANCE_MAP[index.distance] ?? 'euclidean';
2!
635
      clause += ` DISTANCE=${metric}`;
2✔
636
    }
637

638
    return clause;
3✔
639
  }
640

641
  /**
642
   * Generate CREATE INDEX SQL from an IndexNode.
643
   * Delegates to `generateCreateIndex` for unified SQL assembly.
644
   */
645
  generateCreateIndexFromNode(index: IndexNode, options: { ifNotExists: boolean } = { ifNotExists: false }): string {
1✔
646
    return this.generateCreateIndex(
1✔
647
      index.table.name,
648
      {
649
        name: index.name,
650
        columns: index.columns.map((c) => c.name),
1✔
651
        unique: index.unique,
652
        type: index.type,
653
        distance: index.distance,
654
        m: index.m,
655
        efConstruction: index.efConstruction,
656
        lists: index.lists,
657
      },
658
      options,
659
    );
660
  }
661

662
  /**
663
   * Generate DROP TABLE SQL from a TableNode.
664
   */
665
  generateDropTableFromNode(table: TableNode, options: { ifExists?: boolean } = {}): string {
1✔
666
    const ifExists = options.ifExists ? 'IF EXISTS ' : '';
1!
667
    return `DROP TABLE ${ifExists}${this.escapeId(table.name)};`;
1✔
668
  }
669

670
  // ============================================================================
671
  // Phase 3: Builder Operation Methods (Moved forward for unification)
672
  // ============================================================================
673

674
  generateCreateTableFromDefinition(table: TableDefinition, options: { ifNotExists?: boolean } = {}): string {
34✔
675
    const tableNode = this.tableDefinitionToNode(table);
34✔
676
    return this.generateCreateTableFromNode(tableNode, options);
34✔
677
  }
678

679
  generateDropTableSql(tableName: string, options?: { ifExists?: boolean; cascade?: boolean }): string {
680
    const ifExists = options?.ifExists ? 'IF EXISTS ' : '';
65✔
681
    // Use dialect-specific cascade support from config
682
    const cascade = options?.cascade && this.config.features.dropTableCascade ? ' CASCADE' : '';
65✔
683
    return `DROP TABLE ${ifExists}${this.escapeId(tableName)}${cascade};`;
65✔
684
  }
685

686
  generateRenameTableSql(oldName: string, newName: string): string {
687
    if (this.config.renameTableSyntax === 'rename-table') {
1!
688
      return `RENAME TABLE ${this.escapeId(oldName)} TO ${this.escapeId(newName)};`;
×
689
    }
690
    return `ALTER TABLE ${this.escapeId(oldName)} RENAME TO ${this.escapeId(newName)};`;
1✔
691
  }
692

693
  generateAddColumnSql(tableName: string, column: FullColumnDefinition): string {
694
    const colSql = this.generateColumnFromNode(this.fullColumnDefinitionToNode(column, tableName));
1✔
695
    return `ALTER TABLE ${this.escapeId(tableName)} ADD COLUMN ${colSql};`;
1✔
696
  }
697

698
  generateAlterColumnSql(tableName: string, columnName: string, column: FullColumnDefinition): string {
699
    const colSql = this.generateColumnFromNode(this.fullColumnDefinitionToNode(column, tableName));
1✔
700
    return this.generateAlterColumnStatements(tableName, { name: columnName, type: '' } as ColumnSchema, colSql).join(
1✔
701
      '\n',
702
    );
703
  }
704

705
  generateDropColumnSql(tableName: string, columnName: string): string {
706
    return `ALTER TABLE ${this.escapeId(tableName)} DROP COLUMN ${this.escapeId(columnName)};`;
1✔
707
  }
708

709
  generateRenameColumnSql(tableName: string, oldName: string, newName: string): string {
710
    return `ALTER TABLE ${this.escapeId(tableName)} RENAME COLUMN ${this.escapeId(oldName)} TO ${this.escapeId(newName)};`;
1✔
711
  }
712

713
  generateCreateIndexSql(tableName: string, index: IndexSchema): string {
714
    return this.generateCreateIndex(tableName, index);
13✔
715
  }
716

717
  generateDropIndexSql(tableName: string, indexName: string): string {
718
    return this.generateDropIndex(tableName, indexName);
1✔
719
  }
720

721
  generateAddForeignKeySql(tableName: string, foreignKey: TableForeignKeyDefinition): string {
722
    const fkCols = foreignKey.columns.map((c) => this.escapeId(c)).join(', ');
1✔
723
    const refCols = foreignKey.referencesColumns.map((c) => this.escapeId(c)).join(', ');
1✔
724
    const constraintName = foreignKey.name
1!
725
      ? this.escapeId(foreignKey.name)
726
      : this.escapeId(`fk_${tableName}_${foreignKey.columns.join('_')}`);
727

728
    if (!this.config.features.foreignKeyAlter) {
1!
729
      throw new Error(`Dialect ${this.dialect} does not support adding foreign keys to existing tables`);
×
730
    }
731

732
    return (
1✔
733
      `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${constraintName} ` +
734
      `FOREIGN KEY (${fkCols}) REFERENCES ${this.escapeId(foreignKey.referencesTable)} (${refCols}) ` +
735
      `ON DELETE ${foreignKey.onDelete ?? this.defaultForeignKeyAction} ON UPDATE ${foreignKey.onUpdate ?? this.defaultForeignKeyAction};`
2!
736
    );
737
  }
738

739
  generateDropForeignKeySql(tableName: string, constraintName: string): string {
740
    return `ALTER TABLE ${this.escapeId(tableName)} ${this.config.dropForeignKeySyntax} ${this.escapeId(constraintName)};`;
1✔
741
  }
742

743
  private tableDefinitionToNode(def: TableDefinition): TableNode {
744
    const columns = new Map<string, ColumnNode>();
34✔
745
    const pkNodes: ColumnNode[] = [];
34✔
746

747
    const table: TableNode = {
34✔
748
      name: def.name,
749
      columns,
750
      primaryKey: [], // placeholder
751
      indexes: [],
752
      schema: { tables: new Map(), relationships: [], indexes: [] },
753
      incomingRelations: [],
754
      outgoingRelations: [],
755
      comment: def.comment,
756
    };
757

758
    for (const colDef of def.columns) {
34✔
759
      const node = this.fullColumnDefinitionToNode(colDef, def.name);
118✔
760
      (node as { table: TableNode }).table = table;
118✔
761
      columns.set(node.name, node);
118✔
762
      if (node.isPrimaryKey) {
118✔
763
        pkNodes.push(node);
38✔
764
      }
765
    }
766

767
    const finalPrimaryKey = def.primaryKey
34!
768
      ? def.primaryKey.map((name) => columns.get(name)).filter((c): c is ColumnNode => c !== undefined)
×
769
      : pkNodes;
770

771
    (table as { primaryKey: ColumnNode[] }).primaryKey = finalPrimaryKey;
34✔
772

773
    for (const idxDef of def.indexes) {
34✔
774
      const indexNode: IndexNode = {
×
775
        name: idxDef.name,
776
        table,
777
        columns: idxDef.columns.map((name) => columns.get(name)).filter((c): c is ColumnNode => c !== undefined),
×
778
        unique: idxDef.unique,
779
      };
780
      table.indexes.push(indexNode);
×
781
    }
782

783
    for (const fkDef of def.foreignKeys) {
34✔
784
      const relNode: RelationshipNode = {
20✔
785
        name: fkDef.name ?? `fk_${def.name}_${fkDef.columns.join('_')}`,
40✔
786
        type: 'ManyToOne', // Builder default
787
        from: {
788
          table,
789
          columns: fkDef.columns.map((name) => columns.get(name)).filter((c): c is ColumnNode => c !== undefined),
20✔
790
        },
791
        to: {
792
          table: { name: fkDef.referencesTable } as TableNode,
793
          columns: fkDef.referencesColumns.map((name) => ({ name }) as ColumnNode),
20✔
794
        },
795
        onDelete: fkDef.onDelete,
796
        onUpdate: fkDef.onUpdate,
797
      };
798
      table.outgoingRelations.push(relNode);
20✔
799
    }
800

801
    return table;
34✔
802
  }
803

804
  private fullColumnDefinitionToNode(col: FullColumnDefinition, tableName: string): ColumnNode {
805
    return {
120✔
806
      name: col.name,
807
      type: col.type,
808
      nullable: col.nullable,
809
      defaultValue: col.defaultValue,
810
      isPrimaryKey: col.primaryKey,
811
      isAutoIncrement: col.autoIncrement,
812
      isUnique: col.unique,
813
      comment: col.comment,
814
      table: { name: tableName } as TableNode,
815
      referencedBy: [],
816
      references: col.foreignKey
120✔
817
        ? {
818
            name: `fk_${tableName}_${col.name}`,
819
            type: 'ManyToOne',
820
            from: { table: { name: tableName } as TableNode, columns: [] },
821
            to: {
822
              table: { name: col.foreignKey.table } as TableNode,
823
              columns: col.foreignKey.columns.map((name) => ({ name }) as ColumnNode),
20✔
824
            },
825
            onDelete: col.foreignKey.onDelete,
826
            onUpdate: col.foreignKey.onUpdate,
827
          }
828
        : undefined,
829
    };
830
  }
831
}
832

833
import { MongoSchemaGenerator } from './generator/mongoSchemaGenerator.js';
834

835
export { MongoSchemaGenerator };
836

837
/**
838
 * Factory function to create a SchemaGenerator for a specific dialect.
839
 * Returns undefined for unsupported dialects.
840
 */
841
export function createSchemaGenerator(
842
  dialect: Dialect,
843
  namingStrategy?: NamingStrategy,
844
  defaultForeignKeyAction?: ForeignKeyAction,
845
): SchemaGenerator | undefined {
846
  if (dialect === 'mongodb') {
192✔
847
    return new MongoSchemaGenerator(namingStrategy, defaultForeignKeyAction);
5✔
848
  }
849
  // Check if dialect is supported (has config)
850
  const supportedDialects: Dialect[] = ['postgres', 'mysql', 'mariadb', 'sqlite'];
187✔
851
  if (!supportedDialects.includes(dialect)) {
187✔
852
    return undefined;
6✔
853
  }
854
  return new SqlSchemaGenerator(dialect, namingStrategy, defaultForeignKeyAction);
181✔
855
}
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