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/README.md +23 -12
- package/dist/builders.d.ts +28 -0
- package/dist/builders.js +783 -0
- package/dist/classOptions.d.ts +9 -0
- package/dist/classOptions.js +240 -0
- package/dist/columnMethods.d.ts +11 -0
- package/dist/columnMethods.js +6 -0
- package/dist/columnRefs.js +1 -8
- package/dist/context.d.ts +18 -5
- package/dist/context.js +22 -4
- package/dist/definers.d.ts +24 -0
- package/dist/definers.js +34 -0
- package/dist/edits.d.ts +26 -5
- package/dist/edits.js +54 -12
- package/dist/entitySql.js +2 -4
- package/dist/imports.d.ts +21 -5
- package/dist/imports.js +103 -31
- package/dist/index.d.ts +3 -3
- package/dist/index.js +9 -12
- package/dist/jsonIndex.d.ts +11 -0
- package/dist/jsonIndex.js +78 -0
- package/dist/keyMaps.d.ts +3 -1
- package/dist/keyMaps.js +5 -1
- package/dist/migrationBuilders.d.ts +13 -0
- package/dist/migrationBuilders.js +182 -0
- package/dist/relationTarget.d.ts +6 -0
- package/dist/relationTarget.js +21 -0
- package/dist/schemaEntity.d.ts +15 -0
- package/dist/schemaEntity.js +578 -0
- package/dist/schemaMessages.d.ts +8 -0
- package/dist/schemaMessages.js +8 -0
- package/dist/schemaOptions.d.ts +6 -0
- package/dist/schemaOptions.js +6 -0
- package/dist/syntax.d.ts +54 -0
- package/dist/syntax.js +131 -1
- package/dist/transform.d.ts +11 -2
- package/dist/transform.js +142 -131
- package/dist/tsconfig.d.ts +3 -9
- package/dist/tsconfig.js +18 -21
- package/package.json +1 -1
- package/dist/fieldType.d.ts +0 -13
- package/dist/fieldType.js +0 -56
package/dist/imports.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
-
import type
|
|
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
|
|
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: '
|
|
103
|
-
['IColumnFactory', { to: '
|
|
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
|
-
|
|
169
|
-
|
|
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)
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
243
|
+
const imported = REWRITTEN.flatMap((name) => {
|
|
221
244
|
const element = uqlImport(source, name, true);
|
|
222
|
-
return element ?
|
|
223
|
-
};
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
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(...
|
|
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
|
|
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
|
|
20
|
-
*
|
|
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 {
|
|
5
|
+
import { checkTsconfig } from './tsconfig.js';
|
|
6
6
|
export * from './transform.js';
|
|
7
7
|
export * from './tsconfig.js';
|
|
8
8
|
/**
|
|
9
|
-
* Rewrites a project
|
|
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
|
|
12
|
-
*
|
|
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:
|
|
56
|
-
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):
|
|
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 }) => `${
|
|
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;
|