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 +3 -1
- package/dist/index.js +3 -2
- package/dist/transform.js +83 -30
- package/package.json +1 -1
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
|
|
@@ -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(
|
|
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;
|
|
@@ -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,
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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(
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
/**
|
|
297
|
-
function
|
|
298
|
-
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) => {
|
|
299
343
|
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
300
|
-
return
|
|
344
|
+
return [];
|
|
301
345
|
}
|
|
302
346
|
if (statement.moduleSpecifier.text !== 'uql-orm') {
|
|
303
|
-
return
|
|
347
|
+
return [];
|
|
304
348
|
}
|
|
305
349
|
const bindings = statement.importClause?.namedBindings;
|
|
306
|
-
return (
|
|
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 =
|
|
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),
|