bigal 16.0.0-beta.2 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,497 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /* eslint-disable no-console -- CLI tool requires console output */
3
- /**
4
- * BigAl v15 → v16 Migration Codemod
5
- *
6
- * Converts decorator-based model classes to function-based table() definitions.
7
- *
8
- * Usage:
9
- * npx tsx scripts/migrate-v16.ts 'src/models/**\/*.ts'
10
- * npx tsx scripts/migrate-v16.ts 'src/models/**\/*.ts' --write
11
- * npx tsx scripts/migrate-v16.ts --help
12
- */
13
- import { existsSync } from 'node:fs';
14
- import { resolve } from 'node:path';
15
-
16
- import { Project, SyntaxKind } from 'ts-morph';
17
- import type { CallExpression, ClassDeclaration, Decorator, ObjectLiteralExpression, PropertyAssignment, SourceFile } from 'ts-morph';
18
-
19
- const COLUMN_TYPE_MAP: Record<string, string> = {
20
- string: 'text',
21
- integer: 'integer',
22
- float: 'float',
23
- boolean: 'boolean',
24
- date: 'date',
25
- datetime: 'timestamptz',
26
- json: 'jsonb',
27
- uuid: 'uuid',
28
- binary: 'bytea',
29
- 'string[]': 'textArray',
30
- 'integer[]': 'integerArray',
31
- 'boolean[]': 'booleanArray',
32
- };
33
-
34
- interface ColumnInfo {
35
- propertyName: string;
36
- dbColumnName: string | undefined;
37
- decoratorName: string;
38
- type: string | undefined;
39
- required: boolean;
40
- defaultsTo: string | undefined;
41
- model: string | undefined;
42
- collection: string | undefined;
43
- through: string | undefined;
44
- via: string | undefined;
45
- }
46
-
47
- interface TableInfo {
48
- className: string;
49
- tableName: string;
50
- schema: string | undefined;
51
- readonly: boolean;
52
- connection: string | undefined;
53
- columns: ColumnInfo[];
54
- baseClass: string | undefined;
55
- hasBeforeCreate: boolean;
56
- hasBeforeUpdate: boolean;
57
- hasInstanceMethods: string[];
58
- }
59
-
60
- function getDecoratorArg(decorator: Decorator): ObjectLiteralExpression | undefined {
61
- const args = decorator.getArguments();
62
- if (args.length && args[0]?.getKind() === SyntaxKind.ObjectLiteralExpression) {
63
- return args[0] as ObjectLiteralExpression;
64
- }
65
- return undefined;
66
- }
67
-
68
- function getStringProperty(obj: ObjectLiteralExpression, name: string): string | undefined {
69
- const prop = obj.getProperty(name);
70
- if (prop && prop.getKind() === SyntaxKind.PropertyAssignment) {
71
- const init = (prop as PropertyAssignment).getInitializer();
72
- if (init) {
73
- const propText = init.getText();
74
- if ((propText.startsWith("'") && propText.endsWith("'")) || (propText.startsWith('"') && propText.endsWith('"'))) {
75
- return propText.slice(1, -1);
76
- }
77
- return propText;
78
- }
79
- }
80
- return undefined;
81
- }
82
-
83
- function getBooleanProperty(obj: ObjectLiteralExpression, name: string): boolean {
84
- const prop = obj.getProperty(name);
85
- if (prop && prop.getKind() === SyntaxKind.PropertyAssignment) {
86
- const init = (prop as PropertyAssignment).getInitializer();
87
- return init?.getText() === 'true';
88
- }
89
- return false;
90
- }
91
-
92
- function extractTableInfo(classDecl: ClassDeclaration): TableInfo | undefined {
93
- const tableDecorator = classDecl.getDecorator('table');
94
- if (!tableDecorator) {
95
- return undefined;
96
- }
97
-
98
- const decoratorArg = getDecoratorArg(tableDecorator);
99
- const tableName = decoratorArg ? (getStringProperty(decoratorArg, 'name') ?? '') : '';
100
- const schema = decoratorArg ? getStringProperty(decoratorArg, 'schema') : undefined;
101
- const isReadonly = decoratorArg ? getBooleanProperty(decoratorArg, 'readonly') : false;
102
- const connection = decoratorArg ? getStringProperty(decoratorArg, 'connection') : undefined;
103
-
104
- const baseClass = classDecl.getExtends()?.getText();
105
- const columns: ColumnInfo[] = [];
106
- const instanceMethods: string[] = [];
107
-
108
- for (const prop of classDecl.getProperties()) {
109
- const columnDec = prop.getDecorator('column');
110
- const primaryDec = prop.getDecorator('primaryColumn');
111
- const createDateDec = prop.getDecorator('createDateColumn');
112
- const updateDateDec = prop.getDecorator('updateDateColumn');
113
- const versionDec = prop.getDecorator('versionColumn');
114
-
115
- const decorator = columnDec ?? primaryDec ?? createDateDec ?? updateDateDec ?? versionDec;
116
- if (!decorator) {
117
- continue;
118
- }
119
-
120
- const propName = prop.getName();
121
- const arg = getDecoratorArg(decorator);
122
-
123
- const info: ColumnInfo = {
124
- propertyName: propName,
125
- dbColumnName: arg ? getStringProperty(arg, 'name') : undefined,
126
- decoratorName: decorator.getName(),
127
- type: arg ? getStringProperty(arg, 'type') : undefined,
128
- required: arg ? getBooleanProperty(arg, 'required') : false,
129
- defaultsTo: arg ? getStringProperty(arg, 'defaultsTo') : undefined,
130
- model: arg ? getStringProperty(arg, 'model') : undefined,
131
- collection: undefined,
132
- through: undefined,
133
- via: arg ? getStringProperty(arg, 'via') : undefined,
134
- };
135
-
136
- if (arg) {
137
- const collectionProp = arg.getProperty('collection');
138
- if (collectionProp && collectionProp.getKind() === SyntaxKind.PropertyAssignment) {
139
- const init = (collectionProp as PropertyAssignment).getInitializer();
140
- if (init) {
141
- info.collection = init.getText();
142
- }
143
- }
144
-
145
- const throughProp = arg.getProperty('through');
146
- if (throughProp && throughProp.getKind() === SyntaxKind.PropertyAssignment) {
147
- const init = (throughProp as PropertyAssignment).getInitializer();
148
- if (init) {
149
- info.through = init.getText();
150
- }
151
- }
152
- }
153
-
154
- columns.push(info);
155
- }
156
-
157
- for (const method of classDecl.getMethods()) {
158
- if (!method.isStatic()) {
159
- instanceMethods.push(method.getName());
160
- }
161
- }
162
-
163
- const hasBeforeCreate = classDecl.getStaticMethod('beforeCreate') !== undefined;
164
- const hasBeforeUpdate = classDecl.getStaticMethod('beforeUpdate') !== undefined;
165
-
166
- return {
167
- className: classDecl.getName() ?? 'Unknown',
168
- tableName,
169
- schema,
170
- readonly: isReadonly,
171
- connection,
172
- columns,
173
- baseClass,
174
- hasBeforeCreate,
175
- hasBeforeUpdate,
176
- hasInstanceMethods: instanceMethods,
177
- };
178
- }
179
-
180
- function toSnakeCase(str: string): string {
181
- return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
182
- }
183
-
184
- function capitalize(str: string): string {
185
- return str.charAt(0).toUpperCase() + str.slice(1);
186
- }
187
-
188
- function extractModelName(expr: string): string {
189
- // Match arrow functions: () => Foo.name, () => Foo, () => 'Foo'
190
- const arrowNameMatch = /=>\s*(\w+)\.name\b/.exec(expr);
191
- if (arrowNameMatch) {
192
- return arrowNameMatch[1]!;
193
- }
194
-
195
- const arrowIdentMatch = /=>\s*(\w+)\s*[,)]?$/.exec(expr);
196
- if (arrowIdentMatch) {
197
- return arrowIdentMatch[1]!;
198
- }
199
-
200
- const arrowStringMatch = /=>\s*['"](\w+)['"]/.exec(expr);
201
- if (arrowStringMatch) {
202
- return arrowStringMatch[1]!;
203
- }
204
-
205
- // Plain string: strip quotes
206
- const stripped = expr.replace(/['"]/g, '');
207
- return capitalize(stripped);
208
- }
209
-
210
- function generateColumnBuilder(col: ColumnInfo): string {
211
- const derivedDbName = toSnakeCase(col.propertyName);
212
- const explicitDbName = col.dbColumnName && col.dbColumnName !== derivedDbName ? col.dbColumnName : undefined;
213
-
214
- if (col.decoratorName === 'createDateColumn') {
215
- return explicitDbName ? `createdAt({ name: '${explicitDbName}' })` : 'createdAt()';
216
- }
217
- if (col.decoratorName === 'updateDateColumn') {
218
- return explicitDbName ? `updatedAt({ name: '${explicitDbName}' })` : 'updatedAt()';
219
- }
220
- if (col.decoratorName === 'versionColumn') {
221
- return 'integer().version()';
222
- }
223
- if (col.model) {
224
- const modelName = extractModelName(col.model);
225
- const derivedFk = `${toSnakeCase(col.propertyName)}_id`;
226
- const explicitFk = col.dbColumnName && col.dbColumnName !== derivedFk ? col.dbColumnName : undefined;
227
- return explicitFk ? `belongsTo('${modelName}', '${explicitFk}')` : `belongsTo('${modelName}')`;
228
- }
229
- if (col.collection) {
230
- let result = `hasMany('${extractModelName(col.collection)}')`;
231
- if (col.through) {
232
- result += `.through('${extractModelName(col.through)}')`;
233
- }
234
- if (col.via) {
235
- result += `.via('${col.via}')`;
236
- }
237
- return result;
238
- }
239
- if (col.decoratorName === 'primaryColumn') {
240
- const builderFn = col.type ? (COLUMN_TYPE_MAP[col.type] ?? 'text') : 'serial';
241
- if (builderFn === 'serial') {
242
- return 'serial().primaryKey()';
243
- }
244
- return explicitDbName ? `${builderFn}({ name: '${explicitDbName}' }).primaryKey()` : `${builderFn}().primaryKey()`;
245
- }
246
-
247
- const builderFn = col.type ? (COLUMN_TYPE_MAP[col.type] ?? 'text') : 'text';
248
- let result = explicitDbName ? `${builderFn}({ name: '${explicitDbName}' })` : `${builderFn}()`;
249
- if (col.required) {
250
- result += '.notNull()';
251
- }
252
- if (col.defaultsTo !== undefined) {
253
- result += `.default(${col.defaultsTo})`;
254
- }
255
- return result;
256
- }
257
-
258
- function generateTableDefinition(info: TableInfo): string {
259
- const lines: string[] = [];
260
- const varName = info.className;
261
- const fn = info.readonly ? 'view' : 'table';
262
-
263
- const options: string[] = [];
264
- if (info.schema) {
265
- options.push(`schema: '${info.schema}'`);
266
- }
267
- if (info.connection) {
268
- options.push(`connection: '${info.connection}'`);
269
- }
270
- if (info.hasBeforeCreate || info.hasBeforeUpdate) {
271
- options.push('hooks: { /* TODO: migrate beforeCreate/beforeUpdate hooks */ }');
272
- }
273
-
274
- const optionsStr = options.length ? `, { ${options.join(', ')} }` : '';
275
-
276
- lines.push(`export const ${varName} = ${fn}('${info.tableName}', {`);
277
-
278
- if (info.baseClass && info.baseClass !== 'Entity') {
279
- lines.push(' ...modelBase,');
280
- }
281
-
282
- for (const col of info.columns) {
283
- lines.push(` ${col.propertyName}: ${generateColumnBuilder(col)},`);
284
- }
285
-
286
- lines.push(`}${optionsStr});`);
287
-
288
- if (info.hasInstanceMethods.length) {
289
- lines.push('');
290
- for (const method of info.hasInstanceMethods) {
291
- lines.push(`// TODO: manual migration needed — instance method '${method}' removed (use afterFind hook or .map())`);
292
- }
293
- }
294
-
295
- return lines.join('\n');
296
- }
297
-
298
- function generateImports(tableInfos: TableInfo[]): string {
299
- const builders = new Set<string>();
300
- builders.add('table');
301
-
302
- for (const tableInfo of tableInfos) {
303
- for (const col of tableInfo.columns) {
304
- if (col.decoratorName === 'createDateColumn') {
305
- builders.add('createdAt');
306
- continue;
307
- }
308
- if (col.decoratorName === 'updateDateColumn') {
309
- builders.add('updatedAt');
310
- continue;
311
- }
312
- if (col.model) {
313
- builders.add('belongsTo');
314
- continue;
315
- }
316
- if (col.collection) {
317
- builders.add('hasMany');
318
- continue;
319
- }
320
- if (col.decoratorName === 'primaryColumn') {
321
- const fn = col.type ? (COLUMN_TYPE_MAP[col.type] ?? 'text') : 'integer';
322
- builders.add(fn);
323
- continue;
324
- }
325
- const fn = col.type ? (COLUMN_TYPE_MAP[col.type] ?? 'text') : 'text';
326
- builders.add(fn);
327
- }
328
- }
329
-
330
- // Use 'serial' for integer primary keys by default
331
- if (tableInfos.some((tableInfo) => tableInfo.columns.some((col) => col.decoratorName === 'primaryColumn' && (!col.type || col.type === 'integer')))) {
332
- builders.add('serial');
333
- }
334
-
335
- // Add view() if any readonly models
336
- if (tableInfos.some((tableInfo) => tableInfo.readonly)) {
337
- builders.add('view');
338
- }
339
-
340
- const sorted = [...builders].sort();
341
- return `import { ${sorted.join(', ')} } from 'bigal';`;
342
- }
343
-
344
- /**
345
- * Strips `.toJSON()` calls from query chains.
346
- * Results are always plain objects in v16, so `.toJSON()` is unnecessary.
347
- * Returns the number of calls removed.
348
- * @param {object} sourceFile
349
- */
350
- function stripToJSON(sourceFile: SourceFile): number {
351
- let removedCount = 0;
352
-
353
- // Find all .toJSON() call expressions and remove them from chains
354
- // We iterate in reverse to avoid position shifts from earlier edits
355
- const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
356
- const toJsonCalls: CallExpression[] = [];
357
-
358
- for (const call of callExpressions) {
359
- const expression = call.getExpression();
360
- if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
361
- const propAccess = expression.asKind(SyntaxKind.PropertyAccessExpression);
362
- if (propAccess?.getName() === 'toJSON' && call.getArguments().length === 0) {
363
- toJsonCalls.push(call);
364
- }
365
- }
366
- }
367
-
368
- // Process in reverse order to preserve positions
369
- for (const call of toJsonCalls.reverse()) {
370
- const propAccess = call.getExpression().asKind(SyntaxKind.PropertyAccessExpression)!;
371
- const receiver = propAccess.getExpression();
372
- call.replaceWithText(receiver.getText());
373
- removedCount++;
374
- }
375
-
376
- return removedCount;
377
- }
378
-
379
- function processFile(sourceFile: SourceFile): string | undefined {
380
- const classes = sourceFile.getClasses();
381
- const fileTableInfos: TableInfo[] = [];
382
-
383
- for (const cls of classes) {
384
- const info = extractTableInfo(cls);
385
- if (info) {
386
- fileTableInfos.push(info);
387
- }
388
- }
389
-
390
- if (!fileTableInfos.length) {
391
- return undefined;
392
- }
393
-
394
- const lines: string[] = [];
395
-
396
- lines.push(generateImports(fileTableInfos));
397
- lines.push('');
398
-
399
- const hasBase = fileTableInfos.some((tableInfo) => tableInfo.baseClass && tableInfo.baseClass !== 'Entity');
400
- if (hasBase) {
401
- lines.push('// TODO: define shared base columns (extracted from base class)');
402
- lines.push('// const modelBase = { id: serial().primaryKey() };');
403
- lines.push('');
404
- }
405
-
406
- for (const tableInfo of fileTableInfos) {
407
- lines.push(generateTableDefinition(tableInfo));
408
- lines.push('');
409
- }
410
-
411
- return lines.join('\n');
412
- }
413
-
414
- function printHelp(): void {
415
- console.log(`BigAl v15 → v16 Migration Codemod
416
-
417
- Usage:
418
- npx tsx scripts/migrate-v16.ts [glob] [options]
419
-
420
- Arguments:
421
- glob File pattern to process (default: 'src/models/**/*.ts')
422
-
423
- Options:
424
- --write Modify files in place (default: dry-run to stdout)
425
- --help Show this help message
426
-
427
- Examples:
428
- npx tsx scripts/migrate-v16.ts 'src/models/**/*.ts'
429
- npx tsx scripts/migrate-v16.ts 'src/models/Product.ts' --write
430
- `);
431
- }
432
-
433
- function main(): void {
434
- const args = process.argv.slice(2);
435
-
436
- if (args.includes('--help')) {
437
- printHelp();
438
- return;
439
- }
440
-
441
- const writeMode = args.includes('--write');
442
- const glob = args.find((arg) => !arg.startsWith('--')) ?? 'src/models/**/*.ts';
443
-
444
- const tsconfigPath = resolve(process.cwd(), 'tsconfig.json');
445
- const project = new Project({
446
- tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
447
- skipAddingFilesFromTsConfig: true,
448
- });
449
-
450
- project.addSourceFilesAtPaths(glob);
451
- const sourceFiles = project.getSourceFiles();
452
-
453
- if (!sourceFiles.length) {
454
- console.log(`No files found matching: ${glob}`);
455
- return;
456
- }
457
-
458
- let convertedCount = 0;
459
- let skippedCount = 0;
460
- let toJsonRemovedCount = 0;
461
-
462
- for (const sourceFile of sourceFiles) {
463
- const toJsonRemoved = stripToJSON(sourceFile);
464
- if (toJsonRemoved > 0) {
465
- toJsonRemovedCount += toJsonRemoved;
466
- if (writeMode) {
467
- sourceFile.saveSync();
468
- console.log(` stripped ${toJsonRemoved} .toJSON() call(s): ${sourceFile.getFilePath()}`);
469
- } else {
470
- console.log(`=== ${sourceFile.getFilePath()} — ${toJsonRemoved} .toJSON() call(s) to remove ===`);
471
- }
472
- }
473
-
474
- const result = processFile(sourceFile);
475
- if (result) {
476
- convertedCount++;
477
- if (writeMode) {
478
- sourceFile.replaceWithText(result);
479
- sourceFile.saveSync();
480
- console.log(` converted: ${sourceFile.getFilePath()}`);
481
- } else {
482
- console.log(`=== ${sourceFile.getFilePath()} ===`);
483
- console.log(result);
484
- console.log('');
485
- }
486
- } else {
487
- skippedCount++;
488
- }
489
- }
490
-
491
- console.log(`\nSummary: ${convertedCount} model(s) converted, ${skippedCount} skipped, ${toJsonRemovedCount} .toJSON() call(s) stripped`);
492
- if (!writeMode && (convertedCount || toJsonRemovedCount)) {
493
- console.log('Run with --write to apply changes.');
494
- }
495
- }
496
-
497
- main();