uql-codemod 0.26.0 → 0.30.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
@@ -14,11 +14,11 @@ npx uql-codemod --dry-run
14
14
  npx uql-codemod
15
15
  ```
16
16
 
17
- | Flag | |
18
- |---|---|
17
+ | Flag | |
18
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
19
  | `--project=<path>` | `tsconfig.json` to read, defaulting to `./tsconfig.json`. It is a real program and not a parser, because the codemod writes down the types `design:type` used to report at runtime, so a config that covers your entities is what makes the run work. |
20
- | `--dry-run` | Report what would change and touch nothing. |
21
- | `--include=<a,b>` | Only files whose path contains one of these fragments. |
20
+ | `--dry-run` | Report what would change and touch nothing. |
21
+ | `--include=<a,b>` | Only files whose path contains one of these fragments. |
22
22
 
23
23
  Only the `--flag=value` form is read, and anything unrecognised is an error: a misspelled `--dry-run` would otherwise rewrite the project for real. It exits `0` when nothing is left for you, `1` when something is, and `2` when it could not start at all - a bad argument, or a project it cannot read.
24
24
 
@@ -27,6 +27,7 @@ Only the `--flag=value` form is read, and anything unrecognised is an error: a m
27
27
  - `@Field()` and `@Field({ ... })` with no `type`, and `@Id()`: inserts the `type` the property's declared TypeScript type implies. Skipped when `references` is present, because schema generation resolves that column from the referenced primary key.
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
+ - `@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.
30
31
  - `import 'reflect-metadata'` goes. Removing the package from your `package.json` is left to you.
31
32
  - `tsconfig.json`: removes `experimentalDecorators` and `emitDecoratorMetadata`, keeping the rest of the file - comments and formatting included - exactly as written.
32
33
 
@@ -39,11 +40,11 @@ It reports rather than guesses, and exits non-zero when anything is left for you
39
40
  - **`@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.
40
41
  - **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.
41
42
  - **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.
42
- - **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.
43
+ - **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.
43
44
 
44
45
  ## After it runs
45
46
 
46
- `tsc` is the rest of the migration, and that is the point: the annotations the codemod inserts are now *checked* against the properties they describe, so anything it got wrong is a compile error rather than a silently wrong column. See the [upgrade guide](https://uql-orm.dev/upgrade-guide).
47
+ `tsc` is the rest of the migration, and that is the point: the annotations the codemod inserts are now _checked_ against the properties they describe, so anything it got wrong is a compile error rather than a silently wrong column. See the [upgrade guide](https://uql-orm.dev/upgrade-guide).
47
48
 
48
49
  ## Why TypeScript 6
49
50
 
package/dist/transform.js CHANGED
@@ -14,6 +14,15 @@ const REMOVED_DECORATORS = new Map([
14
14
  ['Transactional', 'wrap the body in `pool.transaction(async (querier) => { ... })`'],
15
15
  ['InjectQuerier', 'take the querier from the enclosing `pool.transaction()` callback'],
16
16
  ]);
17
+ /**
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.
20
+ */
21
+ const REMOVED_EXPORTS = new Map([
22
+ ['setQuerierPool', 'pass the pool where it is used: `createFetchHandler({ pool })`, `querierMiddleware({ pool })`'],
23
+ ['getQuerierPool', 'take the pool from the module that builds it, or from Nest DI'],
24
+ ['getQuerier', 'use `pool.withQuerier(...)` / `pool.transaction(...)`, which release the connection'],
25
+ ]);
17
26
  function decoratorName(node) {
18
27
  const call = ts.isCallExpression(node.expression) ? node.expression : undefined;
19
28
  const target = call?.expression ?? node.expression;
@@ -36,9 +45,45 @@ function decoratorOptions(node) {
36
45
  ? { kind: 'opaque', reason: 'its options object spreads another' }
37
46
  : { kind: 'literal', node: first };
38
47
  }
39
- function hasProperty(options, name) {
40
- return (options.kind === 'literal' &&
41
- options.node.properties.some((prop) => prop.name && ts.isIdentifier(prop.name) && prop.name.text === name));
48
+ /**
49
+ * The property `name` in the decorator's options, whether its key is plain, quoted or a shorthand.
50
+ *
51
+ * All three forms, because both callers care about the option being *there*: one to leave an option
52
+ * alone that is already stated, the other to rename it. Matching only the plain key inserted a second
53
+ * `type` beside a quoted one, and left a shorthand `virtual` behind while reporting nothing.
54
+ */
55
+ function findProperty(options, name) {
56
+ if (options.kind !== 'literal') {
57
+ return undefined;
58
+ }
59
+ return options.node.properties.find((prop) => (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && propertyKey(prop.name) === name);
60
+ }
61
+ /** A key's name where it is spelled out, which is every form but a computed one (`{ [k]: v }`). */
62
+ function propertyKey(name) {
63
+ return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
64
+ }
65
+ /**
66
+ * Renames the `virtual` option to `computed`, the one it was renamed to.
67
+ *
68
+ * Only the key is replaced, so the expression, its formatting and any comment inside the options
69
+ * object survive untouched. A shorthand has no value to keep, so it becomes `computed: virtual` -
70
+ * renaming the key alone would rebind it to a local that does not exist.
71
+ */
72
+ function renameVirtualOption(decorator, ctx, node) {
73
+ const options = decoratorOptions(decorator);
74
+ const virtual = findProperty(options, 'virtual');
75
+ if (!virtual) {
76
+ if (options.kind === 'opaque' && /\bvirtual\b/.test(decorator.getText())) {
77
+ ctx.unresolved.push(`${ctx.describe(node)}: ${options.reason}`);
78
+ }
79
+ return;
80
+ }
81
+ if (findProperty(options, 'computed')) {
82
+ ctx.unresolved.push(`${ctx.describe(node)}: gives both 'virtual' and 'computed'; keep 'computed'.`);
83
+ return;
84
+ }
85
+ const text = ts.isShorthandPropertyAssignment(virtual) ? 'computed: virtual' : 'computed';
86
+ ctx.edits.push({ start: virtual.name.getStart(), end: virtual.name.getEnd(), text });
42
87
  }
43
88
  /** Inserts `option` into the decorator's options object, creating one when the call has no arguments. */
44
89
  function insertOption(options, option) {
@@ -61,7 +106,7 @@ function insertOption(options, option) {
61
106
  */
62
107
  function addInferredOption(decorator, node, ctx, spec) {
63
108
  const options = decoratorOptions(decorator);
64
- if (spec.satisfiedBy.some((name) => hasProperty(options, name))) {
109
+ if (spec.satisfiedBy.some((name) => findProperty(options, name))) {
65
110
  return undefined;
66
111
  }
67
112
  if (options.kind === 'opaque') {
@@ -204,6 +249,7 @@ function rewriteProperty(node, ctx) {
204
249
  }
205
250
  if (FIELD_DECORATORS.has(name)) {
206
251
  addFieldType(decorator, node, ctx);
252
+ renameVirtualOption(decorator, ctx, node);
207
253
  }
208
254
  if (RELATION_DECORATORS.has(name)) {
209
255
  addRelationEntity(decorator, node, ctx);
@@ -227,6 +273,65 @@ function reportRemovedDecorators(node, ctx) {
227
273
  }
228
274
  }
229
275
  }
276
+ /** 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;
290
+ const advice = REMOVED_EXPORTS.get(name);
291
+ if (advice) {
292
+ ctx.unresolved.push(`${ctx.describe(element)}: '${name}' was removed; ${advice}`);
293
+ }
294
+ }
295
+ }
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) => {
299
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
300
+ return false;
301
+ }
302
+ if (statement.moduleSpecifier.text !== 'uql-orm') {
303
+ return false;
304
+ }
305
+ const bindings = statement.importClause?.namedBindings;
306
+ return (!!bindings &&
307
+ ts.isNamedImports(bindings) &&
308
+ bindings.elements.some((element) => (element.propertyName ?? element.name).text === 'raw'));
309
+ });
310
+ }
311
+ /**
312
+ * Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`. Only a
313
+ * string-literal first argument qualifies: the callback form is unchanged, and a computed string is
314
+ * left alone because a template cannot be built from a value that is not known here.
315
+ */
316
+ function rewriteRawCall(node, ctx, source) {
317
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'raw') {
318
+ return;
319
+ }
320
+ const [expression, alias] = node.arguments;
321
+ if (!expression || !ts.isStringLiteral(expression) || node.arguments.length > 2) {
322
+ return;
323
+ }
324
+ const suffix = alias ? `.as(${alias.getText(source)})` : '';
325
+ ctx.edits.push({
326
+ start: node.getStart(source),
327
+ end: node.getEnd(),
328
+ text: `raw\`${escapeForTemplate(expression.text)}\`${suffix}`,
329
+ });
330
+ }
331
+ /** A backtick or a `${` inside the old string literal would end or interpolate the template. */
332
+ function escapeForTemplate(text) {
333
+ return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
334
+ }
230
335
  /** Rewrites one source file for the standard decorator spec. */
231
336
  export function transformFile(source, checker) {
232
337
  const ctx = {
@@ -238,9 +343,14 @@ export function transformFile(source, checker) {
238
343
  describe: (node) => `${source.fileName}:${lineOf(node) + 1}`,
239
344
  };
240
345
  const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line;
346
+ const rewritesRaw = importsRaw(source);
241
347
  const visit = (node) => {
242
348
  reportRemovedDecorators(node, ctx);
349
+ reportRemovedExports(node, ctx);
243
350
  countRelationAlias(node, ctx);
351
+ if (rewritesRaw) {
352
+ rewriteRawCall(node, ctx, source);
353
+ }
244
354
  if (ts.isPropertyDeclaration(node)) {
245
355
  rewriteProperty(node, ctx);
246
356
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "uql-codemod",
3
- "description": "Migrates uql-orm entities from legacy decorators to the TC39 standard spec",
3
+ "description": "Upgrade uql-orm across versions that introduce breaking changes",
4
4
  "license": "MIT",
5
- "version": "0.26.0",
5
+ "version": "0.30.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=24"
@@ -21,6 +21,7 @@
21
21
  "README.md"
22
22
  ],
23
23
  "scripts": {
24
+ "prepack": "bun run build",
24
25
  "build": "rimraf dist .tsconfig.tsbuildinfo && tsc -b tsconfig.build.json",
25
26
  "clean": "rimraf dist .tsconfig.tsbuildinfo"
26
27
  },