uql-codemod 0.28.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 +4 -1
- package/dist/index.js +3 -2
- package/dist/transform.js +122 -32
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
# uql-codemod
|
|
9
9
|
|
|
10
|
-
|
|
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
|
|
@@ -27,6 +27,8 @@ 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.
|
|
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.
|
|
30
32
|
- `import 'reflect-metadata'` goes. Removing the package from your `package.json` is left to you.
|
|
31
33
|
- `tsconfig.json`: removes `experimentalDecorators` and `emitDecoratorMetadata`, keeping the rest of the file - comments and formatting included - exactly as written.
|
|
32
34
|
|
|
@@ -37,6 +39,7 @@ It reports rather than guesses, and exits non-zero when anything is left for you
|
|
|
37
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.
|
|
38
40
|
- **A value inherited through `extends`** cannot be edited here, so the base config is reported instead.
|
|
39
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.
|
|
40
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.
|
|
41
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.
|
|
42
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(
|
|
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: [...
|
|
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;
|
|
@@ -45,9 +49,45 @@ function decoratorOptions(node) {
|
|
|
45
49
|
? { kind: 'opaque', reason: 'its options object spreads another' }
|
|
46
50
|
: { kind: 'literal', node: first };
|
|
47
51
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The property `name` in the decorator's options, whether its key is plain, quoted or a shorthand.
|
|
54
|
+
*
|
|
55
|
+
* All three forms, because both callers care about the option being *there*: one to leave an option
|
|
56
|
+
* alone that is already stated, the other to rename it. Matching only the plain key inserted a second
|
|
57
|
+
* `type` beside a quoted one, and left a shorthand `virtual` behind while reporting nothing.
|
|
58
|
+
*/
|
|
59
|
+
function findProperty(options, name) {
|
|
60
|
+
if (options.kind !== 'literal') {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return options.node.properties.find((prop) => (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && propertyKey(prop.name) === name);
|
|
64
|
+
}
|
|
65
|
+
/** A key's name where it is spelled out, which is every form but a computed one (`{ [k]: v }`). */
|
|
66
|
+
function propertyKey(name) {
|
|
67
|
+
return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Renames the `virtual` option to `computed`, the one it was renamed to.
|
|
71
|
+
*
|
|
72
|
+
* Only the key is replaced, so the expression, its formatting and any comment inside the options
|
|
73
|
+
* object survive untouched. A shorthand has no value to keep, so it becomes `computed: virtual` -
|
|
74
|
+
* renaming the key alone would rebind it to a local that does not exist.
|
|
75
|
+
*/
|
|
76
|
+
function renameVirtualOption(decorator, node, ctx) {
|
|
77
|
+
const options = decoratorOptions(decorator);
|
|
78
|
+
const virtual = findProperty(options, 'virtual');
|
|
79
|
+
if (!virtual) {
|
|
80
|
+
if (options.kind === 'opaque' && /\bvirtual\b/.test(decorator.getText())) {
|
|
81
|
+
ctx.unresolved.push(`${ctx.describe(node)}: ${options.reason}`);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (findProperty(options, 'computed')) {
|
|
86
|
+
ctx.unresolved.push(`${ctx.describe(node)}: gives both 'virtual' and 'computed'; keep 'computed'.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const text = ts.isShorthandPropertyAssignment(virtual) ? 'computed: virtual' : 'computed';
|
|
90
|
+
ctx.edits.push({ start: virtual.name.getStart(), end: virtual.name.getEnd(), text });
|
|
51
91
|
}
|
|
52
92
|
/** Inserts `option` into the decorator's options object, creating one when the call has no arguments. */
|
|
53
93
|
function insertOption(options, option) {
|
|
@@ -70,7 +110,7 @@ function insertOption(options, option) {
|
|
|
70
110
|
*/
|
|
71
111
|
function addInferredOption(decorator, node, ctx, spec) {
|
|
72
112
|
const options = decoratorOptions(decorator);
|
|
73
|
-
if (spec.satisfiedBy.some((name) =>
|
|
113
|
+
if (spec.satisfiedBy.some((name) => findProperty(options, name))) {
|
|
74
114
|
return undefined;
|
|
75
115
|
}
|
|
76
116
|
if (options.kind === 'opaque') {
|
|
@@ -203,9 +243,62 @@ function dropDeadImports(source, ctx) {
|
|
|
203
243
|
}
|
|
204
244
|
}
|
|
205
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
|
+
}
|
|
206
299
|
/** Everything the standard spec needs written onto one decorated property. */
|
|
207
300
|
function rewriteProperty(node, ctx) {
|
|
208
|
-
const decorators =
|
|
301
|
+
const decorators = decoratorsOf(node);
|
|
209
302
|
for (const decorator of decorators) {
|
|
210
303
|
const name = decoratorName(decorator);
|
|
211
304
|
if (!name) {
|
|
@@ -213,6 +306,7 @@ function rewriteProperty(node, ctx) {
|
|
|
213
306
|
}
|
|
214
307
|
if (FIELD_DECORATORS.has(name)) {
|
|
215
308
|
addFieldType(decorator, node, ctx);
|
|
309
|
+
renameVirtualOption(decorator, node, ctx);
|
|
216
310
|
}
|
|
217
311
|
if (RELATION_DECORATORS.has(name)) {
|
|
218
312
|
addRelationEntity(decorator, node, ctx);
|
|
@@ -225,10 +319,7 @@ function rewriteProperty(node, ctx) {
|
|
|
225
319
|
}
|
|
226
320
|
/** Names a decorator that no longer exists, wherever it appears. */
|
|
227
321
|
function reportRemovedDecorators(node, ctx) {
|
|
228
|
-
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
for (const decorator of ts.getDecorators(node) ?? []) {
|
|
322
|
+
for (const decorator of decoratorsOf(node)) {
|
|
232
323
|
const name = decoratorName(decorator);
|
|
233
324
|
const advice = name && REMOVED_DECORATORS.get(name);
|
|
234
325
|
if (advice) {
|
|
@@ -237,40 +328,32 @@ function reportRemovedDecorators(node, ctx) {
|
|
|
237
328
|
}
|
|
238
329
|
}
|
|
239
330
|
/** Names an export that no longer exists, where it is imported from the package. */
|
|
240
|
-
function reportRemovedExports(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
if (node.moduleSpecifier.text !== 'uql-orm') {
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
const bindings = node.importClause?.namedBindings;
|
|
248
|
-
if (!bindings || !ts.isNamedImports(bindings)) {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
for (const element of bindings.elements) {
|
|
252
|
-
const name = (element.propertyName ?? element.name).text;
|
|
331
|
+
function reportRemovedExports(source, ctx) {
|
|
332
|
+
for (const element of uqlImports(source)) {
|
|
333
|
+
const name = importedName(element);
|
|
253
334
|
const advice = REMOVED_EXPORTS.get(name);
|
|
254
335
|
if (advice) {
|
|
255
336
|
ctx.unresolved.push(`${ctx.describe(element)}: '${name}' was removed; ${advice}`);
|
|
256
337
|
}
|
|
257
338
|
}
|
|
258
339
|
}
|
|
259
|
-
/**
|
|
260
|
-
function
|
|
261
|
-
return source.statements.
|
|
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) => {
|
|
262
343
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
263
|
-
return
|
|
344
|
+
return [];
|
|
264
345
|
}
|
|
265
346
|
if (statement.moduleSpecifier.text !== 'uql-orm') {
|
|
266
|
-
return
|
|
347
|
+
return [];
|
|
267
348
|
}
|
|
268
349
|
const bindings = statement.importClause?.namedBindings;
|
|
269
|
-
return (
|
|
270
|
-
ts.isNamedImports(bindings) &&
|
|
271
|
-
bindings.elements.some((element) => (element.propertyName ?? element.name).text === 'raw'));
|
|
350
|
+
return bindings && ts.isNamedImports(bindings) ? [...bindings.elements] : [];
|
|
272
351
|
});
|
|
273
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
|
+
}
|
|
274
357
|
/**
|
|
275
358
|
* Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`. Only a
|
|
276
359
|
* string-literal first argument qualifies: the callback form is unchanged, and a computed string is
|
|
@@ -306,10 +389,10 @@ export function transformFile(source, checker) {
|
|
|
306
389
|
describe: (node) => `${source.fileName}:${lineOf(node) + 1}`,
|
|
307
390
|
};
|
|
308
391
|
const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line;
|
|
309
|
-
const rewritesRaw =
|
|
392
|
+
const rewritesRaw = uqlImports(source).some((element) => importedName(element) === 'raw');
|
|
393
|
+
let branded = false;
|
|
310
394
|
const visit = (node) => {
|
|
311
395
|
reportRemovedDecorators(node, ctx);
|
|
312
|
-
reportRemovedExports(node, ctx);
|
|
313
396
|
countRelationAlias(node, ctx);
|
|
314
397
|
if (rewritesRaw) {
|
|
315
398
|
rewriteRawCall(node, ctx, source);
|
|
@@ -317,10 +400,17 @@ export function transformFile(source, checker) {
|
|
|
317
400
|
if (ts.isPropertyDeclaration(node)) {
|
|
318
401
|
rewriteProperty(node, ctx);
|
|
319
402
|
}
|
|
403
|
+
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
|
404
|
+
branded = brandIdKey(node, ctx) || branded;
|
|
405
|
+
}
|
|
320
406
|
ts.forEachChild(node, visit);
|
|
321
407
|
};
|
|
322
408
|
visit(source);
|
|
409
|
+
reportRemovedExports(source, ctx);
|
|
323
410
|
dropDeadImports(source, ctx);
|
|
411
|
+
if (branded) {
|
|
412
|
+
addIdKeyImport(source, ctx);
|
|
413
|
+
}
|
|
324
414
|
return {
|
|
325
415
|
fileName: source.fileName,
|
|
326
416
|
text: applyEdits(source.getFullText(), ctx.edits),
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uql-codemod",
|
|
3
|
-
"description": "
|
|
3
|
+
"description": "Upgrade uql-orm across versions that introduce breaking changes",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.31.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
|
},
|