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

rogerpadilla / uql / 23012384310

12 Mar 2026 04:25PM UTC coverage: 95.267% (+0.04%) from 95.226%
23012384310

push

github

rogerpadilla
docs: implement commitlint for local commit message validation and CI/CD checks

2775 of 3069 branches covered (90.42%)

Branch coverage included in aggregate %.

4975 of 5066 relevant lines covered (98.2%)

305.32 hits per line

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

88.09
/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
    const reference = field.references ?? field.reference;
177✔
275
    if (reference) {
177✔
276
      const refEntity = reference();
1✔
277
      const refMeta = getMeta(refEntity);
1✔
278
      const refIdField = refMeta.fields[refMeta.id!];
1✔
279
      return this.getSqlType(
1✔
280
        { ...refIdField!, references: undefined, reference: undefined, isId: undefined, autoIncrement: false },
281
        refIdField!.type,
282
      );
283
    }
284

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

474
    return false;
26✔
475
  }
476

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

566
    sql += ';';
80✔
567

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

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

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

583
    return sql;
80✔
584
  }
585

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

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

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

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

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

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

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

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

619
    return def;
332✔
620
  }
621

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

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

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

639
    return clause;
3✔
640
  }
641

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

802
    return table;
34✔
803
  }
804

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

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

836
export { MongoSchemaGenerator };
837

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