• 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

91.67
/packages/core/src/migrate/codegen/entityMerger.ts
1
/**
2
 * Entity Merger
3
 *
4
 * Merges generated entity code with existing TypeScript entity files.
5
 * Uses string-based parsing to add new fields with JSDoc comments.
6
 */
7

8
import { canonicalToColumnType, canonicalToTypeScript } from '../../schema/canonicalType.js';
9
import type { CanonicalType, ColumnNode, RelationshipNode } from '../../schema/types.js';
10
import { camelCase, pascalCase, singularize } from '../../util/string.util.js';
11

12
/**
13
 * Options for entity merging.
14
 */
15
export interface EntityMergerOptions {
16
  /** Add JSDoc @sync-added comments to new fields */
17
  addSyncComments?: boolean;
18
  /** Mark removed fields with @deprecated instead of deleting */
19
  markRemovedAsDeprecated?: boolean;
20
  /** Custom property name transformer */
21
  propertyNameTransformer?: (columnName: string) => string;
22
}
23

24
/**
25
 * Represents a field to add to an entity.
26
 */
27
export interface FieldToAdd {
28
  column: ColumnNode;
29
  propertyName: string;
30
  isRelation?: boolean;
31
  relation?: RelationshipNode;
32
}
33

34
/**
35
 * Represents a field to mark as deprecated.
36
 */
37
export interface FieldToDeprecate {
38
  propertyName: string;
39
  reason: string;
40
}
41

42
/**
43
 * Result of entity merge operation.
44
 */
45
export interface MergeResult {
46
  /** The merged source code */
47
  code: string;
48
  /** Number of fields added */
49
  fieldsAdded: number;
50
  /** Number of fields marked deprecated */
51
  fieldsDeprecated: number;
52
  /** Whether the file was modified */
53
  modified: boolean;
54
}
55

56
/**
57
 * Merges new fields from database schema into existing entity files.
58
 */
59
export class EntityMerger {
60
  private readonly options: Required<EntityMergerOptions>;
61

62
  constructor(options: EntityMergerOptions = {}) {
12✔
63
    this.options = {
12✔
64
      addSyncComments: options.addSyncComments ?? true,
23✔
65
      markRemovedAsDeprecated: options.markRemovedAsDeprecated ?? true,
22✔
66
      propertyNameTransformer: options.propertyNameTransformer ?? this.defaultPropertyNameTransformer.bind(this),
24✔
67
    };
68
  }
69

70
  /**
71
   * Merge new fields into existing entity source code.
72
   */
73
  merge(existingCode: string, fieldsToAdd: FieldToAdd[], fieldsToDeprecate: FieldToDeprecate[] = []): MergeResult {
9✔
74
    let code = existingCode;
9✔
75
    let fieldsAdded = 0;
9✔
76
    let fieldsDeprecated = 0;
9✔
77

78
    // Find the existing property names to avoid duplicates
79
    const existingProps = this.extractPropertyNames(code);
9✔
80

81
    // Filter out fields that already exist
82
    const newFields = fieldsToAdd.filter((f) => !existingProps.has(f.propertyName));
9✔
83

84
    // Find the position to insert new fields (before closing brace)
85
    const insertPosition = this.findInsertPosition(code);
9✔
86
    if (insertPosition === -1) {
9✔
87
      return { code, fieldsAdded: 0, fieldsDeprecated: 0, modified: false };
1✔
88
    }
89

90
    // Generate code for new fields
91
    const newFieldsCode = this.generateFieldsCode(newFields);
8✔
92

93
    if (newFieldsCode) {
8✔
94
      // Insert the new fields
95
      code = code.slice(0, insertPosition) + newFieldsCode + code.slice(insertPosition);
6✔
96
      fieldsAdded = newFields.length;
6✔
97
    }
98

99
    // Mark fields as deprecated
100
    if (this.options.markRemovedAsDeprecated) {
8!
101
      for (const field of fieldsToDeprecate) {
8✔
102
        const deprecatedCode = this.markFieldAsDeprecated(code, field);
1✔
103
        if (deprecatedCode !== code) {
1!
104
          code = deprecatedCode;
1✔
105
          fieldsDeprecated++;
1✔
106
        }
107
      }
108
    }
109

110
    return {
8✔
111
      code,
112
      fieldsAdded,
113
      fieldsDeprecated,
114
      modified: fieldsAdded > 0 || fieldsDeprecated > 0,
10✔
115
    };
116
  }
117

118
  /**
119
   * Extract existing property names from entity code.
120
   */
121
  private extractPropertyNames(code: string): Set<string> {
122
    const props = new Set<string>();
9✔
123

124
    // Match property declarations: propertyName?: Type;
125
    const regex = /^\s*(?:readonly\s+)?(\w+)\s*[?!]?\s*:/gm;
9✔
126

127
    let match = regex.exec(code);
9✔
128
    while (match !== null) {
9✔
129
      props.add(match[1]);
4✔
130
      match = regex.exec(code);
4✔
131
    }
132

133
    return props;
9✔
134
  }
135

136
  /**
137
   * Find the position to insert new fields (before the last closing brace).
138
   */
139
  private findInsertPosition(code: string): number {
140
    // Find the last closing brace that ends the class
141
    const lines = code.split('\n');
9✔
142
    let braceCount = 0;
9✔
143
    let inClass = false;
9✔
144
    let lastPropertyLine = -1;
9✔
145

146
    for (let i = 0; i < lines.length; i++) {
9✔
147
      const line = lines[i];
31✔
148

149
      if (line.includes('class ')) {
31✔
150
        inClass = true;
8✔
151
      }
152

153
      if (inClass) {
31✔
154
        // Count braces
155
        for (const char of line) {
20✔
156
          if (char === '{') braceCount++;
357✔
157
          if (char === '}') braceCount--;
357✔
158
        }
159

160
        // Track property declarations
161
        if (/^\s*(?:@\w+|readonly\s+|\w+\s*[?!]?:)/.test(line)) {
20✔
162
          lastPropertyLine = i;
12✔
163
        }
164

165
        // Found the closing brace
166
        if (braceCount === 0 && inClass) {
20✔
167
          // Insert before the closing brace, after the last property
168
          const insertLineIndex = lastPropertyLine >= 0 ? lastPropertyLine + 1 : i;
8!
169

170
          // Calculate character position
171
          let pos = 0;
8✔
172
          for (let j = 0; j < insertLineIndex; j++) {
8✔
173
            pos += lines[j].length + 1; // +1 for newline
26✔
174
          }
175

176
          return pos;
8✔
177
        }
178
      }
179
    }
180

181
    return -1;
1✔
182
  }
183

184
  /**
185
   * Generate code for new fields.
186
   */
187
  private generateFieldsCode(fields: FieldToAdd[]): string {
188
    if (fields.length === 0) return '';
8✔
189

190
    const lines: string[] = [''];
6✔
191

192
    for (const field of fields) {
6✔
193
      if (field.isRelation && field.relation) {
6✔
194
        lines.push(...this.generateRelationField(field));
2✔
195
      } else {
196
        lines.push(...this.generateColumnField(field));
4✔
197
      }
198
      lines.push('');
6✔
199
    }
200

201
    return lines.join('\n');
6✔
202
  }
203

204
  /**
205
   * Generate code for a column field.
206
   */
207
  private generateColumnField(field: FieldToAdd): string[] {
208
    const lines: string[] = [];
4✔
209
    const col = field.column;
4✔
210
    const tsType = canonicalToTypeScript(col.type);
4✔
211

212
    // JSDoc comment
213
    if (this.options.addSyncComments) {
4!
214
      lines.push('  /**');
4✔
215
      lines.push(`   * @sync-added ${new Date().toISOString().split('T')[0]}`);
4✔
216
      lines.push(`   * Column discovered in database but not in entity.`);
4✔
217
      lines.push(`   * Type: ${this.formatTypeDescription(col.type)}, Nullable: ${col.nullable}`);
4✔
218
      lines.push('   */');
4✔
219
    }
220

221
    // Decorator
222
    if (col.isPrimaryKey) {
4✔
223
      lines.push(`  @Id()`);
1✔
224
    } else {
225
      const fieldOptions = this.buildFieldOptions(col);
3✔
226
      lines.push(`  @Field(${fieldOptions})`);
3✔
227
    }
228

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

233
    return lines;
4✔
234
  }
235

236
  /**
237
   * Generate code for a relation field.
238
   */
239
  private generateRelationField(field: FieldToAdd): string[] {
240
    const lines: string[] = [];
2✔
241
    const rel = field.relation;
2✔
242
    if (!rel) return lines;
2!
243

244
    // Determine related table and decorator
245
    const isOutgoing = rel.from.columns.some((c) => c.name === field.column.name);
2✔
246
    const relatedTable = isOutgoing ? rel.to.table : rel.from.table;
2✔
247
    const decoratorName = this.getRelationDecoratorName(rel.type, !isOutgoing);
2✔
248

249
    const relatedClassName = this.toPascalCase(this.singularize(relatedTable.name));
2✔
250

251
    // JSDoc comment
252
    if (this.options.addSyncComments) {
2!
253
      lines.push('  /**');
2✔
254
      lines.push(`   * @sync-added ${new Date().toISOString().split('T')[0]}`);
2✔
255
      lines.push(`   * Foreign key relation detected: references "${relatedTable.name}"`);
2✔
256
      lines.push('   */');
2✔
257
    }
258

259
    // Decorator
260
    lines.push(`  @${decoratorName}({ entity: () => ${relatedClassName} })`);
2✔
261

262
    // Property
263
    const isArray = decoratorName === 'OneToMany' || decoratorName === 'ManyToMany';
2✔
264
    const typeStr = isArray ? `Relation<${relatedClassName}[]>` : `Relation<${relatedClassName}>`;
2✔
265
    lines.push(`  ${field.propertyName}?: ${typeStr};`);
2✔
266

267
    return lines;
2✔
268
  }
269

270
  /**
271
   * Mark a field as deprecated in the source code.
272
   */
273
  private markFieldAsDeprecated(code: string, field: FieldToDeprecate): string {
274
    // Find the property declaration
275
    const propRegex = new RegExp(`(^\\s*)(@\\w+.*?\\n\\s*)*?(${field.propertyName}\\s*[?!]?\\s*:)`, 'm');
1✔
276
    const match = propRegex.exec(code);
1✔
277

278
    if (!match) return code;
1!
279

280
    // Check if already has @deprecated
281
    const beforeMatch = code.slice(Math.max(0, match.index - 100), match.index);
1✔
282
    if (beforeMatch.includes('@deprecated')) return code;
1!
283

284
    // Add @deprecated JSDoc
285
    const indent = match[1] || '  ';
1!
286
    const deprecationComment = [
1✔
287
      `${indent}/**`,
288
      `${indent} * @deprecated ${field.reason}`,
289
      `${indent} * @sync-removed ${new Date().toISOString().split('T')[0]}`,
290
      `${indent} */`,
291
      '',
292
    ].join('\n');
293

294
    return code.slice(0, match.index) + deprecationComment + code.slice(match.index);
1✔
295
  }
296

297
  /**
298
   * Build Field decorator options string.
299
   */
300
  private buildFieldOptions(col: ColumnNode): string {
301
    const options: string[] = [];
3✔
302

303
    const columnType = canonicalToColumnType(col.type);
3✔
304
    if (columnType) {
3!
305
      options.push(`columnType: '${columnType}'`);
3✔
306
    }
307

308
    if (col.type.length && col.type.category === 'string') {
3✔
309
      options.push(`length: ${col.type.length}`);
1✔
310
    }
311

312
    if (col.type.precision !== undefined) {
3✔
313
      options.push(`precision: ${col.type.precision}`);
1✔
314
    }
315

316
    if (col.type.scale !== undefined) {
3✔
317
      options.push(`scale: ${col.type.scale}`);
1✔
318
    }
319

320
    if (col.nullable) {
3✔
321
      options.push('nullable: true');
2✔
322
    }
323

324
    return options.length > 0 ? `{ ${options.join(', ')} }` : '';
3!
325
  }
326

327
  /**
328
   * Format type for description.
329
   */
330
  private formatTypeDescription(type: CanonicalType): string {
331
    let desc = type.category.toUpperCase();
4✔
332
    if (type.size) desc = `${type.size.toUpperCase()}${desc}`;
4!
333
    if (type.length) desc += `(${type.length})`;
4✔
334
    if (type.precision) {
4✔
335
      desc += `(${type.precision}`;
1✔
336
      if (type.scale) desc += `,${type.scale}`;
1!
337
      desc += ')';
1✔
338
    }
339
    return desc;
4✔
340
  }
341

342
  /**
343
   * Get relation decorator name.
344
   */
345
  private getRelationDecoratorName(type: string, inverse: boolean): string {
346
    if (inverse) {
2✔
347
      if (type === 'ManyToOne') return 'OneToMany';
1!
NEW
348
      if (type === 'OneToMany') return 'ManyToOne';
×
349
    }
350
    return type;
1✔
351
  }
352

353
  /**
354
   * Default property name transformer (delegates to shared utility).
355
   */
356
  private defaultPropertyNameTransformer(columnName: string): string {
NEW
357
    return camelCase(columnName);
×
358
  }
359

360
  /**
361
   * Convert to PascalCase (delegates to shared utility).
362
   */
363
  private toPascalCase(str: string): string {
364
    return pascalCase(str);
2✔
365
  }
366

367
  /**
368
   * Singularize (delegates to shared utility).
369
   */
370
  private singularize(name: string): string {
371
    return singularize(name);
2✔
372
  }
373
}
374

375
/**
376
 * Create an EntityMerger instance.
377
 */
378
export function createEntityMerger(options?: EntityMergerOptions): EntityMerger {
379
  return new EntityMerger(options);
1✔
380
}
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