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 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 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', ... })` 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 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 `shape`, a string-literal union `enum`.
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` 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
 
@@ -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, inserted, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
5
- import { deadImports, importRemovalEdits, importText, spelledNames, uqlImportDeclarations } from './imports.js';
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 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');
@@ -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) ? 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
- 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,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} ${it}`).join('')}`),
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 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 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
- * 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
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 { ...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 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
- /** 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. */
@@ -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 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.
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?: (at: number) => string): SchemaResult;
12
+ export declare function toSchemaEntities(fileName: string, text: string, { locate, taken }: {
13
+ readonly locate?: (at: number) => string;
14
+ readonly taken: ReadonlySet<string>;
15
+ }): SchemaResult;
@@ -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 { unusedImportEdits } from './imports.js';
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 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 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(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),
@@ -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;
@@ -403,13 +426,50 @@ 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) {
@@ -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 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";
@@ -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, `'${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
+ 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 };
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.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=22.18"