uql-codemod 0.48.1 → 0.49.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.
Files changed (43) hide show
  1. package/README.md +27 -16
  2. package/dist/builders.d.ts +18 -0
  3. package/dist/builders.js +588 -0
  4. package/dist/classOptions.d.ts +9 -0
  5. package/dist/classOptions.js +240 -0
  6. package/dist/columnMethods.d.ts +11 -0
  7. package/dist/columnMethods.js +6 -0
  8. package/dist/columnRefs.js +3 -10
  9. package/dist/context.d.ts +18 -5
  10. package/dist/context.js +22 -4
  11. package/dist/definers.d.ts +24 -0
  12. package/dist/definers.js +34 -0
  13. package/dist/edits.d.ts +43 -11
  14. package/dist/edits.js +105 -26
  15. package/dist/entitySql.d.ts +15 -16
  16. package/dist/entitySql.js +33 -31
  17. package/dist/imports.d.ts +33 -16
  18. package/dist/imports.js +213 -99
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +9 -12
  21. package/dist/jsonIndex.d.ts +11 -0
  22. package/dist/jsonIndex.js +78 -0
  23. package/dist/keyMaps.d.ts +3 -1
  24. package/dist/keyMaps.js +5 -1
  25. package/dist/migrationBuilders.d.ts +13 -0
  26. package/dist/migrationBuilders.js +182 -0
  27. package/dist/relationTarget.d.ts +6 -0
  28. package/dist/relationTarget.js +21 -0
  29. package/dist/schemaEntity.d.ts +12 -0
  30. package/dist/schemaEntity.js +518 -0
  31. package/dist/schemaOptions.d.ts +6 -0
  32. package/dist/schemaOptions.js +6 -0
  33. package/dist/statementSql.d.ts +2 -1
  34. package/dist/statementSql.js +10 -4
  35. package/dist/syntax.d.ts +54 -0
  36. package/dist/syntax.js +131 -1
  37. package/dist/transform.d.ts +11 -2
  38. package/dist/transform.js +174 -165
  39. package/dist/tsconfig.d.ts +3 -9
  40. package/dist/tsconfig.js +18 -22
  41. package/package.json +1 -1
  42. package/dist/fieldType.d.ts +0 -13
  43. package/dist/fieldType.js +0 -56
package/dist/imports.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import ts from 'typescript';
2
+ import { REWRITTEN } from './context.js';
2
3
  import { inserted, removeFromList, removeStatement, replaced } from './edits.js';
3
4
  /**
4
5
  * Exports that no longer exist, mapped to what to do instead. Reported rather than rewritten: what replaces
5
6
  * each is decided at its call site.
6
7
  */
7
8
  const REMOVED_EXPORTS = new Map([
9
+ ['Relation', "write the property's type without it, and drop the import"],
8
10
  [
9
11
  'setQuerierPool',
10
12
  'pass the pool where it is used: `createFetchHandler({ pool, include })`, `querierMiddleware({ pool, include })`',
@@ -29,8 +31,11 @@ const REMOVED_EXPORTS = new Map([
29
31
  ['SqlCallback', 'use `EntitySql<E>`, which holds the callback form'],
30
32
  [
31
33
  'IndexColumnOptions',
32
- "an entity's index entry is `EntityIndexColumnInput<E>`, the migration builder's `IndexColumnInput`",
34
+ "an entity's index entry is `EntityIndexColumnInput`, the migration builder's `IndexColumnInput`",
33
35
  ],
36
+ ['EntityIndexColumn', "an entity's index entry is `EntityIndexColumnInput`, the migration builder's `IndexEntry`"],
37
+ ['IndexJsonPath', "read the path off the column's ref: `{ column: post.meta.lang, type: Number }`"],
38
+ ['IndexJsonArray', "give the elements' type: `{ column: post.tags, type: [String] }`"],
34
39
  ['isKnownMigratorDialect', 'every dialect has a migrator, so it always held: drop the check'],
35
40
  ['QuerierPoolDialect', "read the pool's own: `P['dialect']`"],
36
41
  ['QuerierPoolQuerier', "read the pool's own: `Awaited<ReturnType<P['getQuerier']>>`"],
@@ -49,7 +54,6 @@ const UTIL_ROOT_EXPORTS = [
49
54
  'currentDate',
50
55
  'currentTime',
51
56
  'currentTimestamp',
52
- 'raw',
53
57
  'sql',
54
58
  'refs',
55
59
  'uuid',
@@ -63,12 +67,17 @@ const UTIL_ROOT_EXPORTS = [
63
67
  'HookContext',
64
68
  'DefaultLogger',
65
69
  ];
70
+ /** The name `uql-orm` gave its SQL tag, and gives it now. */
71
+ export const LEGACY_SQL_TAG = 'raw';
72
+ export const SQL_TAG = 'sql';
66
73
  /**
67
74
  * Exports renamed or moved and nothing else, so the import and every use follow, the import moving to `from`
68
75
  * where the name lives in another entry. Keyed by name, or by `entry#name` where the name stays elsewhere.
69
76
  */
70
77
  const RENAMED_EXPORTS = new Map([
71
78
  ['QueryWhereMap', { to: 'QueryWhere' }],
79
+ ['IndexColumnSchema', { to: 'IndexEntry' }],
80
+ ['IndexColumnModifiers', { to: 'IndexEntryModifiers' }],
72
81
  ['RelationKeyMap', { to: 'KeyMap' }],
73
82
  ['FilterCondition', { to: 'FilterWhere' }],
74
83
  ['PgDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
@@ -97,11 +106,15 @@ const RENAMED_EXPORTS = new Map([
97
106
  ['IMigrationBuilder', { to: 'MigrationBuilder' }],
98
107
  ['ITableBuilder', { to: 'TableBuilder' }],
99
108
  ['IAlterTableBuilder', { to: 'AlterTableBuilder' }],
100
- ['IColumnBuilder', { to: 'ColumnBuilder' }],
101
- ['IColumnFactory', { to: 'ColumnFactory' }],
109
+ ['IColumnBuilder', { to: 'TableColumnBuilder' }],
110
+ ['IColumnFactory', { to: 'TableColumnFactory' }],
111
+ ['uql-orm/migrate#ColumnBuilder', { to: 'TableColumnBuilder' }],
112
+ ['uql-orm/migrate#ColumnFactory', { to: 'TableColumnFactory' }],
102
113
  ['IForeignKeyBuilder', { to: 'ForeignKeyBuilder' }],
103
114
  ['ITableForeignKeyBuilder', { to: 'TableForeignKeyBuilder' }],
104
115
  ['UqlLockUsageError', { to: 'UqlUsageError' }],
116
+ [LEGACY_SQL_TAG, { to: SQL_TAG }],
117
+ [`uql-orm/util#${LEGACY_SQL_TAG}`, { to: SQL_TAG, from: 'uql-orm' }],
105
118
  ['uql-orm/http#Hook', { to: 'RequestHook' }],
106
119
  ['uql-orm/http#HookContext', { to: 'RequestHookContext' }],
107
120
  ...UTIL_ROOT_EXPORTS.map((name) => [`uql-orm/util#${name}`, { to: name, from: 'uql-orm' }]),
@@ -127,6 +140,8 @@ const MOVED_ENTRIES = new Map([
127
140
  ['uql-orm/querier', 'uql-orm'],
128
141
  ['uql-orm/type', 'uql-orm'],
129
142
  ]);
143
+ /** The entry an import from `entry` lives in now. */
144
+ const entryOf = (entry) => MOVED_ENTRIES.get(entry) ?? entry;
130
145
  /**
131
146
  * The file's named imports from `uql-orm`. `entries` adds the driver entries (`uql-orm/postgres`, ...),
132
147
  * for what is read by name only: `idKey` written into one would land on an entry that may not export it.
@@ -137,13 +152,15 @@ export function uqlImportDeclarations(source, entries = false) {
137
152
  return [];
138
153
  }
139
154
  const entry = declaration.moduleSpecifier.text;
140
- if (entry !== 'uql-orm' && !(entries && entry.startsWith('uql-orm/'))) {
155
+ if (entries ? !isUqlEntry(entry) : entry !== 'uql-orm') {
141
156
  return [];
142
157
  }
143
158
  const bindings = declaration.importClause?.namedBindings;
144
159
  return bindings && ts.isNamedImports(bindings) ? [{ declaration, entry, elements: bindings.elements }] : [];
145
160
  });
146
161
  }
162
+ /** Whether `entry` is `uql-orm` itself or one of its `uql-orm/<driver>` entries. */
163
+ const isUqlEntry = (entry) => entry === 'uql-orm' || entry.startsWith('uql-orm/');
147
164
  function uqlImports(source, entries = false) {
148
165
  return uqlImportDeclarations(source, entries).flatMap(({ elements }) => elements);
149
166
  }
@@ -157,18 +174,35 @@ function importedName(element) {
157
174
  }
158
175
  /** Every identifier in the file bound to the same symbol as `name`, besides `name` itself. */
159
176
  export function usesOf(source, name, checker) {
160
- const symbol = checker.getSymbolAtLocation(name);
161
- const uses = [];
177
+ return usesOfEach(source, [name], checker)[0];
178
+ }
179
+ /** {@link usesOf} for each of `names`, in their order, from one walk of the file. */
180
+ function usesOfEach(source, names, checker) {
181
+ const bound = names.map((name) => ({ name, symbol: checker.getSymbolAtLocation(name), uses: [] }));
162
182
  const visit = (node) => {
163
- if (ts.isIdentifier(node) && node !== name && node.text === name.text) {
164
- if (symbol && checker.getSymbolAtLocation(node) === symbol) {
165
- uses.push(node);
183
+ if (ts.isIdentifier(node)) {
184
+ const symbol = checker.getSymbolAtLocation(node);
185
+ for (const binding of bound) {
186
+ if (symbol && binding.symbol === symbol && binding.name !== node)
187
+ binding.uses.push(node);
166
188
  }
167
189
  }
168
190
  ts.forEachChild(node, visit);
169
191
  };
170
192
  visit(source);
171
- return uses;
193
+ return bound.map(({ uses }) => uses);
194
+ }
195
+ /** The names among `names` the file binds or reads as something other than uql-orm's export, which a rewrite would shadow. */
196
+ export function foreignNames(source, names, checker) {
197
+ const found = new Set();
198
+ const visit = (node) => {
199
+ if (ts.isIdentifier(node) && names.has(node.text) && isForeignName(node, checker)) {
200
+ found.add(node.text);
201
+ }
202
+ ts.forEachChild(node, visit);
203
+ };
204
+ visit(source);
205
+ return found;
172
206
  }
173
207
  /** The rename `element` still needs, none where it already names the export at its entry. */
174
208
  function renamedTo(element, entry) {
@@ -186,7 +220,7 @@ export function reportRemovedExports(ctx) {
186
220
  }
187
221
  continue;
188
222
  }
189
- const root = (MOVED_ENTRIES.get(entry) ?? entry) === 'uql-orm';
223
+ const root = entryOf(entry) === 'uql-orm';
190
224
  for (const element of elements) {
191
225
  const name = importedName(element);
192
226
  const removed = REMOVED_EXPORTS.get(name);
@@ -200,116 +234,196 @@ export function reportRemovedExports(ctx) {
200
234
  }
201
235
  }
202
236
  }
203
- /**
204
- * Rewrites each import naming a {@link RENAMED_EXPORTS} export, or from a {@link MOVED_ENTRIES} entry, renaming
205
- * every use: a name moving entries gets an import from its new one, and one the file already imports is dropped,
206
- * as is a `dead` one. Returns the rewritten imports, which nothing else may edit.
207
- */
208
- export function renameExports(dead, ctx) {
209
- const imports = uqlImportDeclarations(ctx.source, true);
210
- const kept = imports.flatMap(({ entry, elements }) => elements.filter((element) => !renamedTo(element, entry)));
211
- const bound = new Set(kept.map((element) => element.name.text));
212
- const rewritten = new Set();
213
- for (const { declaration, entry: imported, elements } of imports) {
214
- const entry = MOVED_ENTRIES.get(imported) ?? imported;
215
- if (entry === imported && !elements.some((element) => renamedTo(element, entry))) {
216
- continue;
217
- }
218
- const byEntry = new Map([[entry, []]]);
219
- for (const element of elements.filter(({ name }) => !dead.has(name.text))) {
220
- const rename = renamedTo(element, entry);
221
- const local = element.propertyName || !rename ? element.name.text : rename.to;
222
- if (rename && !element.propertyName) {
223
- ctx.edits.push(...usesOf(ctx.source, element.name, ctx.checker).map((use) => replaced(use, rename.to)));
224
- }
225
- if (rename && bound.has(local)) {
226
- continue;
227
- }
228
- bound.add(local);
229
- const target = rename?.from ?? entry;
230
- const text = rename
231
- ? `${element.isTypeOnly ? 'type ' : ''}${rename.to}${element.propertyName ? ` as ${local}` : ''}`
232
- : element.getText();
233
- byEntry.set(target, [...(byEntry.get(target) ?? []), text]);
234
- }
235
- const keyword = isTypeOnly(declaration) ? 'import type' : 'import';
236
- const quote = declaration.moduleSpecifier.getText()[0];
237
- const statements = [...byEntry]
238
- .filter(([, names]) => names.length)
239
- .map(([from, names]) => `${keyword} { ${names.join(', ')} } from ${quote}${from}${quote};`);
240
- ctx.edits.push(statements.length ? replaced(declaration, statements.join('\n')) : removeStatement(declaration));
241
- rewritten.add(declaration);
242
- }
243
- return rewritten;
244
- }
245
237
  function isTypeOnly(declaration) {
246
238
  return declaration.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword;
247
239
  }
248
- /**
249
- * The imported names the codemod removes once every use was rewritten: `Relation` unwrapped and `expr` defaults.
250
- * A removed decorator keeps its import on purpose: deleting it would swap a clear error for a puzzling one.
251
- */
240
+ /** The imported names of `REWRITTEN` the codemod removes, once every use was rewritten. */
252
241
  export function deadImportNames(ctx) {
253
242
  const { source, rewritten } = ctx;
254
- const uses = (name) => {
243
+ const imported = REWRITTEN.flatMap((name) => {
255
244
  const element = uqlImport(source, name, true);
256
- return element ? usesOf(source, element.name, ctx.checker).length : 0;
257
- };
258
- const relations = uses('Relation');
259
- if (relations > rewritten.Relation) {
260
- ctx.unresolved.push(`${source.fileName}: ${relations - rewritten.Relation} 'Relation<T>' reference(s) are somewhere this codemod ` +
261
- 'does not rewrite; unwrap them and drop the import by hand');
245
+ return element ? [[name, element.name]] : [];
246
+ });
247
+ const found = usesOfEach(source, imported.map(([, element]) => element), ctx.checker);
248
+ const uses = new Map(imported.map(([name], at) => [name, found[at].length]));
249
+ return new Set(REWRITTEN.filter((name) => rewritten[name] > 0 && rewritten[name] === (uses.get(name) ?? 0)));
250
+ }
251
+ /** The `uql-orm` export `node` names, where its symbol is an import of it or of one of its entries. */
252
+ export function uqlExport(node, checker) {
253
+ const declaration = node && ts.isIdentifier(node) ? checker.getSymbolAtLocation(node)?.declarations?.[0] : undefined;
254
+ return declaration && isUqlSpecifier(declaration) ? importedName(declaration) : undefined;
255
+ }
256
+ function isUqlSpecifier(declaration) {
257
+ if (!ts.isImportSpecifier(declaration)) {
258
+ return false;
262
259
  }
263
- const names = ['Relation', 'expr'];
264
- return new Set(names.filter((name) => rewritten[name] > 0 && rewritten[name] === uses(name)));
260
+ const { moduleSpecifier } = declaration.parent.parent.parent;
261
+ return ts.isStringLiteral(moduleSpecifier) && isUqlEntry(moduleSpecifier.text);
262
+ }
263
+ /** Whether `name` is the SQL tag, by either of its names. */
264
+ export function isSqlExport(name) {
265
+ return name === SQL_TAG || name === LEGACY_SQL_TAG;
266
+ }
267
+ /** The name the file calls the SQL tag by once `raw` is renamed: the alias it imports it as, else `sql`. */
268
+ export function sqlName(source) {
269
+ const element = uqlImports(source, true).find((it) => isSqlExport(importedName(it)));
270
+ return element?.propertyName ? element.name.text : SQL_TAG;
271
+ }
272
+ /** The names the rewrites wrote that the file does not import from `uql-orm` yet, each with what wrote it. */
273
+ function missingNames(ctx) {
274
+ const imported = new Set(uqlImports(ctx.source, true).map(importedName));
275
+ const bound = [...imported].some(isSqlExport);
276
+ return [...ctx.imports].filter(([name]) => !imported.has(name) && !(bound && name === SQL_TAG));
277
+ }
278
+ const MEMBER = ts.SymbolFlags.Property | ts.SymbolFlags.Method | ts.SymbolFlags.Accessor | ts.SymbolFlags.EnumMember;
279
+ /** Whether `node` names a binding or a reference that is not `uql-orm`'s, which an import of the same name would capture. */
280
+ function isForeignName(node, checker) {
281
+ const symbol = checker.getSymbolAtLocation(node);
282
+ if (!symbol) {
283
+ const { parent } = node;
284
+ return !((ts.isPropertyAccessExpression(parent) || ts.isPropertyAssignment(parent)) && parent.name === node);
285
+ }
286
+ return !(symbol.flags & MEMBER) && !symbol.declarations?.every(isUqlSpecifier);
287
+ }
288
+ /**
289
+ * The first identifier of the file already naming something a rewrite writes: a name it imports (`sql`, `refs`,
290
+ * `idKey`) or a rename brings in. The write would shadow it or be shadowed, so the file is left to its owner.
291
+ */
292
+ export function clashingName(ctx) {
293
+ const renamed = uqlImportDeclarations(ctx.source, true).flatMap(({ entry, elements }) => elements.flatMap((element) => {
294
+ const rename = renamedTo(element, entry);
295
+ return rename && !element.propertyName ? [rename.to] : [];
296
+ }));
297
+ const names = new Set([...missingNames(ctx).map(([name]) => name), ...renamed]);
298
+ const visit = (node) => ts.isIdentifier(node) && names.has(node.text) && isForeignName(node, ctx.checker)
299
+ ? node
300
+ : ts.forEachChild(node, visit);
301
+ return names.size ? visit(ctx.source) : undefined;
265
302
  }
266
303
  /**
267
- * Removes `import 'reflect-metadata'`, since the polyfill only ever fed `design:type`, and each `dead` name
268
- * from the imports {@link renameExports} left as written, having left the dead names out of the rest.
304
+ * Rewrites the file's imports in one pass: a name {@link RENAMED_EXPORTS} renames or a {@link MOVED_ENTRIES} entry
305
+ * moves is imported again where it lives now, every use renamed, and one already imported or `dead` is dropped.
306
+ * The names the rewrites wrote join the first `uql-orm` import, or are reported when there is none.
269
307
  */
270
- export function dropDeadImports(dead, rewritten, ctx) {
271
- for (const statement of ctx.source.statements) {
308
+ export function rewriteImports(dead, ctx) {
309
+ const { source } = ctx;
310
+ const imports = new Map(uqlImportDeclarations(source, true).map((it) => [it.declaration, it]));
311
+ const bound = new Set([...imports.values()]
312
+ .flatMap(({ entry, elements }) => elements.filter((element) => !renamedTo(element, entry)))
313
+ .map((element) => element.name.text));
314
+ const home = [...imports.values()].find(({ entry }) => entryOf(entry) === 'uql-orm');
315
+ const written = missingNames(ctx);
316
+ if (!home) {
317
+ for (const [name, what] of written) {
318
+ ctx.unresolved.push(`${source.fileName}: import '${name}' from 'uql-orm' for ${what} written here`);
319
+ }
320
+ }
321
+ for (const statement of source.statements) {
272
322
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
273
323
  continue;
274
324
  }
275
- if (statement.moduleSpecifier.text === 'reflect-metadata' && !statement.importClause) {
276
- ctx.edits.push(removeStatement(statement));
277
- continue;
325
+ const uql = imports.get(statement);
326
+ const join = uql === home ? written.map(([name]) => name) : [];
327
+ const entry = uql && entryOf(uql.entry);
328
+ if (uql && (entry !== uql.entry || uql.elements.some((element) => renamedTo(element, entry)))) {
329
+ rebuildImport(uql, join, dead, bound, ctx);
278
330
  }
279
- const named = statement.importClause?.namedBindings;
280
- if (!named || !ts.isNamedImports(named) || rewritten.has(statement)) {
281
- continue;
331
+ else {
332
+ patchImport(statement, join, dead, ctx);
282
333
  }
283
- const dropped = named.elements.filter((element) => dead.has(element.name.text));
284
- if (dropped.length === named.elements.length && !statement.importClause?.name) {
285
- ctx.edits.push(removeStatement(statement));
286
- continue;
334
+ }
335
+ }
336
+ /** An import of `names` from `from`, quoted as the file quotes its imports. */
337
+ export const importText = (keyword, names, from, quote = "'") => `${keyword} { ${names.join(', ')} } from ${quote}${from}${quote};`;
338
+ /**
339
+ * The import written again: each name under the entry it lives in, `join` first under `uql-orm`. `join` is values,
340
+ * so in a type-only declaration it becomes a value import of its own.
341
+ */
342
+ function rebuildImport({ declaration, entry: imported, elements }, join, dead, bound, ctx) {
343
+ const entry = entryOf(imported);
344
+ const typeOnly = isTypeOnly(declaration);
345
+ const byEntry = new Map([[entry, typeOnly ? [] : [...join]]]);
346
+ for (const element of elements.filter(({ name }) => !dead.has(name.text))) {
347
+ const rename = renamedTo(element, entry);
348
+ const local = element.propertyName || !rename ? element.name.text : rename.to;
349
+ if (rename && !element.propertyName) {
350
+ ctx.edits.push(...usesOf(ctx.source, element.name, ctx.checker).map((use) => replaced(use, rename.to)));
287
351
  }
288
- for (const element of dropped) {
289
- ctx.edits.push(removeFromList(named.elements, element, ctx.source));
352
+ if (rename && bound.has(local)) {
353
+ continue;
290
354
  }
355
+ bound.add(local);
356
+ const target = rename?.from ?? entry;
357
+ const text = rename
358
+ ? `${element.isTypeOnly ? 'type ' : ''}${rename.to}${element.propertyName ? ` as ${local}` : ''}`
359
+ : element.getText();
360
+ byEntry.set(target, [...(byEntry.get(target) ?? []), text]);
361
+ }
362
+ const keyword = typeOnly ? 'import type' : 'import';
363
+ const quote = declaration.moduleSpecifier.getText()[0];
364
+ const statements = [...byEntry]
365
+ .filter(([, names]) => names.length)
366
+ .map(([from, names]) => importText(keyword, names, from, quote));
367
+ if (typeOnly && join.length) {
368
+ statements.unshift(importText('import', join, 'uql-orm', quote));
291
369
  }
370
+ ctx.edits.push(statements.length ? replaced(declaration, statements.join('\n')) : removeStatement(declaration));
292
371
  }
293
372
  /**
294
- * Imports what the rewrites wrote (`idKey`, `raw`) into the file's own `uql-orm` import. Reported instead
295
- * where there is none: the package may be imported under a path this codemod does not recognise.
373
+ * The import without `dropped` of its `elements`: the whole statement where nothing else is left of it, and the
374
+ * braces with them where a default import stays.
296
375
  */
297
- export function addImports(rewritten, ctx) {
298
- const { source } = ctx;
299
- const imported = new Set(uqlImports(source).map(importedName));
300
- const missing = [...ctx.imports].filter(([name]) => !imported.has(name));
301
- if (!missing.length) {
302
- return;
376
+ export function importRemovalEdits(declaration, elements, dropped) {
377
+ if (dropped.length !== elements.length) {
378
+ return removeFromList(elements, dropped, declaration.getSourceFile());
303
379
  }
304
- const anchor = uqlImportDeclarations(source).find(({ declaration }) => !rewritten.has(declaration));
305
- if (!anchor) {
306
- for (const [name, what] of missing) {
307
- ctx.unresolved.push(`${source.fileName}: import '${name}' from 'uql-orm' for ${what} written here`);
380
+ const clause = declaration.importClause;
381
+ return clause?.name
382
+ ? [{ start: clause.name.getEnd(), end: clause.getEnd(), text: '' }]
383
+ : [removeStatement(declaration)];
384
+ }
385
+ /** Every name the file spells outside its imports, which an import it binds is still used under if listed here. */
386
+ export function spelledNames(source) {
387
+ const names = new Set();
388
+ const visit = (node) => {
389
+ if (ts.isIdentifier(node)) {
390
+ names.add(node.text);
308
391
  }
392
+ if (!ts.isImportDeclaration(node)) {
393
+ ts.forEachChild(node, visit);
394
+ }
395
+ };
396
+ visit(source);
397
+ return names;
398
+ }
399
+ /** The import names in `candidates` that the file no longer spells. */
400
+ export function deadImports(elements, candidates, spelled) {
401
+ return elements.filter((it) => candidates.has(it.name.text) && !spelled.has(it.name.text));
402
+ }
403
+ /** The edits dropping each `uql-orm` import name in `candidates` that the file no longer spells. */
404
+ export function unusedImportEdits(source, candidates) {
405
+ const spelled = spelledNames(source);
406
+ return uqlImportDeclarations(source, true).flatMap(({ declaration, elements }) => {
407
+ const dead = deadImports(elements, candidates, spelled);
408
+ return dead.length ? importRemovalEdits(declaration, elements, dead) : [];
409
+ });
410
+ }
411
+ /** The import with the `dead` names out and `join` in, nothing else of it touched. */
412
+ function patchImport(declaration, join, dead, ctx) {
413
+ const named = declaration.importClause?.namedBindings;
414
+ if (!named || !ts.isNamedImports(named)) {
309
415
  return;
310
416
  }
311
- const names = missing.map(([name]) => name);
312
- ctx.edits.push(isTypeOnly(anchor.declaration)
313
- ? inserted(anchor.declaration, `import { ${names.join(', ')} } from 'uql-orm';\n`)
314
- : inserted(anchor.elements[0], names.map((name) => `${name}, `).join('')));
417
+ const dropped = named.elements.filter((element) => dead.has(element.name.text));
418
+ const statement = importText('import', join, 'uql-orm');
419
+ if (join.length && dropped.length === named.elements.length && !declaration.importClause?.name) {
420
+ ctx.edits.push(replaced(declaration, statement));
421
+ return;
422
+ }
423
+ ctx.edits.push(...importRemovalEdits(declaration, named.elements, dropped));
424
+ if (join.length) {
425
+ ctx.edits.push(isTypeOnly(declaration)
426
+ ? inserted(declaration, `${statement}\n`)
427
+ : inserted(named.elements[0], join.map((name) => `${name}, `).join('')));
428
+ }
315
429
  }
package/dist/index.d.ts CHANGED
@@ -14,9 +14,9 @@ export type RunSummary = {
14
14
  readonly notes: readonly string[];
15
15
  };
16
16
  /**
17
- * Rewrites a project's entities for the standard decorator spec.
17
+ * Rewrites a project across the breaking changes of uql-orm that can be made mechanically.
18
18
  *
19
- * @remarks Needs a type checker, not just a parser: the whole point is to write down the types that
20
- * `design:type` used to report at runtime, and only the checker knows what `role?: Role` resolves to.
19
+ * @remarks Needs a type checker, not just a parser: the codemod reads each property's type to name a relation's
20
+ * target and to admit `null`, and only the checker resolves `role?: Role` to its arms.
21
21
  */
22
22
  export declare function run({ project, dryRun, include }: RunOptions): Promise<RunSummary>;
package/dist/index.js CHANGED
@@ -2,14 +2,14 @@ import { writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import ts from 'typescript';
4
4
  import { transformFile } from './transform.js';
5
- import { transformTsconfig } from './tsconfig.js';
5
+ import { checkTsconfig } from './tsconfig.js';
6
6
  export * from './transform.js';
7
7
  export * from './tsconfig.js';
8
8
  /**
9
- * Rewrites a project's entities for the standard decorator spec.
9
+ * Rewrites a project across the breaking changes of uql-orm that can be made mechanically.
10
10
  *
11
- * @remarks Needs a type checker, not just a parser: the whole point is to write down the types that
12
- * `design:type` used to report at runtime, and only the checker knows what `role?: Role` resolves to.
11
+ * @remarks Needs a type checker, not just a parser: the codemod reads each property's type to name a relation's
12
+ * target and to admit `null`, and only the checker resolves `role?: Role` to its arms.
13
13
  */
14
14
  export async function run({ project, dryRun, include }) {
15
15
  // Absolute, because `parseJsonConfigFileContent` resolves every discovered file against the base path
@@ -41,19 +41,16 @@ export async function run({ project, dryRun, include }) {
41
41
  if (result.changed || result.unresolved.length || result.notes.length)
42
42
  results.push(result);
43
43
  }
44
- // The config is rewritten too: leaving `experimentalDecorators` on is what keeps a project compiling
45
- // against the old spec, so a codemod that only touched the sources would leave it half-migrated.
46
- const tsconfig = transformTsconfig(configPath, ts.sys.readFile(configPath) ?? '');
47
44
  const rewritten = results.filter((r) => r.changed);
48
45
  if (!dryRun) {
49
46
  await Promise.all(rewritten.map((r) => writeFile(r.fileName, r.text, 'utf8')));
50
- if (tsconfig.changed) {
51
- await writeFile(configPath, tsconfig.text, 'utf8');
52
- }
53
47
  }
54
48
  return {
55
- changed: [...rewritten.map((r) => r.fileName), ...(tsconfig.changed ? [configPath] : [])],
56
- unresolved: [...results.flatMap((r) => r.unresolved), ...tsconfig.unresolved],
49
+ changed: rewritten.map((r) => r.fileName),
50
+ unresolved: [
51
+ ...results.flatMap((r) => r.unresolved),
52
+ ...checkTsconfig(configPath, ts.sys.readFile(configPath) ?? ''),
53
+ ],
57
54
  notes: results.flatMap((r) => r.notes),
58
55
  };
59
56
  }
@@ -0,0 +1,11 @@
1
+ import ts from 'typescript';
2
+ import type { Context } from './context.js';
3
+ /**
4
+ * Rewrites each JSON entry of an index's columns into the path read off the column's ref, as a `$where` key
5
+ * spells it: `{ column: post.kind, jsonPath: { path: 'theme.color', type: String } }` becomes
6
+ * `{ column: post.kind.theme.color, type: String }`, and a `jsonArray`'s element type becomes `type: [String]`.
7
+ * Its `length` becomes `charLength`, and a column entry's becomes `prefixLength`.
8
+ */
9
+ export declare function rewriteJsonIndexEntries(columns: ts.Expression | undefined, ctx: Context): void;
10
+ /** A migration builder's index entries, each `length` as the option it names now: `charLength` on JSON, `prefixLength` on a column. */
11
+ export declare function rewriteBuilderIndexLengths(columns: ts.Expression | undefined, ctx: Context): void;
@@ -0,0 +1,78 @@
1
+ import ts from 'typescript';
2
+ import { original, replaced } from './edits.js';
3
+ import { memberAccess } from './keyMaps.js';
4
+ import { arrayElements, columnListEntries, namedProperty, propertyAssignment, propertyKey, propertyValue, } from './syntax.js';
5
+ /** The entry's own keys the rewritten entry writes itself rather than carrying over. */
6
+ const MOVED_KEYS = new Set(['column', 'length']);
7
+ /**
8
+ * Rewrites each JSON entry of an index's columns into the path read off the column's ref, as a `$where` key
9
+ * spells it: `{ column: post.kind, jsonPath: { path: 'theme.color', type: String } }` becomes
10
+ * `{ column: post.kind.theme.color, type: String }`, and a `jsonArray`'s element type becomes `type: [String]`.
11
+ * Its `length` becomes `charLength`, and a column entry's becomes `prefixLength`.
12
+ */
13
+ export function rewriteJsonIndexEntries(columns, ctx) {
14
+ for (const entry of columnListEntries(columns).filter(ts.isObjectLiteralExpression)) {
15
+ const read = propertyAssignment(entry, 'jsonPath') ?? propertyAssignment(entry, 'jsonArray');
16
+ const edit = read ? jsonEntryEdit(entry, read) : prefixLengthEdit(entry);
17
+ if (edit) {
18
+ ctx.edits.push(edit);
19
+ }
20
+ else if (read) {
21
+ ctx.report(entry, "read the JSON path off the column's ref, `{ column: post.kind.theme, type: Number }`; this entry could not be read");
22
+ }
23
+ }
24
+ }
25
+ /** The read's options as the path's keys, and the type and length as written, `undefined` where it takes more than these. */
26
+ function readOptions(options) {
27
+ const path = propertyValue(options, 'path');
28
+ const type = propertyValue(options, 'type');
29
+ const length = propertyValue(options, 'length');
30
+ const known = [path, type, length].filter(Boolean).length;
31
+ if (!type || (path && !ts.isStringLiteralLike(path)) || options.properties.length !== known) {
32
+ return undefined;
33
+ }
34
+ const keys = path && ts.isStringLiteralLike(path) ? path.text.split('.') : [];
35
+ return { keys, type, length };
36
+ }
37
+ /** The entry rewritten, or `undefined` where its read names a path or an option this cannot carry over. */
38
+ function jsonEntryEdit(entry, read) {
39
+ const column = propertyValue(entry, 'column');
40
+ const options = read.initializer;
41
+ const parsed = column && ts.isObjectLiteralExpression(options) ? readOptions(options) : undefined;
42
+ const entryLength = propertyValue(entry, 'length');
43
+ if (!column || !parsed || (parsed.length && entryLength)) {
44
+ return undefined;
45
+ }
46
+ const { keys, type } = parsed;
47
+ const length = parsed.length ?? entryLength;
48
+ const elements = propertyKey(read.name) === 'jsonArray';
49
+ const rest = entry.properties.filter((property) => property !== read && !(ts.isPropertyAssignment(property) && MOVED_KEYS.has(propertyKey(property.name))));
50
+ const parts = [
51
+ '{ column: ',
52
+ original(column),
53
+ keys.reduce(memberAccess, ''),
54
+ ', type: ',
55
+ ...(elements ? ['[', original(type), ']'] : [original(type)]),
56
+ ...(length ? [', charLength: ', original(length)] : []),
57
+ ...rest.flatMap((property) => [', ', original(property)]),
58
+ ' }',
59
+ ];
60
+ return replaced(entry, parts);
61
+ }
62
+ /** A column entry's `length` as the `prefixLength` it now names, `undefined` where it states none. */
63
+ function prefixLengthEdit(entry) {
64
+ const length = propertyAssignment(entry, 'length');
65
+ return length && replaced(length.name, 'prefixLength');
66
+ }
67
+ /** The keys only a migration builder's JSON entry states, whose `length` is the `CHAR(n)` it is read as. */
68
+ const JSON_ENTRY_KEYS = ['json', 'path', 'type'];
69
+ /** A migration builder's index entries, each `length` as the option it names now: `charLength` on JSON, `prefixLength` on a column. */
70
+ export function rewriteBuilderIndexLengths(columns, ctx) {
71
+ for (const entry of arrayElements(columns).filter(ts.isObjectLiteralExpression)) {
72
+ const length = propertyAssignment(entry, 'length');
73
+ const json = JSON_ENTRY_KEYS.some((key) => namedProperty(entry, key));
74
+ if (length) {
75
+ ctx.edits.push(replaced(length.name, json ? 'charLength' : 'prefixLength'));
76
+ }
77
+ }
78
+ }
package/dist/keyMaps.d.ts CHANGED
@@ -8,10 +8,12 @@ export declare function paramFor(className: string | undefined): string;
8
8
  export declare function quoted(text: string): string;
9
9
  /** `post.title`, or `post['first-name']` for a key that is no identifier. */
10
10
  export declare function memberAccess(param: string, key: string): string;
11
+ /** A key as an object literal writes it: `title`, or `'first-name'` for one that is no identifier. */
12
+ export declare function memberKey(key: string): string;
11
13
  /** `'amount'` or `['title', 'body']` -> `{ amount: true }`, the key map a statement names fields by. */
12
14
  export declare function fieldKeysEdits(value: ts.Expression): Edits;
13
15
  /** Whether `value` is already a function, the form every rewrite here produces. */
14
- export declare function isCallback(value: ts.Expression): boolean;
16
+ export declare function isCallback(value: ts.Expression): value is ts.ArrowFunction | ts.FunctionExpression;
15
17
  /** `['title', { column: 'createdAt' }]` -> `(post) => [post.title, { column: post.createdAt }]`. */
16
18
  export declare function keyListEdits(list: ts.Expression, param: string): Edits;
17
19
  /** `'author'` -> `(post) => post.author`, `param` being the relation's target. */
package/dist/keyMaps.js CHANGED
@@ -16,13 +16,17 @@ export function quoted(text) {
16
16
  export function memberAccess(param, key) {
17
17
  return isIdentifierName(key) ? `${param}.${key}` : `${param}[${quoted(key)}]`;
18
18
  }
19
+ /** A key as an object literal writes it: `title`, or `'first-name'` for one that is no identifier. */
20
+ export function memberKey(key) {
21
+ return isIdentifierName(key) ? key : quoted(key);
22
+ }
19
23
  /** `'amount'` or `['title', 'body']` -> `{ amount: true }`, the key map a statement names fields by. */
20
24
  export function fieldKeysEdits(value) {
21
25
  const names = ts.isArrayLiteralExpression(value) ? value.elements : [value];
22
26
  if (!names.every((name) => ts.isStringLiteralLike(name))) {
23
27
  return undefined;
24
28
  }
25
- const keys = names.map(({ text }) => `${isIdentifierName(text) ? text : quoted(text)}: true`);
29
+ const keys = names.map(({ text }) => `${memberKey(text)}: true`);
26
30
  return [replaced(value, `{ ${keys.join(', ')} }`)];
27
31
  }
28
32
  /** Whether `value` is already a function, the form every rewrite here produces. */
@@ -0,0 +1,13 @@
1
+ /** The column factories of a migration's table builder. */
2
+ export declare const FACTORY_NAMES: readonly ["id", "integer", "smallint", "bigint", "float", "double", "decimal", "string", "char", "text", "boolean", "date", "time", "timestamp", "timestamptz", "json", "jsonb", "uuid", "blob", "vector"];
3
+ export type MigrationBuildersResult = {
4
+ readonly text: string;
5
+ readonly unresolved: string[];
6
+ };
7
+ /**
8
+ * Writes a migration's columns in an entity column's vocabulary: `table.string('email', { length: 80 })` as
9
+ * `table.string('email').length(80)`, `.defaultValue()` as `.default()`, `.primaryKey()` as `.id()`, `.computed()`
10
+ * as `.as()`, and `.notNullable()` dropped, a column being NOT NULL already. Only a chain off the builder a
11
+ * `createTable`, `alterTable`, `addColumn` or `alterColumn` callback is handed, so no other `.string()` is touched.
12
+ */
13
+ export declare function toMigrationBuilders(fileName: string, text: string, locate?: (at: number) => string): MigrationBuildersResult;