uql-codemod 0.35.1 → 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
@@ -26,10 +26,12 @@ Only `--flag=value` is read, and anything unrecognised is an error. It exits `0`
26
26
 
27
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
28
  - `entity: () => X` on a bare relation decorator, and `Relation<T>` to `T`, its import going with the last use.
29
- - `virtual` to `computed`, and `raw('sql')` to the tagged template.
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
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.
31
33
  - An aggregate's `$agg` to `$select`, and each field it or `$text`'s `$fields` names to a key: `{ $sum: { amount: true } }`.
32
- - Renamed exports, at the import and every use: `QueryWhereMap` to `QueryWhere`, `RelationKeyMap` to `KeyMap`, 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.
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.
33
35
  - The `[idKey]?: '...'` brand, and its import, on a key not called `id`, `_id` or `uuid` and on every composite.
34
36
  - `import 'reflect-metadata'`, and `experimentalDecorators`/`emitDecoratorMetadata` in `tsconfig.json`, comments and formatting kept.
35
37
 
@@ -41,6 +43,9 @@ Reported with what to do, never guessed:
41
43
  - `@Log()`, `@Serialized()`, `@Transactional()` and `@InjectQuerier()`: a `@Transactional()` method becomes a `pool.transaction()` around its body, and only you know which pool.
42
44
  - An export removed with no one-to-one replacement (`setQuerierPool`, `getQuerier`, `augmentWhere`, `AbstractPgQuerier`, ...).
43
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.
44
49
  - A property whose type maps to no column (`Json<T>`, a vector, a mixed union).
45
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.
46
51
 
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
  /**
@@ -38,6 +39,7 @@ const REMOVED_EXPORTS = new Map([
38
39
  const RENAMED_EXPORTS = new Map([
39
40
  ['QueryWhereMap', { to: 'QueryWhere' }],
40
41
  ['RelationKeyMap', { to: 'KeyMap' }],
42
+ ['FilterCondition', { to: 'FilterWhere' }],
41
43
  ['PgDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
42
44
  ['NeonDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
43
45
  ['PgliteDialect', { to: 'PostgresDialect', from: 'uql-orm/postgres' }],
@@ -64,18 +66,19 @@ function decoratorOptions(node) {
64
66
  return { kind: 'opaque', reason: 'it is used without being called' };
65
67
  }
66
68
  const [first] = node.expression.arguments;
67
- if (!first) {
68
- return { kind: 'empty', call: node.expression };
69
- }
70
- if (!ts.isObjectLiteralExpression(first)) {
71
- 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()}'` };
72
75
  }
73
- return first.properties.some(ts.isSpreadAssignment)
76
+ return value.properties.some(ts.isSpreadAssignment)
74
77
  ? { kind: 'opaque', reason: 'its options object spreads another' }
75
- : { kind: 'literal', node: first };
78
+ : { kind: 'literal', node: value };
76
79
  }
77
80
  /**
78
- * 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.
79
82
  *
80
83
  * All three forms, because both callers care about the option being *there*: one to leave an option
81
84
  * alone that is already stated, the other to rename it. Matching only the plain key inserted a second
@@ -88,27 +91,44 @@ function findProperty(options, name) {
88
91
  return options.node.properties.find((prop) => (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && propertyKey(prop.name) === name);
89
92
  }
90
93
  /**
91
- * Renames the `virtual` option to `computed`, the one it was renamed to.
92
- *
93
- * Only the key is replaced, so the expression, its formatting and any comment inside the options
94
- * object survive untouched. A shorthand has no value to keep, so it becomes `computed: virtual` -
95
- * 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.
96
97
  */
97
- function renameVirtualOption(decorator, node, ctx) {
98
- const options = decoratorOptions(decorator);
99
- const virtual = findProperty(options, 'virtual');
100
- if (!virtual) {
101
- if (options.kind === 'opaque' && /\bvirtual\b/.test(decorator.getText())) {
102
- ctx.unresolved.push(`${ctx.describe(node)}: ${options.reason}`);
103
- }
98
+ function renameOption(options, from, to, node, ctx) {
99
+ const property = findProperty(options, from);
100
+ if (!property) {
101
+ return;
102
+ }
103
+ if (findProperty(options, to)) {
104
+ ctx.unresolved.push(`${ctx.describe(node)}: gives both '${from}' and '${to}'; keep '${to}'.`);
104
105
  return;
105
106
  }
106
- if (findProperty(options, 'computed')) {
107
- ctx.unresolved.push(`${ctx.describe(node)}: gives both 'virtual' and 'computed'; keep 'computed'.`);
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) {
108
120
  return;
109
121
  }
110
- const text = ts.isShorthandPropertyAssignment(virtual) ? 'computed: virtual' : 'computed';
111
- ctx.edits.push({ start: virtual.name.getStart(), end: virtual.name.getEnd(), text });
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));
112
132
  }
113
133
  /** Inserts `option` into the decorator's options object, creating one when the call has no arguments. */
114
134
  function insertOption(options, option) {
@@ -118,9 +138,7 @@ function insertOption(options, option) {
118
138
  return { start: args.pos, end: args.end, text: `{ ${option} }` };
119
139
  }
120
140
  const first = options.node.properties[0];
121
- return first
122
- ? { start: first.getStart(), end: first.getStart(), text: `${option}, ` }
123
- : { start: options.node.getStart(), end: options.node.getEnd(), text: `{ ${option} }` };
141
+ return first ? inserted(first, `${option}, `) : replaced(options.node, `{ ${option} }`);
124
142
  }
125
143
  /**
126
144
  * Writes one option the decorator can no longer infer at runtime, or records why it could not.
@@ -177,7 +195,7 @@ function addRelationEntity(decorator, node, ctx) {
177
195
  });
178
196
  }
179
197
  /**
180
- * 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
181
199
  * where it could not be read: what it holds is only known at runtime. A callback or an object literal
182
200
  * is already the new form, and kept.
183
201
  */
@@ -185,23 +203,23 @@ function rewriteValue(value, rewrite, node, advice, ctx) {
185
203
  if (!value || isCallback(value) || ts.isObjectLiteralExpression(value)) {
186
204
  return;
187
205
  }
188
- const edit = rewrite(value);
189
- if (edit) {
190
- ctx.edits.push(edit);
206
+ const edits = rewrite(value);
207
+ if (edits) {
208
+ ctx.edits.push(...edits);
191
209
  }
192
210
  else {
193
211
  ctx.unresolved.push(`${ctx.describe(node)}: ${advice}; this one could not be read`);
194
212
  }
195
213
  }
196
214
  function rewriteKeyList(value, param, node, what, ctx) {
197
- 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);
198
216
  }
199
217
  /**
200
- * Rewrites what a class decorator names by string - `@Index` columns and `include`, and `@Entity`'s
201
- * `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.
202
220
  */
203
221
  function rewriteClassDecorators(node, ctx) {
204
- const param = paramFor(node.name?.text);
222
+ const owner = { param: paramFor(node.name?.text), entity: node };
205
223
  for (const decorator of decoratorsOf(node)) {
206
224
  if (!ts.isCallExpression(decorator.expression)) {
207
225
  continue;
@@ -209,54 +227,118 @@ function rewriteClassDecorators(node, ctx) {
209
227
  const [first, second] = decorator.expression.arguments;
210
228
  const name = decoratorName(decorator);
211
229
  if (name === 'Index') {
212
- rewriteKeyList(first, param, decorator, "'@Index' columns", ctx);
213
- if (second && ts.isObjectLiteralExpression(second)) {
214
- rewriteKeyList(propertyValue(second, 'include'), param, decorator, "'include'", ctx);
215
- }
230
+ rewriteIndex(first, second, owner, decorator, "'@Index' columns", ctx);
231
+ }
232
+ else if (name === 'Filter' && second) {
233
+ renameOption(optionsOf(second), 'condition', 'where', decorator, ctx);
216
234
  }
217
235
  else if (name === 'Entity' && first && ts.isObjectLiteralExpression(first)) {
218
- rewriteEntityOptions(first, param, ctx);
236
+ rewriteEntityOptions(first, owner, ctx);
219
237
  }
220
238
  }
221
239
  }
222
240
  /**
223
- * Rewrites what the imperative API names by string - `defineEntity(Post, { indexes, hooks, relations })`,
224
- * `defineIndex(Post, { columns, include })` and `defineRelation(Post, 'tag', { mappedBy, references })` -
225
- * 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.
226
244
  */
227
245
  function rewriteDefineCall(call, ctx) {
228
246
  const name = ts.isIdentifier(call.expression) ? call.expression.text : undefined;
229
- const [entity, second, third] = call.arguments;
230
- 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 };
231
249
  if (name === 'defineEntity' && second && ts.isObjectLiteralExpression(second)) {
232
- rewriteEntityOptions(second, param, ctx);
250
+ rewriteEntityOptions(second, owner, ctx);
233
251
  }
234
252
  else if (name === 'defineIndex' && second && ts.isObjectLiteralExpression(second)) {
235
- rewriteIndexOptions(second, param, ctx);
253
+ rewriteIndexOptions(second, owner, ctx);
236
254
  }
237
255
  else if (name === 'defineRelation' && third && ts.isObjectLiteralExpression(third)) {
238
- 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);
239
260
  }
240
261
  }
241
- function rewriteEntityOptions(options, param, ctx) {
242
- const indexes = propertyValue(options, 'indexes');
243
- 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'))) {
244
264
  if (ts.isObjectLiteralExpression(index)) {
245
- rewriteIndexOptions(index, param, ctx);
265
+ rewriteIndexOptions(index, owner, ctx);
246
266
  }
247
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
+ }
248
277
  for (const hook of objectProperties(propertyValue(options, 'hooks'))) {
249
- rewriteKeyList(hook.initializer, param, hook, 'a hook list', ctx);
278
+ rewriteKeyList(hook.initializer, owner.param, hook, 'a hook list', ctx);
250
279
  }
251
280
  for (const relation of objectProperties(propertyValue(options, 'relations'))) {
252
281
  if (ts.isObjectLiteralExpression(relation.initializer)) {
253
- rewriteRelationOptions(relation.initializer, param, ctx);
282
+ rewriteRelationOptions(relation.initializer, owner.param, ctx);
254
283
  }
255
284
  }
256
285
  }
257
- function rewriteIndexOptions(index, param, ctx) {
258
- rewriteKeyList(propertyValue(index, 'columns'), param, index, "'columns'", ctx);
259
- 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);
260
342
  }
261
343
  /**
262
344
  * `owner` is the declaring class's parameter and `target` the related one's, read off the `entity`
@@ -264,15 +346,15 @@ function rewriteIndexOptions(index, param, ctx) {
264
346
  */
265
347
  function rewriteRelationOptions(relation, owner, ctx, target = paramFor(entityGetterTarget(relation))) {
266
348
  const advice = (what) => `write '${what}' as a key-map callback`;
267
- const mappedBy = (value) => mappedByEdit(value, target);
268
- const references = (value) => referencesEdit(value, owner, target);
349
+ const mappedBy = (value) => mappedByEdits(value, target);
350
+ const references = (value) => referencesEdits(value, owner, target);
269
351
  rewriteValue(propertyValue(relation, 'mappedBy'), mappedBy, relation, advice('mappedBy'), ctx);
270
352
  rewriteValue(propertyValue(relation, 'references'), references, relation, advice('references'), ctx);
271
353
  }
272
354
  /**
273
- * Rewrites what a statement names by string into keys: an aggregate's `$agg: { total: { $sum: 'amount' } }`
274
- * into `$select: { total: { $sum: { amount: true } } }`, and `$text`'s `$fields: ['title']` into
275
- * `{ 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.
276
358
  */
277
359
  function rewriteStatementKeys(node, ctx) {
278
360
  const key = propertyKey(node.name);
@@ -280,7 +362,21 @@ function rewriteStatementKeys(node, ctx) {
280
362
  rewriteAggregate(node, ctx);
281
363
  }
282
364
  else if (key === '$fields' && isTextSearch(node.parent)) {
283
- 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);
284
380
  }
285
381
  }
286
382
  function isTextSearch(node) {
@@ -298,7 +394,7 @@ function rewriteAggregate(agg, ctx) {
298
394
  for (const op of objectProperties(fn.initializer)) {
299
395
  const isStar = ts.isStringLiteralLike(op.initializer) && op.initializer.text === '*';
300
396
  if (!isStar) {
301
- 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);
302
398
  }
303
399
  }
304
400
  }
@@ -307,6 +403,10 @@ function rewriteAggregate(agg, ctx) {
307
403
  function objectProperties(node) {
308
404
  return node && ts.isObjectLiteralExpression(node) ? node.properties.filter(ts.isPropertyAssignment) : [];
309
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
+ }
310
410
  /**
311
411
  * Drops `declare` from a decorated field. A `declare` field emits nothing, so the standard spec has
312
412
  * nothing to decorate and rejects it ("Decorators are not valid here"). Legacy decorators tolerated it
@@ -337,7 +437,7 @@ function unwrapRelationAlias(node, ctx) {
337
437
  ts.isIdentifier(declared.typeName) &&
338
438
  declared.typeName.text === 'Relation' &&
339
439
  declared.typeArguments?.length === 1) {
340
- ctx.edits.push({ start: declared.getStart(), end: declared.getEnd(), text: declared.typeArguments[0].getText() });
440
+ ctx.edits.push(replaced(declared, declared.typeArguments[0].getText()));
341
441
  ctx.relationAlias.unwrapped += 1;
342
442
  }
343
443
  }
@@ -368,11 +468,10 @@ function deadImportNames(source, ctx) {
368
468
  return new Set(seen > 0 && seen === unwrapped ? ['Relation'] : []);
369
469
  }
370
470
  /**
371
- * Removes `import 'reflect-metadata'`, since the polyfill only ever fed `design:type`, and any name
372
- * `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.
373
473
  */
374
- function dropDeadImports(source, ctx) {
375
- const dead = deadImportNames(source, ctx);
474
+ function dropDeadImports(source, dead, rewritten, ctx) {
376
475
  for (const statement of source.statements) {
377
476
  if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
378
477
  continue;
@@ -382,7 +481,7 @@ function dropDeadImports(source, ctx) {
382
481
  continue;
383
482
  }
384
483
  const named = statement.importClause?.namedBindings;
385
- if (!named || !ts.isNamedImports(named)) {
484
+ if (!named || !ts.isNamedImports(named) || rewritten.has(statement)) {
386
485
  continue;
387
486
  }
388
487
  const dropped = named.elements.filter((element) => dead.has(element.name.text));
@@ -411,62 +510,70 @@ function isIdKeyBrand(member) {
411
510
  * a subclass that replaces an inherited `id` needs the brand precisely because the inherited name
412
511
  * would otherwise be taken for the key.
413
512
  *
414
- * 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}.
415
514
  */
416
515
  function brandIdKey(node, ctx) {
417
516
  const ids = node.members.filter((m) => ts.isPropertyDeclaration(m) && decoratorsOf(m).some((d) => decoratorName(d) === 'Id'));
418
517
  if (!ids.length || node.members.some(isIdKeyBrand)) {
419
- return false;
518
+ return;
420
519
  }
421
520
  const names = ids.map((m) => propertyKey(m.name)).filter((name) => name !== undefined);
422
521
  if (names.length !== ids.length) {
423
522
  ctx.unresolved.push(`${ctx.describe(node)}: a key written as a computed name; add the 'idKey' brand by hand`);
424
- return false;
523
+ return;
425
524
  }
426
525
  if (names.length === 1 && CONVENTIONAL_ID_NAMES.has(names[0])) {
427
- return false;
526
+ return;
428
527
  }
429
528
  const indent = ' '.repeat(node.getSourceFile().getLineAndCharacterOfPosition(ids[0].getStart()).character);
430
529
  const brand = names.map((name) => `'${name}'`).join(' | ');
431
530
  const start = node.members.pos;
432
531
  ctx.edits.push({ start, end: start, text: `\n${indent}[idKey]?: ${brand};` });
433
- return true;
532
+ ctx.imports.set('idKey', 'the brand(s)');
434
533
  }
435
534
  /**
436
- * 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
437
536
  * where there is none: the package may be imported under a path this codemod does not recognise.
438
537
  */
439
- function addIdKeyImport(source, rewritten, ctx) {
440
- if (uqlImports(source).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) {
441
542
  return;
442
543
  }
443
544
  const anchor = uqlImportDeclarations(source).find(({ declaration }) => !rewritten.has(declaration))?.elements[0];
444
545
  if (!anchor) {
445
- 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
+ }
446
549
  return;
447
550
  }
448
- ctx.edits.push({ start: anchor.getStart(), end: anchor.getStart(), text: 'idKey, ' });
551
+ ctx.edits.push(inserted(anchor, missing.map(([name]) => `${name}, `).join('')));
449
552
  }
450
- /** 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. */
451
554
  function rewriteProperty(node, ctx) {
555
+ const owner = { param: paramFor(node.parent.name?.text), entity: node.parent };
452
556
  const decorators = decoratorsOf(node);
453
557
  for (const decorator of decorators) {
454
558
  const name = decoratorName(decorator);
455
559
  if (!name) {
456
560
  continue;
457
561
  }
562
+ const options = decoratorOptions(decorator);
458
563
  if (FIELD_DECORATORS.has(name)) {
459
564
  addFieldType(decorator, node, ctx);
460
- 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);
461
570
  }
462
- const options = decoratorOptions(decorator);
463
571
  if (RELATION_DECORATORS.has(name)) {
464
572
  addRelationEntity(decorator, node, ctx);
465
573
  }
466
574
  if (RELATION_DECORATORS.has(name) && options.kind === 'literal') {
467
- const owner = ts.isClassLike(node.parent) ? node.parent.name?.text : undefined;
468
575
  const target = relationTargetFor(ctx.checker.getTypeAtLocation(node), ctx.checker);
469
- rewriteRelationOptions(options.node, paramFor(owner), ctx, paramFor(target));
576
+ rewriteRelationOptions(options.node, owner.param, ctx, paramFor(target));
470
577
  }
471
578
  }
472
579
  if (decorators.length) {
@@ -496,10 +603,10 @@ function reportRemovedExports(source, ctx) {
496
603
  }
497
604
  /**
498
605
  * Rewrites each import naming a {@link RENAMED_EXPORTS} export, renaming every use of it: a name moving
499
- * entries gets an import from its new one, and a name the file already imports is dropped. Returns the
500
- * rewritten imports, which nothing else may edit.
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.
501
608
  */
502
- function renameExports(source, ctx) {
609
+ function renameExports(source, dead, ctx) {
503
610
  const imports = uqlImportDeclarations(source, true);
504
611
  const kept = imports.flatMap(({ elements }) => elements).filter((element) => !renamedTo(element));
505
612
  const bound = new Set(kept.map((element) => element.name.text));
@@ -509,7 +616,7 @@ function renameExports(source, ctx) {
509
616
  continue;
510
617
  }
511
618
  const byEntry = new Map([[entry, []]]);
512
- for (const element of elements) {
619
+ for (const element of elements.filter(({ name }) => !dead.has(name.text))) {
513
620
  const rename = renamedTo(element);
514
621
  const local = element.propertyName || !rename ? element.name.text : rename.to;
515
622
  if (rename && !element.propertyName) {
@@ -579,28 +686,163 @@ function importedName(element) {
579
686
  return (element.propertyName ?? element.name).text;
580
687
  }
581
688
  /**
582
- * Rewrites `raw('sql')` into the tagged template, and a second alias argument into `.as()`. Only a
583
- * string-literal first argument qualifies: the callback form is unchanged, and a computed string is
584
- * 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.
585
691
  */
586
692
  function rewriteRawCall(node, ctx, source) {
587
693
  if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'raw') {
588
694
  return;
589
695
  }
590
696
  const [expression, alias] = node.arguments;
591
- if (!expression || !ts.isStringLiteral(expression) || node.arguments.length > 2) {
697
+ if (!expression || node.arguments.length > 2) {
592
698
  return;
593
699
  }
594
700
  const suffix = alias ? `.as(${alias.getText(source)})` : '';
595
- ctx.edits.push({
596
- start: node.getStart(source),
597
- end: node.getEnd(),
598
- text: `raw\`${escapeForTemplate(expression.text)}\`${suffix}`,
599
- });
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
+ }
707
+ }
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';
600
754
  }
601
- /** A backtick or a `${` inside the old string literal would end or interpolate the template. */
602
- function escapeForTemplate(text) {
603
- return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
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;
604
846
  }
605
847
  /** Rewrites one source file for the standard decorator spec. */
606
848
  export function transformFile(source, checker) {
@@ -609,12 +851,14 @@ export function transformFile(source, checker) {
609
851
  edits: [],
610
852
  unresolved: [],
611
853
  notes: [],
854
+ imports: new Map(),
612
855
  relationAlias: { seen: 0, unwrapped: 0 },
613
856
  describe: (node) => `${source.fileName}:${lineOf(node) + 1}`,
614
857
  };
615
858
  const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line;
616
859
  const rewritesRaw = uqlImports(source).some((element) => importedName(element) === 'raw');
617
- 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;
618
862
  const visit = (node) => {
619
863
  reportRemovedDecorators(node, ctx);
620
864
  countRelationAlias(node, ctx);
@@ -625,24 +869,26 @@ export function transformFile(source, checker) {
625
869
  rewriteProperty(node, ctx);
626
870
  }
627
871
  if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
628
- branded = brandIdKey(node, ctx) || branded;
872
+ brandIdKey(node, ctx);
629
873
  rewriteClassDecorators(node, ctx);
630
874
  }
631
875
  if (ts.isCallExpression(node)) {
632
876
  rewriteDefineCall(node, ctx);
633
877
  }
878
+ if (importsUql && ts.isCallExpression(node)) {
879
+ rewriteBuilderCall(node, ctx);
880
+ }
634
881
  if (ts.isPropertyAssignment(node)) {
635
882
  rewriteStatementKeys(node, ctx);
636
883
  }
637
884
  ts.forEachChild(node, visit);
638
885
  };
639
886
  visit(source);
640
- const rewritten = renameExports(source, ctx);
887
+ const dead = new Set([...deadImportNames(source, ctx), ...rewriteColumnRefs(source, ctx)]);
888
+ const rewritten = renameExports(source, dead, ctx);
641
889
  reportRemovedExports(source, ctx);
642
- dropDeadImports(source, ctx);
643
- if (branded) {
644
- addIdKeyImport(source, rewritten, ctx);
645
- }
890
+ dropDeadImports(source, dead, rewritten, ctx);
891
+ addImports(source, rewritten, ctx);
646
892
  return {
647
893
  fileName: source.fileName,
648
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.1",
5
+ "version": "0.36.0",
6
6
  "type": "module",
7
7
  "engines": {
8
8
  "node": ">=24"