uql-codemod 0.48.2 → 0.50.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/dist/imports.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript';
2
- import type { Context } from './context.js';
2
+ import { type Context } from './context.js';
3
+ import { type Edit } from './edits.js';
3
4
  /** The name `uql-orm` gave its SQL tag, and gives it now. */
4
5
  export declare const LEGACY_SQL_TAG = "raw";
5
6
  export declare const SQL_TAG = "sql";
@@ -17,12 +18,11 @@ export declare function uqlImportDeclarations(source: ts.SourceFile, entries?: b
17
18
  export declare function uqlImport(source: ts.SourceFile, name: string, entries?: boolean): ts.ImportSpecifier | undefined;
18
19
  /** Every identifier in the file bound to the same symbol as `name`, besides `name` itself. */
19
20
  export declare function usesOf(source: ts.SourceFile, name: ts.Identifier, checker: ts.TypeChecker): ts.Identifier[];
21
+ /** The names among `names` the file binds or reads as something other than uql-orm's export, which a rewrite would shadow. */
22
+ export declare function foreignNames(source: ts.SourceFile, names: ReadonlySet<string>, checker: ts.TypeChecker): ReadonlySet<string>;
20
23
  /** Names an export that no longer exists, where it is imported from the package or one of its entries. */
21
24
  export declare function reportRemovedExports(ctx: Context): void;
22
- /**
23
- * The imported names the codemod removes once every use was rewritten: `Relation` unwrapped and `expr` defaults.
24
- * A removed decorator keeps its import on purpose: deleting it would swap a clear error for a puzzling one.
25
- */
25
+ /** The imported names of `REWRITTEN` the codemod removes, once every use was rewritten. */
26
26
  export declare function deadImportNames(ctx: Context): ReadonlySet<string>;
27
27
  /** The `uql-orm` export `node` names, where its symbol is an import of it or of one of its entries. */
28
28
  export declare function uqlExport(node: ts.Node | undefined, checker: ts.TypeChecker): string | undefined;
@@ -41,4 +41,20 @@ export declare function clashingName(ctx: Context): ts.Identifier | undefined;
41
41
  * The names the rewrites wrote join the first `uql-orm` import, or are reported when there is none.
42
42
  */
43
43
  export declare function rewriteImports(dead: ReadonlySet<string>, ctx: Context): void;
44
+ /** An import of `names` from `from`, quoted as the file quotes its imports. */
45
+ export declare const importText: (keyword: string, names: readonly string[], from: string, quote?: string) => string;
46
+ /**
47
+ * The import without `dropped` of its `elements`: the whole statement where nothing else is left of it, and the
48
+ * braces with them where a default import stays.
49
+ */
50
+ export declare function importRemovalEdits(declaration: ts.ImportDeclaration, elements: readonly ts.ImportSpecifier[], dropped: readonly ts.ImportSpecifier[]): readonly Edit[];
51
+ /** Every name the file spells outside its imports, which an import it binds is still used under if listed here. */
52
+ export declare function spelledNames(source: ts.SourceFile): ReadonlySet<string>;
53
+ /** The import names in `candidates` that the file no longer spells. */
54
+ export declare function deadImports(elements: readonly ts.ImportSpecifier[], candidates: ReadonlySet<string | undefined>, spelled: ReadonlySet<string>): readonly ts.ImportSpecifier[];
55
+ /**
56
+ * The file's `uql-orm` imports with `added` joined to the first, a new import where there is none, and each name in
57
+ * `candidates` the file no longer spells dropped.
58
+ */
59
+ export declare function importEdits(source: ts.SourceFile, added: ReadonlySet<string>, candidates: ReadonlySet<string | undefined>): readonly Edit[];
44
60
  export {};
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']>>`"],
@@ -71,6 +76,8 @@ export const SQL_TAG = 'sql';
71
76
  */
72
77
  const RENAMED_EXPORTS = new Map([
73
78
  ['QueryWhereMap', { to: 'QueryWhere' }],
79
+ ['IndexColumnSchema', { to: 'IndexEntry' }],
80
+ ['IndexColumnModifiers', { to: 'IndexEntryModifiers' }],
74
81
  ['RelationKeyMap', { to: 'KeyMap' }],
75
82
  ['FilterCondition', { to: 'FilterWhere' }],
76
83
  ['PgDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
@@ -99,8 +106,10 @@ const RENAMED_EXPORTS = new Map([
99
106
  ['IMigrationBuilder', { to: 'MigrationBuilder' }],
100
107
  ['ITableBuilder', { to: 'TableBuilder' }],
101
108
  ['IAlterTableBuilder', { to: 'AlterTableBuilder' }],
102
- ['IColumnBuilder', { to: 'ColumnBuilder' }],
103
- ['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' }],
104
113
  ['IForeignKeyBuilder', { to: 'ForeignKeyBuilder' }],
105
114
  ['ITableForeignKeyBuilder', { to: 'TableForeignKeyBuilder' }],
106
115
  ['UqlLockUsageError', { to: 'UqlUsageError' }],
@@ -165,18 +174,35 @@ function importedName(element) {
165
174
  }
166
175
  /** Every identifier in the file bound to the same symbol as `name`, besides `name` itself. */
167
176
  export function usesOf(source, name, checker) {
168
- const symbol = checker.getSymbolAtLocation(name);
169
- 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: [] }));
170
182
  const visit = (node) => {
171
- if (ts.isIdentifier(node) && node !== name && node.text === name.text) {
172
- if (symbol && checker.getSymbolAtLocation(node) === symbol) {
173
- 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);
174
188
  }
175
189
  }
176
190
  ts.forEachChild(node, visit);
177
191
  };
178
192
  visit(source);
179
- 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;
180
206
  }
181
207
  /** The rename `element` still needs, none where it already names the export at its entry. */
182
208
  function renamedTo(element, entry) {
@@ -211,23 +237,16 @@ export function reportRemovedExports(ctx) {
211
237
  function isTypeOnly(declaration) {
212
238
  return declaration.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword;
213
239
  }
214
- /**
215
- * The imported names the codemod removes once every use was rewritten: `Relation` unwrapped and `expr` defaults.
216
- * A removed decorator keeps its import on purpose: deleting it would swap a clear error for a puzzling one.
217
- */
240
+ /** The imported names of `REWRITTEN` the codemod removes, once every use was rewritten. */
218
241
  export function deadImportNames(ctx) {
219
242
  const { source, rewritten } = ctx;
220
- const uses = (name) => {
243
+ const imported = REWRITTEN.flatMap((name) => {
221
244
  const element = uqlImport(source, name, true);
222
- return element ? usesOf(source, element.name, ctx.checker).length : 0;
223
- };
224
- const relations = uses('Relation');
225
- if (relations > rewritten.Relation) {
226
- ctx.unresolved.push(`${source.fileName}: ${relations - rewritten.Relation} 'Relation<T>' reference(s) are somewhere this codemod ` +
227
- 'does not rewrite; unwrap them and drop the import by hand');
228
- }
229
- const names = ['Relation', 'expr'];
230
- return new Set(names.filter((name) => rewritten[name] > 0 && rewritten[name] === uses(name)));
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)));
231
250
  }
232
251
  /** The `uql-orm` export `node` names, where its symbol is an import of it or of one of its entries. */
233
252
  export function uqlExport(node, checker) {
@@ -303,10 +322,6 @@ export function rewriteImports(dead, ctx) {
303
322
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
304
323
  continue;
305
324
  }
306
- if (statement.moduleSpecifier.text === 'reflect-metadata' && !statement.importClause) {
307
- ctx.edits.push(removeStatement(statement));
308
- continue;
309
- }
310
325
  const uql = imports.get(statement);
311
326
  const join = uql === home ? written.map(([name]) => name) : [];
312
327
  const entry = uql && entryOf(uql.entry);
@@ -319,7 +334,7 @@ export function rewriteImports(dead, ctx) {
319
334
  }
320
335
  }
321
336
  /** An import of `names` from `from`, quoted as the file quotes its imports. */
322
- const importText = (keyword, names, from, quote = "'") => `${keyword} { ${names.join(', ')} } from ${quote}${from}${quote};`;
337
+ export const importText = (keyword, names, from, quote = "'") => `${keyword} { ${names.join(', ')} } from ${quote}${from}${quote};`;
323
338
  /**
324
339
  * The import written again: each name under the entry it lives in, `join` first under `uql-orm`. `join` is values,
325
340
  * so in a type-only declaration it becomes a value import of its own.
@@ -354,6 +369,63 @@ function rebuildImport({ declaration, entry: imported, elements }, join, dead, b
354
369
  }
355
370
  ctx.edits.push(statements.length ? replaced(declaration, statements.join('\n')) : removeStatement(declaration));
356
371
  }
372
+ /**
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.
375
+ */
376
+ export function importRemovalEdits(declaration, elements, dropped) {
377
+ if (dropped.length !== elements.length) {
378
+ return removeFromList(elements, dropped, declaration.getSourceFile());
379
+ }
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);
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
+ /**
404
+ * The file's `uql-orm` imports with `added` joined to the first, a new import where there is none, and each name in
405
+ * `candidates` the file no longer spells dropped.
406
+ */
407
+ export function importEdits(source, added, candidates) {
408
+ const declarations = uqlImportDeclarations(source, true);
409
+ const bound = new Set(declarations.flatMap(({ elements }) => elements.map((it) => it.name.text)));
410
+ const joined = [...added].filter((name) => !bound.has(name)).toSorted();
411
+ const [home] = declarations;
412
+ if (!home) {
413
+ return joined.length ? [{ start: 0, end: 0, text: `${importText('import', joined, 'uql-orm')}\n` }] : [];
414
+ }
415
+ const spelled = spelledNames(source);
416
+ return declarations.flatMap(({ declaration, elements }) => {
417
+ const dead = deadImports(elements, candidates, spelled);
418
+ const join = declaration === home.declaration ? joined : [];
419
+ const removals = dead.length ? importRemovalEdits(declaration, elements, dead) : [];
420
+ const first = elements.find((it) => !dead.includes(it));
421
+ if (!join.length) {
422
+ return removals;
423
+ }
424
+ return first
425
+ ? [...removals, inserted(first, `${join.join(', ')}, `)]
426
+ : [replaced(declaration, importText('import', join, 'uql-orm'))];
427
+ });
428
+ }
357
429
  /** The import with the `dead` names out and `join` in, nothing else of it touched. */
358
430
  function patchImport(declaration, join, dead, ctx) {
359
431
  const named = declaration.importClause?.namedBindings;
@@ -362,11 +434,11 @@ function patchImport(declaration, join, dead, ctx) {
362
434
  }
363
435
  const dropped = named.elements.filter((element) => dead.has(element.name.text));
364
436
  const statement = importText('import', join, 'uql-orm');
365
- if (dropped.length === named.elements.length && !declaration.importClause?.name) {
366
- ctx.edits.push(join.length ? replaced(declaration, statement) : removeStatement(declaration));
437
+ if (join.length && dropped.length === named.elements.length && !declaration.importClause?.name) {
438
+ ctx.edits.push(replaced(declaration, statement));
367
439
  return;
368
440
  }
369
- ctx.edits.push(...removeFromList(named.elements, dropped, ctx.source));
441
+ ctx.edits.push(...importRemovalEdits(declaration, named.elements, dropped));
370
442
  if (join.length) {
371
443
  ctx.edits.push(isTypeOnly(declaration)
372
444
  ? inserted(declaration, `${statement}\n`)
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;
@@ -0,0 +1,182 @@
1
+ import ts from 'typescript';
2
+ import { call, flag, flagOrCall } from './columnMethods.js';
3
+ import { formatFindings, locationAt } from './context.js';
4
+ import { applyEdits, replaced } from './edits.js';
5
+ import { chainOf, identifierText, propertiesByKey } from './syntax.js';
6
+ /** The migration builder's calls a column is declared by, and the callback each hands its builder to. */
7
+ const DECLARING_CALLS = new Set(['createTable', 'alterTable', 'addColumn', 'alterColumn']);
8
+ /** The column factories of a migration's table builder. */
9
+ export const FACTORY_NAMES = [
10
+ 'id',
11
+ 'integer',
12
+ 'smallint',
13
+ 'bigint',
14
+ 'float',
15
+ 'double',
16
+ 'decimal',
17
+ 'string',
18
+ 'char',
19
+ 'text',
20
+ 'boolean',
21
+ 'date',
22
+ 'time',
23
+ 'timestamp',
24
+ 'timestamptz',
25
+ 'json',
26
+ 'jsonb',
27
+ 'uuid',
28
+ 'blob',
29
+ 'vector',
30
+ ];
31
+ const FACTORIES = new Set(FACTORY_NAMES);
32
+ /** A chained method renamed, or removed where it now says what a column already is. */
33
+ const RENAMED = {
34
+ notNullable: () => '',
35
+ nullable: ([value]) => (value === 'false' ? '' : '.nullable()'),
36
+ defaultValue: (args) => `.default(${args.join(', ')})`,
37
+ primaryKey: () => '.id()',
38
+ computed: (args) => `.as(${args.join(', ')})`,
39
+ };
40
+ /** Each column option the factories took as a method now, in the order a column chains them. */
41
+ const OPTION_METHODS = [
42
+ ['length', call('length')],
43
+ ['precision', call('precision')],
44
+ ['scale', call('scale')],
45
+ ['nullable', flag('nullable')],
46
+ ['primaryKey', flag('id')],
47
+ ['autoIncrement', flag('autoIncrement')],
48
+ ['unique', flag('unique')],
49
+ ['unsigned', flag('unsigned')],
50
+ ['defaultValue', call('default')],
51
+ ['index', flagOrCall('index')],
52
+ ['comment', call('comment')],
53
+ ];
54
+ /**
55
+ * Writes a migration's columns in an entity column's vocabulary: `table.string('email', { length: 80 })` as
56
+ * `table.string('email').length(80)`, `.defaultValue()` as `.default()`, `.primaryKey()` as `.id()`, `.computed()`
57
+ * as `.as()`, and `.notNullable()` dropped, a column being NOT NULL already. Only a chain off the builder a
58
+ * `createTable`, `alterTable`, `addColumn` or `alterColumn` callback is handed, so no other `.string()` is touched.
59
+ */
60
+ export function toMigrationBuilders(fileName, text, locate) {
61
+ if (![...DECLARING_CALLS].some((call) => text.includes(`${call}(`))) {
62
+ return { text, unresolved: [] };
63
+ }
64
+ const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
65
+ const edits = [];
66
+ const findings = [];
67
+ const visit = (node) => {
68
+ if (ts.isCallExpression(node) && isDeclaringCall(node)) {
69
+ for (const callback of node.arguments) {
70
+ const builder = ts.isArrowFunction(callback) && identifierText(callback.parameters[0]?.name);
71
+ if (builder) {
72
+ collect(callback.body, builder, edits, (at, message) => findings.push({ node: at, message }));
73
+ }
74
+ }
75
+ }
76
+ ts.forEachChild(node, visit);
77
+ };
78
+ visit(source);
79
+ return {
80
+ text: edits.length ? applyEdits(text, edits) : text,
81
+ unresolved: formatFindings(findings, locate ?? ((at) => locationAt(source, at))),
82
+ };
83
+ }
84
+ function isDeclaringCall(call) {
85
+ return ts.isPropertyAccessExpression(call.expression) && DECLARING_CALLS.has(call.expression.name.text);
86
+ }
87
+ /** Each column chain in `body` rooted at `builder`, the outermost call of each rewritten whole. */
88
+ function collect(body, builder, edits, report) {
89
+ const visit = (node) => {
90
+ if (ts.isCallExpression(node) && !isChained(node)) {
91
+ const rewritten = chainSource(node, builder, report);
92
+ if (rewritten !== undefined) {
93
+ if (rewritten !== node.getText())
94
+ edits.push(replaced(node, rewritten));
95
+ return;
96
+ }
97
+ }
98
+ ts.forEachChild(node, visit);
99
+ };
100
+ visit(body);
101
+ }
102
+ /** Whether `call` is the receiver of a further call in the chain, which the outermost one rewrites. */
103
+ function isChained(call) {
104
+ return ts.isPropertyAccessExpression(call.parent);
105
+ }
106
+ /** The chain as source in the new vocabulary, or `undefined` where it is no column chain off `builder`. */
107
+ function chainSource(outer, builder, report) {
108
+ const { root, methods } = chainOf(outer);
109
+ const [first, ...chained] = methods;
110
+ if (!first || identifierText(root) !== builder || !FACTORIES.has(first.name)) {
111
+ return undefined;
112
+ }
113
+ const start = factorySource(first.call, first.name, builder, report);
114
+ if (start === undefined) {
115
+ return outer.getText();
116
+ }
117
+ return start + chained.map(methodSource).join('');
118
+ }
119
+ /** `table.vector('embedding', { dimensions: 3, nullable: true })` as `table.vector('embedding', 3).nullable()`. */
120
+ function factorySource(root, factory, builder, report) {
121
+ const [name, options] = root.arguments;
122
+ const nameText = name?.getText() ?? '';
123
+ if (!options) {
124
+ return root.getText();
125
+ }
126
+ const props = propertiesByKey(options);
127
+ if (!props) {
128
+ report(root, `cannot write '${options.getText()}' as the column's methods: chain each option as one`);
129
+ return undefined;
130
+ }
131
+ const text = (key) => {
132
+ const value = props.get(key)?.getText();
133
+ props.delete(key);
134
+ return value;
135
+ };
136
+ const args = [nameText];
137
+ if (factory === 'vector') {
138
+ const dimensions = text('dimensions');
139
+ if (dimensions !== undefined)
140
+ args.push(dimensions);
141
+ }
142
+ const references = props.get('references');
143
+ props.delete('references');
144
+ const methods = [];
145
+ for (const [option, method] of OPTION_METHODS) {
146
+ const value = text(option);
147
+ const written = value === undefined ? '' : method(value);
148
+ if (written === undefined) {
149
+ report(root, `cannot write '${option}: ${value}' as a method: chain it, or not, by hand`);
150
+ return undefined;
151
+ }
152
+ methods.push(written);
153
+ }
154
+ if (props.size) {
155
+ report(root, `has options no column method writes: ${[...props.keys()].join(', ')}`);
156
+ return undefined;
157
+ }
158
+ const foreign = references && referencesSource(references);
159
+ if (references && foreign === undefined) {
160
+ report(root, `cannot write '${references.getText()}' as '.references(table, column)'`);
161
+ return undefined;
162
+ }
163
+ return `${builder}.${factory}(${args.join(', ')})${methods.join('')}${foreign ?? ''}`;
164
+ }
165
+ /** `{ table: 'users', column: 'id', onDelete: 'CASCADE' }` as `.references('users', 'id').onDelete('CASCADE')`. */
166
+ function referencesSource(value) {
167
+ const props = propertiesByKey(value);
168
+ const table = props?.get('table')?.getText();
169
+ if (!table) {
170
+ return undefined;
171
+ }
172
+ const [column, onDelete, onUpdate] = ['column', 'onDelete', 'onUpdate'].map((key) => props?.get(key)?.getText());
173
+ return (`.references(${[table, column].filter(Boolean).join(', ')})` +
174
+ (onDelete ? `.onDelete(${onDelete})` : '') +
175
+ (onUpdate ? `.onUpdate(${onUpdate})` : ''));
176
+ }
177
+ /** One chained call in the new vocabulary. */
178
+ function methodSource({ call, name }) {
179
+ const args = call.arguments.map((arg) => arg.getText());
180
+ const rename = RENAMED[name];
181
+ return rename ? rename(args) : `.${name}(${args.join(', ')})`;
182
+ }
@@ -0,0 +1,6 @@
1
+ import ts from 'typescript';
2
+ /**
3
+ * The entity name a relation property points at: `Company` for `Company` and `Company[]` alike, since the
4
+ * element of an array is what a to-many relation targets.
5
+ */
6
+ export declare function relationTargetFor(type: ts.Type, checker: ts.TypeChecker): string | undefined;