uql-codemod 0.49.0 → 0.50.1

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 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 column-level `references` to `.references({ authorId: () => User })` and its `aggregates` to `.relations()`' second callback, and each index, check and trigger entry, in `@Entity` and a schema, to the helper its callback is handed: `(post, index) => [index([post.title], 'by_title')]`.
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 defined entity's `fields` likewise; its other options to the sections chained after it, and its `aggregates` to `.relations()`' second callback. A defined 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 `defineEntity`, 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', ... })` and `export type Post = InstanceType<typeof Post>`, the class removed and a table it named kept as `table`: each relation as `author: () => rel.manyToOne(User, { ... })`, a computed field under `computed`, a relation aggregate under `aggregates`, a hook's method as a function of the row (`this` read as the row), and the same class's `defineField`, `defineId`, `defineRelation`, `defineHook`, `defineIndex`, `defineTrigger` and `defineFilter` folded in.
34
+ - A class registered with `defineEntity(Post, { ... })` to the `defineEntity({ ... })` call it stands for, whole or not at all: `export const Post = defineEntity({ name: 'Post', fields })` with its sections chained, as a defined entity'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 `defineEntity({ ... })` 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 defined 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 `shape`, a string-literal union `enum`.
57
+ - A property typed more narrowly than `defineEntity` 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` on a column that is not JSON, a migration column's options passed as a variable, or a flag such as `unique: isUnique` that no literal states.
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
 
@@ -8,7 +8,7 @@ export declare function isBuilder(node: ts.Expression): boolean;
8
8
  /** The names a builder writes, which the file must not bind otherwise. */
9
9
  export declare const BUILDER_NAMES: ReadonlySet<string>;
10
10
  /**
11
- * Writes entity definitions as column builders, `@Field(f.string().nullable())`, and a schema as its columns and a
11
+ * Writes entity definitions as column builders, `@Field(f.string().nullable())`, and a defined entity as its columns and a
12
12
  * chain of sections. Read off the text the other rewrites produced, so it only moves what is written; whatever it
13
13
  * cannot write faithfully is left as it is and reported.
14
14
  */
@@ -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 defined entity'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 chainedEntity(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, inserted, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
5
- import { deadImports, importRemovalEdits, importText, spelledNames, uqlImportDeclarations } from './imports.js';
4
+ import { FOREIGN_KEY_IN_DEFINED, NO_KEY_TYPE, nameClash, ON_DELETE_ON_RELATION } from './definedMessages.js';
5
+ import { CHAINED_OPTIONS, FIRST_CALL_OPTIONS } from './definedOptions.js';
6
+ import { applyEdits, editedText, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
7
+ import { importEdits } from './imports.js';
6
8
  import { isCallback, paramFor } from './keyMaps.js';
7
- 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 SAID_BY_SHAPE = new Set(['type', 'nullable', 'isId', 'enum', 'shape', 'version', 'computed', 'stored']);
45
- const KNOWN = new Set([...SAID_BY_SHAPE, ...METHODS.map(([option]) => option)]);
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
- const shape = text('shape');
138
- return `f.${literal}(${shape ?? ''})`;
135
+ return `f.${literal}()`;
139
136
  }
140
137
  if (literal === 'vector') {
141
138
  const dimensions = text('dimensions');
@@ -235,9 +232,9 @@ function computedDecorator(call, options, computed, findings, used) {
235
232
  export function isBuilder(node) {
236
233
  return identifierText(chainOf(node).root) === 'f';
237
234
  }
238
- /** The options a schema's first call keeps. */
235
+ /** The options a defined entity's first call keeps. */
239
236
  const FIRST_CALL = new Set(FIRST_CALL_OPTIONS);
240
- /** Every key a schema's `defineEntity` options may state, as its first call or as a section. */
237
+ /** Every key `defineEntity`'s options may state, as its first call or as a section. */
241
238
  const SCHEMA_KEYS = new Set([
242
239
  ...FIRST_CALL,
243
240
  ...CHAINED_OPTIONS,
@@ -245,14 +242,14 @@ const SCHEMA_KEYS = new Set([
245
242
  'aggregates',
246
243
  ]);
247
244
  /** `defineEntity({ fields: {...}, relations, ... })` as its first call and a chain of sections. */
248
- function schemaEdits(call, findings, used) {
245
+ function definedEdits(call, findings, used) {
249
246
  const options = objectLiteral(call.arguments[0]);
250
247
  const fields = options && objectLiteral(propertyValue(options, 'fields'));
251
248
  const chained = options?.properties.some((prop) => !FIRST_CALL.has(keyOf(prop)));
252
- if (!options || !fields || (!chained && fields.properties.every((prop) => isSchemaColumn(prop)))) {
249
+ if (!options || !fields || (!chained && fields.properties.every((prop) => isDefinedColumn(prop)))) {
253
250
  return [];
254
251
  }
255
- const param = schemaParam(call);
252
+ const param = definedParam(call);
256
253
  const unknown = options.properties.filter((prop) => !SCHEMA_KEYS.has(keyOf(prop)));
257
254
  if (unknown.length) {
258
255
  findings.push({
@@ -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 columns = fields.properties.map((prop) => {
267
- if (!ts.isPropertyAssignment(prop)) {
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) ? definedColumn(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
- const built = isBuilder(prop.initializer)
273
- ? builtSchemaColumn(prop.initializer, key, references)
274
- : schemaColumnOf(prop.initializer, key, references);
275
- if (typeof built !== 'string') {
276
- findings.push({ node: prop, message: `'${key}' ${built.message}` });
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, built);
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
- if (references.length) {
286
- sections.push(`.references({ ${references.join(', ')} })`);
287
- }
288
- const relations = propertyValue(options, 'relations');
289
- const aggregates = propertyValue(options, 'aggregates');
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,69 +320,237 @@ 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} ${it}`).join('')}`),
324
+ replaced(call, `${call.expression.getText()}(${literal(options, kept)})${sections.map((it) => `\n${indent}${unit}${it}`).join('')}`),
304
325
  ];
305
326
  }
306
- /** The parameter a schema's callbacks are named after: its `name` where that is a literal, else its variable's. */
307
- function schemaParam(call) {
327
+ /** The parameter a defined entity's callbacks are named after: its `name` where that is a literal, else its variable's. */
328
+ function definedParam(call) {
308
329
  const options = objectLiteral(call.arguments[0]);
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 written as options, through {@link schemaColumn}, or why it cannot be. */
313
- function schemaColumnOf(node, key, references) {
314
- const column = optionsByKey(node);
315
- return typeof column === 'string'
316
- ? { message: `cannot be written as a builder because ${column}` }
317
- : schemaColumn(column, key, references);
333
+ /** A defined column's builder off its options or chain, its `references` and `onDelete` taken out to move to a relation. */
334
+ function definedColumn(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_DEFINED };
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_DEFINED };
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 defined entity'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_DEFINED };
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 defined entity'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
- * A schema column's builder, its foreign key moved into `.references()`, where its target is read once every entity
321
- * exists: written in `fields`, it would read the target while that target's own type is still being inferred.
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 schemaColumn(options, key, references) {
324
- const target = options.get('references');
325
- if (!target) {
326
- return columnBuilder(options, false);
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
- if (!options.has('type')) {
329
- return { message: "references another entity but states no 'type': state the column's, as its key's" };
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
- references.push(referenceEntry(key, target.getText(), options.get('onDelete')?.getText()));
332
- return columnBuilder(new Map([...options].filter(([option]) => option !== 'references' && option !== 'onDelete')), false);
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
- /** One entry of `.references()`: the target's getter, with its `onDelete` where one is stated. */
335
- function referenceEntry(key, getter, action) {
336
- return action ? `${key}: { entity: ${getter}, onDelete: ${action} }` : `${key}: ${getter}`;
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
- /** Whether a schema column is a builder naming no foreign key, which a schema's `fields` keep as they are. */
339
- function isSchemaColumn(prop) {
546
+ /** Whether a defined column is a builder naming no foreign key, which a defined entity's `fields` keep as they are. */
547
+ function isDefinedColumn(prop) {
340
548
  return (ts.isPropertyAssignment(prop) && isBuilder(prop.initializer) && !chainMethods(prop.initializer).has('references'));
341
549
  }
342
550
  /** Each method a builder chain calls, by name. */
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.
@@ -490,7 +680,7 @@ function entityEdits(cls, findings) {
490
680
  });
491
681
  });
492
682
  }
493
- /** A schema's computed map as what its section returns: each a column `as()` its SQL. */
683
+ /** A defined entity's computed map as what its section returns: each a column `as()` its SQL. */
494
684
  function computedSection(value, param) {
495
685
  const map = objectLiteral(value);
496
686
  const entries = map?.properties.map((prop) => {
@@ -524,7 +714,7 @@ export const BUILDER_NAMES = new Set(['f', 'Computed']);
524
714
  /** Whether the file declares anything this pass rewrites. */
525
715
  const REWRITES = /@(?:Field|Id)\(|\bdefineEntity\(\s*\{|@Entity\(\s*\{/;
526
716
  /**
527
- * Writes entity definitions as column builders, `@Field(f.string().nullable())`, and a schema as its columns and a
717
+ * Writes entity definitions as column builders, `@Field(f.string().nullable())`, and a defined entity as its columns and a
528
718
  * chain of sections. Read off the text the other rewrites produced, so it only moves what is written; whatever it
529
719
  * cannot write faithfully is left as it is and reported.
530
720
  */
@@ -544,9 +734,9 @@ export function toBuilders(fileName, text, { locate, taken }) {
544
734
  edits.push(...entityEdits(node, findings));
545
735
  }
546
736
  if (ts.isCallExpression(node) && identifierText(node.expression) === 'defineEntity') {
547
- const schema = schemaEdits(node, findings, used);
548
- if (schema.length) {
549
- edits.push(...schema);
737
+ const defined = definedEdits(node, findings, used);
738
+ if (defined.length) {
739
+ edits.push(...defined);
550
740
  return;
551
741
  }
552
742
  }
@@ -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 { ...reparsedAgain(fileName, editedText(text, edits), (edited) => importEdits(edited, used)), unresolved };
758
+ return {
759
+ ...reparsedAgain(fileName, editedText(text, edits), (edited) => importEdits(edited, used, FIELD_DECORATORS)),
760
+ unresolved,
761
+ };
572
762
  }
573
- /** Imports what the pass now writes, `f` and `Computed`, and no longer the field decorators it replaced, once unspelled. */
574
- function importEdits(source, used) {
575
- const [declaration] = uqlImportDeclarations(source, true);
576
- if (!declaration) {
577
- return used.size ? [{ start: 0, end: 0, text: `${importText('import', [...used].toSorted(), 'uql-orm')}\n` }] : [];
578
- }
579
- const names = new Set(declaration.elements.map((it) => it.name.text));
580
- const added = [...used].filter((name) => !names.has(name)).toSorted();
581
- const dead = deadImports(declaration.elements, FIELD_DECORATORS, spelledNames(source));
582
- const first = declaration.elements.find((it) => !dead.includes(it));
583
- if (!first) {
584
- return [replaced(declaration.declaration, importText('import', added, 'uql-orm'))];
585
- }
586
- const removals = importRemovalEdits(declaration.declaration, declaration.elements, dead);
587
- return added.length ? [...removals, inserted(first, `${added.join(', ')}, `)] : removals;
763
+ /**
764
+ * A defined entity'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 chainedEntity(text) {
768
+ const source = ts.createSourceFile('/entity.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(...definedEdits(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
  }
@@ -0,0 +1,15 @@
1
+ import { type Pass } from './edits.js';
2
+ export type DefinedResult = Pass & {
3
+ readonly unresolved: string[];
4
+ readonly notes: string[];
5
+ };
6
+ /**
7
+ * Declares each class `defineEntity(Class, options)` registered as `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
+ */
12
+ export declare function toDefinedEntities(fileName: string, text: string, { locate, taken }: {
13
+ readonly locate?: (at: number) => string;
14
+ readonly taken: ReadonlySet<string>;
15
+ }): DefinedResult;
@@ -1,12 +1,14 @@
1
1
  import ts from 'typescript';
2
+ import { chainedEntity } from './builders.js';
2
3
  import { formatFindings, locationAt } from './context.js';
4
+ import { nameClash } from './definedMessages.js';
5
+ import { KEPT_OPTIONS } from './definedOptions.js';
3
6
  import { CLASS_DEFINERS, definerCall } from './definers.js';
4
7
  import { editedText, removeStatement, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
5
- import { unusedImportEdits } from './imports.js';
8
+ import { importEdits } from './imports.js';
6
9
  import { memberKey, paramFor, quoted } from './keyMaps.js';
7
- 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
- /** Imports a converted file may no longer read: the definers folded in, and the brands a schema derives. */
11
+ /** Imports a converted file may no longer read: the definers folded in, and the brands a defined entity derives. */
10
12
  const FOLDED_IMPORTS = new Set([
11
13
  ...Object.keys(CLASS_DEFINERS).filter((name) => name !== 'defineEntity'),
12
14
  'idKey',
@@ -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 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.
52
+ * Declares each class `defineEntity(Class, options)` registered as `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 toDefinedEntities(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) {
@@ -79,26 +82,38 @@ export function toSchemaEntities(fileName, text, locate) {
79
82
  });
80
83
  continue;
81
84
  }
82
- const schema = schemaOf(name, cls, calls, entityCall, unit);
83
- if ('message' in schema) {
84
- unresolved.push(schema);
85
+ const defined = definedOf(name, cls, calls, entityCall, unit);
86
+ if ('message' in defined) {
87
+ unresolved.push(defined);
85
88
  continue;
86
89
  }
87
- notes.push(...schema.notes);
90
+ const chained = chainedEntity(defined.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);
102
+ notes.push(...defined.notes);
88
103
  edits.push(removeStatement(cls));
89
104
  for (const { call } of calls) {
90
- edits.push(call === entityCall ? replaced(call.parent, indented(schema.text, call.parent)) : removeStatement(call.parent));
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) => unusedImportEdits(edited, FOLDED_IMPORTS))
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),
99
114
  };
100
115
  }
101
- /** The class a `defineEntity(Class, { extends })` names as its base, which its schema's const reads where it is declared. */
116
+ /** The class a `defineEntity(Class, { extends })` names as its base, which its defined const reads where it is declared. */
102
117
  function baseName(entityCall) {
103
118
  const options = objectLiteral(entityCall.arguments[1]);
104
119
  return identifierText(options && propertyValue(options, 'extends'));
@@ -125,7 +140,7 @@ function collectRegistrations(source, unresolved) {
125
140
  registrations.set(name, registration);
126
141
  }
127
142
  else if (read.definer === 'defineEntity') {
128
- unresolved.push({ node, message: 'defineEntity takes a schema now: defineEntity({ name, fields, ... })' });
143
+ unresolved.push({ node, message: 'defineEntity takes options alone now: defineEntity({ name, fields, ... })' });
129
144
  }
130
145
  else {
131
146
  unresolved.push({ node, message: removedDefiner(read.definer, textOf(read.entity)) });
@@ -140,7 +155,7 @@ function collectRegistrations(source, unresolved) {
140
155
  function removedDefiner(definer, entity) {
141
156
  return `${definer} was removed: register '${entity}' with defineEntity in this file, or decorate it (${CLASS_DEFINERS[definer]})`;
142
157
  }
143
- function schemaOf(name, cls, calls, entityCall, unit) {
158
+ function definedOf(name, cls, calls, entityCall, unit) {
144
159
  const refused = (message) => ({ node: entityCall, message });
145
160
  const options = objectLiteral(entityCall.arguments[1]) ?? (entityCall.arguments[1] ? undefined : emptyOptions());
146
161
  if (!options || options.properties.some((prop) => !ts.isPropertyAssignment(prop))) {
@@ -150,15 +165,15 @@ function schemaOf(name, cls, calls, entityCall, unit) {
150
165
  return refused(`'${name}' extends a class: declare the base with defineEntity too and pass it as 'extends'`);
151
166
  }
152
167
  if (decoratorsOf(cls).length) {
153
- return refused(`'${name}' is decorated as well: declare it one way, decorators or a schema`);
168
+ return refused(`'${name}' is decorated as well: declare it one way, decorators or defineEntity`);
154
169
  }
155
170
  const exported = hasModifier(cls, ts.SyntaxKind.ExportKeyword);
156
171
  if (hasModifier(cls, ts.SyntaxKind.DefaultKeyword)) {
157
- return refused(`'${name}' is a default export: export the schema's const by name instead`);
172
+ return refused(`'${name}' is a default export: export its defineEntity const by name instead`);
158
173
  }
159
174
  const unread = objectProperties(options).find(({ name: key }) => !READ_OPTIONS.has(propertyKey(key)));
160
175
  if (unread) {
161
- return refused(`'${textOf(unread.name)}' has no place in a schema: move it by hand`);
176
+ return refused(`'${textOf(unread.name)}' has no place in defineEntity: move it by hand`);
162
177
  }
163
178
  for (const [option, shape] of Object.entries(ENTRY_OPTIONS)) {
164
179
  const value = propertyValue(options, option);
@@ -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.rel}) => (${block(gathered.aggregates, unit)})`);
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, entry, fieldOptions, node) => {
310
- const aggregate = key && aggregateEntry(`${name}.${key}`, key, fieldOptions, relations, rel);
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 value = definer === 'defineId' ? withIsId(third) : textOf(third);
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, `${memberKey(key)}: ${value}`, third, call);
399
+ return addField(key, memberKey(key), value, third, call);
377
400
  }
378
401
  case 'defineRelation':
379
402
  return third && addRelation(key, third) ? undefined : unreadable;
@@ -398,18 +421,55 @@ function gather({ call, definer, key, options: third, rest }, options, gathered,
398
421
  return undefined;
399
422
  }
400
423
  }
401
- /** Whether a field's options compute it, which a schema declares under `computed`. */
424
+ /** Whether a field's options compute it, which a defined entity declares under `computed`. */
402
425
  function isComputed(options) {
403
426
  const literal = objectLiteral(options);
404
427
  return !!literal && propertyValue(literal, 'computed') !== undefined;
405
428
  }
406
- /** `{ ...options, isId: true }` written into a literal's own text, or nothing where it is not one. */
407
- function withIsId(options) {
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
- if (!literal)
410
- return undefined;
411
- const text = textOf(literal);
412
- return literal.properties.length ? text.replace(/^\{\s*/, '{ isId: true, ') : '{ isId: true }';
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) {
@@ -460,7 +520,7 @@ function hookFunction(method, preferred) {
460
520
  }
461
521
  /**
462
522
  * `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.
523
+ * which a defined entity declares among its aggregates; nothing for a field reading no relation, and why where it cannot move.
464
524
  */
465
525
  function aggregateEntry(label, key, options, relations, rel) {
466
526
  const literal = objectLiteral(options);
@@ -486,7 +546,7 @@ function aggregateEntry(label, key, options, relations, rel) {
486
546
  function substituted(body, uses, replacement) {
487
547
  return rebased(replacingUses(body, uses, replacement), body);
488
548
  }
489
- /** Notes each property the class typed more narrowly than its schema derives, with the option that keeps it. */
549
+ /** Notes each property the class typed more narrowly than defineEntity derives, with the option that keeps it. */
490
550
  function looserTypes(cls, options) {
491
551
  const fields = objectLiteral(propertyValue(options, 'fields'));
492
552
  return cls.members.flatMap((member) => {
@@ -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('shape')) {
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 'shape' to keep it typed`,
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 defined 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_DEFINED = "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 defined 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 defined column's foreign key is refused: no relation joins on it, and none can be written for it. */
4
+ export const FOREIGN_KEY_IN_DEFINED = "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 defined 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";
@@ -0,0 +1,6 @@
1
+ /** The options a defined entity'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 defined entity 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 defined entity 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 defined entity'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 defined entity 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 defined entity keeps as they are, and that incremental definers add to. */
6
+ export const KEPT_OPTIONS = ['indexes', 'checks', 'filters', 'triggers'];
@@ -20,5 +20,5 @@ export type DefinerCall = {
20
20
  readonly options: ts.Expression | undefined;
21
21
  readonly rest: readonly ts.Expression[];
22
22
  };
23
- /** `call` read as a class-form definer, or nothing where it is another call, the schema form included. */
23
+ /** `call` read as a class-form definer, or nothing where it is another call, the defined form included. */
24
24
  export declare function definerCall(call: ts.CallExpression): DefinerCall | undefined;
package/dist/definers.js CHANGED
@@ -20,7 +20,7 @@ const KEYED_DEFINERS = new Set([
20
20
  'defineHook',
21
21
  'defineFilter',
22
22
  ]);
23
- /** `call` read as a class-form definer, or nothing where it is another call, the schema form included. */
23
+ /** `call` read as a class-form definer, or nothing where it is another call, the defined form included. */
24
24
  export function definerCall(call) {
25
25
  const definer = identifierText(call.expression);
26
26
  const [entity, ...rest] = call.arguments;
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
- /** The edits dropping each `uql-orm` import name in `candidates` that the file no longer spells. */
56
- export declare function unusedImportEdits(source: ts.SourceFile, candidates: ReadonlySet<string>): readonly Edit[];
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
- /** The edits dropping each `uql-orm` import name in `candidates` that the file no longer spells. */
404
- export function unusedImportEdits(source, candidates) {
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 uqlImportDeclarations(source, true).flatMap(({ declaration, elements }) => {
416
+ return declarations.flatMap(({ declaration, elements }) => {
407
417
  const dead = deadImports(elements, candidates, spelled);
408
- return dead.length ? importRemovalEdits(declaration, elements, dead) : [];
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. */
@@ -10,17 +10,17 @@ export type FileResult = {
10
10
  readonly notes: readonly string[];
11
11
  };
12
12
  /**
13
- * Rewrites one file across every breaking change: the earlier ones off the checker, then column builders and schema
13
+ * Rewrites one file across every breaking change: the earlier ones off the checker, then column builders and defineEntity
14
14
  * chains off the text those produced, so neither pass edits what the other writes.
15
15
  */
16
16
  export declare function transformFile(source: ts.SourceFile, checker: ts.TypeChecker, options?: {
17
17
  readonly strictNullChecks: boolean;
18
18
  }): FileResult;
19
19
  /**
20
- * The rewrites the checker reads types for, up to the schema entities a class-form registration declares. The
20
+ * The rewrites the checker reads types for, up to the defined entities a class-form registration declares. The
21
21
  * builder's and querier's methods are known by name, so they are read only in a file importing uql-orm, and `expr`
22
22
  * only where it is uql's.
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
@@ -3,6 +3,8 @@ import { BUILDER_NAMES, isBuilder, toBuilders } from './builders.js';
3
3
  import { foldClassDecorators } from './classOptions.js';
4
4
  import { rewriteColumnRefs } from './columnRefs.js';
5
5
  import { createContext, instanceTypeOf, locationAt, memberNames, ownerOf, } from './context.js';
6
+ import { toDefinedEntities } from './definedEntity.js';
7
+ import { nameClash } from './definedMessages.js';
6
8
  import { definerCall } from './definers.js';
7
9
  import { appended, editedText, followed, inserted, original, replaced, unedited, } from './edits.js';
8
10
  import { columnExpressions, handNamedColumns, sqlTag, sqlWhereEdit, templateOf } from './entitySql.js';
@@ -11,7 +13,6 @@ import { rewriteBuilderIndexLengths, rewriteJsonIndexEntries } from './jsonIndex
11
13
  import { entityGetterTarget, fieldKeysEdits, isCallback, keyListEdits, mappedByEdits, memberAccess, paramFor, quoted, referencesEdits, } from './keyMaps.js';
12
14
  import { toMigrationBuilders } from './migrationBuilders.js';
13
15
  import { relationTargetFor } from './relationTarget.js';
14
- import { toSchemaEntities } from './schemaEntity.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']);
@@ -681,13 +682,13 @@ function rewriteRawCall(node, ctx) {
681
682
  }
682
683
  }
683
684
  /**
684
- * Rewrites one file across every breaking change: the earlier ones off the checker, then column builders and schema
685
+ * Rewrites one file across every breaking change: the earlier ones off the checker, then column builders and defineEntity
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 {
@@ -699,11 +700,11 @@ export function transformFile(source, checker, options = { strictNullChecks: tru
699
700
  };
700
701
  }
701
702
  /**
702
- * The rewrites the checker reads types for, up to the schema entities a class-form registration declares. The
703
+ * The rewrites the checker reads types for, up to the defined entities a class-form registration declares. The
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,17 +744,20 @@ export function rewriteLegacy(source, checker, options) {
743
744
  visit(source);
744
745
  const clash = clashingName(ctx);
745
746
  if (clash) {
746
- ctx.report(clash, `'${clash.text}' is already named here, and the codemod writes it: rename that, then run again`);
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, (at) => locationAt(source, edited.sourceOffset(at)));
754
- ctx.unresolved.push(...schema.unresolved);
755
- ctx.notes.push(...schema.notes);
756
- return { ...fileResult(ctx, schema.text), sourceOffset: followed(edited, schema).sourceOffset };
754
+ const defined = toDefinedEntities(source.fileName, edited.text, {
755
+ locate: (at) => locationAt(source, edited.sourceOffset(at)),
756
+ taken,
757
+ });
758
+ ctx.unresolved.push(...defined.unresolved);
759
+ ctx.notes.push(...defined.notes);
760
+ return { ...fileResult(ctx, defined.text), sourceOffset: followed(edited, defined).sourceOffset };
757
761
  }
758
762
  function fileResult({ source, unresolved, notes }, text) {
759
763
  return { fileName: source.fileName, text, changed: text !== source.getFullText(), unresolved, notes };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "uql-codemod",
3
3
  "description": "Upgrade uql-orm across versions that introduce breaking changes",
4
4
  "license": "MIT",
5
- "version": "0.49.0",
5
+ "version": "0.50.1",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=22.18"
@@ -1,12 +0,0 @@
1
- import { type Pass } from './edits.js';
2
- export type SchemaResult = Pass & {
3
- readonly unresolved: string[];
4
- readonly notes: string[];
5
- };
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 produced,
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.
11
- */
12
- export declare function toSchemaEntities(fileName: string, text: string, locate?: (at: number) => string): SchemaResult;
@@ -1,6 +0,0 @@
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"];
@@ -1,6 +0,0 @@
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'];