uql-codemod 0.30.0 → 0.31.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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  # uql-codemod
9
9
 
10
- Migrates [uql-orm](https://uql-orm.dev) entities from the legacy TypeScript decorators to the TC39 standard spec. One command, on your own source, with a dry run first:
10
+ Rewrites [uql-orm](https://uql-orm.dev) entities across the breaking changes that can be made mechanically: the move from the legacy TypeScript decorators to the TC39 standard spec, and the renames and annotations that followed it. One command, on your own source, with a dry run first:
11
11
 
12
12
  ```sh
13
13
  npx uql-codemod --dry-run
@@ -28,6 +28,7 @@ Only the `--flag=value` form is read, and anything unrecognised is an error: a m
28
28
  - Bare relation decorators: inserts `entity: () => X`, unwrapping arrays and the old `Relation<T>` alias.
29
29
  - `Relation<T>` becomes `T`, and its import goes with the last usage - `uql-orm` no longer exports it. A usage somewhere the codemod does not reach, such as a type alias, keeps the import and is reported.
30
30
  - `@Field({ virtual })` becomes `@Field({ computed })`, the name it was renamed to - written as a plain key, a quoted one, or a shorthand, which becomes `computed: virtual` so it keeps referring to the same local. A decorator given both names is reported instead, because which one wins is your call.
31
+ - A primary key not called `id`, `_id` or `uuid`, and every composite key, gets the `[idKey]?: '...'` brand naming it, plus the import. `@Id` refuses a key it cannot name, since one that resolves to any column types `findOneById` against the wrong one.
31
32
  - `import 'reflect-metadata'` goes. Removing the package from your `package.json` is left to you.
32
33
  - `tsconfig.json`: removes `experimentalDecorators` and `emitDecoratorMetadata`, keeping the rest of the file - comments and formatting included - exactly as written.
33
34
 
@@ -38,6 +39,7 @@ It reports rather than guesses, and exits non-zero when anything is left for you
38
39
  - **`target: esnext`** is reported, not changed. Removing the line falls back to the compiler default (`es5` for `tsc`), and choosing a replacement means guessing which era the project targets. Any dated target works; `esnext` is the one where TypeScript emits decorator syntax untransformed.
39
40
  - **A value inherited through `extends`** cannot be edited here, so the base config is reported instead.
40
41
  - **`@Log()`, `@Serialized()`, `@Transactional()` and `@InjectQuerier()`** are reported and left in place. They no longer exist, and what to do instead is a judgement call - a `@Transactional()` method becomes a `pool.transaction(async (querier) => { ... })` around its body, and the codemod cannot know which pool.
42
+ - **A key written under a computed name** (`@Id() [KEY]?: string`) is reported: the brand has to spell the name out, and only you know what that expression evaluates to.
41
43
  - **Options it cannot read** (`@Field(sharedOptions)`, a spread, or a `@Field` never called at all) are left exactly as written. Replacing an argument it cannot parse would silently drop the options.
42
44
  - **A property whose type it cannot map to a column** (a `Json<T>`, a vector, a union that disagrees with itself) is reported as `needs a decision`. Those always had to declare `type` by hand anyway, because `design:type` reported the useless `Object`/`Array` for them.
43
45
  - **A branded string id**, e.g. `type UUID = \`${string}-${string}\``, gets `type: String`plus a`worth a look`note. That is a real fork:`String`generates a text column,`'uuid'` generates a native one, and only you know which the database has.
package/dist/index.js CHANGED
@@ -42,14 +42,15 @@ export async function run({ project, dryRun, include }) {
42
42
  // The config is rewritten too: leaving `experimentalDecorators` on is what keeps a project compiling
43
43
  // against the old spec, so a codemod that only touched the sources would leave it half-migrated.
44
44
  const tsconfig = transformTsconfig(configPath, ts.sys.readFile(configPath) ?? '');
45
+ const rewritten = results.filter((r) => r.changed);
45
46
  if (!dryRun) {
46
- await Promise.all(results.filter((r) => r.changed).map((r) => writeFile(r.fileName, r.text, 'utf8')));
47
+ await Promise.all(rewritten.map((r) => writeFile(r.fileName, r.text, 'utf8')));
47
48
  if (tsconfig.changed) {
48
49
  await writeFile(configPath, tsconfig.text, 'utf8');
49
50
  }
50
51
  }
51
52
  return {
52
- changed: [...results.filter((r) => r.changed).map((r) => r.fileName), ...(tsconfig.changed ? [configPath] : [])],
53
+ changed: [...rewritten.map((r) => r.fileName), ...(tsconfig.changed ? [configPath] : [])],
53
54
  unresolved: [...results.flatMap((r) => r.unresolved), ...tsconfig.unresolved],
54
55
  notes: results.flatMap((r) => r.notes),
55
56
  };
package/dist/transform.js CHANGED
@@ -23,6 +23,10 @@ const REMOVED_EXPORTS = new Map([
23
23
  ['getQuerierPool', 'take the pool from the module that builds it, or from Nest DI'],
24
24
  ['getQuerier', 'use `pool.withQuerier(...)` / `pool.transaction(...)`, which release the connection'],
25
25
  ]);
26
+ /** A node's decorators, or none where it is a kind that cannot carry them. */
27
+ function decoratorsOf(node) {
28
+ return ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
29
+ }
26
30
  function decoratorName(node) {
27
31
  const call = ts.isCallExpression(node.expression) ? node.expression : undefined;
28
32
  const target = call?.expression ?? node.expression;
@@ -69,7 +73,7 @@ function propertyKey(name) {
69
73
  * object survive untouched. A shorthand has no value to keep, so it becomes `computed: virtual` -
70
74
  * renaming the key alone would rebind it to a local that does not exist.
71
75
  */
72
- function renameVirtualOption(decorator, ctx, node) {
76
+ function renameVirtualOption(decorator, node, ctx) {
73
77
  const options = decoratorOptions(decorator);
74
78
  const virtual = findProperty(options, 'virtual');
75
79
  if (!virtual) {
@@ -239,9 +243,62 @@ function dropDeadImports(source, ctx) {
239
243
  }
240
244
  }
241
245
  }
246
+ /** The names a key is read from without help, mirroring `NamedIdKey` in `uql-orm`. */
247
+ const CONVENTIONAL_ID_NAMES = new Set(['id', '_id', 'uuid']);
248
+ /** Whether the member is an `[idKey]?: ...` brand, matched by the name it is written under. */
249
+ function isIdKeyBrand(member) {
250
+ return (ts.isPropertyDeclaration(member) &&
251
+ ts.isComputedPropertyName(member.name) &&
252
+ ts.isIdentifier(member.name.expression) &&
253
+ member.name.expression.text === 'idKey');
254
+ }
255
+ /**
256
+ * Names the class's key with the `idKey` brand, where a conventional name does not already.
257
+ *
258
+ * Only the class's own `@Id` properties are read, which is also what makes an overriding key right:
259
+ * a subclass that replaces an inherited `id` needs the brand precisely because the inherited name
260
+ * would otherwise be taken for the key.
261
+ *
262
+ * Reports whether one was written, since `idKey` is then a value the file has to import.
263
+ */
264
+ function brandIdKey(node, ctx) {
265
+ const ids = node.members.filter((m) => ts.isPropertyDeclaration(m) && decoratorsOf(m).some((d) => decoratorName(d) === 'Id'));
266
+ if (!ids.length || node.members.some(isIdKeyBrand)) {
267
+ return false;
268
+ }
269
+ const names = ids.map((m) => propertyKey(m.name)).filter((name) => name !== undefined);
270
+ if (names.length !== ids.length) {
271
+ ctx.unresolved.push(`${ctx.describe(node)}: a key written as a computed name; add the 'idKey' brand by hand`);
272
+ return false;
273
+ }
274
+ if (names.length === 1 && CONVENTIONAL_ID_NAMES.has(names[0])) {
275
+ return false;
276
+ }
277
+ const indent = ' '.repeat(node.getSourceFile().getLineAndCharacterOfPosition(ids[0].getStart()).character);
278
+ const brand = names.map((name) => `'${name}'`).join(' | ');
279
+ const start = node.members.pos;
280
+ ctx.edits.push({ start, end: start, text: `\n${indent}[idKey]?: ${brand};` });
281
+ return true;
282
+ }
283
+ /**
284
+ * Imports `idKey` where a brand was written, into the file's own `uql-orm` import. Reported instead
285
+ * where there is none: the package may be imported under a path this codemod does not recognise.
286
+ */
287
+ function addIdKeyImport(source, ctx) {
288
+ const imported = uqlImports(source);
289
+ if (imported.some((element) => importedName(element) === 'idKey')) {
290
+ return;
291
+ }
292
+ const [anchor] = imported;
293
+ if (!anchor) {
294
+ ctx.unresolved.push(`${source.fileName}: import 'idKey' from 'uql-orm' for the brand(s) written here`);
295
+ return;
296
+ }
297
+ ctx.edits.push({ start: anchor.getStart(), end: anchor.getStart(), text: 'idKey, ' });
298
+ }
242
299
  /** Everything the standard spec needs written onto one decorated property. */
243
300
  function rewriteProperty(node, ctx) {
244
- const decorators = ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
301
+ const decorators = decoratorsOf(node);
245
302
  for (const decorator of decorators) {
246
303
  const name = decoratorName(decorator);
247
304
  if (!name) {
@@ -249,7 +306,7 @@ function rewriteProperty(node, ctx) {
249
306
  }
250
307
  if (FIELD_DECORATORS.has(name)) {
251
308
  addFieldType(decorator, node, ctx);
252
- renameVirtualOption(decorator, ctx, node);
309
+ renameVirtualOption(decorator, node, ctx);
253
310
  }
254
311
  if (RELATION_DECORATORS.has(name)) {
255
312
  addRelationEntity(decorator, node, ctx);
@@ -262,10 +319,7 @@ function rewriteProperty(node, ctx) {
262
319
  }
263
320
  /** Names a decorator that no longer exists, wherever it appears. */
264
321
  function reportRemovedDecorators(node, ctx) {
265
- if (!ts.canHaveDecorators(node)) {
266
- return;
267
- }
268
- for (const decorator of ts.getDecorators(node) ?? []) {
322
+ for (const decorator of decoratorsOf(node)) {
269
323
  const name = decoratorName(decorator);
270
324
  const advice = name && REMOVED_DECORATORS.get(name);
271
325
  if (advice) {
@@ -274,40 +328,32 @@ function reportRemovedDecorators(node, ctx) {
274
328
  }
275
329
  }
276
330
  /** Names an export that no longer exists, where it is imported from the package. */
277
- function reportRemovedExports(node, ctx) {
278
- if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) {
279
- return;
280
- }
281
- if (node.moduleSpecifier.text !== 'uql-orm') {
282
- return;
283
- }
284
- const bindings = node.importClause?.namedBindings;
285
- if (!bindings || !ts.isNamedImports(bindings)) {
286
- return;
287
- }
288
- for (const element of bindings.elements) {
289
- const name = (element.propertyName ?? element.name).text;
331
+ function reportRemovedExports(source, ctx) {
332
+ for (const element of uqlImports(source)) {
333
+ const name = importedName(element);
290
334
  const advice = REMOVED_EXPORTS.get(name);
291
335
  if (advice) {
292
336
  ctx.unresolved.push(`${ctx.describe(element)}: '${name}' was removed; ${advice}`);
293
337
  }
294
338
  }
295
339
  }
296
- /** Whether this file's `raw` is the ORM's, so another library's function of that name is left alone. */
297
- function importsRaw(source) {
298
- return source.statements.some((statement) => {
340
+ /** What the file imports from `uql-orm` by name, which is every import this codemod reads or writes. */
341
+ function uqlImports(source) {
342
+ return source.statements.flatMap((statement) => {
299
343
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
300
- return false;
344
+ return [];
301
345
  }
302
346
  if (statement.moduleSpecifier.text !== 'uql-orm') {
303
- return false;
347
+ return [];
304
348
  }
305
349
  const bindings = statement.importClause?.namedBindings;
306
- return (!!bindings &&
307
- ts.isNamedImports(bindings) &&
308
- bindings.elements.some((element) => (element.propertyName ?? element.name).text === 'raw'));
350
+ return bindings && ts.isNamedImports(bindings) ? [...bindings.elements] : [];
309
351
  });
310
352
  }
353
+ /** The name an import specifier brings in, which is the original one where it was renamed. */
354
+ function importedName(element) {
355
+ return (element.propertyName ?? element.name).text;
356
+ }
311
357
  /**
312
358
  * Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`. Only a
313
359
  * string-literal first argument qualifies: the callback form is unchanged, and a computed string is
@@ -343,10 +389,10 @@ export function transformFile(source, checker) {
343
389
  describe: (node) => `${source.fileName}:${lineOf(node) + 1}`,
344
390
  };
345
391
  const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line;
346
- const rewritesRaw = importsRaw(source);
392
+ const rewritesRaw = uqlImports(source).some((element) => importedName(element) === 'raw');
393
+ let branded = false;
347
394
  const visit = (node) => {
348
395
  reportRemovedDecorators(node, ctx);
349
- reportRemovedExports(node, ctx);
350
396
  countRelationAlias(node, ctx);
351
397
  if (rewritesRaw) {
352
398
  rewriteRawCall(node, ctx, source);
@@ -354,10 +400,17 @@ export function transformFile(source, checker) {
354
400
  if (ts.isPropertyDeclaration(node)) {
355
401
  rewriteProperty(node, ctx);
356
402
  }
403
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
404
+ branded = brandIdKey(node, ctx) || branded;
405
+ }
357
406
  ts.forEachChild(node, visit);
358
407
  };
359
408
  visit(source);
409
+ reportRemovedExports(source, ctx);
360
410
  dropDeadImports(source, ctx);
411
+ if (branded) {
412
+ addIdKeyImport(source, ctx);
413
+ }
361
414
  return {
362
415
  fileName: source.fileName,
363
416
  text: applyEdits(source.getFullText(), ctx.edits),
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uql-codemod",
3
3
  "description": "Upgrade uql-orm across versions that introduce breaking changes",
4
4
  "license": "MIT",
5
- "version": "0.30.0",
5
+ "version": "0.31.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=24"