uql-codemod 0.49.0 → 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 +6 -5
- package/dist/builders.d.ts +10 -0
- package/dist/builders.js +281 -86
- package/dist/imports.d.ts +5 -2
- package/dist/imports.js +22 -4
- package/dist/schemaEntity.d.ts +8 -5
- package/dist/schemaEntity.js +83 -23
- package/dist/schemaMessages.d.ts +8 -0
- package/dist/schemaMessages.js +8 -0
- package/dist/transform.d.ts +1 -1
- package/dist/transform.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,13 +25,13 @@ Only `--flag=value` is read, and anything unrecognised is an error. It exits `0`
|
|
|
25
25
|
## What it rewrites
|
|
26
26
|
|
|
27
27
|
- `virtual` to `computed` (on `@Field` and in `defineEntity`'s `fields`), a check's `expression` and a filter's `condition` to `where` (`@Entity`, `defineEntity`, `@Filter`), `raw('...')` to the `sql` tagged template, and a second argument onto `.as()`.
|
|
28
|
-
- A column's options to its builder, last, after the rewrites above: `@Id()` and `@Field({ type: String, nullable: true, length: 80 })` to `@Field(f.number().id())` and `@Field(f.string().nullable().length(80))`, a NOT NULL column losing its `nullable: false`, `computed` to `@Computed((row) => f.string().as(...))` or `@Computed((row) => row.books.count())`, and a schema's `fields` likewise; a schema's other options to the sections chained after it, its
|
|
28
|
+
- A column's options to its builder, last, after the rewrites above: `@Id()` and `@Field({ type: String, nullable: true, length: 80 })` to `@Field(f.number().id())` and `@Field(f.string().nullable().length(80))`, a NOT NULL column losing its `nullable: false`, `computed` to `@Computed((row) => f.string().as(...))` or `@Computed((row) => row.books.count())`, and a schema's `fields` likewise; a schema's other options to the sections chained after it, and its `aggregates` to `.relations()`' second callback. A schema column's foreign key becomes a relation: its `references` is dropped where a relation joins on the column (by `(order) => order.customerId`, by `[{ local, foreign }]` pairs, or as the ORM infers the one column naming its target, written into it), its `onDelete` moved into that relation, and where none joins, a `manyToOne` named as the column less its `Id` is written for it: `customer: () => rel.manyToOne(Customer, { references: (order) => order.customerId })`. Then each index, check and trigger entry, in `@Entity` and a schema, goes to the helper its callback is handed: `(post, index) => [index([post.title], 'by_title')]`.
|
|
29
29
|
- A migration's columns to the same methods: `table.string('email', { length: 80, nullable: true })` to `table.string('email').length(80).nullable()`, `.defaultValue()` to `.default()`, `.primaryKey()` to `.id()`, `.computed()` to `.as()`, and `.notNullable()` dropped.
|
|
30
30
|
- `@Index`, `@Trigger` and `@Filter` into `@Entity`'s `indexes`, `triggers` and `filters`, each decorator's comments kept above its entry; a class with no `@Entity` becomes `@Entity({ abstract: true, ... })`, a base that is never a table.
|
|
31
31
|
- Members named by string to callbacks: `mappedBy: 'author'` to `(post) => post.author`, and `include`, `references` and `@Entity`/`defineEntity`'s `indexes` and `hooks` likewise: `{ columns: (post) => [post.title] }`.
|
|
32
32
|
- `references: (post) => post.authorId` on a to-one (`@ManyToOne`, `@OneToOne`, or in `defineEntity`) that names no foreign key, declaring `@Field({ references: () => User }) authorId` first on a decorated class that has no such column, typed as the key it points at.
|
|
33
33
|
- A partial-index `where` string to `sql`, importing it, on entities and in the migration builder.
|
|
34
|
-
- A class registered with `defineEntity(Post, { ... })` to the schema it declares, `export const Post = defineEntity({ name: 'Post',
|
|
34
|
+
- A class registered with `defineEntity(Post, { ... })` to the schema it declares, whole or not at all: `export const Post = defineEntity({ name: 'Post', fields })` with its sections chained, as a schema's options are above, and `export type Post = InstanceType<typeof Post>`. The class is removed and a table it named kept as `table`; a field stating no `type` takes the one its property declares (`authorId?: number | null` is `f.number().nullable()`), each relation becomes `author: () => rel.manyToOne(User, { ... })`, a relation aggregate a derived field (`(post) => ({ total: () => post.comments.count() })`), a hook's method a function of the row (`this` read as the row), and the same class's `defineField`, `defineId`, `defineRelation`, `defineHook`, `defineIndex`, `defineTrigger` and `defineFilter` are folded in.
|
|
35
35
|
- A JSON index entry to the path read off the column's ref: `{ column: post.kind, jsonPath: { path: 'theme', type: Number } }` to `{ column: post.kind.theme, type: Number }`, and a `jsonArray`'s element type to `type: [String]`.
|
|
36
36
|
- `col('cost')` to the ref it names, ``(product) => sql`${product.cost}` `` in a `computed` and `refs(Item).cost` in a statement, and `$lock: { wait: 'skip' }` to `{ $wait: 'skip' }`, or to `true` for `{}` and a `'block'` wait.
|
|
37
37
|
- `$exclude` to a `$select` leaving its fields out: `$exclude: { password: true }` to `$select: { password: false }`, at every depth of `$populate`.
|
|
@@ -51,16 +51,17 @@ Reported with what to do, never guessed:
|
|
|
51
51
|
- An `$exclude` it cannot read, or one beside a `$select`: merge it in, each field as `field: false`.
|
|
52
52
|
- A to-one onto a composite key: declare a column per key and pair each with it in `references`.
|
|
53
53
|
- A to-one whose `<relation>Id` is not a declared column: an undecorated property, or a `defineEntity` whose `fields` leave it out.
|
|
54
|
-
- A class `defineEntity` registers that a schema cannot declare as it is: one that extends another class, has a method no hook runs or is decorated as well; options it cannot read; a class registered in another file, or an entity that is no class declared in the file.
|
|
54
|
+
- A class `defineEntity` registers that a schema cannot declare as it is: one that extends another class, has a method no hook runs or is decorated as well; options it cannot read, or a column or section its chain cannot write; a class registered in another file, or an entity that is no class declared in the file. It is left as written, each reason reported.
|
|
55
|
+
- A schema column's foreign key no relation can take: one whose relation name (the column less its `Id`) is taken or cannot be read off it, a key with no type of its own (`f.references(() => Order)`), or an `onDelete` its relation contradicts.
|
|
55
56
|
- A definer on a decorated class (`defineIndex(Post, ...)`), and `@Entity`'s `fields`, `relations`, `hooks` and `extends`: each is a decorator, or the class's own `extends`, now.
|
|
56
|
-
- A property typed more narrowly than its schema derives, as a note: a `Json<T>` column wants
|
|
57
|
+
- A property typed more narrowly than its schema derives, as a note: a `Json<T>` column wants a Standard Schema, a string-literal union `enum`.
|
|
57
58
|
- A partial-index `where` string that interpolates, since `sql` would bind what the template spliced in.
|
|
58
59
|
- A JSON index entry whose `path` is not a string literal, or that takes an option besides `path`, `type` and `length`.
|
|
59
60
|
- A `col()` whose entity it cannot tell: a raw built apart from its query, one under a relation filter, or a column no field maps.
|
|
60
61
|
- SQL in a definition that names a column by hand, as a note: a rename reaches a callback's refs, ``(user) => sql`lower(${user.email})` ``, and never the text.
|
|
61
62
|
- A file that already names something the codemod would write (`sql`, `refs`, `idKey`, `f`, a renamed export): left unchanged and reported, since the new import would shadow it or be shadowed.
|
|
62
63
|
- A property whose type maps to no column (`Json<T>`, a vector, a mixed union).
|
|
63
|
-
- A column option it cannot write as a method: `shape`
|
|
64
|
+
- A column option it cannot write as a method: a `shape` a JSON column no longer takes, a migration column's options passed as a variable, or a flag such as `unique: isUnique` that no literal states.
|
|
64
65
|
- A `@Field`/`@Id` with no `type`, and a relation with no `entity`: declare the column as a builder (`f.string()`) and name the target (`entity: () => Target`). A branded string id's column, `f.uuid()` or `f.string()`, is yours to pick.
|
|
65
66
|
- `Relation<T>` imported from `uql-orm`, which no longer exports it: write the property's type without it, and drop the import.
|
|
66
67
|
|
package/dist/builders.d.ts
CHANGED
|
@@ -16,3 +16,13 @@ export declare function toBuilders(fileName: string, text: string, { locate, tak
|
|
|
16
16
|
readonly locate?: (at: number) => string;
|
|
17
17
|
readonly taken: ReadonlySet<string>;
|
|
18
18
|
}): BuildersResult;
|
|
19
|
+
/**
|
|
20
|
+
* A schema's statement, `const X = defineEntity({...})`, its call written as a chain and the builders it writes, or why
|
|
21
|
+
* it cannot be.
|
|
22
|
+
*/
|
|
23
|
+
export declare function chainedSchema(text: string): {
|
|
24
|
+
readonly text: string;
|
|
25
|
+
readonly builders: ReadonlySet<string>;
|
|
26
|
+
} | {
|
|
27
|
+
readonly reasons: readonly string[];
|
|
28
|
+
};
|
package/dist/builders.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { call, flag, flagOrCall } from './columnMethods.js';
|
|
3
3
|
import { formatFindings, locationAt } from './context.js';
|
|
4
|
-
import { applyEdits, editedText,
|
|
5
|
-
import {
|
|
4
|
+
import { applyEdits, editedText, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
|
|
5
|
+
import { importEdits } from './imports.js';
|
|
6
6
|
import { isCallback, paramFor } from './keyMaps.js';
|
|
7
|
+
import { FOREIGN_KEY_IN_SCHEMA, NO_KEY_TYPE, nameClash, ON_DELETE_ON_RELATION } from './schemaMessages.js';
|
|
7
8
|
import { CHAINED_OPTIONS, FIRST_CALL_OPTIONS } from './schemaOptions.js';
|
|
8
|
-
import { chainOf, decoratorName, decoratorsOf, FIELD_DECORATORS, identifierText, identifierUses, indentOf, indentUnit, keyOf, objectLiteral, optionsOf, propertiesByKey, propertyAssignment, propertyValue, } from './syntax.js';
|
|
9
|
+
import { chainOf, decoratorName, decoratorsOf, FIELD_DECORATORS, identifierText, identifierUses, indentOf, indentUnit, keyOf, objectLiteral, objectProperties, optionsOf, propertiesByKey, propertyAssignment, propertyKey, propertyValue, } from './syntax.js';
|
|
9
10
|
/** The column type each constructor implies, and the builder it is now. */
|
|
10
11
|
const CONSTRUCTORS = {
|
|
11
12
|
String: 'f.string()',
|
|
@@ -41,8 +42,8 @@ const METHODS = [
|
|
|
41
42
|
['onDelete', call('onDelete')],
|
|
42
43
|
];
|
|
43
44
|
/** What a constructor or `nullable()`/`id()` already said, so no method repeats it. */
|
|
44
|
-
const
|
|
45
|
-
const KNOWN = new Set([...
|
|
45
|
+
const SAID_BY_CONSTRUCTOR = new Set(['type', 'nullable', 'isId', 'enum', 'version', 'computed', 'stored']);
|
|
46
|
+
const KNOWN = new Set([...SAID_BY_CONSTRUCTOR, ...METHODS.map(([option]) => option)]);
|
|
46
47
|
/** The options a literal states, each by its key, or why it cannot be read. */
|
|
47
48
|
function optionsByKey(node) {
|
|
48
49
|
const options = optionsOf(node);
|
|
@@ -100,9 +101,6 @@ function decimalOf(text) {
|
|
|
100
101
|
function constructorOf(options, text) {
|
|
101
102
|
const type = text('type');
|
|
102
103
|
const members = text('enum');
|
|
103
|
-
if (options.has('shape') && type !== "'json'" && type !== "'jsonb'") {
|
|
104
|
-
return { message: "has a 'shape' on a column that is no JSON: only a JSON column holds a payload" };
|
|
105
|
-
}
|
|
106
104
|
if (options.has('version')) {
|
|
107
105
|
text('version');
|
|
108
106
|
if (type === undefined || type === 'Number')
|
|
@@ -134,8 +132,7 @@ function constructorOf(options, text) {
|
|
|
134
132
|
return NAMED_TYPES[literal];
|
|
135
133
|
}
|
|
136
134
|
if (literal === 'json' || literal === 'jsonb') {
|
|
137
|
-
|
|
138
|
-
return `f.${literal}(${shape ?? ''})`;
|
|
135
|
+
return `f.${literal}()`;
|
|
139
136
|
}
|
|
140
137
|
if (literal === 'vector') {
|
|
141
138
|
const dimensions = text('dimensions');
|
|
@@ -261,34 +258,59 @@ function schemaEdits(call, findings, used) {
|
|
|
261
258
|
});
|
|
262
259
|
return [];
|
|
263
260
|
}
|
|
264
|
-
const references = [];
|
|
265
261
|
const field = propertyAssignment(options, 'fields');
|
|
266
|
-
const
|
|
267
|
-
|
|
262
|
+
const relations = propertyValue(options, 'relations');
|
|
263
|
+
const aggregates = propertyValue(options, 'aggregates');
|
|
264
|
+
const read = fields.properties.map((prop) => ({
|
|
265
|
+
prop,
|
|
266
|
+
column: ts.isPropertyAssignment(prop) ? schemaColumn(prop.initializer) : undefined,
|
|
267
|
+
}));
|
|
268
|
+
const { joins, inferred } = joinsOf(relations, targetsOf(read), param);
|
|
269
|
+
const targets = {
|
|
270
|
+
param,
|
|
271
|
+
rel: (relations && ts.isArrowFunction(relations) && identifierText(relations.parameters[0]?.name)) || 'rel',
|
|
272
|
+
joins,
|
|
273
|
+
insertable: !relations || returnedLiteral(relations) !== undefined,
|
|
274
|
+
};
|
|
275
|
+
const start = {
|
|
276
|
+
named: new Set([
|
|
277
|
+
fields,
|
|
278
|
+
objectLiteral(propertyValue(options, 'computed')),
|
|
279
|
+
returnedLiteral(relations),
|
|
280
|
+
returnedLiteral(aggregates),
|
|
281
|
+
]
|
|
282
|
+
.flatMap((literal) => objectProperties(literal))
|
|
283
|
+
.map((prop) => propertyKey(prop.name))),
|
|
284
|
+
added: [],
|
|
285
|
+
options: new Map([...inferred].map(([relation, reference]) => [relation, [reference]])),
|
|
286
|
+
};
|
|
287
|
+
const claimed = claimColumns(read, start, targets);
|
|
288
|
+
const columns = read.map(({ prop, column }) => {
|
|
289
|
+
if (!ts.isPropertyAssignment(prop) || !column) {
|
|
268
290
|
findings.push({ node: prop, message: `cannot write '${prop.getText()}' as a column: write its options inline` });
|
|
269
291
|
return undefined;
|
|
270
292
|
}
|
|
271
|
-
const key = prop.name.getText();
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
293
|
+
const key = propertyKey(prop.name) ?? prop.name.getText();
|
|
294
|
+
if ('message' in column) {
|
|
295
|
+
findings.push({ node: prop, message: `'${key}' ${column.message}` });
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
const problem = claimed.problems.get(prop);
|
|
299
|
+
if (problem) {
|
|
300
|
+
findings.push({ node: prop, message: `'${key}' ${problem}` });
|
|
277
301
|
return undefined;
|
|
278
302
|
}
|
|
279
|
-
return withValue(prop,
|
|
303
|
+
return withValue(prop, column.builder);
|
|
280
304
|
});
|
|
281
305
|
const sections = CHAINED_OPTIONS.flatMap((section) => {
|
|
282
306
|
const value = propertyValue(options, section);
|
|
283
307
|
return value ? [chainedSection(section, value, param, findings)] : [];
|
|
284
308
|
});
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (relations || aggregates) {
|
|
291
|
-
sections.push(`.relations(${relations?.getText() ?? '() => ({})'}${aggregates ? `, ${aggregates.getText()}` : ''})`);
|
|
309
|
+
const indent = indentOf(call);
|
|
310
|
+
const unit = indentUnit(call.getSourceFile());
|
|
311
|
+
const related = relationsText(relations, targets.rel, claimed.claims, fields.getText().includes('\n') ? `${indent}${unit}` : undefined, unit);
|
|
312
|
+
if (related || aggregates) {
|
|
313
|
+
sections.push(`.relations(${related ?? '() => ({})'}${aggregates ? `, ${aggregates.getText()}` : ''})`);
|
|
292
314
|
}
|
|
293
315
|
const written = columns.filter((it) => it !== undefined);
|
|
294
316
|
if (written.length !== columns.length || sections.some((it) => it === undefined)) {
|
|
@@ -298,9 +320,8 @@ function schemaEdits(call, findings, used) {
|
|
|
298
320
|
const kept = options.properties
|
|
299
321
|
.filter((prop) => FIRST_CALL.has(keyOf(prop)))
|
|
300
322
|
.map((prop) => (prop === field ? withValue(field, literal(fields, written)) : prop.getFullText()));
|
|
301
|
-
const indent = indentOf(call);
|
|
302
323
|
return [
|
|
303
|
-
replaced(call, `${call.expression.getText()}(${literal(options, kept)})${sections.map((it) => `\n${indent}
|
|
324
|
+
replaced(call, `${call.expression.getText()}(${literal(options, kept)})${sections.map((it) => `\n${indent}${unit}${it}`).join('')}`),
|
|
304
325
|
];
|
|
305
326
|
}
|
|
306
327
|
/** The parameter a schema's callbacks are named after: its `name` where that is a literal, else its variable's. */
|
|
@@ -309,31 +330,218 @@ function schemaParam(call) {
|
|
|
309
330
|
const name = options && /^'(.+)'$/.exec(propertyValue(options, 'name')?.getText() ?? '')?.[1];
|
|
310
331
|
return paramFor(name ?? identifierText(variableOf(call)));
|
|
311
332
|
}
|
|
312
|
-
/** A schema column
|
|
313
|
-
function
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
333
|
+
/** A schema column's builder off its options or chain, its `references` and `onDelete` taken out to move to a relation. */
|
|
334
|
+
function schemaColumn(node) {
|
|
335
|
+
if (isBuilder(node)) {
|
|
336
|
+
const methods = chainMethods(node);
|
|
337
|
+
const references = methods.get('references');
|
|
338
|
+
if (!references) {
|
|
339
|
+
return { builder: node.getText() };
|
|
340
|
+
}
|
|
341
|
+
const target = targetOf(references.call.arguments[0]);
|
|
342
|
+
if (identifierText(references.receiver) === 'f' || !target) {
|
|
343
|
+
return { message: target ? NO_KEY_TYPE : FOREIGN_KEY_IN_SCHEMA };
|
|
344
|
+
}
|
|
345
|
+
const onDelete = methods.get('onDelete');
|
|
346
|
+
const cut = [references, ...(onDelete ? [onDelete] : [])].map((method) => ({
|
|
347
|
+
start: method.receiver.getEnd() - node.getStart(),
|
|
348
|
+
end: method.call.getEnd() - node.getStart(),
|
|
349
|
+
text: '',
|
|
350
|
+
}));
|
|
351
|
+
return {
|
|
352
|
+
builder: applyEdits(node.getText(), cut),
|
|
353
|
+
key: { target, onDelete: onDelete?.call.arguments[0]?.getText() },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const options = optionsByKey(node);
|
|
357
|
+
if (typeof options === 'string') {
|
|
358
|
+
return { message: `cannot be written as a builder because ${options}` };
|
|
359
|
+
}
|
|
360
|
+
const references = options.get('references');
|
|
361
|
+
const target = references && targetOf(references);
|
|
362
|
+
if (references && !target) {
|
|
363
|
+
return { message: FOREIGN_KEY_IN_SCHEMA };
|
|
364
|
+
}
|
|
365
|
+
if (target && !options.has('type')) {
|
|
366
|
+
return { message: NO_KEY_TYPE };
|
|
367
|
+
}
|
|
368
|
+
const built = columnBuilder(new Map([...options].filter(([option]) => !target || (option !== 'references' && option !== 'onDelete'))), false);
|
|
369
|
+
if (typeof built !== 'string') {
|
|
370
|
+
return built;
|
|
371
|
+
}
|
|
372
|
+
return target
|
|
373
|
+
? { builder: built, key: { target, onDelete: options.get('onDelete')?.getText() } }
|
|
374
|
+
: { builder: built };
|
|
375
|
+
}
|
|
376
|
+
/** The entity a `() => Target` getter names, as its text. */
|
|
377
|
+
function targetOf(getter) {
|
|
378
|
+
return getter && ts.isArrowFunction(getter) && !ts.isBlock(getter.body) ? getter.body.getText() : undefined;
|
|
379
|
+
}
|
|
380
|
+
/** Each column's foreign key target, for the columns naming one. */
|
|
381
|
+
function targetsOf(read) {
|
|
382
|
+
return new Map(read.flatMap(({ prop, column }) => column && 'key' in column && column.key ? [[prop.name?.getText(), column.key.target]] : []));
|
|
383
|
+
}
|
|
384
|
+
/** The schema's columns' foreign keys claimed in turn, the problem of each column by its name node. */
|
|
385
|
+
function claimColumns(read, start, targets) {
|
|
386
|
+
const problems = new Map();
|
|
387
|
+
const claims = read.reduce((state, { prop, column }) => {
|
|
388
|
+
if (!ts.isPropertyAssignment(prop) || !column || !('key' in column) || !column.key) {
|
|
389
|
+
return state;
|
|
390
|
+
}
|
|
391
|
+
const outcome = claimKey(prop.name, column.key, state, targets);
|
|
392
|
+
if (outcome.problem) {
|
|
393
|
+
problems.set(prop, outcome.problem);
|
|
394
|
+
}
|
|
395
|
+
return outcome.claims;
|
|
396
|
+
}, start);
|
|
397
|
+
return { claims, problems };
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A column's foreign key moved into the to-one joining on it, with its `onDelete`, else into a `manyToOne` written for
|
|
401
|
+
* it, named as the column less its `Id`; or why it cannot be.
|
|
402
|
+
*/
|
|
403
|
+
function claimKey(name, { target, onDelete }, claims, targets) {
|
|
404
|
+
const key = name.getText();
|
|
405
|
+
const join = targets.joins.get(key);
|
|
406
|
+
if (join) {
|
|
407
|
+
return onDelete ? movedOnDelete(join, onDelete, claims) : { claims };
|
|
408
|
+
}
|
|
409
|
+
const relation = ts.isIdentifier(name) ? /^(.+?)(?:Id|_id)$/.exec(key)?.[1] : undefined;
|
|
410
|
+
if (!relation || claims.named.has(relation) || !targets.insertable) {
|
|
411
|
+
return { claims, problem: FOREIGN_KEY_IN_SCHEMA };
|
|
412
|
+
}
|
|
413
|
+
const written = [
|
|
414
|
+
`references: (${targets.param}) => ${targets.param}.${key}`,
|
|
415
|
+
...(onDelete ? [`onDelete: ${onDelete}`] : []),
|
|
416
|
+
];
|
|
417
|
+
return {
|
|
418
|
+
claims: {
|
|
419
|
+
...claims,
|
|
420
|
+
named: new Set([...claims.named, relation]),
|
|
421
|
+
added: [...claims.added, `${relation}: () => ${targets.rel}.manyToOne(${target}, { ${written.join(', ')} })`],
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** Writes a column's `onDelete` into the to-one joining on it, or says why not: it states another, or options none can be written into. */
|
|
426
|
+
function movedOnDelete(relation, onDelete, claims) {
|
|
427
|
+
const written = `onDelete: ${onDelete}`;
|
|
428
|
+
const options = relation.arguments[1];
|
|
429
|
+
const literal = objectLiteral(options);
|
|
430
|
+
const stated = literal ? propertyValue(literal, 'onDelete')?.getText() : undefined;
|
|
431
|
+
const current = claims.options.get(relation) ?? [];
|
|
432
|
+
const pending = current.find((option) => option.startsWith('onDelete: '));
|
|
433
|
+
if ((options && !literal) || (stated ?? onDelete) !== onDelete || (pending ?? written) !== written) {
|
|
434
|
+
return { claims, problem: ON_DELETE_ON_RELATION };
|
|
435
|
+
}
|
|
436
|
+
return stated || pending
|
|
437
|
+
? { claims }
|
|
438
|
+
: { claims: { ...claims, options: new Map(claims.options).set(relation, [...current, written]) } };
|
|
439
|
+
}
|
|
440
|
+
/** The relation calls a schema's `relations` returns, one per relation. */
|
|
441
|
+
function relationCalls(relations) {
|
|
442
|
+
return objectProperties(returnedLiteral(relations)).flatMap((prop) => {
|
|
443
|
+
const relation = ts.isArrowFunction(prop.initializer) ? prop.initializer.body : undefined;
|
|
444
|
+
return relation && ts.isCallExpression(relation) ? [relation] : [];
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** The object literal a callback returns, `(rel) => ({ ... })`, or nothing where it returns none so. */
|
|
448
|
+
function returnedLiteral(callback) {
|
|
449
|
+
const body = callback && ts.isArrowFunction(callback) && !ts.isBlock(callback.body) ? callback.body : undefined;
|
|
450
|
+
return objectLiteral(body && ts.isParenthesizedExpression(body) ? body.expression : body);
|
|
318
451
|
}
|
|
319
452
|
/**
|
|
320
|
-
*
|
|
321
|
-
*
|
|
453
|
+
* The to-one joining on each column: by the `references` it names, `(row) => row.column` or pairs of `local`s, else by
|
|
454
|
+
* the ORM's inference, the one column whose foreign key names its target, whose `references` is written as inferred.
|
|
322
455
|
*/
|
|
323
|
-
function
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
456
|
+
function joinsOf(relations, targets, param) {
|
|
457
|
+
const joins = new Map();
|
|
458
|
+
const inferred = new Map();
|
|
459
|
+
for (const relation of relationCalls(relations)) {
|
|
460
|
+
const named = namedColumns(relation);
|
|
461
|
+
for (const column of named ?? []) {
|
|
462
|
+
if (!joins.has(column))
|
|
463
|
+
joins.set(column, relation);
|
|
464
|
+
}
|
|
465
|
+
const target = relation.arguments[0]?.getText();
|
|
466
|
+
const matching = [...targets].filter(([, named]) => named === target).map(([key]) => key);
|
|
467
|
+
const column = matching.length === 1 ? matching[0] : undefined;
|
|
468
|
+
if (!named && isToOne(relation) && writableOptions(relation) && column && !joins.has(column)) {
|
|
469
|
+
inferred.set(relation, `references: (${param}) => ${param}.${column}`);
|
|
470
|
+
joins.set(column, relation);
|
|
471
|
+
}
|
|
327
472
|
}
|
|
328
|
-
|
|
329
|
-
|
|
473
|
+
return { joins, inferred };
|
|
474
|
+
}
|
|
475
|
+
/** The columns a relation's `references` names on its local side, or nothing where it names none it can read. */
|
|
476
|
+
function namedColumns(relation) {
|
|
477
|
+
const options = objectLiteral(relation.arguments[1]);
|
|
478
|
+
const references = options && propertyValue(options, 'references');
|
|
479
|
+
if (!references) {
|
|
480
|
+
return undefined;
|
|
330
481
|
}
|
|
331
|
-
|
|
332
|
-
|
|
482
|
+
const local = ts.isArrowFunction(references) ? identifierText(references.parameters[0]?.name) : undefined;
|
|
483
|
+
const column = (node) => node && ts.isPropertyAccessExpression(node) && identifierText(node.expression) === local
|
|
484
|
+
? node.name.text
|
|
485
|
+
: undefined;
|
|
486
|
+
const body = local && ts.isArrowFunction(references) ? references.body : undefined;
|
|
487
|
+
if (body && ts.isArrayLiteralExpression(body)) {
|
|
488
|
+
const locals = body.elements.map((pair) => {
|
|
489
|
+
const literal = objectLiteral(pair);
|
|
490
|
+
return column(literal && propertyValue(literal, 'local'));
|
|
491
|
+
});
|
|
492
|
+
return locals.every((it) => it !== undefined) ? locals : [];
|
|
493
|
+
}
|
|
494
|
+
const single = body && ts.isExpression(body) ? column(body) : undefined;
|
|
495
|
+
return single ? [single] : [];
|
|
496
|
+
}
|
|
497
|
+
function isToOne(relation) {
|
|
498
|
+
return ts.isPropertyAccessExpression(relation.expression) && relation.expression.name.text === 'manyToOne';
|
|
499
|
+
}
|
|
500
|
+
/** Whether a relation's options are absent or a literal, the only kind an option can be written into. */
|
|
501
|
+
function writableOptions(relation) {
|
|
502
|
+
return relation.arguments.length < 2 || objectLiteral(relation.arguments[1]) !== undefined;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The relations' text, each to-one's options written into its relation and each relation added for a foreign key
|
|
506
|
+
* appended, or nothing where there are none. A section of its own is laid out a line per relation, a `unit` in from
|
|
507
|
+
* the section's `indent`, where one is given.
|
|
508
|
+
*/
|
|
509
|
+
function relationsText(relations, rel, { options, added }, indent, unit) {
|
|
510
|
+
if (!relations) {
|
|
511
|
+
if (!added.length)
|
|
512
|
+
return undefined;
|
|
513
|
+
const listed = indent === undefined
|
|
514
|
+
? `{ ${added.join(', ')} }`
|
|
515
|
+
: `{\n${added.map((entry) => `${indent}${unit}${entry},\n`).join('')}${indent}}`;
|
|
516
|
+
return `(${rel}) => (${listed})`;
|
|
517
|
+
}
|
|
518
|
+
const base = relations.getStart();
|
|
519
|
+
const edits = [...options].flatMap(([relation, written]) => written.length ? [withOptions(relation, written, base)] : []);
|
|
520
|
+
const literal = returnedLiteral(relations);
|
|
521
|
+
if (literal && added.length) {
|
|
522
|
+
edits.push(appendedEntries(literal, added, base));
|
|
523
|
+
}
|
|
524
|
+
return applyEdits(relations.getText(), edits);
|
|
525
|
+
}
|
|
526
|
+
/** The edit writing `options` into a relation call: after its options' last, else as options after its target. */
|
|
527
|
+
function withOptions(relation, options, base) {
|
|
528
|
+
const literal = objectLiteral(relation.arguments[1]);
|
|
529
|
+
const last = literal?.properties.at(-1);
|
|
530
|
+
if (literal && !last) {
|
|
531
|
+
return { start: literal.getStart() - base, end: literal.getEnd() - base, text: `{ ${options.join(', ')} }` };
|
|
532
|
+
}
|
|
533
|
+
const at = (last ?? relation.arguments[0]).getEnd() - base;
|
|
534
|
+
return { start: at, end: at, text: last ? `, ${options.join(', ')}` : `, { ${options.join(', ')} }` };
|
|
333
535
|
}
|
|
334
|
-
/**
|
|
335
|
-
function
|
|
336
|
-
|
|
536
|
+
/** The edit appending `entries` to an object literal, laid out as its own entries are. */
|
|
537
|
+
function appendedEntries(literal, entries, base) {
|
|
538
|
+
const last = literal.properties.at(-1);
|
|
539
|
+
if (!last) {
|
|
540
|
+
return { start: literal.getStart() - base, end: literal.getEnd() - base, text: `{ ${entries.join(', ')} }` };
|
|
541
|
+
}
|
|
542
|
+
const separator = literal.getText().includes('\n') ? `,\n${indentOf(last)}` : ', ';
|
|
543
|
+
const at = last.getEnd() - base;
|
|
544
|
+
return { start: at, end: at, text: entries.map((entry) => `${separator}${entry}`).join('') };
|
|
337
545
|
}
|
|
338
546
|
/** Whether a schema column is a builder naming no foreign key, which a schema's `fields` keep as they are. */
|
|
339
547
|
function isSchemaColumn(prop) {
|
|
@@ -343,24 +551,6 @@ function isSchemaColumn(prop) {
|
|
|
343
551
|
function chainMethods(node) {
|
|
344
552
|
return new Map(chainOf(node).methods.map((method) => [method.name, method]));
|
|
345
553
|
}
|
|
346
|
-
/** A builder in `fields` with its foreign key, `.references(() => X)` and `.onDelete()`, moved into `.references()`. */
|
|
347
|
-
function builtSchemaColumn(node, key, references) {
|
|
348
|
-
const methods = chainMethods(node);
|
|
349
|
-
const target = methods.get('references');
|
|
350
|
-
if (!target) {
|
|
351
|
-
return node.getText();
|
|
352
|
-
}
|
|
353
|
-
if (identifierText(target.receiver) === 'f') {
|
|
354
|
-
return { message: "references another entity but states no type: write its builder, as its key's" };
|
|
355
|
-
}
|
|
356
|
-
const onDelete = methods.get('onDelete');
|
|
357
|
-
references.push(referenceEntry(key, target.call.arguments[0]?.getText(), onDelete?.call.arguments[0]?.getText()));
|
|
358
|
-
// Each method is cut out with the chain before it: from that receiver's end to the call's.
|
|
359
|
-
const cut = [onDelete, target].flatMap((method) => method
|
|
360
|
-
? [{ start: method.receiver.getEnd() - node.getStart(), end: method.call.getEnd() - node.getStart(), text: '' }]
|
|
361
|
-
: []);
|
|
362
|
-
return applyEdits(node.getText(), cut);
|
|
363
|
-
}
|
|
364
554
|
/**
|
|
365
555
|
* An object literal of `properties`, each its full text with its comments, laid out as `node` was: on one line
|
|
366
556
|
* where it was written on one, a property a line otherwise.
|
|
@@ -562,27 +752,32 @@ export function toBuilders(fileName, text, { locate, taken }) {
|
|
|
562
752
|
if (clash) {
|
|
563
753
|
return {
|
|
564
754
|
...unedited(text),
|
|
565
|
-
unresolved: [
|
|
566
|
-
...unresolved,
|
|
567
|
-
`${located(0)}: '${clash}' is already named here, and the codemod writes it: rename that, then run again`,
|
|
568
|
-
],
|
|
755
|
+
unresolved: [...unresolved, `${located(0)}: ${nameClash(clash)}: rename that, then run again`],
|
|
569
756
|
};
|
|
570
757
|
}
|
|
571
|
-
return {
|
|
758
|
+
return {
|
|
759
|
+
...reparsedAgain(fileName, editedText(text, edits), (edited) => importEdits(edited, used, FIELD_DECORATORS)),
|
|
760
|
+
unresolved,
|
|
761
|
+
};
|
|
572
762
|
}
|
|
573
|
-
/**
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
763
|
+
/**
|
|
764
|
+
* A schema's statement, `const X = defineEntity({...})`, its call written as a chain and the builders it writes, or why
|
|
765
|
+
* it cannot be.
|
|
766
|
+
*/
|
|
767
|
+
export function chainedSchema(text) {
|
|
768
|
+
const source = ts.createSourceFile('/schema.ts', text, ts.ScriptTarget.Latest, true);
|
|
769
|
+
const findings = [];
|
|
770
|
+
const used = new Set();
|
|
771
|
+
const edits = [];
|
|
772
|
+
const visit = (node) => {
|
|
773
|
+
if (ts.isCallExpression(node) && identifierText(node.expression) === 'defineEntity') {
|
|
774
|
+
edits.push(...schemaEdits(node, findings, used));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
ts.forEachChild(node, visit);
|
|
778
|
+
};
|
|
779
|
+
visit(source);
|
|
780
|
+
return findings.length
|
|
781
|
+
? { reasons: findings.map(({ message }) => message) }
|
|
782
|
+
: { text: applyEdits(text, edits), builders: used };
|
|
588
783
|
}
|
package/dist/imports.d.ts
CHANGED
|
@@ -52,6 +52,9 @@ export declare function importRemovalEdits(declaration: ts.ImportDeclaration, el
|
|
|
52
52
|
export declare function spelledNames(source: ts.SourceFile): ReadonlySet<string>;
|
|
53
53
|
/** The import names in `candidates` that the file no longer spells. */
|
|
54
54
|
export declare function deadImports(elements: readonly ts.ImportSpecifier[], candidates: ReadonlySet<string | undefined>, spelled: ReadonlySet<string>): readonly ts.ImportSpecifier[];
|
|
55
|
-
/**
|
|
56
|
-
|
|
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[];
|
|
57
60
|
export {};
|
package/dist/imports.js
CHANGED
|
@@ -400,12 +400,30 @@ export function spelledNames(source) {
|
|
|
400
400
|
export function deadImports(elements, candidates, spelled) {
|
|
401
401
|
return elements.filter((it) => candidates.has(it.name.text) && !spelled.has(it.name.text));
|
|
402
402
|
}
|
|
403
|
-
/**
|
|
404
|
-
|
|
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
|
+
}
|
|
405
415
|
const spelled = spelledNames(source);
|
|
406
|
-
return
|
|
416
|
+
return declarations.flatMap(({ declaration, elements }) => {
|
|
407
417
|
const dead = deadImports(elements, candidates, spelled);
|
|
408
|
-
|
|
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'))];
|
|
409
427
|
});
|
|
410
428
|
}
|
|
411
429
|
/** The import with the `dead` names out and `join` in, nothing else of it touched. */
|
package/dist/schemaEntity.d.ts
CHANGED
|
@@ -4,9 +4,12 @@ export type SchemaResult = Pass & {
|
|
|
4
4
|
readonly notes: string[];
|
|
5
5
|
};
|
|
6
6
|
/**
|
|
7
|
-
* Declares each class `defineEntity(Class, options)` registered as the schema `defineEntity({ name,
|
|
8
|
-
* folding the string-keyed definers of the same class in. Read off the text the other rewrites
|
|
9
|
-
* whose options already name members by callback, so it only moves what is written. An entity it cannot
|
|
10
|
-
* declare faithfully is left as written and reported.
|
|
7
|
+
* Declares each class `defineEntity(Class, options)` registered as the schema `defineEntity({ name, fields })` and its
|
|
8
|
+
* chain of sections, folding the string-keyed definers of the same class in. Read off the text the other rewrites
|
|
9
|
+
* produced, whose options already name members by callback, so it only moves what is written. An entity it cannot
|
|
10
|
+
* declare faithfully, `taken` naming a builder it writes among them, is left as written and reported.
|
|
11
11
|
*/
|
|
12
|
-
export declare function toSchemaEntities(fileName: string, text: string, locate
|
|
12
|
+
export declare function toSchemaEntities(fileName: string, text: string, { locate, taken }: {
|
|
13
|
+
readonly locate?: (at: number) => string;
|
|
14
|
+
readonly taken: ReadonlySet<string>;
|
|
15
|
+
}): SchemaResult;
|
package/dist/schemaEntity.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
+
import { chainedSchema } from './builders.js';
|
|
2
3
|
import { formatFindings, locationAt } from './context.js';
|
|
3
4
|
import { CLASS_DEFINERS, definerCall } from './definers.js';
|
|
4
5
|
import { editedText, removeStatement, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
|
|
5
|
-
import {
|
|
6
|
+
import { importEdits } from './imports.js';
|
|
6
7
|
import { memberKey, paramFor, quoted } from './keyMaps.js';
|
|
8
|
+
import { nameClash } from './schemaMessages.js';
|
|
7
9
|
import { KEPT_OPTIONS } from './schemaOptions.js';
|
|
8
10
|
import { arrayElements, decoratorsOf, freeName, hasModifier, identifierText, identifierUses, indentOf, indentUnit, isNamedProperty, objectLiteral, objectProperties, propertyKey, propertyValue, } from './syntax.js';
|
|
9
11
|
/** Imports a converted file may no longer read: the definers folded in, and the brands a schema derives. */
|
|
@@ -47,12 +49,12 @@ function readsAs(value, shape) {
|
|
|
47
49
|
/** What a class-form relation said that a `rel` builder says by its name and first argument. */
|
|
48
50
|
const RELATION_SHAPE = new Set(['cardinality', 'entity']);
|
|
49
51
|
/**
|
|
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
|
|
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.
|
|
52
|
+
* Declares each class `defineEntity(Class, options)` registered as the schema `defineEntity({ name, fields })` and its
|
|
53
|
+
* chain of sections, folding the string-keyed definers of the same class in. Read off the text the other rewrites
|
|
54
|
+
* produced, whose options already name members by callback, so it only moves what is written. An entity it cannot
|
|
55
|
+
* declare faithfully, `taken` naming a builder it writes among them, is left as written and reported.
|
|
54
56
|
*/
|
|
55
|
-
export function toSchemaEntities(fileName, text, locate) {
|
|
57
|
+
export function toSchemaEntities(fileName, text, { locate, taken }) {
|
|
56
58
|
if (!CLASS_FORM_CALL.test(text)) {
|
|
57
59
|
return { ...unedited(text), unresolved: [], notes: [] };
|
|
58
60
|
}
|
|
@@ -62,6 +64,7 @@ export function toSchemaEntities(fileName, text, locate) {
|
|
|
62
64
|
const unresolved = [];
|
|
63
65
|
const notes = [];
|
|
64
66
|
const edits = [];
|
|
67
|
+
const written = new Set();
|
|
65
68
|
const registrations = collectRegistrations(source, unresolved);
|
|
66
69
|
const entityCallOf = (calls) => calls.find(({ definer }) => definer === 'defineEntity')?.call;
|
|
67
70
|
for (const [name, { cls, calls }] of registrations) {
|
|
@@ -84,15 +87,27 @@ export function toSchemaEntities(fileName, text, locate) {
|
|
|
84
87
|
unresolved.push(schema);
|
|
85
88
|
continue;
|
|
86
89
|
}
|
|
90
|
+
const chained = chainedSchema(schema.text);
|
|
91
|
+
if (!('text' in chained)) {
|
|
92
|
+
unresolved.push(...chained.reasons.map((reason) => ({ node: entityCall, message: `'${name}' is left as written: ${reason}` })));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const clash = [...chained.builders].find((builder) => taken.has(builder));
|
|
96
|
+
if (clash !== undefined) {
|
|
97
|
+
unresolved.push({ node: entityCall, message: `'${name}' is left as written: ${nameClash(clash)}` });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const builder of chained.builders)
|
|
101
|
+
written.add(builder);
|
|
87
102
|
notes.push(...schema.notes);
|
|
88
103
|
edits.push(removeStatement(cls));
|
|
89
104
|
for (const { call } of calls) {
|
|
90
|
-
edits.push(call === entityCall ? replaced(call.parent, indented(
|
|
105
|
+
edits.push(call === entityCall ? replaced(call.parent, indented(chained.text, call.parent)) : removeStatement(call.parent));
|
|
91
106
|
}
|
|
92
107
|
}
|
|
93
108
|
return {
|
|
94
109
|
...(edits.length
|
|
95
|
-
? reparsedAgain(fileName, editedText(text, edits), (edited) =>
|
|
110
|
+
? reparsedAgain(fileName, editedText(text, edits), (edited) => importEdits(edited, written, FOLDED_IMPORTS))
|
|
96
111
|
: unedited(text)),
|
|
97
112
|
unresolved: formatFindings(unresolved, located),
|
|
98
113
|
notes: formatFindings(notes, located),
|
|
@@ -171,6 +186,8 @@ function schemaOf(name, cls, calls, entityCall, unit) {
|
|
|
171
186
|
name,
|
|
172
187
|
param: paramFor(name),
|
|
173
188
|
rel: freeName('rel', calls.map(({ call }) => call)),
|
|
189
|
+
self: freeName(paramFor(name), outerReads(paramFor(name), options, calls)),
|
|
190
|
+
declared: declaredTypes(cls),
|
|
174
191
|
relations: new Set([
|
|
175
192
|
...objectProperties(propertyValue(options, 'relations')).map((it) => propertyKey(it.name)),
|
|
176
193
|
...calls.filter((read) => read.definer === 'defineRelation').map((read) => read.key),
|
|
@@ -182,6 +199,7 @@ function schemaOf(name, cls, calls, entityCall, unit) {
|
|
|
182
199
|
};
|
|
183
200
|
const gathered = {
|
|
184
201
|
fields: [],
|
|
202
|
+
typed: false,
|
|
185
203
|
computed: [],
|
|
186
204
|
aggregates: [],
|
|
187
205
|
relations: new Map(),
|
|
@@ -221,6 +239,7 @@ function schemaOf(name, cls, calls, entityCall, unit) {
|
|
|
221
239
|
const fieldsValue = propertyValue(options, 'fields');
|
|
222
240
|
const untouched = fieldsValue &&
|
|
223
241
|
gathered.fields.length === objectProperties(fieldsValue).length &&
|
|
242
|
+
!gathered.typed &&
|
|
224
243
|
!gathered.computed.length &&
|
|
225
244
|
!gathered.aggregates.length;
|
|
226
245
|
lines.push(`fields: ${untouched ? textOf(fieldsValue) : block(gathered.fields, unit)}`);
|
|
@@ -231,7 +250,7 @@ function schemaOf(name, cls, calls, entityCall, unit) {
|
|
|
231
250
|
lines.push(`relations: (${scope.rel}) => (${block([...gathered.relations.values()], unit)})`);
|
|
232
251
|
}
|
|
233
252
|
if (gathered.aggregates.length) {
|
|
234
|
-
lines.push(`aggregates: (${scope.
|
|
253
|
+
lines.push(`aggregates: (${scope.self}) => (${block(gathered.aggregates, unit)})`);
|
|
235
254
|
}
|
|
236
255
|
for (const key of KEPT_OPTIONS) {
|
|
237
256
|
const entries = gathered.kept.get(key);
|
|
@@ -283,7 +302,7 @@ function entriesOfValue(key, value) {
|
|
|
283
302
|
* Adds what one registration declares to `gathered`: `options` for the `defineEntity` call, its arguments for an
|
|
284
303
|
* incremental definer. Returns why it cannot, where it cannot.
|
|
285
304
|
*/
|
|
286
|
-
function gather({ call, definer, key, options: third, rest }, options, gathered, { name, param, rel, relations, methods, hooked }) {
|
|
305
|
+
function gather({ call, definer, key, options: third, rest }, options, gathered, { name, param, rel, self, relations, methods, hooked, declared }) {
|
|
287
306
|
const unreadable = {
|
|
288
307
|
node: call,
|
|
289
308
|
message: `${definer}'s arguments could not be read: fold them into defineEntity by hand`,
|
|
@@ -306,8 +325,8 @@ function gather({ call, definer, key, options: third, rest }, options, gathered,
|
|
|
306
325
|
gathered.hooks.set(event, runs);
|
|
307
326
|
return undefined;
|
|
308
327
|
};
|
|
309
|
-
const addField = (key,
|
|
310
|
-
const aggregate = key && aggregateEntry(`${name}.${key}`, key, fieldOptions, relations,
|
|
328
|
+
const addField = (key, label, value, fieldOptions, node) => {
|
|
329
|
+
const aggregate = key && aggregateEntry(`${name}.${key}`, key, fieldOptions, relations, self);
|
|
311
330
|
if (typeof aggregate === 'string') {
|
|
312
331
|
gathered.aggregates.push(aggregate);
|
|
313
332
|
return undefined;
|
|
@@ -315,6 +334,9 @@ function gather({ call, definer, key, options: third, rest }, options, gathered,
|
|
|
315
334
|
if (aggregate) {
|
|
316
335
|
return { node, message: aggregate.message };
|
|
317
336
|
}
|
|
337
|
+
const type = key === undefined ? undefined : untyped(fieldOptions) && declared.get(key);
|
|
338
|
+
gathered.typed ||= !!type;
|
|
339
|
+
const entry = `${label}: ${type ? withOption(value, `type: ${type}`) : value}`;
|
|
318
340
|
(isComputed(fieldOptions) ? gathered.computed : gathered.fields).push(entry);
|
|
319
341
|
return undefined;
|
|
320
342
|
};
|
|
@@ -332,7 +354,7 @@ function gather({ call, definer, key, options: third, rest }, options, gathered,
|
|
|
332
354
|
};
|
|
333
355
|
if (options) {
|
|
334
356
|
for (const field of objectProperties(propertyValue(options, 'fields'))) {
|
|
335
|
-
const problem = addField(propertyKey(field.name), textOf(field), field.initializer, field);
|
|
357
|
+
const problem = addField(propertyKey(field.name), textOf(field.name), textOf(field.initializer), field.initializer, field);
|
|
336
358
|
if (problem)
|
|
337
359
|
return problem;
|
|
338
360
|
}
|
|
@@ -370,10 +392,11 @@ function gather({ call, definer, key, options: third, rest }, options, gathered,
|
|
|
370
392
|
case 'defineId': {
|
|
371
393
|
if (!key || !third)
|
|
372
394
|
return unreadable;
|
|
373
|
-
const
|
|
395
|
+
const literal = objectLiteral(third);
|
|
396
|
+
const value = definer === 'defineId' ? literal && withOption(textOf(literal), 'isId: true') : textOf(third);
|
|
374
397
|
if (!value)
|
|
375
398
|
return unreadable;
|
|
376
|
-
return addField(key,
|
|
399
|
+
return addField(key, memberKey(key), value, third, call);
|
|
377
400
|
}
|
|
378
401
|
case 'defineRelation':
|
|
379
402
|
return third && addRelation(key, third) ? undefined : unreadable;
|
|
@@ -403,13 +426,50 @@ function isComputed(options) {
|
|
|
403
426
|
const literal = objectLiteral(options);
|
|
404
427
|
return !!literal && propertyValue(literal, 'computed') !== undefined;
|
|
405
428
|
}
|
|
406
|
-
/**
|
|
407
|
-
|
|
429
|
+
/**
|
|
430
|
+
* The bodies of the field callbacks whose parameter is not `name`, where a `name` they read is the file's: a
|
|
431
|
+
* callback taking `name` reads its own under it, which the aggregate it becomes reads under the same name.
|
|
432
|
+
*/
|
|
433
|
+
function outerReads(name, options, calls) {
|
|
434
|
+
const declared = [
|
|
435
|
+
...objectProperties(propertyValue(options, 'fields')).map((field) => field.initializer),
|
|
436
|
+
...calls.flatMap(({ options: third }) => (third ? [third] : [])),
|
|
437
|
+
];
|
|
438
|
+
return declared.flatMap((field) => {
|
|
439
|
+
const literal = objectLiteral(field);
|
|
440
|
+
const computed = literal && propertyValue(literal, 'computed');
|
|
441
|
+
return computed && ts.isArrowFunction(computed) && identifierText(computed.parameters[0]?.name) !== name
|
|
442
|
+
? [computed.body]
|
|
443
|
+
: [];
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/** An options literal's text with `option` written first: `{ isId: true, type: String }`. */
|
|
447
|
+
function withOption(literal, option) {
|
|
448
|
+
return /^\{\s*\}$/.test(literal) ? `{ ${option} }` : literal.replace(/^\{\s*/, `{ ${option}, `);
|
|
449
|
+
}
|
|
450
|
+
/** Whether a field's options are a literal stating no column type, which the class's declaration may say instead. */
|
|
451
|
+
function untyped(options) {
|
|
408
452
|
const literal = objectLiteral(options);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
453
|
+
return !!literal && ['type', 'enum', 'version'].every((option) => propertyValue(literal, option) === undefined);
|
|
454
|
+
}
|
|
455
|
+
/** The constructors the TypeScript types of a class's properties name: `number | null` is `Number`. */
|
|
456
|
+
const DECLARED_TYPES = {
|
|
457
|
+
number: 'Number',
|
|
458
|
+
string: 'String',
|
|
459
|
+
boolean: 'Boolean',
|
|
460
|
+
bigint: 'BigInt',
|
|
461
|
+
Date: 'Date',
|
|
462
|
+
};
|
|
463
|
+
/** Each property a class declares as one of {@link DECLARED_TYPES}, with that type's constructor. */
|
|
464
|
+
function declaredTypes(cls) {
|
|
465
|
+
return new Map(cls.members.filter(ts.isPropertyDeclaration).flatMap((member) => {
|
|
466
|
+
const key = propertyKey(member.name);
|
|
467
|
+
const type = member.type;
|
|
468
|
+
const arms = type && ts.isUnionTypeNode(type) ? type.types : type ? [type] : [];
|
|
469
|
+
const named = arms.map((arm) => arm.getText()).filter((arm) => arm !== 'null' && arm !== 'undefined');
|
|
470
|
+
const constructor = named.length === 1 ? DECLARED_TYPES[named[0]] : undefined;
|
|
471
|
+
return key && constructor ? [[key, constructor]] : [];
|
|
472
|
+
}));
|
|
413
473
|
}
|
|
414
474
|
/** `author: () => rel.manyToOne(User, { references })` from `{ cardinality: 'm1', entity: () => User, references }`. */
|
|
415
475
|
function relationEntry(key, value, rel) {
|
|
@@ -498,11 +558,11 @@ function looserTypes(cls, options) {
|
|
|
498
558
|
}
|
|
499
559
|
const written = textOf(declared);
|
|
500
560
|
const said = (option) => propertyValue(field, option) !== undefined;
|
|
501
|
-
if (/\bJson</.test(written) && !said('
|
|
561
|
+
if (/\bJson</.test(written) && !said('schema')) {
|
|
502
562
|
return [
|
|
503
563
|
{
|
|
504
564
|
node: member,
|
|
505
|
-
message: `'${key}' was declared ${written}: describe its payload with '
|
|
565
|
+
message: `'${key}' was declared ${written}: describe its payload with a Standard Schema, 'f.json(schema)', to keep it typed`,
|
|
506
566
|
},
|
|
507
567
|
];
|
|
508
568
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Why a name the codemod writes is refused: a file already binds it. */
|
|
2
|
+
export declare const nameClash: (name: string) => string;
|
|
3
|
+
/** Why a schema column's foreign key is refused: no relation joins on it, and none can be written for it. */
|
|
4
|
+
export declare const FOREIGN_KEY_IN_SCHEMA = "references another entity, and no relation can be named for it: declare one in '.relations()', as 'rel.manyToOne(Target, { references: (row) => row.key })'";
|
|
5
|
+
/** Why a joined column's `onDelete` is refused: its relation states another, or takes options the codemod cannot write into. */
|
|
6
|
+
export declare const ON_DELETE_ON_RELATION = "joins through a relation that cannot take its 'onDelete': settle the action in that relation's options";
|
|
7
|
+
/** Why a foreign key with no type is refused: a schema column states its own, which only its target's key implied. */
|
|
8
|
+
export declare const NO_KEY_TYPE = "takes its type from its target: write its builder, f.number() and so on, as its key's type";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Why a name the codemod writes is refused: a file already binds it. */
|
|
2
|
+
export const nameClash = (name) => `'${name}' is already named here, and the codemod writes it`;
|
|
3
|
+
/** Why a schema column's foreign key is refused: no relation joins on it, and none can be written for it. */
|
|
4
|
+
export const FOREIGN_KEY_IN_SCHEMA = "references another entity, and no relation can be named for it: declare one in '.relations()', as 'rel.manyToOne(Target, { references: (row) => row.key })'";
|
|
5
|
+
/** Why a joined column's `onDelete` is refused: its relation states another, or takes options the codemod cannot write into. */
|
|
6
|
+
export const ON_DELETE_ON_RELATION = "joins through a relation that cannot take its 'onDelete': settle the action in that relation's options";
|
|
7
|
+
/** Why a foreign key with no type is refused: a schema column states its own, which only its target's key implied. */
|
|
8
|
+
export const NO_KEY_TYPE = "takes its type from its target: write its builder, f.number() and so on, as its key's type";
|
package/dist/transform.d.ts
CHANGED
|
@@ -23,4 +23,4 @@ export declare function transformFile(source: ts.SourceFile, checker: ts.TypeChe
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function rewriteLegacy(source: ts.SourceFile, checker: ts.TypeChecker, options: {
|
|
25
25
|
readonly strictNullChecks: boolean;
|
|
26
|
-
}): FileResult & Pass;
|
|
26
|
+
}, taken?: ReadonlySet<string>): FileResult & Pass;
|
package/dist/transform.js
CHANGED
|
@@ -12,6 +12,7 @@ import { entityGetterTarget, fieldKeysEdits, isCallback, keyListEdits, mappedByE
|
|
|
12
12
|
import { toMigrationBuilders } from './migrationBuilders.js';
|
|
13
13
|
import { relationTargetFor } from './relationTarget.js';
|
|
14
14
|
import { toSchemaEntities } from './schemaEntity.js';
|
|
15
|
+
import { nameClash } from './schemaMessages.js';
|
|
15
16
|
import { statementTemplate } from './statementSql.js';
|
|
16
17
|
import { arrayElements, decoratorName, decoratorsOf, FIELD_DECORATORS, identifierText, indentOf, namedProperty, objectLiteral, objectProperties, optionsOf, propertyKey, propertyValue, } from './syntax.js';
|
|
17
18
|
const RELATION_DECORATORS = new Set(['OneToOne', 'ManyToOne', 'OneToMany', 'ManyToMany']);
|
|
@@ -685,9 +686,9 @@ function rewriteRawCall(node, ctx) {
|
|
|
685
686
|
* chains off the text those produced, so neither pass edits what the other writes.
|
|
686
687
|
*/
|
|
687
688
|
export function transformFile(source, checker, options = { strictNullChecks: true }) {
|
|
688
|
-
const legacy = rewriteLegacy(source, checker, options);
|
|
689
|
-
const located = (pass) => (at) => locationAt(source, pass.sourceOffset(at));
|
|
690
689
|
const taken = foreignNames(source, BUILDER_NAMES, checker);
|
|
690
|
+
const legacy = rewriteLegacy(source, checker, options, taken);
|
|
691
|
+
const located = (pass) => (at) => locationAt(source, pass.sourceOffset(at));
|
|
691
692
|
const builders = toBuilders(source.fileName, legacy.text, { locate: located(legacy), taken });
|
|
692
693
|
const { text, unresolved } = toMigrationBuilders(source.fileName, builders.text, located(followed(legacy, builders)));
|
|
693
694
|
return {
|
|
@@ -703,7 +704,7 @@ export function transformFile(source, checker, options = { strictNullChecks: tru
|
|
|
703
704
|
* builder's and querier's methods are known by name, so they are read only in a file importing uql-orm, and `expr`
|
|
704
705
|
* only where it is uql's.
|
|
705
706
|
*/
|
|
706
|
-
export function rewriteLegacy(source, checker, options) {
|
|
707
|
+
export function rewriteLegacy(source, checker, options, taken = foreignNames(source, BUILDER_NAMES, checker)) {
|
|
707
708
|
const ctx = createContext(source, checker, options.strictNullChecks);
|
|
708
709
|
const importsUql = uqlImportDeclarations(source, true).length > 0;
|
|
709
710
|
const importsExpr = uqlImport(source, 'expr', true) !== undefined;
|
|
@@ -743,14 +744,17 @@ export function rewriteLegacy(source, checker, options) {
|
|
|
743
744
|
visit(source);
|
|
744
745
|
const clash = clashingName(ctx);
|
|
745
746
|
if (clash) {
|
|
746
|
-
ctx.report(clash,
|
|
747
|
+
ctx.report(clash, `${nameClash(clash.text)}: rename that, then run again`);
|
|
747
748
|
return { ...fileResult(ctx, source.getFullText()), ...unedited(source.getFullText()) };
|
|
748
749
|
}
|
|
749
750
|
const dead = new Set([...deadImportNames(ctx), ...rewriteColumnRefs(ctx)]);
|
|
750
751
|
rewriteImports(dead, ctx);
|
|
751
752
|
reportRemovedExports(ctx);
|
|
752
753
|
const edited = editedText(source.getFullText(), ctx.edits);
|
|
753
|
-
const schema = toSchemaEntities(source.fileName, edited.text,
|
|
754
|
+
const schema = toSchemaEntities(source.fileName, edited.text, {
|
|
755
|
+
locate: (at) => locationAt(source, edited.sourceOffset(at)),
|
|
756
|
+
taken,
|
|
757
|
+
});
|
|
754
758
|
ctx.unresolved.push(...schema.unresolved);
|
|
755
759
|
ctx.notes.push(...schema.notes);
|
|
756
760
|
return { ...fileResult(ctx, schema.text), sourceOffset: followed(edited, schema).sourceOffset };
|