uql-codemod 0.48.2 → 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.
- package/README.md +22 -12
- package/dist/builders.d.ts +18 -0
- package/dist/builders.js +588 -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 +18 -5
- package/dist/imports.js +85 -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 +12 -0
- package/dist/schemaEntity.js +518 -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 +137 -130
- 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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { original } from './edits.js';
|
|
3
|
+
import { memberKey } from './keyMaps.js';
|
|
4
|
+
import { arrayElements, decoratorName, decoratorsOf, indentOf, indentUnit, lineStartOf, namedProperty, objectLiteral, } from './syntax.js';
|
|
5
|
+
/** The class decorators `@Entity`'s options took over, each with the option its entries go under. */
|
|
6
|
+
const FOLDED = { Index: 'indexes', Trigger: 'triggers', Filter: 'filters' };
|
|
7
|
+
const isFolded = (name) => name !== undefined && Object.hasOwn(FOLDED, name);
|
|
8
|
+
const slotEntries = (entries) => new Map([...entries].map(([option, { before, after }]) => [option, [...before, ...after]]));
|
|
9
|
+
/**
|
|
10
|
+
* Folds `@Index`, `@Trigger` and `@Filter` into the class's `@Entity` options, `indexes`, `triggers` and `filters`.
|
|
11
|
+
* Triggers keep the order they register in, which is the order they run in: decorators apply bottom-up, so those
|
|
12
|
+
* below `@Entity` go ahead of its own and those above after them. Indexes and filters, whose order means nothing,
|
|
13
|
+
* follow `@Entity`'s own in the order written. A class with no `@Entity` becomes `@Entity({ abstract: true, ... })`.
|
|
14
|
+
*/
|
|
15
|
+
export function foldClassDecorators(node, ctx) {
|
|
16
|
+
const decorators = decoratorsOf(node);
|
|
17
|
+
const unit = indentUnit(node.getSourceFile());
|
|
18
|
+
const entity = decorators.find((decorator) => decoratorName(decorator) === 'Entity');
|
|
19
|
+
const at = entity ? decorators.indexOf(entity) : -1;
|
|
20
|
+
const folded = decorators.flatMap((decorator, index) => {
|
|
21
|
+
const name = decoratorName(decorator);
|
|
22
|
+
return isFolded(name) && ts.isCallExpression(decorator.expression)
|
|
23
|
+
? [{ decorator, call: decorator.expression, name, index }]
|
|
24
|
+
: [];
|
|
25
|
+
});
|
|
26
|
+
if (!folded.length) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const call = entity && entityCall(entity, ctx);
|
|
30
|
+
const options = call && entityOptions(call, ctx);
|
|
31
|
+
if (call === null || options === null) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const triggers = folded.filter(({ name }) => name === 'Trigger');
|
|
35
|
+
const applied = (above) => triggers.filter(({ index }) => index < at === above).toReversed();
|
|
36
|
+
const before = applied(false);
|
|
37
|
+
const after = [...applied(true), ...folded.filter(({ name }) => name !== 'Trigger')];
|
|
38
|
+
const entries = new Map();
|
|
39
|
+
for (const [list, side] of [
|
|
40
|
+
[before, 'before'],
|
|
41
|
+
[after, 'after'],
|
|
42
|
+
]) {
|
|
43
|
+
for (const { call, name, decorator } of list) {
|
|
44
|
+
const written = entriesOf(name, call, decorator, ctx);
|
|
45
|
+
if (!written) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const comments = commentTexts(decorator);
|
|
49
|
+
const slot = entries.get(FOLDED[name]) ?? { before: [], after: [] };
|
|
50
|
+
slot[side].push(...written.map((parts, index) => ({ parts, comments: index ? [] : comments })));
|
|
51
|
+
entries.set(FOLDED[name], slot);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const { decorator, name } of folded) {
|
|
55
|
+
ctx.edits.push(removedDecorator(decorator));
|
|
56
|
+
ctx.rewritten[name] += 1;
|
|
57
|
+
}
|
|
58
|
+
if (!call) {
|
|
59
|
+
const first = removedDecorator(folded[0].decorator);
|
|
60
|
+
const indent = indentOf(folded[0].decorator);
|
|
61
|
+
const text = ['@Entity({ abstract: true, ', ...optionParts(slotEntries(entries), indent, unit), ' })'];
|
|
62
|
+
const onItsLine = node.getSourceFile().text.slice(first.start, first.end).endsWith('\n');
|
|
63
|
+
ctx.edits.push({
|
|
64
|
+
start: first.start,
|
|
65
|
+
end: first.start,
|
|
66
|
+
text: onItsLine ? [indent, ...text, '\n'] : [...text, ' '],
|
|
67
|
+
});
|
|
68
|
+
ctx.imports.set('Entity', 'the abstract base its class decorators moved into');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
ctx.edits.push(...entityEdits(call, options, entries, unit));
|
|
72
|
+
}
|
|
73
|
+
/** `@Entity`'s call, or `null`, reported, where it is written uncalled and so takes no options. */
|
|
74
|
+
function entityCall(entity, ctx) {
|
|
75
|
+
if (ts.isCallExpression(entity.expression)) {
|
|
76
|
+
return entity.expression;
|
|
77
|
+
}
|
|
78
|
+
ctx.report(entity, "call '@Entity()' to move '@Index', '@Trigger' and '@Filter' into its options");
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/** `@Entity`'s options literal, `undefined` where it takes none, or `null`, reported, where it cannot be read. */
|
|
82
|
+
function entityOptions(call, ctx) {
|
|
83
|
+
const [first] = call.arguments;
|
|
84
|
+
if (!first) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const literal = objectLiteral(first);
|
|
88
|
+
if (!literal) {
|
|
89
|
+
ctx.report(call, "move '@Index', '@Trigger' and '@Filter' into '@Entity' options this cannot read");
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return literal;
|
|
93
|
+
}
|
|
94
|
+
/** The entries one decorator registers, or nothing, reported, where it cannot say them as an option. */
|
|
95
|
+
function entriesOf(name, call, decorator, ctx) {
|
|
96
|
+
const [first, second] = call.arguments;
|
|
97
|
+
if (name === 'Trigger') {
|
|
98
|
+
return call.arguments.map((trigger) => [original(trigger)]);
|
|
99
|
+
}
|
|
100
|
+
if (name === 'Index') {
|
|
101
|
+
if (!first) {
|
|
102
|
+
ctx.report(decorator, "move this '@Index' into '@Entity({ indexes })' by hand: it names no columns");
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const props = objectLiteral(second);
|
|
106
|
+
const rest = props
|
|
107
|
+
? props.properties.flatMap((prop) => [', ', original(prop)])
|
|
108
|
+
: second
|
|
109
|
+
? [', ...', original(second)]
|
|
110
|
+
: [];
|
|
111
|
+
return [['{ columns: ', original(first), ...rest, ' }']];
|
|
112
|
+
}
|
|
113
|
+
if (!first || !ts.isStringLiteralLike(first) || !second) {
|
|
114
|
+
ctx.report(decorator, "move this '@Filter' into '@Entity({ filters })' by hand: its name is not a string");
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return [[memberKey(first.text), ': ', original(second)]];
|
|
118
|
+
}
|
|
119
|
+
/** Each option's entries as comma-separated properties, a list holding a comment one entry a line. */
|
|
120
|
+
function optionParts(entries, indent, unit) {
|
|
121
|
+
return [...entries].flatMap(([option, list], index) => [
|
|
122
|
+
...(index ? [', '] : []),
|
|
123
|
+
...bracketed(option, list, indent, unit),
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
function bracketed(option, list, indent, unit) {
|
|
127
|
+
const [open, close] = option === 'filters' ? ['{', '}'] : ['[', ']'];
|
|
128
|
+
if (list.length < 2 && !list.some(({ comments }) => comments.length)) {
|
|
129
|
+
const pad = option === 'filters' ? ' ' : '';
|
|
130
|
+
return [`${option}: ${open}${pad}`, ...joined(list, ', '), `${pad}${close}`];
|
|
131
|
+
}
|
|
132
|
+
const inner = `${indent}${unit}`;
|
|
133
|
+
return [`${option}: ${open}\n`, ...lines(list, inner), `${indent}${close}`];
|
|
134
|
+
}
|
|
135
|
+
function joined(list, separator) {
|
|
136
|
+
return list.flatMap(({ parts }, index) => (index ? [separator, ...parts] : [...parts]));
|
|
137
|
+
}
|
|
138
|
+
/** Each entry on its own line under `indent`, its comments on the lines above it. */
|
|
139
|
+
function lines(list, indent) {
|
|
140
|
+
return list.flatMap(({ parts, comments }) => [
|
|
141
|
+
...comments.flatMap((comment) => [indent, comment, '\n']),
|
|
142
|
+
indent,
|
|
143
|
+
...parts,
|
|
144
|
+
',\n',
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The line comments directly above `decorator`, which say something of it: a run with no blank line before the
|
|
149
|
+
* decorator. A doc comment, or one a blank line parts from it, is the class's and stays where it is.
|
|
150
|
+
*/
|
|
151
|
+
function decoratorComments(decorator) {
|
|
152
|
+
const text = decorator.getSourceFile().text;
|
|
153
|
+
const ranges = ts.getLeadingCommentRanges(text, decorator.pos) ?? [];
|
|
154
|
+
const kept = [];
|
|
155
|
+
let next = decorator.getStart();
|
|
156
|
+
for (const range of ranges.toReversed()) {
|
|
157
|
+
const between = text.slice(range.end, next);
|
|
158
|
+
if (range.kind !== ts.SyntaxKind.SingleLineCommentTrivia || /\n\s*\n/.test(between))
|
|
159
|
+
break;
|
|
160
|
+
kept.unshift(range);
|
|
161
|
+
next = range.pos;
|
|
162
|
+
}
|
|
163
|
+
return kept;
|
|
164
|
+
}
|
|
165
|
+
/** A comment closing the decorator's line, which says something of it too. */
|
|
166
|
+
function trailingComments(decorator) {
|
|
167
|
+
const text = decorator.getSourceFile().text;
|
|
168
|
+
return (ts.getTrailingCommentRanges(text, decorator.getEnd()) ?? []).filter(({ kind }) => kind === ts.SyntaxKind.SingleLineCommentTrivia);
|
|
169
|
+
}
|
|
170
|
+
/** The comments the decorator's entry carries: those above it, then the one closing its line. */
|
|
171
|
+
function commentTexts(decorator) {
|
|
172
|
+
const text = decorator.getSourceFile().text;
|
|
173
|
+
return [...decoratorComments(decorator), ...trailingComments(decorator)].map(({ pos, end }) => text.slice(pos, end));
|
|
174
|
+
}
|
|
175
|
+
/** The edits writing `entries` into `@Entity`'s options, each option's own list merged where it states one. */
|
|
176
|
+
function entityEdits(call, options, entries, unit) {
|
|
177
|
+
const indent = indentOf(call);
|
|
178
|
+
if (!options) {
|
|
179
|
+
const at = call.getEnd() - 1;
|
|
180
|
+
return [{ start: at, end: at, text: ['{ ', ...optionParts(slotEntries(entries), indent, unit), ' }'] }];
|
|
181
|
+
}
|
|
182
|
+
const edits = [];
|
|
183
|
+
const added = new Map();
|
|
184
|
+
for (const [option, slot] of entries) {
|
|
185
|
+
const stated = namedProperty(options, option);
|
|
186
|
+
const value = stated && ts.isPropertyAssignment(stated) ? stated.initializer : undefined;
|
|
187
|
+
const list = value && (ts.isArrayLiteralExpression(value) ? arrayElements(value) : objectLiteral(value)?.properties);
|
|
188
|
+
if (!value || !list) {
|
|
189
|
+
added.set(option, [...slot.before, ...slot.after]);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const open = value.getStart() + 1;
|
|
193
|
+
const close = value.getEnd() - 1;
|
|
194
|
+
const inner = `${indentOf(value)}${unit}`;
|
|
195
|
+
const commented = (side) => side.some(({ comments }) => comments.length);
|
|
196
|
+
if (slot.before.length) {
|
|
197
|
+
const text = commented(slot.before)
|
|
198
|
+
? ['\n', ...lines(slot.before, inner), inner]
|
|
199
|
+
: [...joined(slot.before, ', '), ...(list.length ? [', '] : [])];
|
|
200
|
+
edits.push({ start: open, end: open, text });
|
|
201
|
+
}
|
|
202
|
+
if (slot.after.length) {
|
|
203
|
+
const end = list.length ? list[list.length - 1].getEnd() : close;
|
|
204
|
+
const lead = list.length || slot.before.length ? ', ' : '';
|
|
205
|
+
const text = commented(slot.after)
|
|
206
|
+
? slot.after.flatMap(({ parts, comments }, index) => [
|
|
207
|
+
index || lead ? ',\n' : '\n',
|
|
208
|
+
...comments.flatMap((comment) => [inner, comment, '\n']),
|
|
209
|
+
inner,
|
|
210
|
+
...parts,
|
|
211
|
+
])
|
|
212
|
+
: [lead, ...joined(slot.after, ', ')];
|
|
213
|
+
edits.push({ start: end, end, text });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (added.size) {
|
|
217
|
+
const last = options.properties.at(-1);
|
|
218
|
+
const at = last ? last.getEnd() : options.getEnd() - 1;
|
|
219
|
+
const parts = optionParts(added, indent, unit);
|
|
220
|
+
edits.push({ start: at, end: at, text: last ? [', ', ...parts] : [' ', ...parts, ' '] });
|
|
221
|
+
}
|
|
222
|
+
return edits;
|
|
223
|
+
}
|
|
224
|
+
/** Removes a decorator, with its line where it stands alone on one, or the blank after it where it shares one. */
|
|
225
|
+
function removedDecorator(decorator) {
|
|
226
|
+
const text = decorator.getSourceFile().text;
|
|
227
|
+
const start = decoratorComments(decorator)[0]?.pos ?? decorator.getStart();
|
|
228
|
+
const end = trailingComments(decorator).at(-1)?.end ?? decorator.getEnd();
|
|
229
|
+
const lineStart = lineStartOf(text, start);
|
|
230
|
+
const lineEnd = text.indexOf('\n', end);
|
|
231
|
+
// A class follows its decorators, so the line of one standing alone ends before the file does.
|
|
232
|
+
const alone = text.slice(lineStart, start).trim() === '' && text.slice(end, lineEnd).trim() === '';
|
|
233
|
+
if (alone) {
|
|
234
|
+
return { start: lineStart, end: lineEnd + 1, text: '' };
|
|
235
|
+
}
|
|
236
|
+
const after = /\s*/y;
|
|
237
|
+
after.lastIndex = end;
|
|
238
|
+
after.exec(text);
|
|
239
|
+
return { start, end: after.lastIndex, text: '' };
|
|
240
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** How an option's value becomes a column method, or `undefined` where it cannot be written as one. */
|
|
2
|
+
export type OptionMethod<R extends string | undefined = string> = readonly [
|
|
3
|
+
option: string,
|
|
4
|
+
method: (value: string) => R
|
|
5
|
+
];
|
|
6
|
+
/** The option's value passed to `method`: `.length(80)`. */
|
|
7
|
+
export declare const call: (method: string) => (value: string) => string;
|
|
8
|
+
/** A flag as its method where it is `true`, nothing where `false`, and `undefined` where no literal says. */
|
|
9
|
+
export declare const flag: (method: string) => (value: string) => string | undefined;
|
|
10
|
+
/** {@link flag}, its value passed to the method where it is no literal. */
|
|
11
|
+
export declare const flagOrCall: (method: string) => (value: string) => string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The option's value passed to `method`: `.length(80)`. */
|
|
2
|
+
export const call = (method) => (value) => `.${method}(${value})`;
|
|
3
|
+
/** A flag as its method where it is `true`, nothing where `false`, and `undefined` where no literal says. */
|
|
4
|
+
export const flag = (method) => (value) => value === 'true' ? `.${method}()` : value === 'false' ? '' : undefined;
|
|
5
|
+
/** {@link flag}, its value passed to the method where it is no literal. */
|
|
6
|
+
export const flagOrCall = (method) => (value) => flag(method)(value) ?? call(method)(value);
|
package/dist/columnRefs.js
CHANGED
|
@@ -4,7 +4,7 @@ import { inserted, replaced } from './edits.js';
|
|
|
4
4
|
import { isSqlCall } from './entitySql.js';
|
|
5
5
|
import { uqlImport, usesOf } from './imports.js';
|
|
6
6
|
import { memberAccess } from './keyMaps.js';
|
|
7
|
-
import { identifierText, propertyKey, propertyValue } from './syntax.js';
|
|
7
|
+
import { identifierText, mentions, propertyKey, propertyValue } from './syntax.js';
|
|
8
8
|
/**
|
|
9
9
|
* Rewrites each `col('x')` into the ref it names - `product.x` in a `computed` callback, `refs(Item).x` in a
|
|
10
10
|
* statement - where the entity its SQL renders against, and a member named `x`, can be told from here. Every
|
|
@@ -85,13 +85,6 @@ function columnMember(call, members) {
|
|
|
85
85
|
const [name] = call.arguments;
|
|
86
86
|
return call.arguments.length === 1 && ts.isStringLiteralLike(name) && members.has(name.text) ? name.text : undefined;
|
|
87
87
|
}
|
|
88
|
-
/** Whether `node` reads a binding named `name`, which a callback parameter of that name would shadow. */
|
|
89
|
-
function mentions(node, name) {
|
|
90
|
-
if (ts.isIdentifier(node)) {
|
|
91
|
-
return node.text === name && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node);
|
|
92
|
-
}
|
|
93
|
-
return ts.forEachChild(node, (child) => mentions(child, name) || undefined) ?? false;
|
|
94
|
-
}
|
|
95
88
|
/**
|
|
96
89
|
* The entity a statement's SQL renders against at `node`: the one its query names, or a relation a `$populate`
|
|
97
90
|
* reads there. Nothing where a function or a relation filter in between opens a scope of its own.
|
package/dist/context.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import type { Edit } from './edits.js';
|
|
3
|
+
/**
|
|
4
|
+
* Each name whose import goes once every use was rewritten: `expr` defaults, and the class decorators folded into
|
|
5
|
+
* `@Entity`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const REWRITTEN: readonly ["expr", "Index", "Trigger", "Filter"];
|
|
8
|
+
type Rewritten = (typeof REWRITTEN)[number];
|
|
3
9
|
/** What a rewrite of one file reads and writes. */
|
|
4
10
|
export type Context = {
|
|
5
11
|
readonly source: ts.SourceFile;
|
|
@@ -11,16 +17,22 @@ export type Context = {
|
|
|
11
17
|
readonly notes: string[];
|
|
12
18
|
/** Names written that the file has to import from `uql-orm`, each with what it was written for. */
|
|
13
19
|
readonly imports: Map<string, string>;
|
|
14
|
-
/** Uses rewritten of each
|
|
15
|
-
readonly rewritten:
|
|
16
|
-
Relation: number;
|
|
17
|
-
expr: number;
|
|
18
|
-
};
|
|
20
|
+
/** Uses rewritten of each of `REWRITTEN`. */
|
|
21
|
+
readonly rewritten: Record<Rewritten, number>;
|
|
19
22
|
/** Records what needs a human, at `node`. */
|
|
20
23
|
readonly report: (node: ts.Node, message: string) => void;
|
|
21
24
|
/** Records a rewrite that is correct but worth a second look, at `node`. */
|
|
22
25
|
readonly note: (node: ts.Node, message: string) => void;
|
|
23
26
|
};
|
|
27
|
+
/** `file:line` of offset `at` in `source`. */
|
|
28
|
+
export declare function locationAt(source: ts.SourceFile, at: number): string;
|
|
29
|
+
/** A finding before it is located: the node it is about, and what to do. */
|
|
30
|
+
export type Finding = {
|
|
31
|
+
readonly node: ts.Node;
|
|
32
|
+
readonly message: string;
|
|
33
|
+
};
|
|
34
|
+
/** Findings in source order, each as `file:line: message`, the line `locate` reads its offset at. */
|
|
35
|
+
export declare function formatFindings(findings: readonly Finding[], locate: (at: number) => string): string[];
|
|
24
36
|
export declare function createContext(source: ts.SourceFile, checker: ts.TypeChecker, strictNullChecks: boolean): Context;
|
|
25
37
|
/** The entity a definition describes: the parameter its callbacks are named after, and the node naming it. */
|
|
26
38
|
export type Owner = {
|
|
@@ -36,3 +48,4 @@ export declare function instanceTypeOf(node: ts.Node, checker: ts.TypeChecker):
|
|
|
36
48
|
export declare function propertyNames(type: ts.Type): ReadonlySet<string>;
|
|
37
49
|
/** The names of the members of the entity `node` declares or names. */
|
|
38
50
|
export declare function memberNames(node: ts.Node, checker: ts.TypeChecker): ReadonlySet<string>;
|
|
51
|
+
export {};
|
package/dist/context.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { paramFor } from './keyMaps.js';
|
|
3
3
|
import { identifierText } from './syntax.js';
|
|
4
|
+
/**
|
|
5
|
+
* Each name whose import goes once every use was rewritten: `expr` defaults, and the class decorators folded into
|
|
6
|
+
* `@Entity`.
|
|
7
|
+
*/
|
|
8
|
+
export const REWRITTEN = ['expr', 'Index', 'Trigger', 'Filter'];
|
|
9
|
+
/** Where `node` is, as a finding names it: `file:line`. */
|
|
10
|
+
function locationOf(node) {
|
|
11
|
+
return locationAt(node.getSourceFile(), node.getStart());
|
|
12
|
+
}
|
|
13
|
+
/** `file:line` of offset `at` in `source`. */
|
|
14
|
+
export function locationAt(source, at) {
|
|
15
|
+
return `${source.fileName}:${source.getLineAndCharacterOfPosition(at).line + 1}`;
|
|
16
|
+
}
|
|
17
|
+
/** Findings in source order, each as `file:line: message`, the line `locate` reads its offset at. */
|
|
18
|
+
export function formatFindings(findings, locate) {
|
|
19
|
+
return findings
|
|
20
|
+
.toSorted((a, b) => a.node.getStart() - b.node.getStart())
|
|
21
|
+
.map(({ node, message }) => `${locate(node.getStart())}: ${message}`);
|
|
22
|
+
}
|
|
4
23
|
export function createContext(source, checker, strictNullChecks) {
|
|
5
24
|
const unresolved = [];
|
|
6
25
|
const notes = [];
|
|
7
|
-
const at = (node) => `${source.fileName}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
|
|
8
26
|
return {
|
|
9
27
|
source,
|
|
10
28
|
checker,
|
|
@@ -13,12 +31,12 @@ export function createContext(source, checker, strictNullChecks) {
|
|
|
13
31
|
unresolved,
|
|
14
32
|
notes,
|
|
15
33
|
imports: new Map(),
|
|
16
|
-
rewritten: {
|
|
34
|
+
rewritten: { expr: 0, Index: 0, Trigger: 0, Filter: 0 },
|
|
17
35
|
report: (node, message) => {
|
|
18
|
-
unresolved.push(`${
|
|
36
|
+
unresolved.push(`${locationOf(node)}: ${message}`);
|
|
19
37
|
},
|
|
20
38
|
note: (node, message) => {
|
|
21
|
-
notes.push(`${
|
|
39
|
+
notes.push(`${locationOf(node)}: ${message}`);
|
|
22
40
|
},
|
|
23
41
|
};
|
|
24
42
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/** The definers a class was registered with, each with the decorator that registers the same on a decorated class. */
|
|
3
|
+
export declare const CLASS_DEFINERS: {
|
|
4
|
+
readonly defineEntity: "@Entity";
|
|
5
|
+
readonly defineField: "@Field";
|
|
6
|
+
readonly defineId: "@Id";
|
|
7
|
+
readonly defineRelation: "a relation decorator";
|
|
8
|
+
readonly defineHook: "a hook decorator";
|
|
9
|
+
readonly defineIndex: "@Entity's 'indexes'";
|
|
10
|
+
readonly defineTrigger: "@Entity's 'triggers'";
|
|
11
|
+
readonly defineFilter: "@Entity's 'filters'";
|
|
12
|
+
};
|
|
13
|
+
export type DefinerName = keyof typeof CLASS_DEFINERS;
|
|
14
|
+
/** A class-form definer's arguments by what each is: `defineX(Entity, key?, options)`, every one after the entity in `rest`. */
|
|
15
|
+
export type DefinerCall = {
|
|
16
|
+
readonly call: ts.CallExpression;
|
|
17
|
+
readonly definer: DefinerName;
|
|
18
|
+
readonly entity: ts.Expression;
|
|
19
|
+
readonly key: string | undefined;
|
|
20
|
+
readonly options: ts.Expression | undefined;
|
|
21
|
+
readonly rest: readonly ts.Expression[];
|
|
22
|
+
};
|
|
23
|
+
/** `call` read as a class-form definer, or nothing where it is another call, the schema form included. */
|
|
24
|
+
export declare function definerCall(call: ts.CallExpression): DefinerCall | undefined;
|
package/dist/definers.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { identifierText } from './syntax.js';
|
|
3
|
+
/** The definers a class was registered with, each with the decorator that registers the same on a decorated class. */
|
|
4
|
+
export const CLASS_DEFINERS = {
|
|
5
|
+
defineEntity: '@Entity',
|
|
6
|
+
defineField: '@Field',
|
|
7
|
+
defineId: '@Id',
|
|
8
|
+
defineRelation: 'a relation decorator',
|
|
9
|
+
defineHook: 'a hook decorator',
|
|
10
|
+
defineIndex: "@Entity's 'indexes'",
|
|
11
|
+
defineTrigger: "@Entity's 'triggers'",
|
|
12
|
+
defineFilter: "@Entity's 'filters'",
|
|
13
|
+
};
|
|
14
|
+
const isDefinerName = (name) => name !== undefined && Object.hasOwn(CLASS_DEFINERS, name);
|
|
15
|
+
/** The definers naming a member or a filter after the entity, their options after that. */
|
|
16
|
+
const KEYED_DEFINERS = new Set([
|
|
17
|
+
'defineField',
|
|
18
|
+
'defineId',
|
|
19
|
+
'defineRelation',
|
|
20
|
+
'defineHook',
|
|
21
|
+
'defineFilter',
|
|
22
|
+
]);
|
|
23
|
+
/** `call` read as a class-form definer, or nothing where it is another call, the schema form included. */
|
|
24
|
+
export function definerCall(call) {
|
|
25
|
+
const definer = identifierText(call.expression);
|
|
26
|
+
const [entity, ...rest] = call.arguments;
|
|
27
|
+
if (!isDefinerName(definer) || !entity || ts.isObjectLiteralExpression(entity)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const [second, third] = rest;
|
|
31
|
+
const keyed = KEYED_DEFINERS.has(definer);
|
|
32
|
+
const key = keyed && second && ts.isStringLiteralLike(second) ? second.text : undefined;
|
|
33
|
+
return { call, definer, entity, key, options: keyed ? third : second, rest };
|
|
34
|
+
}
|
package/dist/edits.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import ts from 'typescript';
|
|
2
2
|
/** A stretch of the original text, which a replacement can keep. */
|
|
3
3
|
export type Span = {
|
|
4
4
|
readonly start: number;
|
|
@@ -22,14 +22,35 @@ export declare function original(node: ts.Node): Span;
|
|
|
22
22
|
* replacement keeps the stretch it sits in; edits that overlap without one holding the other are a bug.
|
|
23
23
|
*/
|
|
24
24
|
export declare function applyEdits(source: string, edits: readonly Edit[]): string;
|
|
25
|
+
/** A text a pass wrote, with where each of its offsets was in the text the pass read. */
|
|
26
|
+
export type Pass = {
|
|
27
|
+
readonly text: string;
|
|
28
|
+
readonly sourceOffset: (at: number) => number;
|
|
29
|
+
};
|
|
30
|
+
/** `text` as a pass that changed nothing. */
|
|
31
|
+
export declare const unedited: (text: string) => Pass;
|
|
32
|
+
/** `second` run on what `first` wrote, each offset of its text mapped back to what `first` read. */
|
|
33
|
+
export declare function followed(first: Pass, second: Pass): Pass;
|
|
34
|
+
/** `pass` edited again by the edits `edits` reads off its text, parsed. */
|
|
35
|
+
export declare function reparsedAgain(fileName: string, pass: Pass, edits: (source: ts.SourceFile) => readonly Edit[]): Pass;
|
|
36
|
+
/**
|
|
37
|
+
* {@link applyEdits}, with where each offset of the result was in `source`, for a later pass reading the result to
|
|
38
|
+
* report against the file as written: an offset inside an edit's text maps to where the edit starts.
|
|
39
|
+
*/
|
|
40
|
+
export declare function editedText(source: string, edits: readonly Edit[]): Pass;
|
|
25
41
|
export declare function replaced(node: ts.Node, text: Replacement): Edit;
|
|
26
42
|
export declare function inserted(node: ts.Node, text: string): Edit;
|
|
27
43
|
export declare function appended(node: ts.Node, text: string): Edit;
|
|
44
|
+
/** `node`'s text with each of `uses`, the nodes inside it, written as `text`. */
|
|
45
|
+
export declare function replacingUses(node: ts.Node, uses: readonly ts.Node[], text: string): string;
|
|
28
46
|
/**
|
|
29
|
-
* Removes elements of a comma-separated list with the separators that would dangle.
|
|
30
|
-
* the text up to the next item; the removed run at the end takes the separator before
|
|
31
|
-
* overlap. `source` is passed because a tree from `ts.parseJsonText`
|
|
47
|
+
* Removes elements of a comma-separated list, at least one of them kept, with the separators that would dangle.
|
|
48
|
+
* Each removed element takes the text up to the next item; the removed run at the end takes the separator before
|
|
49
|
+
* it, so the ranges never overlap. `source` is passed because a tree from `ts.parseJsonText` has no `getStart()`.
|
|
32
50
|
*/
|
|
33
51
|
export declare function removeFromList(items: readonly ts.Node[], removed: readonly ts.Node[], source: ts.SourceFile): readonly Edit[];
|
|
34
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Removes a statement with the blanks before it on its line, the rest of its line and the blank lines after it,
|
|
54
|
+
* so nothing is left blank behind it.
|
|
55
|
+
*/
|
|
35
56
|
export declare function removeStatement(node: ts.Node): Edit;
|
package/dist/edits.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { lineStartOf } from './syntax.js';
|
|
1
3
|
/** The text of `node` as written, for a replacement to keep. */
|
|
2
4
|
export function original(node) {
|
|
3
5
|
return { start: node.getStart(), end: node.getEnd() };
|
|
@@ -9,20 +11,49 @@ const isInsertion = (edit) => edit.start === edit.end;
|
|
|
9
11
|
* replacement keeps the stretch it sits in; edits that overlap without one holding the other are a bug.
|
|
10
12
|
*/
|
|
11
13
|
export function applyEdits(source, edits) {
|
|
14
|
+
return editedText(source, edits).text;
|
|
15
|
+
}
|
|
16
|
+
/** `text` as a pass that changed nothing. */
|
|
17
|
+
export const unedited = (text) => ({ text, sourceOffset: (at) => at });
|
|
18
|
+
/** `second` run on what `first` wrote, each offset of its text mapped back to what `first` read. */
|
|
19
|
+
export function followed(first, second) {
|
|
20
|
+
return { text: second.text, sourceOffset: (at) => first.sourceOffset(second.sourceOffset(at)) };
|
|
21
|
+
}
|
|
22
|
+
/** `pass` edited again by `edits`, which read its text. */
|
|
23
|
+
function editedAgain(pass, edits) {
|
|
24
|
+
return followed(pass, editedText(pass.text, edits));
|
|
25
|
+
}
|
|
26
|
+
/** `pass` edited again by the edits `edits` reads off its text, parsed. */
|
|
27
|
+
export function reparsedAgain(fileName, pass, edits) {
|
|
28
|
+
return editedAgain(pass, edits(ts.createSourceFile(fileName, pass.text, ts.ScriptTarget.Latest, true)));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* {@link applyEdits}, with where each offset of the result was in `source`, for a later pass reading the result to
|
|
32
|
+
* report against the file as written: an offset inside an edit's text maps to where the edit starts.
|
|
33
|
+
*/
|
|
34
|
+
export function editedText(source, edits) {
|
|
35
|
+
const moved = [];
|
|
12
36
|
const ordered = edits
|
|
13
37
|
.map((edit, index) => ({ edit, index }))
|
|
14
38
|
.sort((a, b) => a.edit.start - b.edit.start ||
|
|
15
39
|
Number(!isInsertion(a.edit)) - Number(!isInsertion(b.edit)) ||
|
|
16
40
|
(isInsertion(a.edit) ? b.index - a.index : b.edit.end - a.edit.end))
|
|
17
41
|
.map(({ edit }) => edit);
|
|
18
|
-
|
|
42
|
+
const text = render(source, ordered, { start: 0, end: source.length }, undefined, moved);
|
|
43
|
+
const sourceOffset = (at) => {
|
|
44
|
+
const before = moved.findLast((edit) => edit.from <= at);
|
|
45
|
+
if (!before)
|
|
46
|
+
return at;
|
|
47
|
+
return at < before.to ? before.start : before.end + at - before.to;
|
|
48
|
+
};
|
|
49
|
+
return { text, sourceOffset };
|
|
19
50
|
}
|
|
20
51
|
/**
|
|
21
52
|
* `range` of `source` with `edits` (ordered outermost first, an insertion before a replacement at its offset, a
|
|
22
53
|
* later insertion before an earlier) applied. `parent` is the edit keeping `range`: an insertion at its edges
|
|
23
54
|
* is written beside it, so it is not written again inside.
|
|
24
55
|
*/
|
|
25
|
-
function render(source, edits, range, parent) {
|
|
56
|
+
function render(source, edits, range, parent, moved) {
|
|
26
57
|
const within = (edit) => edit !== parent &&
|
|
27
58
|
range.start <= edit.start &&
|
|
28
59
|
edit.end <= range.end &&
|
|
@@ -39,7 +70,9 @@ function render(source, edits, range, parent) {
|
|
|
39
70
|
}
|
|
40
71
|
const parts = typeof edit.text === 'string' ? [edit.text] : edit.text;
|
|
41
72
|
written += source.slice(at, edit.start);
|
|
73
|
+
const from = written.length;
|
|
42
74
|
written += parts.map((part) => (typeof part === 'string' ? part : render(source, inside, part, edit))).join('');
|
|
75
|
+
moved?.push({ start: edit.start, end: edit.end, from, to: written.length });
|
|
43
76
|
at = edit.end;
|
|
44
77
|
}
|
|
45
78
|
return written + source.slice(at, range.end);
|
|
@@ -53,10 +86,14 @@ export function inserted(node, text) {
|
|
|
53
86
|
export function appended(node, text) {
|
|
54
87
|
return { start: node.getEnd(), end: node.getEnd(), text };
|
|
55
88
|
}
|
|
89
|
+
/** `node`'s text with each of `uses`, the nodes inside it, written as `text`. */
|
|
90
|
+
export function replacingUses(node, uses, text) {
|
|
91
|
+
return applyEdits(node.getText(), uses.map((use) => ({ start: use.getStart() - node.getStart(), end: use.getEnd() - node.getStart(), text })));
|
|
92
|
+
}
|
|
56
93
|
/**
|
|
57
|
-
* Removes elements of a comma-separated list with the separators that would dangle.
|
|
58
|
-
* the text up to the next item; the removed run at the end takes the separator before
|
|
59
|
-
* overlap. `source` is passed because a tree from `ts.parseJsonText`
|
|
94
|
+
* Removes elements of a comma-separated list, at least one of them kept, with the separators that would dangle.
|
|
95
|
+
* Each removed element takes the text up to the next item; the removed run at the end takes the separator before
|
|
96
|
+
* it, so the ranges never overlap. `source` is passed because a tree from `ts.parseJsonText` has no `getStart()`.
|
|
60
97
|
*/
|
|
61
98
|
export function removeFromList(items, removed, source) {
|
|
62
99
|
const kept = items.map((item) => !removed.includes(item));
|
|
@@ -68,13 +105,18 @@ export function removeFromList(items, removed, source) {
|
|
|
68
105
|
if (!trailing.length) {
|
|
69
106
|
return between;
|
|
70
107
|
}
|
|
71
|
-
|
|
72
|
-
return [...between, { start: from, end: items[items.length - 1].getEnd(), text: '' }];
|
|
108
|
+
return [...between, { start: items[lastKept].getEnd(), end: items[items.length - 1].getEnd(), text: '' }];
|
|
73
109
|
}
|
|
74
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Removes a statement with the blanks before it on its line, the rest of its line and the blank lines after it,
|
|
112
|
+
* so nothing is left blank behind it.
|
|
113
|
+
*/
|
|
75
114
|
export function removeStatement(node) {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
115
|
+
const text = node.getSourceFile().text;
|
|
116
|
+
const lineStart = lineStartOf(text, node.getStart());
|
|
117
|
+
const head = text.slice(lineStart, node.getStart());
|
|
118
|
+
const after = /[^\n]*\n?(?:[ \t]*\n)*/y;
|
|
119
|
+
after.lastIndex = node.getEnd();
|
|
120
|
+
after.exec(text);
|
|
121
|
+
return { start: lineStart + head.replace(/[ \t]*$/, '').length, end: after.lastIndex, text: '' };
|
|
80
122
|
}
|
package/dist/entitySql.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { inserted, replaced } from './edits.js';
|
|
3
3
|
import { isSqlExport, uqlExport } from './imports.js';
|
|
4
|
-
import { propertyValue } from './syntax.js';
|
|
4
|
+
import { columnListEntries, propertyValue } from './syntax.js';
|
|
5
5
|
export function isSqlCall(node, checker) {
|
|
6
6
|
const callee = ts.isTaggedTemplateExpression(node)
|
|
7
7
|
? node.tag
|
|
@@ -12,9 +12,7 @@ export function isSqlCall(node, checker) {
|
|
|
12
12
|
}
|
|
13
13
|
/** The expressions of a column list, written as one or returned by its callback: each entry or `column` that is `sql`. */
|
|
14
14
|
export function columnExpressions(list, checker) {
|
|
15
|
-
|
|
16
|
-
const entries = body && ts.isArrayLiteralExpression(body) ? body.elements : [];
|
|
17
|
-
return entries.flatMap((entry) => {
|
|
15
|
+
return columnListEntries(list).flatMap((entry) => {
|
|
18
16
|
const sql = ts.isObjectLiteralExpression(entry) ? propertyValue(entry, 'column') : entry;
|
|
19
17
|
return sql && isSqlCall(sql, checker) ? [sql] : [];
|
|
20
18
|
});
|
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,17 @@ 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
|
+
/** The edits dropping each `uql-orm` import name in `candidates` that the file no longer spells. */
|
|
56
|
+
export declare function unusedImportEdits(source: ts.SourceFile, candidates: ReadonlySet<string>): readonly Edit[];
|
|
44
57
|
export {};
|