uql-codemod 0.30.0 → 0.32.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,8 @@ 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
+ - **An export that no longer exists** (`setQuerierPool`, `getQuerier`, `QueryWhereMap`, `augmentWhere`, ...) is reported with what replaces it. A `$where` given an id, a list of them or a bare `raw()` needs no report: it is a compile error on that value, fixed by naming the key (`{ id: [1, 2] }`) or wrapping the raw in `$and`.
43
+ - **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
44
  - **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
45
  - **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
46
  - **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
@@ -16,13 +16,21 @@ const REMOVED_DECORATORS = new Map([
16
16
  ]);
17
17
  /**
18
18
  * Exports that no longer exist, mapped to what to do instead. Reported rather than rewritten, for the
19
- * same reason as the decorators: which pool belongs at a given call site is a judgement call.
19
+ * same reason as the decorators: what replaces each is decided at its call site.
20
20
  */
21
21
  const REMOVED_EXPORTS = new Map([
22
22
  ['setQuerierPool', 'pass the pool where it is used: `createFetchHandler({ pool })`, `querierMiddleware({ pool })`'],
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
+ ['QueryWhereMap', 'it is `QueryWhere` now'],
26
+ ['QueryWhereFieldMap', 'use `QueryWhere`'],
27
+ ['augmentWhere', 'spread the two maps: `{ ...where, ...extra }`'],
28
+ ['buildQueryWhereAsMap', 'a `$where` is a map already; name the key for ids: `{ id: [1, 2] }`'],
25
29
  ]);
30
+ /** A node's decorators, or none where it is a kind that cannot carry them. */
31
+ function decoratorsOf(node) {
32
+ return ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
33
+ }
26
34
  function decoratorName(node) {
27
35
  const call = ts.isCallExpression(node.expression) ? node.expression : undefined;
28
36
  const target = call?.expression ?? node.expression;
@@ -69,7 +77,7 @@ function propertyKey(name) {
69
77
  * object survive untouched. A shorthand has no value to keep, so it becomes `computed: virtual` -
70
78
  * renaming the key alone would rebind it to a local that does not exist.
71
79
  */
72
- function renameVirtualOption(decorator, ctx, node) {
80
+ function renameVirtualOption(decorator, node, ctx) {
73
81
  const options = decoratorOptions(decorator);
74
82
  const virtual = findProperty(options, 'virtual');
75
83
  if (!virtual) {
@@ -239,9 +247,62 @@ function dropDeadImports(source, ctx) {
239
247
  }
240
248
  }
241
249
  }
250
+ /** The names a key is read from without help, mirroring `NamedIdKey` in `uql-orm`. */
251
+ const CONVENTIONAL_ID_NAMES = new Set(['id', '_id', 'uuid']);
252
+ /** Whether the member is an `[idKey]?: ...` brand, matched by the name it is written under. */
253
+ function isIdKeyBrand(member) {
254
+ return (ts.isPropertyDeclaration(member) &&
255
+ ts.isComputedPropertyName(member.name) &&
256
+ ts.isIdentifier(member.name.expression) &&
257
+ member.name.expression.text === 'idKey');
258
+ }
259
+ /**
260
+ * Names the class's key with the `idKey` brand, where a conventional name does not already.
261
+ *
262
+ * Only the class's own `@Id` properties are read, which is also what makes an overriding key right:
263
+ * a subclass that replaces an inherited `id` needs the brand precisely because the inherited name
264
+ * would otherwise be taken for the key.
265
+ *
266
+ * Reports whether one was written, since `idKey` is then a value the file has to import.
267
+ */
268
+ function brandIdKey(node, ctx) {
269
+ const ids = node.members.filter((m) => ts.isPropertyDeclaration(m) && decoratorsOf(m).some((d) => decoratorName(d) === 'Id'));
270
+ if (!ids.length || node.members.some(isIdKeyBrand)) {
271
+ return false;
272
+ }
273
+ const names = ids.map((m) => propertyKey(m.name)).filter((name) => name !== undefined);
274
+ if (names.length !== ids.length) {
275
+ ctx.unresolved.push(`${ctx.describe(node)}: a key written as a computed name; add the 'idKey' brand by hand`);
276
+ return false;
277
+ }
278
+ if (names.length === 1 && CONVENTIONAL_ID_NAMES.has(names[0])) {
279
+ return false;
280
+ }
281
+ const indent = ' '.repeat(node.getSourceFile().getLineAndCharacterOfPosition(ids[0].getStart()).character);
282
+ const brand = names.map((name) => `'${name}'`).join(' | ');
283
+ const start = node.members.pos;
284
+ ctx.edits.push({ start, end: start, text: `\n${indent}[idKey]?: ${brand};` });
285
+ return true;
286
+ }
287
+ /**
288
+ * Imports `idKey` where a brand was written, into the file's own `uql-orm` import. Reported instead
289
+ * where there is none: the package may be imported under a path this codemod does not recognise.
290
+ */
291
+ function addIdKeyImport(source, ctx) {
292
+ const imported = uqlImports(source);
293
+ if (imported.some((element) => importedName(element) === 'idKey')) {
294
+ return;
295
+ }
296
+ const [anchor] = imported;
297
+ if (!anchor) {
298
+ ctx.unresolved.push(`${source.fileName}: import 'idKey' from 'uql-orm' for the brand(s) written here`);
299
+ return;
300
+ }
301
+ ctx.edits.push({ start: anchor.getStart(), end: anchor.getStart(), text: 'idKey, ' });
302
+ }
242
303
  /** Everything the standard spec needs written onto one decorated property. */
243
304
  function rewriteProperty(node, ctx) {
244
- const decorators = ts.canHaveDecorators(node) ? (ts.getDecorators(node) ?? []) : [];
305
+ const decorators = decoratorsOf(node);
245
306
  for (const decorator of decorators) {
246
307
  const name = decoratorName(decorator);
247
308
  if (!name) {
@@ -249,7 +310,7 @@ function rewriteProperty(node, ctx) {
249
310
  }
250
311
  if (FIELD_DECORATORS.has(name)) {
251
312
  addFieldType(decorator, node, ctx);
252
- renameVirtualOption(decorator, ctx, node);
313
+ renameVirtualOption(decorator, node, ctx);
253
314
  }
254
315
  if (RELATION_DECORATORS.has(name)) {
255
316
  addRelationEntity(decorator, node, ctx);
@@ -262,10 +323,7 @@ function rewriteProperty(node, ctx) {
262
323
  }
263
324
  /** Names a decorator that no longer exists, wherever it appears. */
264
325
  function reportRemovedDecorators(node, ctx) {
265
- if (!ts.canHaveDecorators(node)) {
266
- return;
267
- }
268
- for (const decorator of ts.getDecorators(node) ?? []) {
326
+ for (const decorator of decoratorsOf(node)) {
269
327
  const name = decoratorName(decorator);
270
328
  const advice = name && REMOVED_DECORATORS.get(name);
271
329
  if (advice) {
@@ -274,40 +332,32 @@ function reportRemovedDecorators(node, ctx) {
274
332
  }
275
333
  }
276
334
  /** 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;
335
+ function reportRemovedExports(source, ctx) {
336
+ for (const element of uqlImports(source)) {
337
+ const name = importedName(element);
290
338
  const advice = REMOVED_EXPORTS.get(name);
291
339
  if (advice) {
292
340
  ctx.unresolved.push(`${ctx.describe(element)}: '${name}' was removed; ${advice}`);
293
341
  }
294
342
  }
295
343
  }
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) => {
344
+ /** What the file imports from `uql-orm` by name, which is every import this codemod reads or writes. */
345
+ function uqlImports(source) {
346
+ return source.statements.flatMap((statement) => {
299
347
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
300
- return false;
348
+ return [];
301
349
  }
302
350
  if (statement.moduleSpecifier.text !== 'uql-orm') {
303
- return false;
351
+ return [];
304
352
  }
305
353
  const bindings = statement.importClause?.namedBindings;
306
- return (!!bindings &&
307
- ts.isNamedImports(bindings) &&
308
- bindings.elements.some((element) => (element.propertyName ?? element.name).text === 'raw'));
354
+ return bindings && ts.isNamedImports(bindings) ? [...bindings.elements] : [];
309
355
  });
310
356
  }
357
+ /** The name an import specifier brings in, which is the original one where it was renamed. */
358
+ function importedName(element) {
359
+ return (element.propertyName ?? element.name).text;
360
+ }
311
361
  /**
312
362
  * Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`. Only a
313
363
  * string-literal first argument qualifies: the callback form is unchanged, and a computed string is
@@ -343,10 +393,10 @@ export function transformFile(source, checker) {
343
393
  describe: (node) => `${source.fileName}:${lineOf(node) + 1}`,
344
394
  };
345
395
  const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line;
346
- const rewritesRaw = importsRaw(source);
396
+ const rewritesRaw = uqlImports(source).some((element) => importedName(element) === 'raw');
397
+ let branded = false;
347
398
  const visit = (node) => {
348
399
  reportRemovedDecorators(node, ctx);
349
- reportRemovedExports(node, ctx);
350
400
  countRelationAlias(node, ctx);
351
401
  if (rewritesRaw) {
352
402
  rewriteRawCall(node, ctx, source);
@@ -354,10 +404,17 @@ export function transformFile(source, checker) {
354
404
  if (ts.isPropertyDeclaration(node)) {
355
405
  rewriteProperty(node, ctx);
356
406
  }
407
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
408
+ branded = brandIdKey(node, ctx) || branded;
409
+ }
357
410
  ts.forEachChild(node, visit);
358
411
  };
359
412
  visit(source);
413
+ reportRemovedExports(source, ctx);
360
414
  dropDeadImports(source, ctx);
415
+ if (branded) {
416
+ addIdKeyImport(source, ctx);
417
+ }
361
418
  return {
362
419
  fileName: source.fileName,
363
420
  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.32.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=24"