uql-codemod 0.48.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -16
- 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 +3 -10
- 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 +43 -11
- package/dist/edits.js +105 -26
- package/dist/entitySql.d.ts +15 -16
- package/dist/entitySql.js +33 -31
- package/dist/imports.d.ts +33 -16
- package/dist/imports.js +213 -99
- 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/statementSql.d.ts +2 -1
- package/dist/statementSql.js +10 -4
- package/dist/syntax.d.ts +54 -0
- package/dist/syntax.js +131 -1
- package/dist/transform.d.ts +11 -2
- package/dist/transform.js +174 -165
- package/dist/tsconfig.d.ts +3 -9
- package/dist/tsconfig.js +18 -22
- package/package.json +1 -1
- package/dist/fieldType.d.ts +0 -13
- package/dist/fieldType.js +0 -56
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { formatFindings, locationAt } from './context.js';
|
|
3
|
+
import { CLASS_DEFINERS, definerCall } from './definers.js';
|
|
4
|
+
import { editedText, removeStatement, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
|
|
5
|
+
import { unusedImportEdits } from './imports.js';
|
|
6
|
+
import { memberKey, paramFor, quoted } from './keyMaps.js';
|
|
7
|
+
import { KEPT_OPTIONS } from './schemaOptions.js';
|
|
8
|
+
import { arrayElements, decoratorsOf, freeName, hasModifier, identifierText, identifierUses, indentOf, indentUnit, isNamedProperty, objectLiteral, objectProperties, propertyKey, propertyValue, } from './syntax.js';
|
|
9
|
+
/** Imports a converted file may no longer read: the definers folded in, and the brands a schema derives. */
|
|
10
|
+
const FOLDED_IMPORTS = new Set([
|
|
11
|
+
...Object.keys(CLASS_DEFINERS).filter((name) => name !== 'defineEntity'),
|
|
12
|
+
'idKey',
|
|
13
|
+
'versionKey',
|
|
14
|
+
'filledKey',
|
|
15
|
+
]);
|
|
16
|
+
const RELATION_KINDS = new Map([
|
|
17
|
+
['11', 'oneToOne'],
|
|
18
|
+
['m1', 'manyToOne'],
|
|
19
|
+
['1m', 'oneToMany'],
|
|
20
|
+
['mm', 'manyToMany'],
|
|
21
|
+
]);
|
|
22
|
+
const READ_OPTIONS = new Set([
|
|
23
|
+
'name',
|
|
24
|
+
'schema',
|
|
25
|
+
'extends',
|
|
26
|
+
'fields',
|
|
27
|
+
'relations',
|
|
28
|
+
'hooks',
|
|
29
|
+
...KEPT_OPTIONS,
|
|
30
|
+
]);
|
|
31
|
+
/** How each option holding entries is written for its entries to be read one by one: a map of plain properties, or a list. */
|
|
32
|
+
const ENTRY_OPTIONS = {
|
|
33
|
+
fields: 'map',
|
|
34
|
+
relations: 'map',
|
|
35
|
+
hooks: 'map',
|
|
36
|
+
filters: 'map',
|
|
37
|
+
indexes: 'list',
|
|
38
|
+
checks: 'list',
|
|
39
|
+
triggers: 'list',
|
|
40
|
+
};
|
|
41
|
+
/** Whether `value` is written as `shape` says, so no entry hides in a variable, a spread or a shorthand. */
|
|
42
|
+
function readsAs(value, shape) {
|
|
43
|
+
return shape === 'list'
|
|
44
|
+
? ts.isArrayLiteralExpression(value)
|
|
45
|
+
: ts.isObjectLiteralExpression(value) && value.properties.every(ts.isPropertyAssignment);
|
|
46
|
+
}
|
|
47
|
+
/** What a class-form relation said that a `rel` builder says by its name and first argument. */
|
|
48
|
+
const RELATION_SHAPE = new Set(['cardinality', 'entity']);
|
|
49
|
+
/**
|
|
50
|
+
* Declares each class `defineEntity(Class, options)` registered as the schema `defineEntity({ name, ... })`,
|
|
51
|
+
* folding the string-keyed definers of the same class in. Read off the text the other rewrites produced,
|
|
52
|
+
* whose options already name members by callback, so it only moves what is written. An entity it cannot
|
|
53
|
+
* declare faithfully is left as written and reported.
|
|
54
|
+
*/
|
|
55
|
+
export function toSchemaEntities(fileName, text, locate) {
|
|
56
|
+
if (!CLASS_FORM_CALL.test(text)) {
|
|
57
|
+
return { ...unedited(text), unresolved: [], notes: [] };
|
|
58
|
+
}
|
|
59
|
+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
|
|
60
|
+
const located = locate ?? ((at) => locationAt(source, at));
|
|
61
|
+
const unit = indentUnit(source);
|
|
62
|
+
const unresolved = [];
|
|
63
|
+
const notes = [];
|
|
64
|
+
const edits = [];
|
|
65
|
+
const registrations = collectRegistrations(source, unresolved);
|
|
66
|
+
const entityCallOf = (calls) => calls.find(({ definer }) => definer === 'defineEntity')?.call;
|
|
67
|
+
for (const [name, { cls, calls }] of registrations) {
|
|
68
|
+
const entityCall = entityCallOf(calls);
|
|
69
|
+
if (!entityCall) {
|
|
70
|
+
unresolved.push(...calls.map(({ call, definer }) => ({ node: call, message: removedDefiner(definer, name) })));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const base = baseName(entityCall);
|
|
74
|
+
const baseCall = base && entityCallOf(registrations.get(base)?.calls ?? []);
|
|
75
|
+
if (baseCall && baseCall.getStart() > entityCall.getStart()) {
|
|
76
|
+
unresolved.push({
|
|
77
|
+
node: entityCall,
|
|
78
|
+
message: `'${name}' extends '${base}', declared below it: move defineEntity(${base}, ...) above this one, then run again`,
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const schema = schemaOf(name, cls, calls, entityCall, unit);
|
|
83
|
+
if ('message' in schema) {
|
|
84
|
+
unresolved.push(schema);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
notes.push(...schema.notes);
|
|
88
|
+
edits.push(removeStatement(cls));
|
|
89
|
+
for (const { call } of calls) {
|
|
90
|
+
edits.push(call === entityCall ? replaced(call.parent, indented(schema.text, call.parent)) : removeStatement(call.parent));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...(edits.length
|
|
95
|
+
? reparsedAgain(fileName, editedText(text, edits), (edited) => unusedImportEdits(edited, FOLDED_IMPORTS))
|
|
96
|
+
: unedited(text)),
|
|
97
|
+
unresolved: formatFindings(unresolved, located),
|
|
98
|
+
notes: formatFindings(notes, located),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** The class a `defineEntity(Class, { extends })` names as its base, which its schema's const reads where it is declared. */
|
|
102
|
+
function baseName(entityCall) {
|
|
103
|
+
const options = objectLiteral(entityCall.arguments[1]);
|
|
104
|
+
return identifierText(options && propertyValue(options, 'extends'));
|
|
105
|
+
}
|
|
106
|
+
/** A call a class-form definer makes, its entity named first: what the pass has anything to do with. */
|
|
107
|
+
const CLASS_FORM_CALL = new RegExp(`\\b(?:${Object.keys(CLASS_DEFINERS).join('|')})\\(\\s*[\\w$.]+\\s*[,)]`);
|
|
108
|
+
/**
|
|
109
|
+
* Each class the file declares that a class-form definer names, with those calls, in source order. A call naming
|
|
110
|
+
* anything else, or not written as a statement of its own, is reported: there is no class here to declare.
|
|
111
|
+
*/
|
|
112
|
+
function collectRegistrations(source, unresolved) {
|
|
113
|
+
const classes = new Map(source.statements
|
|
114
|
+
.filter((statement) => ts.isClassDeclaration(statement) && !!statement.name)
|
|
115
|
+
.map((cls) => [cls.name.text, cls]));
|
|
116
|
+
const registrations = new Map();
|
|
117
|
+
const visit = (node) => {
|
|
118
|
+
const read = ts.isCallExpression(node) ? definerCall(node) : undefined;
|
|
119
|
+
if (read) {
|
|
120
|
+
const name = identifierText(read.entity);
|
|
121
|
+
const cls = name ? classes.get(name) : undefined;
|
|
122
|
+
if (name && cls && ts.isExpressionStatement(node.parent) && node.parent.parent === source) {
|
|
123
|
+
const registration = registrations.get(name) ?? { cls, calls: [] };
|
|
124
|
+
registration.calls.push(read);
|
|
125
|
+
registrations.set(name, registration);
|
|
126
|
+
}
|
|
127
|
+
else if (read.definer === 'defineEntity') {
|
|
128
|
+
unresolved.push({ node, message: 'defineEntity takes a schema now: defineEntity({ name, fields, ... })' });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
unresolved.push({ node, message: removedDefiner(read.definer, textOf(read.entity)) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
ts.forEachChild(node, visit);
|
|
135
|
+
};
|
|
136
|
+
visit(source);
|
|
137
|
+
return registrations;
|
|
138
|
+
}
|
|
139
|
+
/** Why an incremental definer naming `entity` has nowhere to fold into. */
|
|
140
|
+
function removedDefiner(definer, entity) {
|
|
141
|
+
return `${definer} was removed: register '${entity}' with defineEntity in this file, or decorate it (${CLASS_DEFINERS[definer]})`;
|
|
142
|
+
}
|
|
143
|
+
function schemaOf(name, cls, calls, entityCall, unit) {
|
|
144
|
+
const refused = (message) => ({ node: entityCall, message });
|
|
145
|
+
const options = objectLiteral(entityCall.arguments[1]) ?? (entityCall.arguments[1] ? undefined : emptyOptions());
|
|
146
|
+
if (!options || options.properties.some((prop) => !ts.isPropertyAssignment(prop))) {
|
|
147
|
+
return refused("defineEntity's options could not be read: write them as an object literal");
|
|
148
|
+
}
|
|
149
|
+
if (cls.heritageClauses?.length) {
|
|
150
|
+
return refused(`'${name}' extends a class: declare the base with defineEntity too and pass it as 'extends'`);
|
|
151
|
+
}
|
|
152
|
+
if (decoratorsOf(cls).length) {
|
|
153
|
+
return refused(`'${name}' is decorated as well: declare it one way, decorators or a schema`);
|
|
154
|
+
}
|
|
155
|
+
const exported = hasModifier(cls, ts.SyntaxKind.ExportKeyword);
|
|
156
|
+
if (hasModifier(cls, ts.SyntaxKind.DefaultKeyword)) {
|
|
157
|
+
return refused(`'${name}' is a default export: export the schema's const by name instead`);
|
|
158
|
+
}
|
|
159
|
+
const unread = objectProperties(options).find(({ name: key }) => !READ_OPTIONS.has(propertyKey(key)));
|
|
160
|
+
if (unread) {
|
|
161
|
+
return refused(`'${textOf(unread.name)}' has no place in a schema: move it by hand`);
|
|
162
|
+
}
|
|
163
|
+
for (const [option, shape] of Object.entries(ENTRY_OPTIONS)) {
|
|
164
|
+
const value = propertyValue(options, option);
|
|
165
|
+
if (value && !readsAs(value, shape)) {
|
|
166
|
+
const literal = shape === 'map' ? 'an object literal of its entries' : 'an array literal';
|
|
167
|
+
return { node: value, message: `'${option}' could not be read: write it as ${literal}` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const scope = {
|
|
171
|
+
name,
|
|
172
|
+
param: paramFor(name),
|
|
173
|
+
rel: freeName('rel', calls.map(({ call }) => call)),
|
|
174
|
+
relations: new Set([
|
|
175
|
+
...objectProperties(propertyValue(options, 'relations')).map((it) => propertyKey(it.name)),
|
|
176
|
+
...calls.filter((read) => read.definer === 'defineRelation').map((read) => read.key),
|
|
177
|
+
]),
|
|
178
|
+
methods: new Map(cls.members
|
|
179
|
+
.filter((member) => ts.isMethodDeclaration(member))
|
|
180
|
+
.map((method) => [textOf(method.name), method])),
|
|
181
|
+
hooked: new Map(),
|
|
182
|
+
};
|
|
183
|
+
const gathered = {
|
|
184
|
+
fields: [],
|
|
185
|
+
computed: [],
|
|
186
|
+
aggregates: [],
|
|
187
|
+
relations: new Map(),
|
|
188
|
+
hooks: new Map(),
|
|
189
|
+
kept: new Map(),
|
|
190
|
+
};
|
|
191
|
+
const { hooked } = scope;
|
|
192
|
+
const notes = [];
|
|
193
|
+
for (const read of calls) {
|
|
194
|
+
const problem = gather(read, read.call === entityCall ? options : undefined, gathered, scope);
|
|
195
|
+
if (problem) {
|
|
196
|
+
return problem;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const member = cls.members.find((it) => !(ts.isPropertyDeclaration(it) && !hasModifier(it, ts.SyntaxKind.StaticKeyword)) &&
|
|
200
|
+
!(ts.isMethodDeclaration(it) && hooked.has(textOf(it.name))));
|
|
201
|
+
if (member) {
|
|
202
|
+
const what = ts.isMethodDeclaration(member) ? 'method' : 'member';
|
|
203
|
+
const label = member.name ? textOf(member.name) : textOf(member).split('\n')[0];
|
|
204
|
+
return refused(`'${name}' has the ${what} '${label}': rows are plain objects, so make it a function of the row, then run again`);
|
|
205
|
+
}
|
|
206
|
+
for (const [method, declaration] of hooked) {
|
|
207
|
+
notes.push({
|
|
208
|
+
node: declaration,
|
|
209
|
+
message: `'${method}' runs as a hook of the row now, no longer a method of '${name}': call sites need the function`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
notes.push(...looserTypes(cls, options));
|
|
213
|
+
const table = propertyValue(options, 'name');
|
|
214
|
+
const lines = [`name: ${quoted(name)}`, ...(table ? [`table: ${textOf(table)}`] : [])];
|
|
215
|
+
for (const key of ['schema', 'extends']) {
|
|
216
|
+
const value = propertyValue(options, key);
|
|
217
|
+
if (value) {
|
|
218
|
+
lines.push(`${key}: ${textOf(value)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const fieldsValue = propertyValue(options, 'fields');
|
|
222
|
+
const untouched = fieldsValue &&
|
|
223
|
+
gathered.fields.length === objectProperties(fieldsValue).length &&
|
|
224
|
+
!gathered.computed.length &&
|
|
225
|
+
!gathered.aggregates.length;
|
|
226
|
+
lines.push(`fields: ${untouched ? textOf(fieldsValue) : block(gathered.fields, unit)}`);
|
|
227
|
+
if (gathered.computed.length) {
|
|
228
|
+
lines.push(`computed: ${block(gathered.computed, unit)}`);
|
|
229
|
+
}
|
|
230
|
+
if (gathered.relations.size) {
|
|
231
|
+
lines.push(`relations: (${scope.rel}) => (${block([...gathered.relations.values()], unit)})`);
|
|
232
|
+
}
|
|
233
|
+
if (gathered.aggregates.length) {
|
|
234
|
+
lines.push(`aggregates: (${scope.rel}) => (${block(gathered.aggregates, unit)})`);
|
|
235
|
+
}
|
|
236
|
+
for (const key of KEPT_OPTIONS) {
|
|
237
|
+
const entries = gathered.kept.get(key);
|
|
238
|
+
if (entries) {
|
|
239
|
+
lines.push(`${key}: ${keptValue(key, entries, propertyValue(options, key))}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (gathered.hooks.size) {
|
|
243
|
+
lines.push(`hooks: ${block([...gathered.hooks].map(([event, runs]) => `${event}: [${runs.join(', ')}]`), unit)}`);
|
|
244
|
+
}
|
|
245
|
+
const prefix = exported ? 'export ' : '';
|
|
246
|
+
return {
|
|
247
|
+
text: `${prefix}const ${name} = defineEntity({\n${lines.map((line) => `${unit}${line},`).join('\n')}\n});\n${prefix}type ${name} = InstanceType<typeof ${name}>;`,
|
|
248
|
+
notes,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const emptyOptions = () => ts.factory.createObjectLiteralExpression();
|
|
252
|
+
/** Entries as an object literal a level in: `{\n a,\n }`. */
|
|
253
|
+
function block(entries, unit) {
|
|
254
|
+
return entries.length ? `{\n${entries.map((entry) => `${unit}${unit}${entry},\n`).join('')}${unit}}` : '{}';
|
|
255
|
+
}
|
|
256
|
+
/** `node`'s text, its lines after the first indented relative to the statement holding it. */
|
|
257
|
+
function textOf(node) {
|
|
258
|
+
return rebased(node.getText(), node);
|
|
259
|
+
}
|
|
260
|
+
/** `text`, written inside `node`, with the indentation of the statement holding it taken off its lines. */
|
|
261
|
+
function rebased(text, node) {
|
|
262
|
+
return text.replaceAll(`\n${indentOf(statementOf(node))}`, '\n');
|
|
263
|
+
}
|
|
264
|
+
function statementOf(node) {
|
|
265
|
+
return ts.isSourceFile(node.parent) ? node : statementOf(node.parent);
|
|
266
|
+
}
|
|
267
|
+
/** `text` replacing `node`, each line after its first indented as `node` is. */
|
|
268
|
+
function indented(text, node) {
|
|
269
|
+
return text.replaceAll('\n', `\n${indentOf(node)}`);
|
|
270
|
+
}
|
|
271
|
+
/** A kept option's value: as written where nothing was folded into it, its entries joined otherwise. */
|
|
272
|
+
function keptValue(key, entries, written) {
|
|
273
|
+
if (written && entries.length === entriesOfValue(key, written).length) {
|
|
274
|
+
return textOf(written);
|
|
275
|
+
}
|
|
276
|
+
return ENTRY_OPTIONS[key] === 'map' ? `{ ${entries.join(', ')} }` : `[${entries.join(', ')}]`;
|
|
277
|
+
}
|
|
278
|
+
/** The entries of a kept option as written: a map's properties, a list's elements. */
|
|
279
|
+
function entriesOfValue(key, value) {
|
|
280
|
+
return ENTRY_OPTIONS[key] === 'map' ? objectProperties(value) : arrayElements(value);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Adds what one registration declares to `gathered`: `options` for the `defineEntity` call, its arguments for an
|
|
284
|
+
* incremental definer. Returns why it cannot, where it cannot.
|
|
285
|
+
*/
|
|
286
|
+
function gather({ call, definer, key, options: third, rest }, options, gathered, { name, param, rel, relations, methods, hooked }) {
|
|
287
|
+
const unreadable = {
|
|
288
|
+
node: call,
|
|
289
|
+
message: `${definer}'s arguments could not be read: fold them into defineEntity by hand`,
|
|
290
|
+
};
|
|
291
|
+
const addHook = (event, method) => {
|
|
292
|
+
const declaration = methods.get(method);
|
|
293
|
+
if (!declaration) {
|
|
294
|
+
return { node: call, message: `the hook '${method}' names no method of the class` };
|
|
295
|
+
}
|
|
296
|
+
const run = hookFunction(declaration, param);
|
|
297
|
+
if (!run) {
|
|
298
|
+
return {
|
|
299
|
+
node: declaration,
|
|
300
|
+
message: `'${method}' takes more than the hook context: make it a function of the row by hand`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
hooked.set(method, declaration);
|
|
304
|
+
const runs = gathered.hooks.get(event) ?? [];
|
|
305
|
+
runs.push(run);
|
|
306
|
+
gathered.hooks.set(event, runs);
|
|
307
|
+
return undefined;
|
|
308
|
+
};
|
|
309
|
+
const addField = (key, entry, fieldOptions, node) => {
|
|
310
|
+
const aggregate = key && aggregateEntry(`${name}.${key}`, key, fieldOptions, relations, rel);
|
|
311
|
+
if (typeof aggregate === 'string') {
|
|
312
|
+
gathered.aggregates.push(aggregate);
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
if (aggregate) {
|
|
316
|
+
return { node, message: aggregate.message };
|
|
317
|
+
}
|
|
318
|
+
(isComputed(fieldOptions) ? gathered.computed : gathered.fields).push(entry);
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
321
|
+
const addRelation = (key, value) => {
|
|
322
|
+
const built = key && relationEntry(key, value, rel);
|
|
323
|
+
if (built) {
|
|
324
|
+
gathered.relations.set(key, built);
|
|
325
|
+
}
|
|
326
|
+
return !!built;
|
|
327
|
+
};
|
|
328
|
+
const addKept = (option, entries) => {
|
|
329
|
+
const kept = gathered.kept.get(option) ?? [];
|
|
330
|
+
kept.push(...entries);
|
|
331
|
+
gathered.kept.set(option, kept);
|
|
332
|
+
};
|
|
333
|
+
if (options) {
|
|
334
|
+
for (const field of objectProperties(propertyValue(options, 'fields'))) {
|
|
335
|
+
const problem = addField(propertyKey(field.name), textOf(field), field.initializer, field);
|
|
336
|
+
if (problem)
|
|
337
|
+
return problem;
|
|
338
|
+
}
|
|
339
|
+
for (const relation of objectProperties(propertyValue(options, 'relations'))) {
|
|
340
|
+
if (!addRelation(propertyKey(relation.name), relation.initializer)) {
|
|
341
|
+
return {
|
|
342
|
+
node: relation,
|
|
343
|
+
message: 'a relation could not be read: write it as rel.<kind>(Target, { ... }) by hand',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const hook of objectProperties(propertyValue(options, 'hooks'))) {
|
|
348
|
+
const listed = hookMethods(hook.initializer);
|
|
349
|
+
if (!listed) {
|
|
350
|
+
return { node: hook, message: 'a hook list could not be read: write its functions by hand' };
|
|
351
|
+
}
|
|
352
|
+
for (const method of listed) {
|
|
353
|
+
const problem = addHook(textOf(hook.name), method);
|
|
354
|
+
if (problem)
|
|
355
|
+
return problem;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const option of KEPT_OPTIONS) {
|
|
359
|
+
const value = propertyValue(options, option);
|
|
360
|
+
if (value) {
|
|
361
|
+
addKept(option, entriesOfValue(option, value).map((it) => textOf(it)));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
switch (definer) {
|
|
367
|
+
case 'defineEntity':
|
|
368
|
+
return unreadable;
|
|
369
|
+
case 'defineField':
|
|
370
|
+
case 'defineId': {
|
|
371
|
+
if (!key || !third)
|
|
372
|
+
return unreadable;
|
|
373
|
+
const value = definer === 'defineId' ? withIsId(third) : textOf(third);
|
|
374
|
+
if (!value)
|
|
375
|
+
return unreadable;
|
|
376
|
+
return addField(key, `${memberKey(key)}: ${value}`, third, call);
|
|
377
|
+
}
|
|
378
|
+
case 'defineRelation':
|
|
379
|
+
return third && addRelation(key, third) ? undefined : unreadable;
|
|
380
|
+
case 'defineHook': {
|
|
381
|
+
const event = third && ts.isStringLiteralLike(third) ? third.text : undefined;
|
|
382
|
+
return key && event ? addHook(event, key) : unreadable;
|
|
383
|
+
}
|
|
384
|
+
case 'defineIndex':
|
|
385
|
+
if (!third)
|
|
386
|
+
return unreadable;
|
|
387
|
+
addKept('indexes', [textOf(third)]);
|
|
388
|
+
return undefined;
|
|
389
|
+
case 'defineTrigger':
|
|
390
|
+
if (!rest.length)
|
|
391
|
+
return unreadable;
|
|
392
|
+
addKept('triggers', rest.map((it) => textOf(it)));
|
|
393
|
+
return undefined;
|
|
394
|
+
case 'defineFilter':
|
|
395
|
+
if (!key || !third)
|
|
396
|
+
return unreadable;
|
|
397
|
+
addKept('filters', [`${memberKey(key)}: ${textOf(third)}`]);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Whether a field's options compute it, which a schema declares under `computed`. */
|
|
402
|
+
function isComputed(options) {
|
|
403
|
+
const literal = objectLiteral(options);
|
|
404
|
+
return !!literal && propertyValue(literal, 'computed') !== undefined;
|
|
405
|
+
}
|
|
406
|
+
/** `{ ...options, isId: true }` written into a literal's own text, or nothing where it is not one. */
|
|
407
|
+
function withIsId(options) {
|
|
408
|
+
const literal = objectLiteral(options);
|
|
409
|
+
if (!literal)
|
|
410
|
+
return undefined;
|
|
411
|
+
const text = textOf(literal);
|
|
412
|
+
return literal.properties.length ? text.replace(/^\{\s*/, '{ isId: true, ') : '{ isId: true }';
|
|
413
|
+
}
|
|
414
|
+
/** `author: () => rel.manyToOne(User, { references })` from `{ cardinality: 'm1', entity: () => User, references }`. */
|
|
415
|
+
function relationEntry(key, value, rel) {
|
|
416
|
+
const relation = objectLiteral(value);
|
|
417
|
+
const cardinality = relation && propertyValue(relation, 'cardinality');
|
|
418
|
+
const getter = relation && propertyValue(relation, 'entity');
|
|
419
|
+
const kind = cardinality && ts.isStringLiteralLike(cardinality) ? RELATION_KINDS.get(cardinality.text) : undefined;
|
|
420
|
+
if (!relation || !kind || !getter || !ts.isArrowFunction(getter) || ts.isBlock(getter.body)) {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
const properties = relation.properties.filter(isNamedProperty);
|
|
424
|
+
if (properties.length !== relation.properties.length) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
const rest = properties.filter((prop) => !RELATION_SHAPE.has(propertyKey(prop.name))).map((prop) => textOf(prop));
|
|
428
|
+
const args = [textOf(getter.body), ...(rest.length ? [`{ ${rest.join(', ')} }`] : [])];
|
|
429
|
+
return `${memberKey(key)}: () => ${rel}.${kind}(${args.join(', ')})`;
|
|
430
|
+
}
|
|
431
|
+
/** The methods a hook list names, `(post) => [post.stamp]`, or nothing where it is not one. */
|
|
432
|
+
function hookMethods(list) {
|
|
433
|
+
if (!ts.isArrowFunction(list) || !ts.isArrayLiteralExpression(list.body) || list.parameters.length !== 1) {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
const param = identifierText(list.parameters[0].name);
|
|
437
|
+
const names = list.body.elements.map((entry) => ts.isPropertyAccessExpression(entry) && identifierText(entry.expression) === param ? entry.name.text : undefined);
|
|
438
|
+
return names.every((it) => it !== undefined) ? names : undefined;
|
|
439
|
+
}
|
|
440
|
+
/** A hook method as a function of the row: `this` read as the row, named `preferred` where the body leaves it free. */
|
|
441
|
+
function hookFunction(method, preferred) {
|
|
442
|
+
if (!method.body || method.parameters.length > 1) {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
const row = freeName(preferred, [method]);
|
|
446
|
+
const isAsync = hasModifier(method, ts.SyntaxKind.AsyncKeyword);
|
|
447
|
+
const params = [row, ...method.parameters.map((it) => textOf(it))].join(', ');
|
|
448
|
+
const body = method.body;
|
|
449
|
+
const uses = [];
|
|
450
|
+
const visit = (node) => {
|
|
451
|
+
if (node.kind === ts.SyntaxKind.ThisKeyword) {
|
|
452
|
+
uses.push(node);
|
|
453
|
+
}
|
|
454
|
+
else if (!ts.isFunctionLike(node) || ts.isArrowFunction(node)) {
|
|
455
|
+
ts.forEachChild(node, visit);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
ts.forEachChild(body, visit);
|
|
459
|
+
return `${isAsync ? 'async ' : ''}(${params}) => ${substituted(body, uses, row)}`;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* `total: () => rel.items.count()` from a field computed over a relation, `{ computed: (counted) => counted.items.count() }`,
|
|
463
|
+
* which a schema declares among its aggregates; nothing for a field reading no relation, and why where it cannot move.
|
|
464
|
+
*/
|
|
465
|
+
function aggregateEntry(label, key, options, relations, rel) {
|
|
466
|
+
const literal = objectLiteral(options);
|
|
467
|
+
const computed = literal && propertyValue(literal, 'computed');
|
|
468
|
+
const param = computed && ts.isArrowFunction(computed) ? identifierText(computed.parameters[0]?.name) : undefined;
|
|
469
|
+
if (!computed || !ts.isArrowFunction(computed) || !param) {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
const uses = identifierUses(computed.body, param);
|
|
473
|
+
const overRelation = (use) => ts.isPropertyAccessExpression(use.parent) && use.parent.expression === use && relations.has(use.parent.name.text);
|
|
474
|
+
if (!uses.some(overRelation)) {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
if (literal.properties.length > 1) {
|
|
478
|
+
return { message: `'${label}' aggregates a relation with options an aggregate takes none of: move it by hand` };
|
|
479
|
+
}
|
|
480
|
+
if (ts.isBlock(computed.body) || !uses.every(overRelation)) {
|
|
481
|
+
return { message: `'${label}' reads more than a relation's aggregate: move it by hand` };
|
|
482
|
+
}
|
|
483
|
+
return `${memberKey(key)}: () => ${substituted(computed.body, uses, rel)}`;
|
|
484
|
+
}
|
|
485
|
+
/** `body`'s text, rebased, with each of `uses` written as `replacement`. */
|
|
486
|
+
function substituted(body, uses, replacement) {
|
|
487
|
+
return rebased(replacingUses(body, uses, replacement), body);
|
|
488
|
+
}
|
|
489
|
+
/** Notes each property the class typed more narrowly than its schema derives, with the option that keeps it. */
|
|
490
|
+
function looserTypes(cls, options) {
|
|
491
|
+
const fields = objectLiteral(propertyValue(options, 'fields'));
|
|
492
|
+
return cls.members.flatMap((member) => {
|
|
493
|
+
const key = ts.isPropertyDeclaration(member) ? propertyKey(member.name) : undefined;
|
|
494
|
+
const declared = ts.isPropertyDeclaration(member) ? member.type : undefined;
|
|
495
|
+
const field = key && fields ? objectLiteral(propertyValue(fields, key)) : undefined;
|
|
496
|
+
if (!key || !declared || !field) {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
const written = textOf(declared);
|
|
500
|
+
const said = (option) => propertyValue(field, option) !== undefined;
|
|
501
|
+
if (/\bJson</.test(written) && !said('shape')) {
|
|
502
|
+
return [
|
|
503
|
+
{
|
|
504
|
+
node: member,
|
|
505
|
+
message: `'${key}' was declared ${written}: describe its payload with 'shape' to keep it typed`,
|
|
506
|
+
},
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
const literals = ts.isUnionTypeNode(declared) &&
|
|
510
|
+
declared.types.filter((type) => ts.isLiteralTypeNode(type) && ts.isStringLiteral(type.literal));
|
|
511
|
+
if (literals && literals.length > 1 && !said('enum')) {
|
|
512
|
+
return [
|
|
513
|
+
{ node: member, message: `'${key}' was declared ${written}: list its values in 'enum' to keep them typed` },
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
return [];
|
|
517
|
+
});
|
|
518
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The options a schema's first call keeps, each as it is written there. */
|
|
2
|
+
export declare const FIRST_CALL_OPTIONS: readonly ["name", "table", "schema", "abstract", "extends", "fields"];
|
|
3
|
+
/** The sections a schema chains after its first call, in the order it chains them, `relations` last. */
|
|
4
|
+
export declare const CHAINED_OPTIONS: readonly ["computed", "indexes", "checks", "triggers", "filters", "hooks"];
|
|
5
|
+
/** The options a class-form `defineEntity` took that a schema keeps as they are, and that incremental definers add to. */
|
|
6
|
+
export declare const KEPT_OPTIONS: readonly ["indexes", "checks", "filters", "triggers"];
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The options a schema's first call keeps, each as it is written there. */
|
|
2
|
+
export const FIRST_CALL_OPTIONS = ['name', 'table', 'schema', 'abstract', 'extends', 'fields'];
|
|
3
|
+
/** The sections a schema chains after its first call, in the order it chains them, `relations` last. */
|
|
4
|
+
export const CHAINED_OPTIONS = ['computed', 'indexes', 'checks', 'triggers', 'filters', 'hooks'];
|
|
5
|
+
/** The options a class-form `defineEntity` took that a schema keeps as they are, and that incremental definers add to. */
|
|
6
|
+
export const KEPT_OPTIONS = ['indexes', 'checks', 'filters', 'triggers'];
|
package/dist/statementSql.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
+
import { type Part } from './edits.js';
|
|
2
3
|
/**
|
|
3
4
|
* A querier's `run(sql, values)` statement as the template its tag takes, each placeholder replaced by the value
|
|
4
5
|
* it binds, or nothing where that is not certain: the SQL is no literal, or the values no array literal of
|
|
5
6
|
* plain elements, or its placeholders cannot be told apart from the rest of the text.
|
|
6
7
|
*/
|
|
7
|
-
export declare function statementTemplate(sql: ts.Expression, values: readonly ts.Expression[]):
|
|
8
|
+
export declare function statementTemplate(sql: ts.Expression, values: readonly ts.Expression[]): readonly Part[] | undefined;
|
package/dist/statementSql.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
-
import {
|
|
2
|
+
import { original } from './edits.js';
|
|
3
|
+
import { templateOf, templateWith } from './entitySql.js';
|
|
3
4
|
/**
|
|
4
5
|
* A quoted string or identifier, or a comment, skipped; a `?` or `$n` placeholder; or a character a placeholder
|
|
5
6
|
* could hide behind that this does not read: an unclosed quote or comment, a backslash, a `#`, any other `$`.
|
|
@@ -40,7 +41,7 @@ export function statementTemplate(sql, values) {
|
|
|
40
41
|
return undefined;
|
|
41
42
|
}
|
|
42
43
|
if (!values.length) {
|
|
43
|
-
return templateOf(
|
|
44
|
+
return [templateOf(sql.text)];
|
|
44
45
|
}
|
|
45
46
|
const [list] = values;
|
|
46
47
|
const elements = ts.isArrayLiteralExpression(list) ? list.elements : undefined;
|
|
@@ -48,6 +49,11 @@ export function statementTemplate(sql, values) {
|
|
|
48
49
|
return undefined;
|
|
49
50
|
}
|
|
50
51
|
const placeholders = placeholdersOf(sql.text, elements.length);
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
if (!placeholders || placeholders.values.some((at) => isReused(placeholders.values, at) && !isPlain(elements[at]))) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return templateWith(placeholders.texts, placeholders.values.map((at) => original(elements[at])));
|
|
53
56
|
}
|
|
57
|
+
/** A value read twice in the template is evaluated twice, which only a name or a literal can do unseen. */
|
|
58
|
+
const isPlain = (value) => ts.isIdentifier(value) || ts.isNumericLiteral(value) || ts.isStringLiteral(value);
|
|
59
|
+
const isReused = (values, at) => values.filter((value) => value === at).length > 1;
|
package/dist/syntax.d.ts
CHANGED
|
@@ -5,18 +5,72 @@ export declare function identifierText(node: ts.Node | undefined): string | unde
|
|
|
5
5
|
export declare function propertyKey(name: ts.PropertyName): string | undefined;
|
|
6
6
|
/** A property of an object literal written with its own name: a plain or quoted key, or a shorthand. */
|
|
7
7
|
export type NamedProperty = ts.PropertyAssignment | ts.ShorthandPropertyAssignment;
|
|
8
|
+
export declare function isNamedProperty(prop: ts.ObjectLiteralElementLike): prop is NamedProperty;
|
|
8
9
|
/** The property `name` of an object literal, a shorthand included. */
|
|
9
10
|
export declare function namedProperty(node: ts.ObjectLiteralExpression, name: string): NamedProperty | undefined;
|
|
11
|
+
/** The key a property is written under, a shorthand's included; nothing for a spread, a method or a computed key. */
|
|
12
|
+
export declare function keyOf(prop: ts.ObjectLiteralElementLike): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* What the codemod can see of an options object. `opaque` is one it cannot write into without dropping what
|
|
15
|
+
* the author put there: `@Field(shared)`, or `{ ...base }`, whose spread may carry the option and would win.
|
|
16
|
+
*/
|
|
17
|
+
export type Options = {
|
|
18
|
+
readonly kind: 'empty';
|
|
19
|
+
readonly call: ts.CallExpression;
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: 'literal';
|
|
22
|
+
readonly node: ts.ObjectLiteralExpression;
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: 'opaque';
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
};
|
|
27
|
+
/** The options `value` writes: a literal to read and write into, or why it is not one. */
|
|
28
|
+
export declare function optionsOf(value: ts.Expression): Exclude<Options, {
|
|
29
|
+
kind: 'empty';
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* An object literal's named properties by key, each as its value (a shorthand's value is its name), or `undefined`
|
|
33
|
+
* where `node` is no literal or one of its properties is spread, computed or a method.
|
|
34
|
+
*/
|
|
35
|
+
export declare function propertiesByKey(node: ts.Expression): Map<string, ts.Expression> | undefined;
|
|
36
|
+
/** A method called on a chain: the call, the method's name, and the receiver it is called on. */
|
|
37
|
+
export type ChainMethod = {
|
|
38
|
+
readonly call: ts.CallExpression;
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly receiver: ts.Expression;
|
|
41
|
+
};
|
|
42
|
+
/** The root a method chain starts from, and its methods in source order: `f.string().nullable()` is `f` and two. */
|
|
43
|
+
export declare function chainOf(node: ts.Expression): {
|
|
44
|
+
readonly root: ts.Expression;
|
|
45
|
+
readonly methods: readonly ChainMethod[];
|
|
46
|
+
};
|
|
47
|
+
export declare function hasModifier(node: ts.Node, kind: ts.ModifierSyntaxKind): boolean;
|
|
48
|
+
/** `preferred`, or it numbered past every name `nodes` mention, so a parameter named so shadows nothing they read. */
|
|
49
|
+
export declare function freeName(preferred: string, nodes: readonly ts.Node[]): string;
|
|
50
|
+
/** Each identifier in `node` naming `name`, in source order, nested functions included, but none a member named so. */
|
|
51
|
+
export declare function identifierUses(node: ts.Node, name: string): ts.Identifier[];
|
|
52
|
+
/** Whether `node` reads a binding named `name`, which a callback parameter of that name would shadow: stops at the first. */
|
|
53
|
+
export declare function mentions(node: ts.Node, name: string): boolean;
|
|
10
54
|
/** The property `name` of an object literal that is written with a value. */
|
|
11
55
|
export declare function propertyAssignment(node: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined;
|
|
12
56
|
export declare function propertyValue(node: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined;
|
|
13
57
|
/** The property assignments of an object literal, or none where `node` is not one. */
|
|
14
58
|
export declare function objectProperties(node: ts.Expression | undefined): readonly ts.PropertyAssignment[];
|
|
59
|
+
/** The entries of a column list, written as an array or returned by its callback. */
|
|
60
|
+
export declare function columnListEntries(list: ts.Expression | undefined): readonly ts.Expression[];
|
|
15
61
|
/** The elements of an array literal, or none where `node` is not one. */
|
|
16
62
|
export declare function arrayElements(node: ts.Expression | undefined): readonly ts.Expression[];
|
|
17
63
|
/** `node` where it is an object literal. */
|
|
18
64
|
export declare function objectLiteral(node: ts.Expression | undefined): ts.ObjectLiteralExpression | undefined;
|
|
65
|
+
/** One level of indentation as the file writes it: the shallowest indent of its code, two spaces where it has none. */
|
|
66
|
+
export declare function indentUnit(source: ts.SourceFile): string;
|
|
19
67
|
/** A node's decorators, or none where it is a kind that cannot carry them. */
|
|
20
68
|
export declare function decoratorsOf(node: ts.Node): readonly ts.Decorator[];
|
|
21
69
|
/** `Field` for `@Field` and `@Field(...)`. */
|
|
22
70
|
export declare function decoratorName(node: ts.Decorator): string | undefined;
|
|
71
|
+
/** The decorators declaring a field: `@Field`, and `@Id` for the key the class names. */
|
|
72
|
+
export declare const FIELD_DECORATORS: ReadonlySet<string | undefined>;
|
|
73
|
+
/** Where the line holding offset `at` of `text` starts. */
|
|
74
|
+
export declare function lineStartOf(text: string, at: number): number;
|
|
75
|
+
/** The whitespace the line holding `node` starts with. */
|
|
76
|
+
export declare function indentOf(node: ts.Node): string;
|