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

rogerpadilla / uql / 20834844061

08 Jan 2026 11:05PM UTC coverage: 94.064% (-4.3%) from 98.408%
20834844061

push

github

web-flow
Merge pull request #81 from rogerpadilla/feat/80-unify-orm-schema-synchronization-system-via-schema-ast

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

86.55
/packages/core/src/migrate/codegen/entityCodeGenerator.ts
1
/**
2
 * Entity Code Generator
3
 *
4
 * Generates TypeScript entity files from SchemaAST.
5
 * Supports:
6
 * - ES Module syntax
7
 * - TypeScript types
8
 * - Relations with proper decorators
9
 * - Indexes
10
 * - JSDoc comments for sync-added fields
11
 */
12

13
import { canonicalToColumnType, canonicalToTypeScript } from '../../schema/canonicalType.js';
14
import type { SchemaAST } from '../../schema/schemaAST.js';
15
import type { CanonicalType, ColumnNode, RelationshipNode, RelationshipType, TableNode } from '../../schema/types.js';
16
import { camelCase, pascalCase, singularize } from '../../util/string.util.js';
17

18
/**
19
 * Options for entity code generation.
20
 */
21
export interface EntityCodeGeneratorOptions {
22
  /** Base import path for @uql/core (default: '@uql/core') */
23
  uqlImportPath?: string;
24
  /** Whether to add JSDoc with @sync-added for generated fields */
25
  addSyncComments?: boolean;
26
  /** Custom class name transformer (default: PascalCase singularized) */
27
  classNameTransformer?: (tableName: string) => string;
28
  /** Custom property name transformer (default: camelCase) */
29
  propertyNameTransformer?: (columnName: string) => string;
30
  /** Whether to generate relation properties (default: true) */
31
  includeRelations?: boolean;
32
  /** Whether to include index decorators (default: true) */
33
  includeIndexes?: boolean;
34
  /** Custom singularize function */
35
  singularize?: (name: string) => string;
36
}
37

38
/**
39
 * Generated entity result.
40
 */
41
export interface GeneratedEntity {
42
  /** The entity class name */
43
  className: string;
44
  /** The table name */
45
  tableName: string;
46
  /** The generated TypeScript code */
47
  code: string;
48
  /** The suggested file name */
49
  fileName: string;
50
}
51

52
/**
53
 * Generates TypeScript entity code from SchemaAST.
54
 */
55
export class EntityCodeGenerator {
56
  private readonly options: Required<EntityCodeGeneratorOptions>;
57

58
  constructor(
59
    private readonly ast: SchemaAST,
19✔
60
    options: EntityCodeGeneratorOptions = {},
19✔
61
  ) {
62
    this.options = {
19✔
63
      uqlImportPath: options.uqlImportPath ?? '@uql/core',
38✔
64
      addSyncComments: options.addSyncComments ?? true,
38✔
65
      classNameTransformer: options.classNameTransformer ?? this.defaultClassNameTransformer.bind(this),
38✔
66
      propertyNameTransformer: options.propertyNameTransformer ?? this.defaultPropertyNameTransformer.bind(this),
38✔
67
      includeRelations: options.includeRelations ?? true,
38✔
68
      includeIndexes: options.includeIndexes ?? true,
38✔
69
      singularize: options.singularize ?? this.defaultSingularize.bind(this),
38✔
70
    };
71
  }
72

73
  /**
74
   * Generate entities for all tables in the AST.
75
   */
76
  generateAll(): GeneratedEntity[] {
77
    const entities: GeneratedEntity[] = [];
1✔
78

79
    for (const table of this.ast.tables.values()) {
1✔
80
      entities.push(this.generateEntity(table));
2✔
81
    }
82

83
    return entities;
1✔
84
  }
85

86
  /**
87
   * Generate entity for a specific table.
88
   */
89
  generateForTable(tableName: string): GeneratedEntity | undefined {
90
    const table = this.ast.getTable(tableName);
17✔
91
    if (!table) return undefined;
17!
92
    return this.generateEntity(table);
17✔
93
  }
94

95
  /**
96
   * Generate entity code for a table.
97
   */
98
  private generateEntity(table: TableNode): GeneratedEntity {
99
    const className = this.options.classNameTransformer(table.name);
19✔
100
    const fileName = `${className}.ts`;
19✔
101

102
    const imports = this.buildImports(table);
19✔
103
    const decorators = this.buildEntityDecorators(table);
19✔
104
    const fields = this.buildFields(table);
19✔
105
    const relations = this.options.includeRelations ? this.buildRelations(table) : '';
19!
106

107
    const code = [imports, '', decorators, `export class ${className} {`, fields, relations, '}', ''].join('\n');
19✔
108

109
    return {
19✔
110
      className,
111
      tableName: table.name,
112
      code,
113
      fileName,
114
    };
115
  }
116

117
  /**
118
   * Build import statements.
119
   */
120
  private buildImports(table: TableNode): string {
121
    const uqlImports = new Set<string>(['Entity', 'Field']);
19✔
122
    const relatedImports: string[] = [];
19✔
123

124
    // Check for Id decorator
125
    for (const col of table.columns.values()) {
19✔
126
      if (col.isPrimaryKey) {
41✔
127
        uqlImports.add('Id');
19✔
128
      }
129
    }
130

131
    // Check for relation decorators
132
    if (this.options.includeRelations) {
19!
133
      for (const rel of [...table.incomingRelations, ...table.outgoingRelations]) {
19✔
134
        uqlImports.add(this.getRelationDecoratorName(rel.type));
4✔
135
        uqlImports.add('Relation');
4✔
136

137
        // Add import for related entity
138
        const relatedTable = rel.from.table === table ? rel.to.table : rel.from.table;
4✔
139
        const relatedClassName = this.options.classNameTransformer(relatedTable.name);
4✔
140
        if (!relatedImports.includes(relatedClassName)) {
4!
141
          relatedImports.push(relatedClassName);
4✔
142
        }
143
      }
144
    }
145

146
    // Sort imports
147
    const sortedUqlImports = Array.from(uqlImports).sort();
19✔
148

149
    let code = `import { ${sortedUqlImports.join(', ')} } from '${this.options.uqlImportPath}';\n`;
19✔
150

151
    // Add related entity imports
152
    for (const className of relatedImports) {
19✔
153
      code += `import type { ${className} } from './${className}.js';\n`;
4✔
154
    }
155

156
    return code;
19✔
157
  }
158

159
  /**
160
   * Build entity decorators.
161
   */
162
  private buildEntityDecorators(table: TableNode): string {
163
    const lines: string[] = [];
19✔
164

165
    // Add composite index decorators
166
    if (this.options.includeIndexes) {
19!
167
      const compositeIndexes = this.ast.getTableIndexes(table.name).filter((idx) => idx.columns.length > 1);
19✔
168

169
      for (const idx of compositeIndexes) {
19✔
NEW
170
        const columnNames = idx.columns.map((c) => `'${c.name}'`).join(', ');
×
NEW
171
        const options: string[] = [];
×
NEW
172
        if (idx.name) options.push(`name: '${idx.name}'`);
×
NEW
173
        if (idx.unique) options.push('unique: true');
×
174

NEW
175
        const optStr = options.length > 0 ? `, { ${options.join(', ')} }` : '';
×
NEW
176
        lines.push(`// @Index([${columnNames}]${optStr})`);
×
177
      }
178
    }
179

180
    // Entity decorator
181
    lines.push(`@Entity({ name: '${table.name}' })`);
19✔
182

183
    return lines.join('\n');
19✔
184
  }
185

186
  /**
187
   * Build field definitions.
188
   */
189
  private buildFields(table: TableNode): string {
190
    const lines: string[] = [];
19✔
191

192
    for (const col of table.columns.values()) {
19✔
193
      const fieldCode = this.buildField(col);
41✔
194
      lines.push(fieldCode);
41✔
195
    }
196

197
    return lines.join('\n\n');
19✔
198
  }
199

200
  /**
201
   * Build a single field definition.
202
   */
203
  private buildField(col: ColumnNode): string {
204
    const lines: string[] = [];
41✔
205
    const propertyName = this.options.propertyNameTransformer(col.name);
41✔
206
    const tsType = canonicalToTypeScript(col.type);
41✔
207

208
    // JSDoc comment if enabled
209
    if (this.options.addSyncComments) {
41!
210
      lines.push('  /**');
41✔
211
      lines.push(`   * @sync-added ${new Date().toISOString().split('T')[0]}`);
41✔
212
      lines.push(`   * Column: ${col.name} (${this.formatTypeDescription(col.type)})`);
41✔
213
      if (col.comment) {
41!
NEW
214
        lines.push(`   * ${col.comment}`);
×
215
      }
216
      lines.push('   */');
41✔
217
    }
218

219
    // Decorator
220
    if (col.isPrimaryKey) {
41✔
221
      const idOptions = this.buildIdOptions(col);
19✔
222
      lines.push(`  @Id(${idOptions})`);
19✔
223
    } else {
224
      const fieldOptions = this.buildFieldOptions(col);
22✔
225
      lines.push(`  @Field(${fieldOptions})`);
22✔
226
    }
227

228
    // Property
229
    const nullable = col.nullable ? '?' : '';
41!
230
    lines.push(`  ${propertyName}${nullable}: ${tsType};`);
41✔
231

232
    return lines.join('\n');
41✔
233
  }
234

235
  /**
236
   * Build Id decorator options.
237
   */
238
  private buildIdOptions(col: ColumnNode): string {
239
    const options: string[] = [];
19✔
240

241
    if (col.name !== 'id') {
19✔
242
      options.push(`name: '${col.name}'`);
1✔
243
    }
244

245
    return options.length > 0 ? `{ ${options.join(', ')} }` : '';
19✔
246
  }
247

248
  /**
249
   * Build Field decorator options.
250
   */
251
  private buildFieldOptions(col: ColumnNode): string {
252
    const options: string[] = [];
22✔
253

254
    // Column type
255
    const columnType = canonicalToColumnType(col.type);
22✔
256
    if (columnType) {
22!
257
      options.push(`columnType: '${columnType}'`);
22✔
258
    }
259

260
    // Length
261
    if (col.type.length && col.type.category === 'string') {
22✔
262
      options.push(`length: ${col.type.length}`);
1✔
263
    }
264

265
    // Precision and scale
266
    if (col.type.precision) {
22✔
267
      options.push(`precision: ${col.type.precision}`);
1✔
268
      if (col.type.scale) {
1!
269
        options.push(`scale: ${col.type.scale}`);
1✔
270
      }
271
    }
272

273
    // Nullable
274
    if (col.nullable) {
22!
275
      options.push('nullable: true');
22✔
276
    }
277

278
    // Unique
279
    if (col.isUnique) {
22✔
280
      options.push('unique: true');
1✔
281
    }
282

283
    // Default value
284
    if (col.defaultValue !== undefined) {
22✔
285
      options.push(`defaultValue: ${this.formatDefaultValue(col.defaultValue)}`);
7✔
286
    }
287

288
    // Index (single column)
289
    if (this.options.includeIndexes) {
22!
290
      const indexes = this.ast.getTableIndexes(col.table.name);
22✔
291
      const singleColIndex = indexes.find((idx) => idx.columns.length === 1 && idx.columns[0].name === col.name);
22✔
292

293
      if (singleColIndex) {
22✔
294
        options.push(`index: '${singleColIndex.name}'`);
1✔
295
      }
296
    }
297

298
    return options.length > 0 ? `{ ${options.join(', ')} }` : '';
22!
299
  }
300

301
  /**
302
   * Build relation definitions.
303
   */
304
  private buildRelations(table: TableNode): string {
305
    const lines: string[] = [];
19✔
306

307
    // Outgoing relations (this table has FK)
308
    for (const rel of table.outgoingRelations) {
19✔
309
      const relCode = this.buildOutgoingRelation(rel, table);
3✔
310
      lines.push(relCode);
3✔
311
    }
312

313
    // Incoming relations (other tables have FK to this)
314
    for (const rel of table.incomingRelations) {
19✔
315
      const relCode = this.buildIncomingRelation(rel, table);
1✔
316
      lines.push(relCode);
1✔
317
    }
318

319
    if (lines.length > 0) {
19✔
320
      return '\n' + lines.join('\n\n');
3✔
321
    }
322

323
    return '';
16✔
324
  }
325

326
  /**
327
   * Build outgoing relation (ManyToOne or OneToOne where this table has FK).
328
   */
329
  private buildOutgoingRelation(rel: RelationshipNode, table: TableNode): string {
330
    const lines: string[] = [];
3✔
331
    const relatedClassName = this.options.classNameTransformer(rel.to.table.name);
3✔
332

333
    // Try to derive property name from FK column name (e.g., author_id -> author)
334
    let propertyName = '';
3✔
335
    const firstCol = rel.from.columns[0]?.name;
3✔
336
    if (firstCol && (firstCol.toLowerCase().endsWith('_id') || firstCol.toLowerCase().endsWith('id'))) {
3!
337
      const baseName = firstCol.replace(/_?id$/i, '');
3✔
338
      propertyName = this.options.propertyNameTransformer(baseName);
3✔
339
    } else {
NEW
340
      propertyName = this.options.propertyNameTransformer(this.options.singularize(rel.to.table.name));
×
341
    }
342

343
    const decoratorName = this.getRelationDecoratorName(rel.type);
3✔
344

345
    // JSDoc
346
    if (this.options.addSyncComments) {
3!
347
      lines.push('  /**');
3✔
348
      lines.push(`   * @sync-added ${new Date().toISOString().split('T')[0]}`);
3✔
349
      lines.push(`   * Relation to ${rel.to.table.name} via ${rel.from.columns.map((c) => c.name).join(', ')}`);
3✔
350
      if (rel.confidence !== undefined && rel.confidence < 1.0) {
3!
NEW
351
        lines.push(`   * Inferred (${(rel.confidence * 100).toFixed(0)}% confidence)`);
×
352
      }
353
      lines.push('   */');
3✔
354
    }
355

356
    // Decorator
357
    lines.push(`  @${decoratorName}({ entity: () => ${relatedClassName} })`);
3✔
358

359
    // Property
360
    lines.push(`  ${propertyName}?: Relation<${relatedClassName}>;`);
3✔
361

362
    return lines.join('\n');
3✔
363
  }
364

365
  /**
366
   * Build incoming relation (OneToMany where other tables have FK to this).
367
   */
368
  private buildIncomingRelation(rel: RelationshipNode, table: TableNode): string {
369
    const lines: string[] = [];
1✔
370
    const relatedClassName = this.options.classNameTransformer(rel.from.table.name);
1✔
371
    const propertyName = this.options.propertyNameTransformer(rel.from.table.name);
1✔
372
    const inverseType = this.ast.getInverseRelationType(rel.type);
1✔
373
    const decoratorName = this.getRelationDecoratorName(inverseType);
1✔
374

375
    // JSDoc
376
    if (this.options.addSyncComments) {
1!
377
      lines.push('  /**');
1✔
378
      lines.push(`   * @sync-added ${new Date().toISOString().split('T')[0]}`);
1✔
379
      lines.push(`   * Inverse relation from ${rel.from.table.name}`);
1✔
380
      lines.push('   */');
1✔
381
    }
382

383
    // Decorator - includes references to property name
384
    const inverseProp = this.options.propertyNameTransformer(this.options.singularize(table.name));
1✔
385
    lines.push(`  @${decoratorName}({ entity: () => ${relatedClassName}, references: '${inverseProp}' })`);
1✔
386

387
    // Property
388
    if (inverseType === 'OneToMany' || inverseType === 'ManyToMany') {
1!
389
      lines.push(`  ${propertyName}?: Relation<${relatedClassName}[]>;`);
1✔
390
    } else {
NEW
391
      lines.push(`  ${propertyName}?: Relation<${relatedClassName}>;`);
×
392
    }
393

394
    return lines.join('\n');
1✔
395
  }
396

397
  // ============================================================================
398
  // Helper Methods
399
  // ============================================================================
400

401
  /**
402
   * Get decorator name for relation type.
403
   */
404
  private getRelationDecoratorName(type: RelationshipType): string {
405
    switch (type) {
8✔
406
      case 'OneToOne':
407
        return 'OneToOne';
2✔
408
      case 'OneToMany':
409
        return 'OneToMany';
1✔
410
      case 'ManyToOne':
411
        return 'ManyToOne';
3✔
412
      case 'ManyToMany':
413
        return 'ManyToMany';
2✔
414
    }
415
  }
416

417
  /**
418
   * Format type for description.
419
   */
420
  private formatTypeDescription(type: CanonicalType): string {
421
    let desc = type.category.toUpperCase();
41✔
422
    if (type.size) desc = `${type.size.toUpperCase()}${desc}`;
41!
423
    if (type.length) desc += `(${type.length})`;
41✔
424
    if (type.precision) {
41✔
425
      desc += `(${type.precision}`;
1✔
426
      if (type.scale) desc += `,${type.scale}`;
1!
427
      desc += ')';
1✔
428
    }
429
    if (type.unsigned) desc += ' UNSIGNED';
41!
430
    return desc;
41✔
431
  }
432

433
  /**
434
   * Format default value for code.
435
   */
436
  private formatDefaultValue(value: unknown): string {
437
    if (typeof value === 'string') {
7✔
438
      // Check for SQL expressions
439
      if (
2✔
440
        value.includes('(') ||
5✔
441
        value.toUpperCase() === 'CURRENT_TIMESTAMP' ||
442
        value.toUpperCase().includes('NEXTVAL')
443
      ) {
444
        return `'${value}'`; // Keep as string for expressions
1✔
445
      }
446
      return `'${value.replace(/'/g, "\\'")}'`;
1✔
447
    }
448
    if (typeof value === 'boolean') {
5✔
449
      return value.toString();
1✔
450
    }
451
    if (typeof value === 'number') {
4✔
452
      return value.toString();
1✔
453
    }
454
    if (value === null) {
3✔
455
      return 'null';
1✔
456
    }
457
    return JSON.stringify(value);
2✔
458
  }
459

460
  /**
461
   * Default class name transformer: table_name -> TableName (PascalCase, singular).
462
   */
463
  private defaultClassNameTransformer(tableName: string): string {
464
    const singular = this.options.singularize(tableName);
27✔
465
    return this.toPascalCase(singular);
27✔
466
  }
467

468
  /**
469
   * Default property name transformer: column_name -> columnName (camelCase).
470
   */
471
  private defaultPropertyNameTransformer(name: string): string {
472
    return this.toCamelCase(name);
46✔
473
  }
474

475
  /**
476
   * Convert to PascalCase (delegates to shared utility).
477
   */
478
  private toPascalCase(str: string): string {
479
    return pascalCase(str);
27✔
480
  }
481

482
  /**
483
   * Convert to camelCase (delegates to shared utility).
484
   */
485
  private toCamelCase(str: string): string {
486
    return camelCase(str);
46✔
487
  }
488

489
  /**
490
   * Default singularize function (delegates to shared utility).
491
   */
492
  private defaultSingularize(name: string): string {
493
    return singularize(name);
28✔
494
  }
495
}
496

497
/**
498
 * Create an EntityCodeGenerator from SchemaAST.
499
 */
500
export function createEntityCodeGenerator(ast: SchemaAST, options?: EntityCodeGeneratorOptions): EntityCodeGenerator {
501
  return new EntityCodeGenerator(ast, options);
1✔
502
}
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