uql-codemod 0.35.0 → 0.36.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
@@ -7,7 +7,7 @@
7
7
 
8
8
  # uql-codemod
9
9
 
10
- Rewrites [uql-orm](https://uql-orm.dev) entities across the breaking changes that can be made mechanically: the move from the legacy TypeScript decorators to the TC39 standard spec, and the renames and annotations that followed it. One command, on your own source, with a dry run first:
10
+ Rewrites a [uql-orm](https://uql-orm.dev) project across the breaking changes that can be made mechanically. Dry run first:
11
11
 
12
12
  ```sh
13
13
  npx uql-codemod --dry-run
@@ -20,42 +20,42 @@ npx uql-codemod
20
20
  | `--dry-run` | Report what would change and touch nothing. |
21
21
  | `--include=<a,b>` | Only files whose path contains one of these fragments. |
22
22
 
23
- Only the `--flag=value` form is read, and anything unrecognised is an error: a misspelled `--dry-run` would otherwise rewrite the project for real. It exits `0` when nothing is left for you, `1` when something is, and `2` when it could not start at all - a bad argument, or a project it cannot read.
23
+ Only `--flag=value` is read, and anything unrecognised is an error. It exits `0` when nothing is left for you, `1` when something is, and `2` when it could not start. Edits are spliced into the text, so everything it does not touch stays byte for byte as written.
24
24
 
25
25
  ## What it rewrites
26
26
 
27
- - `@Field()` and `@Field({ ... })` with no `type`, and `@Id()`: inserts the `type` the property's declared TypeScript type implies. Skipped when `references` is present, because schema generation resolves that column from the referenced primary key.
28
- - Bare relation decorators: inserts `entity: () => X`, unwrapping arrays and the old `Relation<T>` alias.
29
- - `Relation<T>` becomes `T`, and its import goes with the last usage - `uql-orm` no longer exports it. A usage somewhere the codemod does not reach, such as a type alias, keeps the import and is reported.
30
- - `@Field({ virtual })` becomes `@Field({ computed })`, the name it was renamed to - written as a plain key, a quoted one, or a shorthand, which becomes `computed: virtual` so it keeps referring to the same local. A decorator given both names is reported instead, because which one wins is your call.
31
- - `mappedBy: 'author'` becomes the callback `(post) => post.author`, the one form left, named after the relation's target.
32
- - `@Index(['title', { column: 'createdAt', order: 'desc' }], { include: ['slug'] })` becomes `@Index((post) => [post.title, { column: post.createdAt, order: 'desc' }], { include: (post) => [post.slug] })`, and `references` pairs and the `@Entity`/`defineEntity`/`defineIndex`/`defineRelation` string forms likewise. A value that is not a literal is reported.
33
- - An aggregate's `$agg: { total: { $sum: 'amount' } }` becomes `$select: { total: { $sum: { amount: true } } }`, and `$text`'s `$fields: ['title']` becomes `$fields: { title: true }`.
34
- - `QueryWhereMap` and `RelationKeyMap`, renamed to `QueryWhere` and `KeyMap`, are renamed at their import and every use.
35
- - A primary key not called `id`, `_id` or `uuid`, and every composite key, gets the `[idKey]?: '...'` brand naming it, plus the import. `@Id` refuses a key it cannot name, since one that resolves to any column types `findOneById` against the wrong one.
36
- - `import 'reflect-metadata'` goes. Removing the package from your `package.json` is left to you.
37
- - `tsconfig.json`: removes `experimentalDecorators` and `emitDecoratorMetadata`, keeping the rest of the file - comments and formatting included - exactly as written.
38
-
39
- ## What it refuses to do
40
-
41
- It reports rather than guesses, and exits non-zero when anything is left for you:
42
-
43
- - **`target: esnext`** is reported, not changed. Removing the line falls back to the compiler default (`es5` for `tsc`), and choosing a replacement means guessing which era the project targets. Any dated target works; `esnext` is the one where TypeScript emits decorator syntax untransformed.
44
- - **A value inherited through `extends`** cannot be edited here, so the base config is reported instead.
45
- - **`@Log()`, `@Serialized()`, `@Transactional()` and `@InjectQuerier()`** are reported and left in place. They no longer exist, and what to do instead is a judgement call - a `@Transactional()` method becomes a `pool.transaction(async (querier) => { ... })` around its body, and the codemod cannot know which pool.
46
- - **An export that no longer exists** (`setQuerierPool`, `getQuerier`, `augmentWhere`, `RelationMappedBy`, ...) is reported with what replaces it. A `$where` given an id, a list of them or a bare `raw()` needs no report: it is a compile error on that value, fixed by naming the key (`{ id: [1, 2] }`) or wrapping the raw in `$and`.
47
- - **A key written under a computed name** (`@Id() [KEY]?: string`) is reported: the brand has to spell the name out, and only you know what that expression evaluates to.
48
- - **Options it cannot read** (`@Field(sharedOptions)`, a spread, or a `@Field` never called at all) are left exactly as written. Replacing an argument it cannot parse would silently drop the options.
49
- - **A property whose type it cannot map to a column** (a `Json<T>`, a vector, a union that disagrees with itself) is reported as `needs a decision`. Those always had to declare `type` by hand anyway, because `design:type` reported the useless `Object`/`Array` for them.
50
- - **A branded string id**, e.g. ``type UUID = `${string}-${string}` ``, gets `type: String` plus a `worth a look` note. That is a real fork: `String` generates a text column, `'uuid'` a native one, and only you know which the database has.
27
+ - `type` on a `@Field`/`@Id` that has none, from the property's declared type; not beside `references`, whose column comes from the key it points at.
28
+ - `entity: () => X` on a bare relation decorator, and `Relation<T>` to `T`, its import going with the last use.
29
+ - `virtual` to `computed` (on `@Field` and in `defineEntity`'s `fields`), a check's `expression` and a filter's `condition` to `where` (`@Entity`/`defineEntity`, `@Filter`, `defineFilter`), `raw('sql')` to the tagged template, and a `raw` alias argument onto `.as()`.
30
+ - Members named by string to key-map callbacks: `mappedBy: 'author'` to `(post) => post.author`, and `@Index`, `include`, `references` and `@Entity`/`defineEntity`'s `indexes` and `hooks` likewise: `@Index((post) => [post.title])`.
31
+ - An index expression to the callback an index takes, as an entry or a `column`: ``raw`lower("email")` `` to ``() => raw`lower("email")` ``, and a partial-index `where` string to `raw`, importing it; on entities and in the migration builder.
32
+ - `col('cost')` to the ref it names, ``(product) => raw`${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.
33
+ - An aggregate's `$agg` to `$select`, and each field it or `$text`'s `$fields` names to a key: `{ $sum: { amount: true } }`.
34
+ - Renamed exports, at the import and every use: `QueryWhereMap` to `QueryWhere`, `RelationKeyMap` to `KeyMap`, `FilterCondition` to `FilterWhere`, and the removed driver classes to the one each extended: `PgDialect`/`NeonDialect`/`PgliteDialect` to `PostgresDialect`, `CrdbQuerier`/`NeonQuerier` to `PgQuerier`, `LibsqlQuerier`/`TursoQuerier` to `HranaQuerier`, `MySql2Dialect` to `MySqlDialect`, `MongodbNativeDialect` to `MongoDialect`, moving the import to the entry that exports it.
35
+ - The `[idKey]?: '...'` brand, and its import, on a key not called `id`, `_id` or `uuid` and on every composite.
36
+ - `import 'reflect-metadata'`, and `experimentalDecorators`/`emitDecoratorMetadata` in `tsconfig.json`, comments and formatting kept.
37
+
38
+ ## What it leaves to you
39
+
40
+ Reported with what to do, never guessed:
41
+
42
+ - `target: esnext`, the one target that leaves decorators untransformed, and a value inherited through `extends`.
43
+ - `@Log()`, `@Serialized()`, `@Transactional()` and `@InjectQuerier()`: a `@Transactional()` method becomes a `pool.transaction()` around its body, and only you know which pool.
44
+ - An export removed with no one-to-one replacement (`setQuerierPool`, `getQuerier`, `augmentWhere`, `AbstractPgQuerier`, ...).
45
+ - A value it cannot read: options passed as a variable or a spread, a list that is not a literal, a key under a computed name.
46
+ - A partial-index `where` string that interpolates, since `raw` would bind what the template spliced in.
47
+ - 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.
48
+ - SQL in a definition that names a column by hand, as a note: a rename reaches a callback's refs, ``(user) => raw`lower(${user.email})` ``, and never the text.
49
+ - A property whose type maps to no column (`Json<T>`, a vector, a mixed union).
50
+ - A branded string id, written as `type: String` to keep the column you have, with a note: `'uuid'` is a native column, and a migration.
51
51
 
52
52
  ## After it runs
53
53
 
54
- `tsc` is the rest of the migration, and that is the point: the annotations the codemod inserts are now _checked_ against the properties they describe, so anything it got wrong is a compile error rather than a silently wrong column. See the [upgrade guide](https://uql-orm.dev/upgrade-guide).
54
+ `tsc` is the rest of the migration: what the codemod wrote is checked against the property it describes, so anything it got wrong is a compile error. See the [upgrade guide](https://uql-orm.dev/upgrade-guide).
55
55
 
56
56
  ## Why TypeScript 6
57
57
 
58
- TypeScript is a runtime dependency here: reading each property's type needs the checker. Version 6 is pinned because 7's `typescript` entry point exports no compiler API yet (it moved to `typescript/unstable/*`), and 7 has no standalone parser, which the `tsconfig.json` rewrite needs to keep your comments and formatting. A one-shot tool for a spec `uql-orm` no longer supports needs nothing newer; revisit once that API is stable.
58
+ Reading each property's type needs the checker, so TypeScript is a runtime dependency. 7's entry point exports no compiler API yet and has no standalone parser, which the `tsconfig.json` rewrite needs to keep your formatting; revisit once that API is stable.
59
59
 
60
60
  ## License
61
61
 
package/dist/edits.d.ts CHANGED
@@ -6,12 +6,13 @@ export type Edit = {
6
6
  readonly text: string;
7
7
  };
8
8
  /**
9
- * Applies edits back to front, so an earlier edit cannot shift the offsets of a later one.
10
- *
11
- * Replacing text rather than reprinting the file is what keeps the rest of it byte-for-byte: a codemod
12
- * that reformats everything it touches buries its own change in the diff.
9
+ * Applies edits back to front, so none shifts the offsets of another, and at a shared offset the replacement
10
+ * first, so an insertion there lands before it. Splicing rather than reprinting keeps the rest of the file
11
+ * byte for byte: a codemod that reformats everything it touches buries its own change in the diff.
13
12
  */
14
13
  export declare function applyEdits(text: string, edits: readonly Edit[]): string;
14
+ export declare function replaced(node: ts.Node, text: string): Edit;
15
+ export declare function inserted(node: ts.Node, text: string): Edit;
15
16
  /**
16
17
  * Removes one element of a comma-separated list - a parameter, a named import, a JSON property - taking
17
18
  * the separator that would otherwise dangle. The last element gives up the comma before it; any other
package/dist/edits.js CHANGED
@@ -1,16 +1,21 @@
1
1
  /**
2
- * Applies edits back to front, so an earlier edit cannot shift the offsets of a later one.
3
- *
4
- * Replacing text rather than reprinting the file is what keeps the rest of it byte-for-byte: a codemod
5
- * that reformats everything it touches buries its own change in the diff.
2
+ * Applies edits back to front, so none shifts the offsets of another, and at a shared offset the replacement
3
+ * first, so an insertion there lands before it. Splicing rather than reprinting keeps the rest of the file
4
+ * byte for byte: a codemod that reformats everything it touches buries its own change in the diff.
6
5
  */
7
6
  export function applyEdits(text, edits) {
8
7
  let result = text;
9
- for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
8
+ for (const edit of [...edits].sort((a, b) => b.start - a.start || b.end - a.end)) {
10
9
  result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
11
10
  }
12
11
  return result;
13
12
  }
13
+ export function replaced(node, text) {
14
+ return { start: node.getStart(), end: node.getEnd(), text };
15
+ }
16
+ export function inserted(node, text) {
17
+ return { start: node.getStart(), end: node.getStart(), text };
18
+ }
14
19
  /**
15
20
  * Removes one element of a comma-separated list - a parameter, a named import, a JSON property - taking
16
21
  * the separator that would otherwise dangle. The last element gives up the comma before it; any other
@@ -0,0 +1,23 @@
1
+ import ts from 'typescript';
2
+ import { type Edit } from './edits.js';
3
+ /** `raw\`...\`` or `raw(...)`. */
4
+ type RawSql = ts.TaggedTemplateExpression | ts.CallExpression;
5
+ export declare function isRaw(node: ts.Node): node is RawSql;
6
+ /**
7
+ * `() => ` before each expression a column list indexes, which an index takes as a callback now. One that
8
+ * interpolates takes the list callback's parameter as its refs: `(user) => [raw\`lower(${user.email})\`]`
9
+ * spliced in the key `'email'`, where the ref it reads now renders the column.
10
+ */
11
+ export declare function expressionEntryEdits(list: ts.Expression | undefined): readonly Edit[];
12
+ /** The expressions of a column list, written as one or returned by its callback: each entry or `column` that is `raw`. */
13
+ export declare function columnExpressions(list: ts.Expression | undefined): readonly RawSql[];
14
+ /** SQL text as a `raw` tagged template, escaping what would end or interpolate it. */
15
+ export declare function rawTag(sql: string): string;
16
+ /** A partial-index `where` string as `raw`, or nothing for a template that interpolates, whose values `raw` would bind. */
17
+ export declare function rawWhereEdit(where: ts.Expression): Edit | undefined;
18
+ /**
19
+ * The columns SQL names by hand, which a rename does not reach: each quoted identifier, and each bare word
20
+ * naming one of `members`. What a string literal holds is left out, as is a word called as a function.
21
+ */
22
+ export declare function handNamedColumns(sql: ts.Expression, members: ReadonlySet<string>): readonly string[];
23
+ export {};
@@ -0,0 +1,67 @@
1
+ import ts from 'typescript';
2
+ import { inserted, replaced } from './edits.js';
3
+ import { propertyValue } from './keyMaps.js';
4
+ export function isRaw(node) {
5
+ const callee = ts.isTaggedTemplateExpression(node)
6
+ ? node.tag
7
+ : ts.isCallExpression(node)
8
+ ? node.expression
9
+ : undefined;
10
+ return callee !== undefined && ts.isIdentifier(callee) && callee.text === 'raw';
11
+ }
12
+ /**
13
+ * `() => ` before each expression a column list indexes, which an index takes as a callback now. One that
14
+ * interpolates takes the list callback's parameter as its refs: `(user) => [raw\`lower(${user.email})\`]`
15
+ * spliced in the key `'email'`, where the ref it reads now renders the column.
16
+ */
17
+ export function expressionEntryEdits(list) {
18
+ const param = list && ts.isArrowFunction(list) ? list.parameters[0]?.name : undefined;
19
+ const refs = param && ts.isIdentifier(param) ? param.text : '';
20
+ return columnExpressions(list).map((sql) => inserted(sql, `(${interpolates(sql) ? refs : ''}) => `));
21
+ }
22
+ /** The expressions of a column list, written as one or returned by its callback: each entry or `column` that is `raw`. */
23
+ export function columnExpressions(list) {
24
+ const body = list && ts.isArrowFunction(list) ? list.body : list;
25
+ const entries = body && ts.isArrayLiteralExpression(body) ? body.elements : [];
26
+ return entries.flatMap((entry) => {
27
+ const sql = ts.isObjectLiteralExpression(entry) ? propertyValue(entry, 'column') : entry;
28
+ return sql && isRaw(sql) ? [sql] : [];
29
+ });
30
+ }
31
+ function interpolates(sql) {
32
+ return ts.isTaggedTemplateExpression(sql) && ts.isTemplateExpression(sql.template);
33
+ }
34
+ /** SQL text as a `raw` tagged template, escaping what would end or interpolate it. */
35
+ export function rawTag(sql) {
36
+ return `raw\`${sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')}\``;
37
+ }
38
+ /** A partial-index `where` string as `raw`, or nothing for a template that interpolates, whose values `raw` would bind. */
39
+ export function rawWhereEdit(where) {
40
+ if (ts.isNoSubstitutionTemplateLiteral(where)) {
41
+ return inserted(where, 'raw');
42
+ }
43
+ return ts.isStringLiteral(where) ? replaced(where, rawTag(where.text)) : undefined;
44
+ }
45
+ /**
46
+ * The columns SQL names by hand, which a rename does not reach: each quoted identifier, and each bare word
47
+ * naming one of `members`. What a string literal holds is left out, as is a word called as a function.
48
+ */
49
+ export function handNamedColumns(sql, members) {
50
+ const code = sqlText(sql)?.replace(/'(?:[^']|'')*'/g, "''") ?? '';
51
+ const quoted = Array.from(code.matchAll(/"[^"]+"|`[^`]+`/g), ([token]) => token.slice(1, -1));
52
+ const bare = Array.from(code.matchAll(/\b[A-Za-z_]\w*\b(?!\s*\()/g), ([word]) => word);
53
+ return [...new Set([...quoted, ...bare.filter((word) => members.has(word))])];
54
+ }
55
+ /** The text of a `raw` or a string, `?` standing in for each interpolation. */
56
+ function sqlText(sql) {
57
+ if (!sql) {
58
+ return undefined;
59
+ }
60
+ if (isRaw(sql)) {
61
+ return sqlText(ts.isTaggedTemplateExpression(sql) ? sql.template : sql.arguments[0]);
62
+ }
63
+ if (ts.isTemplateExpression(sql)) {
64
+ return [sql.head, ...sql.templateSpans.map((span) => span.literal)].map((part) => part.text).join(' ? ');
65
+ }
66
+ return ts.isStringLiteralLike(sql) ? sql.text : undefined;
67
+ }
package/dist/keyMaps.d.ts CHANGED
@@ -1,25 +1,28 @@
1
1
  import ts from 'typescript';
2
- import type { Edit } from './edits.js';
2
+ import { type Edit } from './edits.js';
3
+ /** The edits of one rewrite, or `undefined` where the value it reads could not be read. */
4
+ export type Edits = readonly Edit[] | undefined;
3
5
  /** A callback's parameter, named after the class it reads: `Post` -> `post`. */
4
6
  export declare function paramFor(className: string | undefined): string;
7
+ /** `post.title`, or `post['first-name']` for a key that is no identifier. */
8
+ export declare function memberAccess(param: string, key: string): string;
5
9
  /** A key's name where it is spelled out, which is every form but a computed one (`{ [k]: v }`). */
6
10
  export declare function propertyKey(name: ts.PropertyName): string | undefined;
7
11
  /** The initializer of the property `name` in an object literal, however its key is written. */
8
12
  export declare function propertyValue(node: ts.ObjectLiteralExpression, name: string): ts.Expression | undefined;
9
13
  /** `'amount'` or `['title', 'body']` -> `{ amount: true }`, the key map a statement names fields by. */
10
- export declare function fieldKeysEdit(value: ts.Expression): Edit | undefined;
14
+ export declare function fieldKeysEdits(value: ts.Expression): Edits;
11
15
  /** Whether `value` is already a function, the form every rewrite here produces. */
12
16
  export declare function isCallback(value: ts.Expression): boolean;
13
17
  /** `['title', { column: 'createdAt' }]` -> `(post) => [post.title, { column: post.createdAt }]`. */
14
- export declare function keyListEdit(list: ts.Expression, param: string): Edit | undefined;
18
+ export declare function keyListEdits(list: ts.Expression, param: string): Edits;
15
19
  /** `'author'` -> `(post) => post.author`, `param` being the relation's target. */
16
- export declare function mappedByEdit(value: ts.Expression, param: string): Edit | undefined;
20
+ export declare function mappedByEdits(value: ts.Expression, param: string): Edits;
17
21
  /**
18
22
  * `[{ local: 'customerCode', foreign: 'code' }]` -> `(order, customer) => [{ local: order.customerCode,
19
23
  * foreign: customer.code }]`. A self-relation would give both parameters one name, so it reads
20
24
  * `(local, foreign)` instead.
21
25
  */
22
- export declare function referencesEdit(value: ts.Expression, owner: string, target: string): Edit | undefined;
26
+ export declare function referencesEdits(value: ts.Expression, owner: string, target: string): Edits;
23
27
  /** The class an `entity: () => Post` getter returns, as written. */
24
28
  export declare function entityGetterTarget(relation: ts.ObjectLiteralExpression): string | undefined;
25
- export declare function replaced(node: ts.Node, text: string): Edit;
package/dist/keyMaps.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript';
2
+ import { inserted, replaced } from './edits.js';
2
3
  /** A callback's parameter, named after the class it reads: `Post` -> `post`. */
3
4
  export function paramFor(className) {
4
5
  return className ? className[0].toLowerCase() + className.slice(1) : 'entity';
@@ -7,7 +8,7 @@ function isIdentifierName(key) {
7
8
  return /^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/u.test(key);
8
9
  }
9
10
  /** `post.title`, or `post['first-name']` for a key that is no identifier. */
10
- function memberAccess(param, key) {
11
+ export function memberAccess(param, key) {
11
12
  return isIdentifierName(key) ? `${param}.${key}` : `${param}['${key}']`;
12
13
  }
13
14
  /** A key's name where it is spelled out, which is every form but a computed one (`{ [k]: v }`). */
@@ -20,93 +21,76 @@ export function propertyValue(node, name) {
20
21
  return property?.initializer;
21
22
  }
22
23
  /** `'amount'` or `['title', 'body']` -> `{ amount: true }`, the key map a statement names fields by. */
23
- export function fieldKeysEdit(value) {
24
+ export function fieldKeysEdits(value) {
24
25
  const names = ts.isArrayLiteralExpression(value) ? value.elements : [value];
25
26
  if (!names.every((name) => ts.isStringLiteralLike(name))) {
26
27
  return undefined;
27
28
  }
28
29
  const keys = names.map(({ text }) => `${isIdentifierName(text) ? text : `'${text}'`}: true`);
29
- return replaced(value, `{ ${keys.join(', ')} }`);
30
+ return [replaced(value, `{ ${keys.join(', ')} }`)];
30
31
  }
31
32
  /** Whether `value` is already a function, the form every rewrite here produces. */
32
33
  export function isCallback(value) {
33
34
  return ts.isArrowFunction(value) || ts.isFunctionExpression(value);
34
35
  }
35
36
  /** `['title', { column: 'createdAt' }]` -> `(post) => [post.title, { column: post.createdAt }]`. */
36
- export function keyListEdit(list, param) {
37
- const text = ts.isArrayLiteralExpression(list)
38
- ? spliced(list, list.elements.map((entry) => [entry, keyListEntry(entry, param)]))
37
+ export function keyListEdits(list, param) {
38
+ return ts.isArrayLiteralExpression(list)
39
+ ? callbackEdits(list, param, list.elements.map((entry) => keyListEntryEdits(entry, param)))
39
40
  : undefined;
40
- return callbackEdit(list, param, text);
41
41
  }
42
42
  /** `'author'` -> `(post) => post.author`, `param` being the relation's target. */
43
- export function mappedByEdit(value, param) {
44
- return callbackEdit(value, param, ts.isStringLiteralLike(value) ? memberAccess(param, value.text) : undefined);
43
+ export function mappedByEdits(value, param) {
44
+ return ts.isStringLiteralLike(value)
45
+ ? [replaced(value, `(${param}) => ${memberAccess(param, value.text)}`)]
46
+ : undefined;
45
47
  }
46
48
  /**
47
49
  * `[{ local: 'customerCode', foreign: 'code' }]` -> `(order, customer) => [{ local: order.customerCode,
48
50
  * foreign: customer.code }]`. A self-relation would give both parameters one name, so it reads
49
51
  * `(local, foreign)` instead.
50
52
  */
51
- export function referencesEdit(value, owner, target) {
53
+ export function referencesEdits(value, owner, target) {
52
54
  const [local, foreign] = owner === target ? ['local', 'foreign'] : [owner, target];
53
- const text = ts.isArrayLiteralExpression(value)
54
- ? spliced(value, value.elements.map((pair) => [pair, referenceText(pair, local, foreign)]))
55
+ return ts.isArrayLiteralExpression(value)
56
+ ? callbackEdits(value, `${local}, ${foreign}`, value.elements.map((pair) => referenceEdits(pair, local, foreign)))
55
57
  : undefined;
56
- return callbackEdit(value, `${local}, ${foreign}`, text);
57
58
  }
58
59
  /** The class an `entity: () => Post` getter returns, as written. */
59
60
  export function entityGetterTarget(relation) {
60
61
  const getter = propertyValue(relation, 'entity');
61
62
  return getter && ts.isArrowFunction(getter) && ts.isIdentifier(getter.body) ? getter.body.text : undefined;
62
63
  }
63
- /** An expression entry (`raw\`...\``) and a `column` that is not a string stay as written. */
64
- function keyListEntry(entry, param) {
64
+ /** A column-list entry, its name read off the key map: an expression, or a `column` given otherwise, has none. */
65
+ function keyListEntryEdits(entry, param) {
65
66
  if (ts.isStringLiteralLike(entry)) {
66
- return memberAccess(param, entry.text);
67
+ return [replaced(entry, memberAccess(param, entry.text))];
67
68
  }
68
69
  if (ts.isTaggedTemplateExpression(entry) || ts.isCallExpression(entry)) {
69
- return entry.getText();
70
+ return [];
70
71
  }
71
72
  if (!ts.isObjectLiteralExpression(entry)) {
72
73
  return undefined;
73
74
  }
74
75
  const column = propertyValue(entry, 'column');
75
- return column && ts.isStringLiteralLike(column)
76
- ? spliced(entry, [[column, memberAccess(param, column.text)]])
77
- : entry.getText();
76
+ return column && ts.isStringLiteralLike(column) ? [replaced(column, memberAccess(param, column.text))] : [];
78
77
  }
79
- function referenceText(pair, local, foreign) {
78
+ function referenceEdits(pair, local, foreign) {
80
79
  if (!ts.isObjectLiteralExpression(pair)) {
81
80
  return undefined;
82
81
  }
83
82
  const localKey = propertyValue(pair, 'local');
84
83
  const foreignKey = propertyValue(pair, 'foreign');
85
84
  return localKey && foreignKey && ts.isStringLiteralLike(localKey) && ts.isStringLiteralLike(foreignKey)
86
- ? spliced(pair, [
87
- [localKey, memberAccess(local, localKey.text)],
88
- [foreignKey, memberAccess(foreign, foreignKey.text)],
89
- ])
85
+ ? [
86
+ replaced(localKey, memberAccess(local, localKey.text)),
87
+ replaced(foreignKey, memberAccess(foreign, foreignKey.text)),
88
+ ]
90
89
  : undefined;
91
90
  }
92
- /** `node`'s text with each part swapped for its own, everything between kept as written. */
93
- function spliced(node, parts) {
94
- if (!parts.every((part) => part[1] !== undefined)) {
95
- return undefined;
96
- }
97
- const start = node.getStart();
98
- const source = node.getText();
99
- const sorted = [...parts].sort(([a], [b]) => a.getStart() - b.getStart());
100
- const { text, at } = sorted.reduce((acc, [part, replacement]) => ({
101
- text: acc.text + source.slice(acc.at, part.getStart() - start) + replacement,
102
- at: part.getEnd() - start,
103
- }), { text: '', at: 0 });
104
- return text + source.slice(at);
105
- }
106
- /** `value` replaced by `(params) => body`, or nothing when the body could not be read. */
107
- function callbackEdit(value, params, body) {
108
- return body === undefined ? undefined : replaced(value, `(${params}) => ${body}`);
109
- }
110
- export function replaced(node, text) {
111
- return { start: node.getStart(), end: node.getEnd(), text };
91
+ /** `(params) => ` before `body`, with the edits of its parts, or nothing where one could not be read. */
92
+ function callbackEdits(body, params, parts) {
93
+ return parts.every((part) => part !== undefined)
94
+ ? [inserted(body, `(${params}) => `), ...parts.flat()]
95
+ : undefined;
112
96
  }
package/dist/transform.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import ts from 'typescript';
2
- import { applyEdits, removeFromList } from './edits.js';
2
+ import { applyEdits, inserted, removeFromList, replaced } from './edits.js';
3
+ import { columnExpressions, expressionEntryEdits, handNamedColumns, isRaw, rawTag, rawWhereEdit } from './entitySql.js';
3
4
  import { fieldTypeFor, isBrandedString, relationTargetFor } from './fieldType.js';
4
- import { entityGetterTarget, fieldKeysEdit, isCallback, keyListEdit, mappedByEdit, paramFor, propertyKey, propertyValue, referencesEdit, replaced, } from './keyMaps.js';
5
+ import { entityGetterTarget, fieldKeysEdits, isCallback, keyListEdits, mappedByEdits, memberAccess, paramFor, propertyKey, propertyValue, referencesEdits, } from './keyMaps.js';
5
6
  const FIELD_DECORATORS = new Set(['Field', 'Id']);
6
7
  const RELATION_DECORATORS = new Set(['OneToOne', 'ManyToOne', 'OneToMany', 'ManyToMany']);
7
8
  /**
@@ -29,21 +30,25 @@ const REMOVED_EXPORTS = new Map([
29
30
  ['RelationKeyMapper', 'it is `(keys: KeyMap<E>) => Key<E>` now'],
30
31
  ['augmentWhere', 'spread the two maps: `{ ...where, ...extra }`'],
31
32
  ['buildQueryWhereAsMap', 'a `$where` is a map already; name the key for ids: `{ id: [1, 2] }`'],
32
- ['PgDialect', 'the pools build `PostgresDialect` (`uql-orm/postgres`); name that where a dialect is typed'],
33
- ['NeonDialect', 'the pools build `PostgresDialect` (`uql-orm/postgres`); name that where a dialect is typed'],
34
- ['PgliteDialect', 'the pools build `PostgresDialect` (`uql-orm/postgres`); name that where a dialect is typed'],
35
- ['MySql2Dialect', 'the pool builds `MySqlDialect` (`uql-orm/mysql`)'],
36
- ['MongodbNativeDialect', 'the pool builds `MongoDialect` (`uql-orm/mongo`)'],
37
33
  ['AbstractPgQuerier', 'every pg-compatible pool returns `PgQuerier` (`uql-orm/postgres`), which is concrete'],
38
- ['CrdbQuerier', 'every pg-compatible pool returns `PgQuerier` (`uql-orm/postgres`)'],
39
- ['NeonQuerier', 'every pg-compatible pool returns `PgQuerier` (`uql-orm/postgres`)'],
40
- ['LibsqlQuerier', 'the libSQL and Turso pools return `HranaQuerier` (`uql-orm/sqlite`)'],
41
- ['TursoQuerier', 'the libSQL and Turso pools return `HranaQuerier` (`uql-orm/sqlite`)'],
42
34
  ]);
43
- /** Exports renamed and nothing else, so the import and every use of it in the file follow. */
35
+ /**
36
+ * Exports renamed and nothing else - the driver classes were empty subclasses - so the import and every
37
+ * use follow, the import moving to `from` where the new name lives in another entry.
38
+ */
44
39
  const RENAMED_EXPORTS = new Map([
45
- ['QueryWhereMap', 'QueryWhere'],
46
- ['RelationKeyMap', 'KeyMap'],
40
+ ['QueryWhereMap', { to: 'QueryWhere' }],
41
+ ['RelationKeyMap', { to: 'KeyMap' }],
42
+ ['FilterCondition', { to: 'FilterWhere' }],
43
+ ['PgDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
44
+ ['NeonDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
45
+ ['PgliteDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
46
+ ['CrdbQuerier', { to: 'PgQuerier', from: 'uql-orm/postgres' }],
47
+ ['NeonQuerier', { to: 'PgQuerier', from: 'uql-orm/postgres' }],
48
+ ['MySql2Dialect', { to: 'MySqlDialect', from: 'uql-orm/mysql' }],
49
+ ['MongodbNativeDialect', { to: 'MongoDialect', from: 'uql-orm/mongo' }],
50
+ ['LibsqlQuerier', { to: 'HranaQuerier', from: 'uql-orm/sqlite' }],
51
+ ['TursoQuerier', { to: 'HranaQuerier', from: 'uql-orm/sqlite' }],
47
52
  ]);
48
53
  /** A node's decorators, or none where it is a kind that cannot carry them. */
49
54
  function decoratorsOf(node) {
@@ -61,18 +66,19 @@ function decoratorOptions(node) {
61
66
  return { kind: 'opaque', reason: 'it is used without being called' };
62
67
  }
63
68
  const [first] = node.expression.arguments;
64
- if (!first) {
65
- return { kind: 'empty', call: node.expression };
66
- }
67
- if (!ts.isObjectLiteralExpression(first)) {
68
- return { kind: 'opaque', reason: `its options are passed as '${first.getText()}'` };
69
+ return first ? optionsOf(first) : { kind: 'empty', call: node.expression };
70
+ }
71
+ /** The options `value` writes: a literal to read and write into, or why it is not one. */
72
+ function optionsOf(value) {
73
+ if (!ts.isObjectLiteralExpression(value)) {
74
+ return { kind: 'opaque', reason: `its options are passed as '${value.getText()}'` };
69
75
  }
70
- return first.properties.some(ts.isSpreadAssignment)
76
+ return value.properties.some(ts.isSpreadAssignment)
71
77
  ? { kind: 'opaque', reason: 'its options object spreads another' }
72
- : { kind: 'literal', node: first };
78
+ : { kind: 'literal', node: value };
73
79
  }
74
80
  /**
75
- * The property `name` in the decorator's options, whether its key is plain, quoted or a shorthand.
81
+ * The property `name` in the options, whether its key is plain, quoted or a shorthand.
76
82
  *
77
83
  * All three forms, because both callers care about the option being *there*: one to leave an option
78
84
  * alone that is already stated, the other to rename it. Matching only the plain key inserted a second
@@ -85,27 +91,44 @@ function findProperty(options, name) {
85
91
  return options.node.properties.find((prop) => (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && propertyKey(prop.name) === name);
86
92
  }
87
93
  /**
88
- * Renames the `virtual` option to `computed`, the one it was renamed to.
89
- *
90
- * Only the key is replaced, so the expression, its formatting and any comment inside the options
91
- * object survive untouched. A shorthand has no value to keep, so it becomes `computed: virtual` -
92
- * renaming the key alone would rebind it to a local that does not exist.
94
+ * Renames the option `from` to `to`. Only the key is replaced, so the value, its formatting and any comment
95
+ * inside survive; a shorthand has no value to keep, so it becomes `to: from`, since renaming the key alone
96
+ * would rebind it to a local that does not exist.
93
97
  */
94
- function renameVirtualOption(decorator, node, ctx) {
95
- const options = decoratorOptions(decorator);
96
- const virtual = findProperty(options, 'virtual');
97
- if (!virtual) {
98
- if (options.kind === 'opaque' && /\bvirtual\b/.test(decorator.getText())) {
99
- ctx.unresolved.push(`${ctx.describe(node)}: ${options.reason}`);
100
- }
98
+ function renameOption(options, from, to, node, ctx) {
99
+ const property = findProperty(options, from);
100
+ if (!property) {
101
101
  return;
102
102
  }
103
- if (findProperty(options, 'computed')) {
104
- ctx.unresolved.push(`${ctx.describe(node)}: gives both 'virtual' and 'computed'; keep 'computed'.`);
103
+ if (findProperty(options, to)) {
104
+ ctx.unresolved.push(`${ctx.describe(node)}: gives both '${from}' and '${to}'; keep '${to}'.`);
105
105
  return;
106
106
  }
107
- const text = ts.isShorthandPropertyAssignment(virtual) ? 'computed: virtual' : 'computed';
108
- ctx.edits.push({ start: virtual.name.getStart(), end: virtual.name.getEnd(), text });
107
+ ctx.edits.push(replaced(property.name, ts.isShorthandPropertyAssignment(property) ? `${to}: ${from}` : to));
108
+ }
109
+ /** {@link renameOption} for an option holding SQL - a field's `virtual`, a check's `expression` - noting that SQL. */
110
+ function renameSqlOption(options, from, to, node, owner, ctx) {
111
+ renameOption(options, from, to, node, ctx);
112
+ if (options.kind === 'literal') {
113
+ noteHandNamedColumns(propertyValue(options.node, from) ?? propertyValue(options.node, to), owner, ctx);
114
+ }
115
+ }
116
+ /** Notes SQL that names a column by hand, which a rename does not reach. */
117
+ function noteHandNamedColumns(sql, owner, ctx) {
118
+ const names = sql ? handNamedColumns(sql, memberNames(owner.entity, ctx.checker)) : [];
119
+ if (!sql || !names.length) {
120
+ return;
121
+ }
122
+ ctx.notes.push(`${ctx.describe(sql)}: SQL names ${names.map((name) => `'${name}'`).join(', ')} by hand, which a rename ` +
123
+ `does not reach; read each off a callback's refs instead: (${owner.param}) => raw\`...\${${owner.param}.<member>}...\``);
124
+ }
125
+ /** The names of the members of the entity `node` declares or names. */
126
+ function memberNames(node, checker) {
127
+ const type = checker.getTypeAtLocation(node);
128
+ return propertyNames(type.getConstructSignatures()[0]?.getReturnType() ?? type);
129
+ }
130
+ function propertyNames(type) {
131
+ return new Set(type.getProperties().map(({ name }) => name));
109
132
  }
110
133
  /** Inserts `option` into the decorator's options object, creating one when the call has no arguments. */
111
134
  function insertOption(options, option) {
@@ -115,9 +138,7 @@ function insertOption(options, option) {
115
138
  return { start: args.pos, end: args.end, text: `{ ${option} }` };
116
139
  }
117
140
  const first = options.node.properties[0];
118
- return first
119
- ? { start: first.getStart(), end: first.getStart(), text: `${option}, ` }
120
- : { start: options.node.getStart(), end: options.node.getEnd(), text: `{ ${option} }` };
141
+ return first ? inserted(first, `${option}, `) : replaced(options.node, `{ ${option} }`);
121
142
  }
122
143
  /**
123
144
  * Writes one option the decorator can no longer infer at runtime, or records why it could not.
@@ -174,7 +195,7 @@ function addRelationEntity(decorator, node, ctx) {
174
195
  });
175
196
  }
176
197
  /**
177
- * Writes the edit `rewrite` reads off `value`, which still names members as strings, or reports it
198
+ * Writes the edits `rewrite` reads off `value`, which still names members as strings, or reports it
178
199
  * where it could not be read: what it holds is only known at runtime. A callback or an object literal
179
200
  * is already the new form, and kept.
180
201
  */
@@ -182,23 +203,23 @@ function rewriteValue(value, rewrite, node, advice, ctx) {
182
203
  if (!value || isCallback(value) || ts.isObjectLiteralExpression(value)) {
183
204
  return;
184
205
  }
185
- const edit = rewrite(value);
186
- if (edit) {
187
- ctx.edits.push(edit);
206
+ const edits = rewrite(value);
207
+ if (edits) {
208
+ ctx.edits.push(...edits);
188
209
  }
189
210
  else {
190
211
  ctx.unresolved.push(`${ctx.describe(node)}: ${advice}; this one could not be read`);
191
212
  }
192
213
  }
193
214
  function rewriteKeyList(value, param, node, what, ctx) {
194
- rewriteValue(value, (list) => keyListEdit(list, param), node, `write ${what} as a key-map callback`, ctx);
215
+ rewriteValue(value, (list) => keyListEdits(list, param), node, `write ${what} as a key-map callback`, ctx);
195
216
  }
196
217
  /**
197
- * Rewrites what a class decorator names by string - `@Index` columns and `include`, and `@Entity`'s
198
- * `indexes`, `hooks` and `relations` - into key-map callbacks named after the class.
218
+ * Rewrites a class's decorators - `@Index`, `@Filter` and `@Entity`'s options - for what they define now:
219
+ * members named by string read off key maps named after the class, and SQL written as it is taken.
199
220
  */
200
221
  function rewriteClassDecorators(node, ctx) {
201
- const param = paramFor(node.name?.text);
222
+ const owner = { param: paramFor(node.name?.text), entity: node };
202
223
  for (const decorator of decoratorsOf(node)) {
203
224
  if (!ts.isCallExpression(decorator.expression)) {
204
225
  continue;
@@ -206,54 +227,118 @@ function rewriteClassDecorators(node, ctx) {
206
227
  const [first, second] = decorator.expression.arguments;
207
228
  const name = decoratorName(decorator);
208
229
  if (name === 'Index') {
209
- rewriteKeyList(first, param, decorator, "'@Index' columns", ctx);
210
- if (second && ts.isObjectLiteralExpression(second)) {
211
- rewriteKeyList(propertyValue(second, 'include'), param, decorator, "'include'", ctx);
212
- }
230
+ rewriteIndex(first, second, owner, decorator, "'@Index' columns", ctx);
231
+ }
232
+ else if (name === 'Filter' && second) {
233
+ renameOption(optionsOf(second), 'condition', 'where', decorator, ctx);
213
234
  }
214
235
  else if (name === 'Entity' && first && ts.isObjectLiteralExpression(first)) {
215
- rewriteEntityOptions(first, param, ctx);
236
+ rewriteEntityOptions(first, owner, ctx);
216
237
  }
217
238
  }
218
239
  }
219
240
  /**
220
- * Rewrites what the imperative API names by string - `defineEntity(Post, { indexes, hooks, relations })`,
221
- * `defineIndex(Post, { columns, include })` and `defineRelation(Post, 'tag', { mappedBy, references })` -
222
- * into key-map callbacks. Only uql's shapes, a class and a literal options object, are read.
241
+ * Rewrites the imperative API the same way: `defineEntity(Post, { ... })`, `defineIndex(Post, { columns })`,
242
+ * `defineRelation(Post, 'tag', { mappedBy, references })` and `defineFilter(Post, 'live', { condition })`.
243
+ * Only uql's shapes, a class and a literal options object, are read.
223
244
  */
224
245
  function rewriteDefineCall(call, ctx) {
225
246
  const name = ts.isIdentifier(call.expression) ? call.expression.text : undefined;
226
- const [entity, second, third] = call.arguments;
227
- const param = paramFor(entity && ts.isIdentifier(entity) ? entity.text : undefined);
247
+ const [entity = call, second, third] = call.arguments;
248
+ const owner = { param: paramFor(ts.isIdentifier(entity) ? entity.text : undefined), entity };
228
249
  if (name === 'defineEntity' && second && ts.isObjectLiteralExpression(second)) {
229
- rewriteEntityOptions(second, param, ctx);
250
+ rewriteEntityOptions(second, owner, ctx);
230
251
  }
231
252
  else if (name === 'defineIndex' && second && ts.isObjectLiteralExpression(second)) {
232
- rewriteIndexOptions(second, param, ctx);
253
+ rewriteIndexOptions(second, owner, ctx);
233
254
  }
234
255
  else if (name === 'defineRelation' && third && ts.isObjectLiteralExpression(third)) {
235
- rewriteRelationOptions(third, param, ctx);
256
+ rewriteRelationOptions(third, owner.param, ctx);
257
+ }
258
+ else if (name === 'defineFilter' && third) {
259
+ renameOption(optionsOf(third), 'condition', 'where', call, ctx);
236
260
  }
237
261
  }
238
- function rewriteEntityOptions(options, param, ctx) {
239
- const indexes = propertyValue(options, 'indexes');
240
- for (const index of indexes && ts.isArrayLiteralExpression(indexes) ? indexes.elements : []) {
262
+ function rewriteEntityOptions(options, owner, ctx) {
263
+ for (const index of arrayElements(propertyValue(options, 'indexes'))) {
241
264
  if (ts.isObjectLiteralExpression(index)) {
242
- rewriteIndexOptions(index, param, ctx);
265
+ rewriteIndexOptions(index, owner, ctx);
243
266
  }
244
267
  }
268
+ for (const check of arrayElements(propertyValue(options, 'checks'))) {
269
+ renameSqlOption(optionsOf(check), 'expression', 'where', check, owner, ctx);
270
+ }
271
+ for (const field of objectProperties(propertyValue(options, 'fields'))) {
272
+ renameSqlOption(optionsOf(field.initializer), 'virtual', 'computed', field, owner, ctx);
273
+ }
274
+ for (const filter of objectProperties(propertyValue(options, 'filters'))) {
275
+ renameOption(optionsOf(filter.initializer), 'condition', 'where', filter, ctx);
276
+ }
245
277
  for (const hook of objectProperties(propertyValue(options, 'hooks'))) {
246
- rewriteKeyList(hook.initializer, param, hook, 'a hook list', ctx);
278
+ rewriteKeyList(hook.initializer, owner.param, hook, 'a hook list', ctx);
247
279
  }
248
280
  for (const relation of objectProperties(propertyValue(options, 'relations'))) {
249
281
  if (ts.isObjectLiteralExpression(relation.initializer)) {
250
- rewriteRelationOptions(relation.initializer, param, ctx);
282
+ rewriteRelationOptions(relation.initializer, owner.param, ctx);
251
283
  }
252
284
  }
253
285
  }
254
- function rewriteIndexOptions(index, param, ctx) {
255
- rewriteKeyList(propertyValue(index, 'columns'), param, index, "'columns'", ctx);
256
- rewriteKeyList(propertyValue(index, 'include'), param, index, "'include'", ctx);
286
+ function rewriteIndexOptions(index, owner, ctx) {
287
+ rewriteIndex(propertyValue(index, 'columns'), index, owner, index, "'columns'", ctx);
288
+ }
289
+ /**
290
+ * An index, as `@Index(columns, options)` or a `defineIndex`/`indexes` entry holding both: its columns and
291
+ * `include` read off the key map, its SQL written as {@link rewriteIndexSql} writes it, and noted.
292
+ */
293
+ function rewriteIndex(columns, options, owner, node, what, ctx) {
294
+ const literal = options && ts.isObjectLiteralExpression(options) ? options : undefined;
295
+ rewriteKeyList(columns, owner.param, node, what, ctx);
296
+ rewriteKeyList(literal && propertyValue(literal, 'include'), owner.param, node, "'include'", ctx);
297
+ rewriteIndexSql(columns, literal, ctx);
298
+ for (const sql of [...columnExpressions(columns), literal && propertyValue(literal, 'where')]) {
299
+ noteHandNamedColumns(sql, owner, ctx);
300
+ }
301
+ }
302
+ /**
303
+ * An index's SQL as an index takes it now, on an entity and in the migration builder alike: each expression
304
+ * a callback, and a `where` string `raw`. A string it cannot write as `raw` is reported.
305
+ */
306
+ function rewriteIndexSql(columns, options, ctx) {
307
+ ctx.edits.push(...expressionEntryEdits(columns));
308
+ const where = options && propertyValue(options, 'where');
309
+ if (!where || !isStringTyped(ctx.checker.getTypeAtLocation(where))) {
310
+ return;
311
+ }
312
+ const edit = rawWhereEdit(where);
313
+ if (edit) {
314
+ ctx.edits.push(edit);
315
+ ctx.imports.set('raw', 'the raw`...`');
316
+ }
317
+ else {
318
+ ctx.unresolved.push(`${ctx.describe(where)}: write the partial-index 'where' as raw\`...\` or a predicate; this one could not be read`);
319
+ }
320
+ }
321
+ /** Whether every value `type` admits is a string. */
322
+ function isStringTyped(type) {
323
+ return type.isUnion() ? type.types.every(isStringTyped) : Boolean(type.flags & ts.TypeFlags.StringLike);
324
+ }
325
+ /** The migration builder's index methods, each with the position of the columns it takes. */
326
+ const BUILDER_INDEX_METHODS = new Map([
327
+ ['index', 0],
328
+ ['unique', 0],
329
+ ['addIndex', 0],
330
+ ['createIndex', 1],
331
+ ]);
332
+ /** Rewrites the SQL of the migration builder's `t.index()`, `t.unique()`, `t.addIndex()` and `m.createIndex()`. */
333
+ function rewriteBuilderCall(call, ctx) {
334
+ const at = ts.isPropertyAccessExpression(call.expression)
335
+ ? BUILDER_INDEX_METHODS.get(call.expression.name.text)
336
+ : undefined;
337
+ if (at === undefined) {
338
+ return;
339
+ }
340
+ const options = call.arguments[at + 1];
341
+ rewriteIndexSql(call.arguments[at], options && ts.isObjectLiteralExpression(options) ? options : undefined, ctx);
257
342
  }
258
343
  /**
259
344
  * `owner` is the declaring class's parameter and `target` the related one's, read off the `entity`
@@ -261,15 +346,15 @@ function rewriteIndexOptions(index, param, ctx) {
261
346
  */
262
347
  function rewriteRelationOptions(relation, owner, ctx, target = paramFor(entityGetterTarget(relation))) {
263
348
  const advice = (what) => `write '${what}' as a key-map callback`;
264
- const mappedBy = (value) => mappedByEdit(value, target);
265
- const references = (value) => referencesEdit(value, owner, target);
349
+ const mappedBy = (value) => mappedByEdits(value, target);
350
+ const references = (value) => referencesEdits(value, owner, target);
266
351
  rewriteValue(propertyValue(relation, 'mappedBy'), mappedBy, relation, advice('mappedBy'), ctx);
267
352
  rewriteValue(propertyValue(relation, 'references'), references, relation, advice('references'), ctx);
268
353
  }
269
354
  /**
270
- * Rewrites what a statement names by string into keys: an aggregate's `$agg: { total: { $sum: 'amount' } }`
271
- * into `$select: { total: { $sum: { amount: true } } }`, and `$text`'s `$fields: ['title']` into
272
- * `{ title: true }`. Both keys are uql's own, so they are read wherever a literal holds them.
355
+ * Rewrites a statement's keys: an aggregate's `$agg: { total: { $sum: 'amount' } }` into
356
+ * `$select: { total: { $sum: { amount: true } } }`, `$text`'s `$fields: ['title']` into `{ title: true }`, and
357
+ * `$lock`'s `wait` into `$wait`. Each key is uql's own, so it is read wherever a literal holds it.
273
358
  */
274
359
  function rewriteStatementKeys(node, ctx) {
275
360
  const key = propertyKey(node.name);
@@ -277,7 +362,21 @@ function rewriteStatementKeys(node, ctx) {
277
362
  rewriteAggregate(node, ctx);
278
363
  }
279
364
  else if (key === '$fields' && isTextSearch(node.parent)) {
280
- rewriteValue(node.initializer, fieldKeysEdit, node, "write '$fields' as { field: true }", ctx);
365
+ rewriteValue(node.initializer, fieldKeysEdits, node, "write '$fields' as { field: true }", ctx);
366
+ }
367
+ else if (key === '$lock' && ts.isObjectLiteralExpression(node.initializer)) {
368
+ rewriteLock(node.initializer, ctx);
369
+ }
370
+ }
371
+ /** `{ wait: 'skip' }` as `{ $wait: 'skip' }`, and `{}` or a `'block'` wait as `true`, which waits. */
372
+ function rewriteLock(lock, ctx) {
373
+ const wait = propertyValue(lock, 'wait');
374
+ const blocks = wait && ts.isStringLiteralLike(wait) && wait.text === 'block';
375
+ if (!lock.properties.length || (blocks && lock.properties.length === 1)) {
376
+ ctx.edits.push(replaced(lock, 'true'));
377
+ }
378
+ else {
379
+ renameOption(optionsOf(lock), 'wait', '$wait', lock, ctx);
281
380
  }
282
381
  }
283
382
  function isTextSearch(node) {
@@ -295,7 +394,7 @@ function rewriteAggregate(agg, ctx) {
295
394
  for (const op of objectProperties(fn.initializer)) {
296
395
  const isStar = ts.isStringLiteralLike(op.initializer) && op.initializer.text === '*';
297
396
  if (!isStar) {
298
- rewriteValue(op.initializer, fieldKeysEdit, op, `write '${op.name.getText()}' as { field: true }`, ctx);
397
+ rewriteValue(op.initializer, fieldKeysEdits, op, `write '${op.name.getText()}' as { field: true }`, ctx);
299
398
  }
300
399
  }
301
400
  }
@@ -304,6 +403,10 @@ function rewriteAggregate(agg, ctx) {
304
403
  function objectProperties(node) {
305
404
  return node && ts.isObjectLiteralExpression(node) ? node.properties.filter(ts.isPropertyAssignment) : [];
306
405
  }
406
+ /** The elements of an array literal, or none where `node` is not one. */
407
+ function arrayElements(node) {
408
+ return node && ts.isArrayLiteralExpression(node) ? node.elements : [];
409
+ }
307
410
  /**
308
411
  * Drops `declare` from a decorated field. A `declare` field emits nothing, so the standard spec has
309
412
  * nothing to decorate and rejects it ("Decorators are not valid here"). Legacy decorators tolerated it
@@ -334,7 +437,7 @@ function unwrapRelationAlias(node, ctx) {
334
437
  ts.isIdentifier(declared.typeName) &&
335
438
  declared.typeName.text === 'Relation' &&
336
439
  declared.typeArguments?.length === 1) {
337
- ctx.edits.push({ start: declared.getStart(), end: declared.getEnd(), text: declared.typeArguments[0].getText() });
440
+ ctx.edits.push(replaced(declared, declared.typeArguments[0].getText()));
338
441
  ctx.relationAlias.unwrapped += 1;
339
442
  }
340
443
  }
@@ -365,11 +468,10 @@ function deadImportNames(source, ctx) {
365
468
  return new Set(seen > 0 && seen === unwrapped ? ['Relation'] : []);
366
469
  }
367
470
  /**
368
- * Removes `import 'reflect-metadata'`, since the polyfill only ever fed `design:type`, and any name
369
- * `uql-orm` no longer exports.
471
+ * Removes `import 'reflect-metadata'`, since the polyfill only ever fed `design:type`, and each `dead` name
472
+ * from the imports {@link renameExports} left as written, having left the dead names out of the rest.
370
473
  */
371
- function dropDeadImports(source, ctx) {
372
- const dead = deadImportNames(source, ctx);
474
+ function dropDeadImports(source, dead, rewritten, ctx) {
373
475
  for (const statement of source.statements) {
374
476
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
375
477
  continue;
@@ -379,7 +481,7 @@ function dropDeadImports(source, ctx) {
379
481
  continue;
380
482
  }
381
483
  const named = statement.importClause?.namedBindings;
382
- if (!named || !ts.isNamedImports(named)) {
484
+ if (!named || !ts.isNamedImports(named) || rewritten.has(statement)) {
383
485
  continue;
384
486
  }
385
487
  const dropped = named.elements.filter((element) => dead.has(element.name.text));
@@ -408,63 +510,70 @@ function isIdKeyBrand(member) {
408
510
  * a subclass that replaces an inherited `id` needs the brand precisely because the inherited name
409
511
  * would otherwise be taken for the key.
410
512
  *
411
- * Reports whether one was written, since `idKey` is then a value the file has to import.
513
+ * One written makes `idKey` a value the file has to import, which is recorded for {@link addImports}.
412
514
  */
413
515
  function brandIdKey(node, ctx) {
414
516
  const ids = node.members.filter((m) => ts.isPropertyDeclaration(m) && decoratorsOf(m).some((d) => decoratorName(d) === 'Id'));
415
517
  if (!ids.length || node.members.some(isIdKeyBrand)) {
416
- return false;
518
+ return;
417
519
  }
418
520
  const names = ids.map((m) => propertyKey(m.name)).filter((name) => name !== undefined);
419
521
  if (names.length !== ids.length) {
420
522
  ctx.unresolved.push(`${ctx.describe(node)}: a key written as a computed name; add the 'idKey' brand by hand`);
421
- return false;
523
+ return;
422
524
  }
423
525
  if (names.length === 1 && CONVENTIONAL_ID_NAMES.has(names[0])) {
424
- return false;
526
+ return;
425
527
  }
426
528
  const indent = ' '.repeat(node.getSourceFile().getLineAndCharacterOfPosition(ids[0].getStart()).character);
427
529
  const brand = names.map((name) => `'${name}'`).join(' | ');
428
530
  const start = node.members.pos;
429
531
  ctx.edits.push({ start, end: start, text: `\n${indent}[idKey]?: ${brand};` });
430
- return true;
532
+ ctx.imports.set('idKey', 'the brand(s)');
431
533
  }
432
534
  /**
433
- * Imports `idKey` where a brand was written, into the file's own `uql-orm` import. Reported instead
535
+ * Imports what the rewrites wrote (`idKey`, `raw`) into the file's own `uql-orm` import. Reported instead
434
536
  * where there is none: the package may be imported under a path this codemod does not recognise.
435
537
  */
436
- function addIdKeyImport(source, ctx) {
437
- const imported = uqlImports(source);
438
- if (imported.some((element) => importedName(element) === 'idKey')) {
538
+ function addImports(source, rewritten, ctx) {
539
+ const imported = new Set(uqlImports(source).map(importedName));
540
+ const missing = [...ctx.imports].filter(([name]) => !imported.has(name));
541
+ if (!missing.length) {
439
542
  return;
440
543
  }
441
- const [anchor] = imported;
544
+ const anchor = uqlImportDeclarations(source).find(({ declaration }) => !rewritten.has(declaration))?.elements[0];
442
545
  if (!anchor) {
443
- ctx.unresolved.push(`${source.fileName}: import 'idKey' from 'uql-orm' for the brand(s) written here`);
546
+ for (const [name, what] of missing) {
547
+ ctx.unresolved.push(`${source.fileName}: import '${name}' from 'uql-orm' for ${what} written here`);
548
+ }
444
549
  return;
445
550
  }
446
- ctx.edits.push({ start: anchor.getStart(), end: anchor.getStart(), text: 'idKey, ' });
551
+ ctx.edits.push(inserted(anchor, missing.map(([name]) => `${name}, `).join('')));
447
552
  }
448
- /** Everything the standard spec needs written onto one decorated property. */
553
+ /** Everything the standard spec needs written onto one decorated property, and the options renamed since. */
449
554
  function rewriteProperty(node, ctx) {
555
+ const owner = { param: paramFor(node.parent.name?.text), entity: node.parent };
450
556
  const decorators = decoratorsOf(node);
451
557
  for (const decorator of decorators) {
452
558
  const name = decoratorName(decorator);
453
559
  if (!name) {
454
560
  continue;
455
561
  }
562
+ const options = decoratorOptions(decorator);
456
563
  if (FIELD_DECORATORS.has(name)) {
457
564
  addFieldType(decorator, node, ctx);
458
- renameVirtualOption(decorator, node, ctx);
565
+ // Options it cannot read may still hold `virtual`, so they are reported where they mention it.
566
+ if (options.kind === 'opaque' && /\bvirtual\b/.test(decorator.getText())) {
567
+ ctx.unresolved.push(`${ctx.describe(node)}: ${options.reason}`);
568
+ }
569
+ renameSqlOption(options, 'virtual', 'computed', node, owner, ctx);
459
570
  }
460
- const options = decoratorOptions(decorator);
461
571
  if (RELATION_DECORATORS.has(name)) {
462
572
  addRelationEntity(decorator, node, ctx);
463
573
  }
464
574
  if (RELATION_DECORATORS.has(name) && options.kind === 'literal') {
465
- const owner = ts.isClassLike(node.parent) ? node.parent.name?.text : undefined;
466
575
  const target = relationTargetFor(ctx.checker.getTypeAtLocation(node), ctx.checker);
467
- rewriteRelationOptions(options.node, paramFor(owner), ctx, paramFor(target));
576
+ rewriteRelationOptions(options.node, owner.param, ctx, paramFor(target));
468
577
  }
469
578
  }
470
579
  if (decorators.length) {
@@ -493,24 +602,49 @@ function reportRemovedExports(source, ctx) {
493
602
  }
494
603
  }
495
604
  /**
496
- * Renames each import of a {@link RENAMED_EXPORTS} name and every use of it in the file. An aliased
497
- * import only changes the name it imports; one whose new name the file already imports is dropped.
605
+ * Rewrites each import naming a {@link RENAMED_EXPORTS} export, renaming every use of it: a name moving
606
+ * entries gets an import from its new one, and a name the file already imports is dropped, as is a `dead`
607
+ * one. Returns the rewritten imports, which nothing else may edit.
498
608
  */
499
- function renameExports(source, ctx) {
500
- const imported = uqlImports(source);
501
- for (const element of imported) {
502
- const renamed = RENAMED_EXPORTS.get(importedName(element));
503
- if (!renamed) {
609
+ function renameExports(source, dead, ctx) {
610
+ const imports = uqlImportDeclarations(source, true);
611
+ const kept = imports.flatMap(({ elements }) => elements).filter((element) => !renamedTo(element));
612
+ const bound = new Set(kept.map((element) => element.name.text));
613
+ const rewritten = new Set();
614
+ for (const { declaration, entry, elements } of imports) {
615
+ if (!elements.some(renamedTo)) {
504
616
  continue;
505
617
  }
506
- if (element.propertyName) {
507
- ctx.edits.push(replaced(element.propertyName, renamed));
508
- continue;
618
+ const byEntry = new Map([[entry, []]]);
619
+ for (const element of elements.filter(({ name }) => !dead.has(name.text))) {
620
+ const rename = renamedTo(element);
621
+ const local = element.propertyName || !rename ? element.name.text : rename.to;
622
+ if (rename && !element.propertyName) {
623
+ ctx.edits.push(...usesOf(source, element.name, ctx.checker).map((use) => replaced(use, rename.to)));
624
+ }
625
+ if (rename && bound.has(local)) {
626
+ continue;
627
+ }
628
+ bound.add(local);
629
+ const target = rename?.from ?? entry;
630
+ const text = rename
631
+ ? `${element.isTypeOnly ? 'type ' : ''}${rename.to}${element.propertyName ? ` as ${local}` : ''}`
632
+ : element.getText();
633
+ byEntry.set(target, [...(byEntry.get(target) ?? []), text]);
509
634
  }
510
- ctx.edits.push(...usesOf(source, element.name, ctx.checker).map((use) => replaced(use, renamed)));
511
- const duplicate = imported.some((other) => other.name.text === renamed);
512
- ctx.edits.push(duplicate ? removeFromList(element.parent.elements, element, source) : replaced(element.name, renamed));
635
+ const typeOnly = declaration.importClause?.phaseModifier === ts.SyntaxKind.TypeKeyword;
636
+ const keyword = typeOnly ? 'import type' : 'import';
637
+ const quote = declaration.moduleSpecifier.getText()[0];
638
+ const statements = [...byEntry]
639
+ .filter(([, names]) => names.length)
640
+ .map(([from, names]) => `${keyword} { ${names.join(', ')} } from ${quote}${from}${quote};`);
641
+ ctx.edits.push(statements.length ? replaced(declaration, statements.join('\n')) : removeStatement(declaration));
642
+ rewritten.add(declaration);
513
643
  }
644
+ return rewritten;
645
+ }
646
+ function renamedTo(element) {
647
+ return RENAMED_EXPORTS.get(importedName(element));
514
648
  }
515
649
  /** Every identifier in the file bound to the same symbol as `name`, besides `name` itself. */
516
650
  function usesOf(source, name, checker) {
@@ -528,50 +662,187 @@ function usesOf(source, name, checker) {
528
662
  return uses;
529
663
  }
530
664
  /**
531
- * What the file imports from `uql-orm` by name, which is every import this codemod reads or writes.
532
- * `entries` adds the driver entries (`uql-orm/postgres`, ...), for reporting only: an import written
533
- * into one of those would land on an entry that may not export it.
665
+ * The file's named imports from `uql-orm`. `entries` adds the driver entries (`uql-orm/postgres`, ...),
666
+ * for what is read by name only: `idKey` written into one would land on an entry that may not export it.
534
667
  */
535
- function uqlImports(source, entries = false) {
536
- return source.statements.flatMap((statement) => {
537
- if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
668
+ function uqlImportDeclarations(source, entries = false) {
669
+ return source.statements.flatMap((declaration) => {
670
+ if (!ts.isImportDeclaration(declaration) || !ts.isStringLiteral(declaration.moduleSpecifier)) {
538
671
  return [];
539
672
  }
540
- const specifier = statement.moduleSpecifier.text;
541
- if (specifier !== 'uql-orm' && !(entries && specifier.startsWith('uql-orm/'))) {
673
+ const entry = declaration.moduleSpecifier.text;
674
+ if (entry !== 'uql-orm' && !(entries && entry.startsWith('uql-orm/'))) {
542
675
  return [];
543
676
  }
544
- const bindings = statement.importClause?.namedBindings;
545
- return bindings && ts.isNamedImports(bindings) ? [...bindings.elements] : [];
677
+ const bindings = declaration.importClause?.namedBindings;
678
+ return bindings && ts.isNamedImports(bindings) ? [{ declaration, entry, elements: bindings.elements }] : [];
546
679
  });
547
680
  }
681
+ function uqlImports(source, entries = false) {
682
+ return uqlImportDeclarations(source, entries).flatMap(({ elements }) => elements);
683
+ }
548
684
  /** The name an import specifier brings in, which is the original one where it was renamed. */
549
685
  function importedName(element) {
550
686
  return (element.propertyName ?? element.name).text;
551
687
  }
552
688
  /**
553
- * Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`. Only a
554
- * string-literal first argument qualifies: the callback form is unchanged, and a computed string is
555
- * left alone because a template cannot be built from a value that is not known here.
689
+ * Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`, the callback
690
+ * form's included. A computed string is left alone: a template cannot be built from a value not known here.
556
691
  */
557
692
  function rewriteRawCall(node, ctx, source) {
558
693
  if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'raw') {
559
694
  return;
560
695
  }
561
696
  const [expression, alias] = node.arguments;
562
- if (!expression || !ts.isStringLiteral(expression) || node.arguments.length > 2) {
697
+ if (!expression || node.arguments.length > 2) {
563
698
  return;
564
699
  }
565
700
  const suffix = alias ? `.as(${alias.getText(source)})` : '';
566
- ctx.edits.push({
567
- start: node.getStart(source),
568
- end: node.getEnd(),
569
- text: `raw\`${escapeForTemplate(expression.text)}\`${suffix}`,
570
- });
701
+ if (ts.isStringLiteral(expression)) {
702
+ ctx.edits.push(replaced(node, `${rawTag(expression.text)}${suffix}`));
703
+ }
704
+ else if (alias) {
705
+ ctx.edits.push({ start: expression.getEnd(), end: node.getEnd(), text: `)${suffix}` });
706
+ }
571
707
  }
572
- /** A backtick or a `${` inside the old string literal would end or interpolate the template. */
573
- function escapeForTemplate(text) {
574
- return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
708
+ /**
709
+ * Rewrites each `col('x')` into the ref it names - `product.x` in a `computed` callback, `refs(Item).x` in a
710
+ * statement - where the entity its SQL renders against, and a member named `x`, can be told from here. Every
711
+ * other use is reported. Returns `col` as a dead import once no use is left.
712
+ */
713
+ function rewriteColumnRefs(source, ctx) {
714
+ const element = uqlImports(source).find((use) => importedName(use) === 'col');
715
+ if (!element) {
716
+ return [];
717
+ }
718
+ const computed = new Map();
719
+ const unread = [];
720
+ for (const use of usesOf(source, element.name, ctx.checker)) {
721
+ const call = ts.isCallExpression(use.parent) && use.parent.expression === use ? use.parent : undefined;
722
+ const sql = call && ts.findAncestor(call, isRaw);
723
+ const option = sql?.parent;
724
+ const owner = option && ts.isPropertyAssignment(option) && isComputedOption(option) ? computedOwner(option) : undefined;
725
+ const edit = call && !owner ? statementRefEdit(call, ctx) : undefined;
726
+ if (call && sql && ts.isTaggedTemplateExpression(sql) && owner) {
727
+ computed.set(sql, { owner, calls: [...(computed.get(sql)?.calls ?? []), call] });
728
+ }
729
+ else if (edit) {
730
+ ctx.edits.push(edit);
731
+ ctx.imports.set('refs', 'the refs(...)');
732
+ }
733
+ else {
734
+ unread.push(use);
735
+ }
736
+ }
737
+ for (const [sql, { owner, calls }] of computed) {
738
+ const edits = computedRefEdits(sql, calls, owner, ctx);
739
+ if (edits) {
740
+ ctx.edits.push(...edits);
741
+ }
742
+ else {
743
+ unread.push(...calls);
744
+ }
745
+ }
746
+ for (const node of unread.sort((a, b) => a.getStart() - b.getStart())) {
747
+ ctx.unresolved.push(`${ctx.describe(node)}: 'col' was removed; read the column off refs(Entity), which the codemod could not tell here`);
748
+ }
749
+ return unread.length ? [] : ['col'];
750
+ }
751
+ function isComputedOption(option) {
752
+ const key = propertyKey(option.name);
753
+ return key === 'computed' || key === 'virtual';
754
+ }
755
+ /** The entity a field's `computed` SQL is declared on: the decorated property's class, or `defineEntity`'s first argument. */
756
+ function computedOwner(option) {
757
+ const holder = ts.findAncestor(option, (node) => ts.isPropertyDeclaration(node) ||
758
+ (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'defineEntity'));
759
+ if (holder && ts.isPropertyDeclaration(holder)) {
760
+ return { param: paramFor(holder.parent.name?.text), entity: holder.parent };
761
+ }
762
+ const [entity] = holder?.arguments ?? [];
763
+ return entity && { param: paramFor(ts.isIdentifier(entity) ? entity.text : undefined), entity };
764
+ }
765
+ /** `(product) => ` before a `computed` raw, and `product.x` for each `col('x')` in it, where every one names a member. */
766
+ function computedRefEdits(sql, calls, owner, ctx) {
767
+ const members = memberNames(owner.entity, ctx.checker);
768
+ const names = calls.map((call) => columnMember(call, members));
769
+ if (mentions(sql, owner.param) || !names.every((name) => name !== undefined)) {
770
+ return undefined;
771
+ }
772
+ return [
773
+ inserted(sql, `(${owner.param}) => `),
774
+ ...calls.map((call, at) => replaced(call, memberAccess(owner.param, names[at]))),
775
+ ];
776
+ }
777
+ /** `refs(Item).x` for a `col('x')` in a statement, where the entity its SQL renders against can be told. */
778
+ function statementRefEdit(call, ctx) {
779
+ const entity = statementEntity(call, ctx.checker);
780
+ const name = entity && columnMember(call, propertyNames(entity.type));
781
+ return entity && name !== undefined ? replaced(call, memberAccess(`refs(${entity.name})`, name)) : undefined;
782
+ }
783
+ /** The member a `col('x')` names, where its one argument is a literal naming one of `members`. */
784
+ function columnMember(call, members) {
785
+ const [name] = call.arguments;
786
+ return call.arguments.length === 1 && ts.isStringLiteralLike(name) && members.has(name.text) ? name.text : undefined;
787
+ }
788
+ /** Whether `node` reads a binding named `name`, which a callback parameter of that name would shadow. */
789
+ function mentions(node, name) {
790
+ if (ts.isIdentifier(node)) {
791
+ return node.text === name && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node);
792
+ }
793
+ return ts.forEachChild(node, (child) => mentions(child, name) || undefined) ?? false;
794
+ }
795
+ /**
796
+ * The entity a statement's SQL renders against at `node`: the one its query names, or a relation a `$populate`
797
+ * reads there. Nothing where a function or a relation filter in between opens a scope of its own.
798
+ */
799
+ function statementEntity(node, checker) {
800
+ const path = [];
801
+ for (let at = node; at.parent; at = at.parent) {
802
+ const { parent } = at;
803
+ if (ts.isFunctionLike(parent)) {
804
+ return undefined;
805
+ }
806
+ if (ts.isPropertyAssignment(parent)) {
807
+ path.unshift(propertyKey(parent.name) ?? '');
808
+ }
809
+ const queried = ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === at);
810
+ const entity = queried ? queriedEntity(parent, checker) : undefined;
811
+ if (entity) {
812
+ return entityAlong(entity, path, node, checker);
813
+ }
814
+ }
815
+ return undefined;
816
+ }
817
+ /** The entity a call queries: its first argument's class, or the `$entity` its query names. */
818
+ function queriedEntity(call, checker) {
819
+ const [first] = call.arguments;
820
+ const entity = first && ts.isObjectLiteralExpression(first) ? propertyValue(first, '$entity') : first;
821
+ const instance = entity && checker.getTypeAtLocation(entity).getConstructSignatures()[0]?.getReturnType();
822
+ return entity && ts.isIdentifier(entity) && instance ? { name: entity.text, type: instance } : undefined;
823
+ }
824
+ /** `entity` followed along `path`: into each relation a `$populate` names, and nowhere a relation filter opens. */
825
+ function entityAlong(entity, path, node, checker) {
826
+ let current = entity;
827
+ for (const [at, key] of path.entries()) {
828
+ const property = current.type.getProperty(key);
829
+ const related = property && relatedClass(property, node, checker);
830
+ if (!related) {
831
+ continue;
832
+ }
833
+ const inScope = checker.getSymbolsInScope(node, ts.SymbolFlags.Class).some(({ name }) => name === related.name);
834
+ if (path[at - 1] !== '$populate' || !inScope) {
835
+ return undefined;
836
+ }
837
+ current = { name: related.name, type: checker.getDeclaredTypeOfSymbol(related) };
838
+ }
839
+ return current;
840
+ }
841
+ /** The class a relation property points at, one or many, or nothing where the property holds no class. */
842
+ function relatedClass(property, node, checker) {
843
+ const type = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(property, node));
844
+ const symbol = (checker.getIndexTypeOfType(type, ts.IndexKind.Number) ?? type).getSymbol();
845
+ return symbol && symbol.flags & ts.SymbolFlags.Class ? symbol : undefined;
575
846
  }
576
847
  /** Rewrites one source file for the standard decorator spec. */
577
848
  export function transformFile(source, checker) {
@@ -580,12 +851,14 @@ export function transformFile(source, checker) {
580
851
  edits: [],
581
852
  unresolved: [],
582
853
  notes: [],
854
+ imports: new Map(),
583
855
  relationAlias: { seen: 0, unwrapped: 0 },
584
856
  describe: (node) => `${source.fileName}:${lineOf(node) + 1}`,
585
857
  };
586
858
  const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line;
587
859
  const rewritesRaw = uqlImports(source).some((element) => importedName(element) === 'raw');
588
- let branded = false;
860
+ // The builder's methods are known by name alone, so they are read only in a file that imports uql-orm.
861
+ const importsUql = uqlImportDeclarations(source, true).length > 0;
589
862
  const visit = (node) => {
590
863
  reportRemovedDecorators(node, ctx);
591
864
  countRelationAlias(node, ctx);
@@ -596,24 +869,26 @@ export function transformFile(source, checker) {
596
869
  rewriteProperty(node, ctx);
597
870
  }
598
871
  if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
599
- branded = brandIdKey(node, ctx) || branded;
872
+ brandIdKey(node, ctx);
600
873
  rewriteClassDecorators(node, ctx);
601
874
  }
602
875
  if (ts.isCallExpression(node)) {
603
876
  rewriteDefineCall(node, ctx);
604
877
  }
878
+ if (importsUql && ts.isCallExpression(node)) {
879
+ rewriteBuilderCall(node, ctx);
880
+ }
605
881
  if (ts.isPropertyAssignment(node)) {
606
882
  rewriteStatementKeys(node, ctx);
607
883
  }
608
884
  ts.forEachChild(node, visit);
609
885
  };
610
886
  visit(source);
611
- renameExports(source, ctx);
887
+ const dead = new Set([...deadImportNames(source, ctx), ...rewriteColumnRefs(source, ctx)]);
888
+ const rewritten = renameExports(source, dead, ctx);
612
889
  reportRemovedExports(source, ctx);
613
- dropDeadImports(source, ctx);
614
- if (branded) {
615
- addIdKeyImport(source, ctx);
616
- }
890
+ dropDeadImports(source, dead, rewritten, ctx);
891
+ addImports(source, rewritten, ctx);
617
892
  return {
618
893
  fileName: source.fileName,
619
894
  text: applyEdits(source.getFullText(), ctx.edits),
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.35.0",
5
+ "version": "0.36.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=24"