uql-codemod 0.48.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,783 @@
1
+ import ts from 'typescript';
2
+ import { call, flag, flagOrCall } from './columnMethods.js';
3
+ import { formatFindings, locationAt } from './context.js';
4
+ import { applyEdits, editedText, replaced, replacingUses, reparsedAgain, unedited, } from './edits.js';
5
+ import { importEdits } from './imports.js';
6
+ import { isCallback, paramFor } from './keyMaps.js';
7
+ import { FOREIGN_KEY_IN_SCHEMA, NO_KEY_TYPE, nameClash, ON_DELETE_ON_RELATION } from './schemaMessages.js';
8
+ import { CHAINED_OPTIONS, FIRST_CALL_OPTIONS } from './schemaOptions.js';
9
+ import { chainOf, decoratorName, decoratorsOf, FIELD_DECORATORS, identifierText, identifierUses, indentOf, indentUnit, keyOf, objectLiteral, objectProperties, optionsOf, propertiesByKey, propertyAssignment, propertyKey, propertyValue, } from './syntax.js';
10
+ /** The column type each constructor implies, and the builder it is now. */
11
+ const CONSTRUCTORS = {
12
+ String: 'f.string()',
13
+ Number: 'f.number()',
14
+ BigInt: 'f.bigint()',
15
+ Boolean: 'f.boolean()',
16
+ Date: 'f.date()',
17
+ };
18
+ /** The column types a builder of their own names. */
19
+ const NAMED_TYPES = { text: 'f.text()', uuid: 'f.uuid()' };
20
+ /** The method a `false` states and a `true` says already, nothing; a value no literal states is `undefined`. */
21
+ const onlyFalse = (method) => (value) => value === 'false' ? `.${method}(false)` : value === 'true' ? '' : undefined;
22
+ /** Each option a method writes, in the order a builder chains them, with how its value becomes a call. */
23
+ const METHODS = [
24
+ ['defaultValue', call('default')],
25
+ ['onInsert', call('onInsert')],
26
+ ['onUpdate', call('onUpdate')],
27
+ ['updatable', onlyFalse('updatable')],
28
+ ['eager', onlyFalse('eager')],
29
+ ['softDelete', flagOrCall('softDelete')],
30
+ ['unique', flag('unique')],
31
+ ['index', (value) => (value === 'true' ? '.index()' : call('index')(value))],
32
+ ['autoIncrement', (value) => (value === 'true' ? '.autoIncrement()' : call('autoIncrement')(value))],
33
+ ['length', call('length')],
34
+ ['precision', call('precision')],
35
+ ['scale', call('scale')],
36
+ ['dimensions', call('dimensions')],
37
+ ['distance', call('distance')],
38
+ ['columnType', call('columnType')],
39
+ ['name', call('name')],
40
+ ['comment', call('comment')],
41
+ ['references', call('references')],
42
+ ['onDelete', call('onDelete')],
43
+ ];
44
+ /** What a constructor or `nullable()`/`id()` already said, so no method repeats it. */
45
+ const SAID_BY_CONSTRUCTOR = new Set(['type', 'nullable', 'isId', 'enum', 'version', 'computed', 'stored']);
46
+ const KNOWN = new Set([...SAID_BY_CONSTRUCTOR, ...METHODS.map(([option]) => option)]);
47
+ /** The options a literal states, each by its key, or why it cannot be read. */
48
+ function optionsByKey(node) {
49
+ const options = optionsOf(node);
50
+ if (options.kind === 'opaque') {
51
+ return options.reason;
52
+ }
53
+ return propertiesByKey(options.node) ?? 'its options name one by a computed key';
54
+ }
55
+ /** `value` without an `as const`, which a builder's `const` type parameter makes redundant. */
56
+ function withoutAsConst(value) {
57
+ return ts.isAsExpression(value) && value.type.getText() === 'const' ? value.expression.getText() : value.getText();
58
+ }
59
+ /** The builder a column's options describe, `f.string().nullable()`, or why it cannot be written. */
60
+ function columnBuilder(options, key) {
61
+ const unknown = [...options.keys()].filter((option) => !KNOWN.has(option));
62
+ if (unknown.length) {
63
+ return { message: `has options no builder writes: ${unknown.join(', ')}` };
64
+ }
65
+ const consumed = new Set();
66
+ const text = (option) => {
67
+ const value = options.get(option);
68
+ if (value)
69
+ consumed.add(option);
70
+ return value && withoutAsConst(value);
71
+ };
72
+ const constructor = constructorOf(options, text);
73
+ if (typeof constructor !== 'string') {
74
+ return constructor;
75
+ }
76
+ const nullable = text('nullable');
77
+ if (nullable !== undefined && nullable !== 'true' && nullable !== 'false') {
78
+ return { message: `has 'nullable: ${nullable}', which no builder states unless it is a literal` };
79
+ }
80
+ const isKey = key || text('isId') === 'true';
81
+ const present = nullable === 'false' || isKey || options.has('version');
82
+ const shape = isKey ? '.id()' : present ? '' : '.nullable()';
83
+ const methods = [];
84
+ for (const [option, method] of METHODS) {
85
+ const value = consumed.has(option) ? undefined : text(option);
86
+ if (value === undefined)
87
+ continue;
88
+ const written = method(value);
89
+ if (written === undefined) {
90
+ return { message: `has '${option}: ${value}', which no builder method states unless it is a literal` };
91
+ }
92
+ methods.push(written);
93
+ }
94
+ return `${constructor}${shape}${methods.join('')}`;
95
+ }
96
+ /** `f.decimal()` with the precision and scale its options state. */
97
+ function decimalOf(text) {
98
+ return `f.decimal(${[text('precision'), text('scale')].filter(Boolean).join(', ')})`;
99
+ }
100
+ /** The builder a column starts from, its `type` read with whatever the constructor itself takes. */
101
+ function constructorOf(options, text) {
102
+ const type = text('type');
103
+ const members = text('enum');
104
+ if (options.has('version')) {
105
+ text('version');
106
+ if (type === undefined || type === 'Number')
107
+ return 'f.version()';
108
+ return type === 'BigInt' ? 'f.version(BigInt)' : { message: `is a version of type ${type}: use f.version()` };
109
+ }
110
+ if (members !== undefined) {
111
+ return type === 'String' || type === 'Number' || type === undefined
112
+ ? `f.enum(${members})`
113
+ : { message: `has an enum on a '${type}' column: declare it f.enum([...]).columnType(${type})` };
114
+ }
115
+ if (type === undefined) {
116
+ const references = text('references');
117
+ return references ? `f.references(${references})` : { message: "has no 'type': state it, f.string() and so on" };
118
+ }
119
+ const columnType = options.get('columnType')?.getText();
120
+ if (type === 'String' && (columnType === "'decimal'" || columnType === "'numeric'")) {
121
+ text('columnType');
122
+ return columnType === "'decimal'" ? decimalOf(text) : `f.column('numeric')`;
123
+ }
124
+ if (CONSTRUCTORS[type]) {
125
+ return CONSTRUCTORS[type];
126
+ }
127
+ const literal = /^'(.+)'$/.exec(type)?.[1];
128
+ if (!literal) {
129
+ return { message: `has a type written as '${type}': write its builder, f.string() and so on` };
130
+ }
131
+ if (NAMED_TYPES[literal]) {
132
+ return NAMED_TYPES[literal];
133
+ }
134
+ if (literal === 'json' || literal === 'jsonb') {
135
+ return `f.${literal}()`;
136
+ }
137
+ if (literal === 'vector') {
138
+ const dimensions = text('dimensions');
139
+ return dimensions ? `f.vector(${dimensions})` : `f.column('vector')`;
140
+ }
141
+ if (literal === 'decimal') {
142
+ return decimalOf(text);
143
+ }
144
+ return `f.column('${literal}')`;
145
+ }
146
+ /** A computed field's builder: the column it reads as, then the SQL, kept where `stored` says so. */
147
+ function computedBuilder(options, expression) {
148
+ const stored = options.get('stored')?.getText();
149
+ const column = new Map([...options].filter(([option]) => option !== 'computed' && option !== 'stored'));
150
+ const built = columnBuilder(column, false);
151
+ if (typeof built !== 'string') {
152
+ return built;
153
+ }
154
+ const keep = stored === undefined || stored === 'false' ? '' : stored === 'true' ? '.stored()' : `.stored(${stored})`;
155
+ return `${built}.as(${expression})${keep}`;
156
+ }
157
+ /** `(post) => sql`...``'s body, its parameter renamed `param`; the expression itself where it is no callback. */
158
+ function bodyAs(value, param) {
159
+ if (!isCallback(value)) {
160
+ return value.getText();
161
+ }
162
+ const [first, ...rest] = value.parameters;
163
+ const body = value.body;
164
+ if (rest.length || ts.isBlock(body)) {
165
+ return undefined;
166
+ }
167
+ const name = first && identifierText(first.name);
168
+ if (!name || name === param) {
169
+ return parenthesized(body);
170
+ }
171
+ return parenthesized(body, identifierUses(body, name), param);
172
+ }
173
+ /** The text of `body`, each of `uses` written `replacement`, unwrapped of the parentheses an object body needs. */
174
+ function parenthesized(body, uses = [], replacement = '') {
175
+ const inner = ts.isParenthesizedExpression(body) ? body.expression : body;
176
+ return replacingUses(inner, uses, replacement);
177
+ }
178
+ /** `@Field({...})` and `@Id({...})` as builders, a computed one as `@Computed`. */
179
+ function decoratorEdits(property, findings, used) {
180
+ return decoratorsOf(property).flatMap((decorator) => {
181
+ const name = decoratorName(decorator);
182
+ if ((name !== 'Field' && name !== 'Id') || !ts.isCallExpression(decorator.expression)) {
183
+ return [];
184
+ }
185
+ const [argument] = decorator.expression.arguments;
186
+ if (!argument && name === 'Id') {
187
+ used.add('f');
188
+ return [replaced(decorator.expression, 'Field(f.number().id())')];
189
+ }
190
+ if (!argument || isBuilder(argument)) {
191
+ return [];
192
+ }
193
+ const options = optionsByKey(argument);
194
+ if (typeof options === 'string') {
195
+ findings.push({ node: decorator, message: `cannot write a builder because ${options}` });
196
+ return [];
197
+ }
198
+ const computed = options.get('computed');
199
+ if (computed) {
200
+ return computedDecorator(decorator.expression, options, computed, findings, used);
201
+ }
202
+ const built = columnBuilder(options, name === 'Id');
203
+ if (typeof built !== 'string') {
204
+ findings.push({ node: decorator, message: built.message });
205
+ return [];
206
+ }
207
+ used.add('f');
208
+ return [replaced(decorator.expression, `Field(${built})`)];
209
+ });
210
+ }
211
+ /** `@Computed((row) => ...)`: an aggregate as it is, SQL as the column it reads as. */
212
+ function computedDecorator(call, options, computed, findings, used) {
213
+ used.add('Computed');
214
+ if (!options.has('type') && !options.has('references')) {
215
+ return [replaced(call, `Computed(${computed.getText()})`)];
216
+ }
217
+ const param = (ts.isArrowFunction(computed) && identifierText(computed.parameters[0]?.name)) || 'row';
218
+ const expression = bodyAs(computed, param);
219
+ if (expression === undefined) {
220
+ findings.push({ node: call, message: 'cannot inline a computed callback with a block body' });
221
+ return [];
222
+ }
223
+ const built = computedBuilder(options, expression);
224
+ if (typeof built !== 'string') {
225
+ findings.push({ node: call, message: built.message });
226
+ return [];
227
+ }
228
+ used.add('f');
229
+ return [replaced(call, `Computed((${param}) => ${built})`)];
230
+ }
231
+ /** Whether `node` is already a builder, `f.string()...`. */
232
+ export function isBuilder(node) {
233
+ return identifierText(chainOf(node).root) === 'f';
234
+ }
235
+ /** The options a schema's first call keeps. */
236
+ const FIRST_CALL = new Set(FIRST_CALL_OPTIONS);
237
+ /** Every key a schema's `defineEntity` options may state, as its first call or as a section. */
238
+ const SCHEMA_KEYS = new Set([
239
+ ...FIRST_CALL,
240
+ ...CHAINED_OPTIONS,
241
+ 'relations',
242
+ 'aggregates',
243
+ ]);
244
+ /** `defineEntity({ fields: {...}, relations, ... })` as its first call and a chain of sections. */
245
+ function schemaEdits(call, findings, used) {
246
+ const options = objectLiteral(call.arguments[0]);
247
+ const fields = options && objectLiteral(propertyValue(options, 'fields'));
248
+ const chained = options?.properties.some((prop) => !FIRST_CALL.has(keyOf(prop)));
249
+ if (!options || !fields || (!chained && fields.properties.every((prop) => isSchemaColumn(prop)))) {
250
+ return [];
251
+ }
252
+ const param = schemaParam(call);
253
+ const unknown = options.properties.filter((prop) => !SCHEMA_KEYS.has(keyOf(prop)));
254
+ if (unknown.length) {
255
+ findings.push({
256
+ node: call,
257
+ message: `cannot write a chain: it states ${unknown.map((it) => it.getText()).join(', ')}`,
258
+ });
259
+ return [];
260
+ }
261
+ const field = propertyAssignment(options, 'fields');
262
+ const relations = propertyValue(options, 'relations');
263
+ const aggregates = propertyValue(options, 'aggregates');
264
+ const read = fields.properties.map((prop) => ({
265
+ prop,
266
+ column: ts.isPropertyAssignment(prop) ? schemaColumn(prop.initializer) : undefined,
267
+ }));
268
+ const { joins, inferred } = joinsOf(relations, targetsOf(read), param);
269
+ const targets = {
270
+ param,
271
+ rel: (relations && ts.isArrowFunction(relations) && identifierText(relations.parameters[0]?.name)) || 'rel',
272
+ joins,
273
+ insertable: !relations || returnedLiteral(relations) !== undefined,
274
+ };
275
+ const start = {
276
+ named: new Set([
277
+ fields,
278
+ objectLiteral(propertyValue(options, 'computed')),
279
+ returnedLiteral(relations),
280
+ returnedLiteral(aggregates),
281
+ ]
282
+ .flatMap((literal) => objectProperties(literal))
283
+ .map((prop) => propertyKey(prop.name))),
284
+ added: [],
285
+ options: new Map([...inferred].map(([relation, reference]) => [relation, [reference]])),
286
+ };
287
+ const claimed = claimColumns(read, start, targets);
288
+ const columns = read.map(({ prop, column }) => {
289
+ if (!ts.isPropertyAssignment(prop) || !column) {
290
+ findings.push({ node: prop, message: `cannot write '${prop.getText()}' as a column: write its options inline` });
291
+ return undefined;
292
+ }
293
+ const key = propertyKey(prop.name) ?? prop.name.getText();
294
+ if ('message' in column) {
295
+ findings.push({ node: prop, message: `'${key}' ${column.message}` });
296
+ return undefined;
297
+ }
298
+ const problem = claimed.problems.get(prop);
299
+ if (problem) {
300
+ findings.push({ node: prop, message: `'${key}' ${problem}` });
301
+ return undefined;
302
+ }
303
+ return withValue(prop, column.builder);
304
+ });
305
+ const sections = CHAINED_OPTIONS.flatMap((section) => {
306
+ const value = propertyValue(options, section);
307
+ return value ? [chainedSection(section, value, param, findings)] : [];
308
+ });
309
+ const indent = indentOf(call);
310
+ const unit = indentUnit(call.getSourceFile());
311
+ const related = relationsText(relations, targets.rel, claimed.claims, fields.getText().includes('\n') ? `${indent}${unit}` : undefined, unit);
312
+ if (related || aggregates) {
313
+ sections.push(`.relations(${related ?? '() => ({})'}${aggregates ? `, ${aggregates.getText()}` : ''})`);
314
+ }
315
+ const written = columns.filter((it) => it !== undefined);
316
+ if (written.length !== columns.length || sections.some((it) => it === undefined)) {
317
+ return [];
318
+ }
319
+ used.add('f');
320
+ const kept = options.properties
321
+ .filter((prop) => FIRST_CALL.has(keyOf(prop)))
322
+ .map((prop) => (prop === field ? withValue(field, literal(fields, written)) : prop.getFullText()));
323
+ return [
324
+ replaced(call, `${call.expression.getText()}(${literal(options, kept)})${sections.map((it) => `\n${indent}${unit}${it}`).join('')}`),
325
+ ];
326
+ }
327
+ /** The parameter a schema's callbacks are named after: its `name` where that is a literal, else its variable's. */
328
+ function schemaParam(call) {
329
+ const options = objectLiteral(call.arguments[0]);
330
+ const name = options && /^'(.+)'$/.exec(propertyValue(options, 'name')?.getText() ?? '')?.[1];
331
+ return paramFor(name ?? identifierText(variableOf(call)));
332
+ }
333
+ /** A schema column's builder off its options or chain, its `references` and `onDelete` taken out to move to a relation. */
334
+ function schemaColumn(node) {
335
+ if (isBuilder(node)) {
336
+ const methods = chainMethods(node);
337
+ const references = methods.get('references');
338
+ if (!references) {
339
+ return { builder: node.getText() };
340
+ }
341
+ const target = targetOf(references.call.arguments[0]);
342
+ if (identifierText(references.receiver) === 'f' || !target) {
343
+ return { message: target ? NO_KEY_TYPE : FOREIGN_KEY_IN_SCHEMA };
344
+ }
345
+ const onDelete = methods.get('onDelete');
346
+ const cut = [references, ...(onDelete ? [onDelete] : [])].map((method) => ({
347
+ start: method.receiver.getEnd() - node.getStart(),
348
+ end: method.call.getEnd() - node.getStart(),
349
+ text: '',
350
+ }));
351
+ return {
352
+ builder: applyEdits(node.getText(), cut),
353
+ key: { target, onDelete: onDelete?.call.arguments[0]?.getText() },
354
+ };
355
+ }
356
+ const options = optionsByKey(node);
357
+ if (typeof options === 'string') {
358
+ return { message: `cannot be written as a builder because ${options}` };
359
+ }
360
+ const references = options.get('references');
361
+ const target = references && targetOf(references);
362
+ if (references && !target) {
363
+ return { message: FOREIGN_KEY_IN_SCHEMA };
364
+ }
365
+ if (target && !options.has('type')) {
366
+ return { message: NO_KEY_TYPE };
367
+ }
368
+ const built = columnBuilder(new Map([...options].filter(([option]) => !target || (option !== 'references' && option !== 'onDelete'))), false);
369
+ if (typeof built !== 'string') {
370
+ return built;
371
+ }
372
+ return target
373
+ ? { builder: built, key: { target, onDelete: options.get('onDelete')?.getText() } }
374
+ : { builder: built };
375
+ }
376
+ /** The entity a `() => Target` getter names, as its text. */
377
+ function targetOf(getter) {
378
+ return getter && ts.isArrowFunction(getter) && !ts.isBlock(getter.body) ? getter.body.getText() : undefined;
379
+ }
380
+ /** Each column's foreign key target, for the columns naming one. */
381
+ function targetsOf(read) {
382
+ return new Map(read.flatMap(({ prop, column }) => column && 'key' in column && column.key ? [[prop.name?.getText(), column.key.target]] : []));
383
+ }
384
+ /** The schema's columns' foreign keys claimed in turn, the problem of each column by its name node. */
385
+ function claimColumns(read, start, targets) {
386
+ const problems = new Map();
387
+ const claims = read.reduce((state, { prop, column }) => {
388
+ if (!ts.isPropertyAssignment(prop) || !column || !('key' in column) || !column.key) {
389
+ return state;
390
+ }
391
+ const outcome = claimKey(prop.name, column.key, state, targets);
392
+ if (outcome.problem) {
393
+ problems.set(prop, outcome.problem);
394
+ }
395
+ return outcome.claims;
396
+ }, start);
397
+ return { claims, problems };
398
+ }
399
+ /**
400
+ * A column's foreign key moved into the to-one joining on it, with its `onDelete`, else into a `manyToOne` written for
401
+ * it, named as the column less its `Id`; or why it cannot be.
402
+ */
403
+ function claimKey(name, { target, onDelete }, claims, targets) {
404
+ const key = name.getText();
405
+ const join = targets.joins.get(key);
406
+ if (join) {
407
+ return onDelete ? movedOnDelete(join, onDelete, claims) : { claims };
408
+ }
409
+ const relation = ts.isIdentifier(name) ? /^(.+?)(?:Id|_id)$/.exec(key)?.[1] : undefined;
410
+ if (!relation || claims.named.has(relation) || !targets.insertable) {
411
+ return { claims, problem: FOREIGN_KEY_IN_SCHEMA };
412
+ }
413
+ const written = [
414
+ `references: (${targets.param}) => ${targets.param}.${key}`,
415
+ ...(onDelete ? [`onDelete: ${onDelete}`] : []),
416
+ ];
417
+ return {
418
+ claims: {
419
+ ...claims,
420
+ named: new Set([...claims.named, relation]),
421
+ added: [...claims.added, `${relation}: () => ${targets.rel}.manyToOne(${target}, { ${written.join(', ')} })`],
422
+ },
423
+ };
424
+ }
425
+ /** Writes a column's `onDelete` into the to-one joining on it, or says why not: it states another, or options none can be written into. */
426
+ function movedOnDelete(relation, onDelete, claims) {
427
+ const written = `onDelete: ${onDelete}`;
428
+ const options = relation.arguments[1];
429
+ const literal = objectLiteral(options);
430
+ const stated = literal ? propertyValue(literal, 'onDelete')?.getText() : undefined;
431
+ const current = claims.options.get(relation) ?? [];
432
+ const pending = current.find((option) => option.startsWith('onDelete: '));
433
+ if ((options && !literal) || (stated ?? onDelete) !== onDelete || (pending ?? written) !== written) {
434
+ return { claims, problem: ON_DELETE_ON_RELATION };
435
+ }
436
+ return stated || pending
437
+ ? { claims }
438
+ : { claims: { ...claims, options: new Map(claims.options).set(relation, [...current, written]) } };
439
+ }
440
+ /** The relation calls a schema's `relations` returns, one per relation. */
441
+ function relationCalls(relations) {
442
+ return objectProperties(returnedLiteral(relations)).flatMap((prop) => {
443
+ const relation = ts.isArrowFunction(prop.initializer) ? prop.initializer.body : undefined;
444
+ return relation && ts.isCallExpression(relation) ? [relation] : [];
445
+ });
446
+ }
447
+ /** The object literal a callback returns, `(rel) => ({ ... })`, or nothing where it returns none so. */
448
+ function returnedLiteral(callback) {
449
+ const body = callback && ts.isArrowFunction(callback) && !ts.isBlock(callback.body) ? callback.body : undefined;
450
+ return objectLiteral(body && ts.isParenthesizedExpression(body) ? body.expression : body);
451
+ }
452
+ /**
453
+ * The to-one joining on each column: by the `references` it names, `(row) => row.column` or pairs of `local`s, else by
454
+ * the ORM's inference, the one column whose foreign key names its target, whose `references` is written as inferred.
455
+ */
456
+ function joinsOf(relations, targets, param) {
457
+ const joins = new Map();
458
+ const inferred = new Map();
459
+ for (const relation of relationCalls(relations)) {
460
+ const named = namedColumns(relation);
461
+ for (const column of named ?? []) {
462
+ if (!joins.has(column))
463
+ joins.set(column, relation);
464
+ }
465
+ const target = relation.arguments[0]?.getText();
466
+ const matching = [...targets].filter(([, named]) => named === target).map(([key]) => key);
467
+ const column = matching.length === 1 ? matching[0] : undefined;
468
+ if (!named && isToOne(relation) && writableOptions(relation) && column && !joins.has(column)) {
469
+ inferred.set(relation, `references: (${param}) => ${param}.${column}`);
470
+ joins.set(column, relation);
471
+ }
472
+ }
473
+ return { joins, inferred };
474
+ }
475
+ /** The columns a relation's `references` names on its local side, or nothing where it names none it can read. */
476
+ function namedColumns(relation) {
477
+ const options = objectLiteral(relation.arguments[1]);
478
+ const references = options && propertyValue(options, 'references');
479
+ if (!references) {
480
+ return undefined;
481
+ }
482
+ const local = ts.isArrowFunction(references) ? identifierText(references.parameters[0]?.name) : undefined;
483
+ const column = (node) => node && ts.isPropertyAccessExpression(node) && identifierText(node.expression) === local
484
+ ? node.name.text
485
+ : undefined;
486
+ const body = local && ts.isArrowFunction(references) ? references.body : undefined;
487
+ if (body && ts.isArrayLiteralExpression(body)) {
488
+ const locals = body.elements.map((pair) => {
489
+ const literal = objectLiteral(pair);
490
+ return column(literal && propertyValue(literal, 'local'));
491
+ });
492
+ return locals.every((it) => it !== undefined) ? locals : [];
493
+ }
494
+ const single = body && ts.isExpression(body) ? column(body) : undefined;
495
+ return single ? [single] : [];
496
+ }
497
+ function isToOne(relation) {
498
+ return ts.isPropertyAccessExpression(relation.expression) && relation.expression.name.text === 'manyToOne';
499
+ }
500
+ /** Whether a relation's options are absent or a literal, the only kind an option can be written into. */
501
+ function writableOptions(relation) {
502
+ return relation.arguments.length < 2 || objectLiteral(relation.arguments[1]) !== undefined;
503
+ }
504
+ /**
505
+ * The relations' text, each to-one's options written into its relation and each relation added for a foreign key
506
+ * appended, or nothing where there are none. A section of its own is laid out a line per relation, a `unit` in from
507
+ * the section's `indent`, where one is given.
508
+ */
509
+ function relationsText(relations, rel, { options, added }, indent, unit) {
510
+ if (!relations) {
511
+ if (!added.length)
512
+ return undefined;
513
+ const listed = indent === undefined
514
+ ? `{ ${added.join(', ')} }`
515
+ : `{\n${added.map((entry) => `${indent}${unit}${entry},\n`).join('')}${indent}}`;
516
+ return `(${rel}) => (${listed})`;
517
+ }
518
+ const base = relations.getStart();
519
+ const edits = [...options].flatMap(([relation, written]) => written.length ? [withOptions(relation, written, base)] : []);
520
+ const literal = returnedLiteral(relations);
521
+ if (literal && added.length) {
522
+ edits.push(appendedEntries(literal, added, base));
523
+ }
524
+ return applyEdits(relations.getText(), edits);
525
+ }
526
+ /** The edit writing `options` into a relation call: after its options' last, else as options after its target. */
527
+ function withOptions(relation, options, base) {
528
+ const literal = objectLiteral(relation.arguments[1]);
529
+ const last = literal?.properties.at(-1);
530
+ if (literal && !last) {
531
+ return { start: literal.getStart() - base, end: literal.getEnd() - base, text: `{ ${options.join(', ')} }` };
532
+ }
533
+ const at = (last ?? relation.arguments[0]).getEnd() - base;
534
+ return { start: at, end: at, text: last ? `, ${options.join(', ')}` : `, { ${options.join(', ')} }` };
535
+ }
536
+ /** The edit appending `entries` to an object literal, laid out as its own entries are. */
537
+ function appendedEntries(literal, entries, base) {
538
+ const last = literal.properties.at(-1);
539
+ if (!last) {
540
+ return { start: literal.getStart() - base, end: literal.getEnd() - base, text: `{ ${entries.join(', ')} }` };
541
+ }
542
+ const separator = literal.getText().includes('\n') ? `,\n${indentOf(last)}` : ', ';
543
+ const at = last.getEnd() - base;
544
+ return { start: at, end: at, text: entries.map((entry) => `${separator}${entry}`).join('') };
545
+ }
546
+ /** Whether a schema column is a builder naming no foreign key, which a schema's `fields` keep as they are. */
547
+ function isSchemaColumn(prop) {
548
+ return (ts.isPropertyAssignment(prop) && isBuilder(prop.initializer) && !chainMethods(prop.initializer).has('references'));
549
+ }
550
+ /** Each method a builder chain calls, by name. */
551
+ function chainMethods(node) {
552
+ return new Map(chainOf(node).methods.map((method) => [method.name, method]));
553
+ }
554
+ /**
555
+ * An object literal of `properties`, each its full text with its comments, laid out as `node` was: on one line
556
+ * where it was written on one, a property a line otherwise.
557
+ */
558
+ function literal(node, properties) {
559
+ if (!properties.length) {
560
+ return '{}';
561
+ }
562
+ if (!node.getText().includes('\n')) {
563
+ return `{ ${properties.map((it) => it.trim()).join(', ')} }`;
564
+ }
565
+ return `{${properties.join(',')},\n${indentOf(node)}}`;
566
+ }
567
+ /** One chained section, or `undefined` where its findings say why not. */
568
+ function chainedSection(section, value, param, findings) {
569
+ if (isHelped(section)) {
570
+ const callback = sectionCallback(section, value, param, findings, value);
571
+ return callback && `.${section}(${callback})`;
572
+ }
573
+ if (section !== 'computed') {
574
+ return `.${section}(${value.getText()})`;
575
+ }
576
+ const computed = computedSection(value, param);
577
+ if (!computed)
578
+ findings.push({ node: value, message: "cannot write 'computed' as a section: inline each callback" });
579
+ return computed && `.computed((${param}) => (${computed}))`;
580
+ }
581
+ /** What each section's helper is named. */
582
+ const SECTION_HELPERS = { indexes: 'index', checks: 'check', triggers: 'trigger' };
583
+ const isHelped = (key) => Object.hasOwn(SECTION_HELPERS, key);
584
+ /** {@link listCallback}, reported against `node` where it cannot be written. */
585
+ function sectionCallback(section, list, param, findings, node) {
586
+ const callback = list && listCallback(section, list, param);
587
+ if (!callback) {
588
+ findings.push({
589
+ node,
590
+ message: `cannot write '${section}' as a section: write each entry as ${SECTION_HELPERS[section]}(...) over its refs`,
591
+ });
592
+ }
593
+ return callback;
594
+ }
595
+ /**
596
+ * A list of indexes, checks or triggers as one callback over the entity's refs, each entry through its helper, so
597
+ * TypeScript checks every key: `(post, index) => [index([post.title], { unique: true })]`. Comments above an entry
598
+ * stay above it.
599
+ */
600
+ function listCallback(section, list, param) {
601
+ if (!ts.isArrayLiteralExpression(list)) {
602
+ return undefined;
603
+ }
604
+ const helper = SECTION_HELPERS[section];
605
+ const entries = list.elements.map((entry) => {
606
+ const options = objectLiteral(entry);
607
+ const call = options && helperCall(section, options, param);
608
+ return call && `${leadingComments(entry)}${call}`;
609
+ });
610
+ if (!entries.every((entry) => entry !== undefined)) {
611
+ return undefined;
612
+ }
613
+ const multiline = list.getText().includes('\n');
614
+ const indent = `\n${indentOf(list)}${indentUnit(list.getSourceFile())}`;
615
+ const listed = multiline
616
+ ? `[${entries.map((entry) => (entry.startsWith('\n') ? entry : `${indent}${entry}`)).join(',')},\n${indentOf(list)}]`
617
+ : `[${entries.join(', ')}]`;
618
+ return `(${param}, ${helper}) => ${listed}`;
619
+ }
620
+ /** One entry as its helper's call: `index(columns, options)`, `check(where, name)` or `trigger({...})`. */
621
+ function helperCall(section, options, param) {
622
+ const props = optionsByKey(options);
623
+ if (typeof props === 'string') {
624
+ return undefined;
625
+ }
626
+ const read = (key) => {
627
+ const value = props.get(key);
628
+ return value && bodyAs(value, param);
629
+ };
630
+ if (section === 'checks') {
631
+ const where = read('where');
632
+ const name = props.get('name')?.getText();
633
+ const others = [...props.keys()].filter((key) => key !== 'where' && key !== 'name');
634
+ return where && !others.length ? `check(${[where, name].filter(Boolean).join(', ')})` : undefined;
635
+ }
636
+ const inlined = (keys) => {
637
+ const entries = [...props].map(([key, value]) => {
638
+ const text = keys.includes(key) ? bodyAs(value, param) : value.getText();
639
+ return text === undefined ? undefined : `${key}: ${text}`;
640
+ });
641
+ return entries.every((entry) => entry !== undefined) ? entries : undefined;
642
+ };
643
+ if (section === 'triggers') {
644
+ const entries = inlined(['of']);
645
+ return entries && `trigger({ ${entries.join(', ')} })`;
646
+ }
647
+ const columns = read('columns');
648
+ props.delete('columns');
649
+ const rest = inlined(['include', 'where']);
650
+ if (!columns || !rest) {
651
+ return undefined;
652
+ }
653
+ const name = props.get('name');
654
+ if (rest.length === 1 && name && ts.isStringLiteralLike(name)) {
655
+ return `index(${columns}, ${name.getText()})`;
656
+ }
657
+ return rest.length ? `index(${columns}, { ${rest.join(', ')} })` : `index(${columns})`;
658
+ }
659
+ /** The comments written above `node`, each on its own line, kept as they are; nothing where there are none. */
660
+ function leadingComments(node) {
661
+ const trivia = node.getFullText().slice(0, node.getStart() - node.getFullStart());
662
+ return /\/\/|\/\*/.test(trivia) ? trivia : '';
663
+ }
664
+ /** `@Entity({ indexes: [...] })`'s lists as sections over the class's refs. */
665
+ function entityEdits(cls, findings) {
666
+ const param = paramFor(identifierText(cls.name));
667
+ return decoratorsOf(cls).flatMap((decorator) => {
668
+ if (decoratorName(decorator) !== 'Entity' || !ts.isCallExpression(decorator.expression)) {
669
+ return [];
670
+ }
671
+ const options = objectLiteral(decorator.expression.arguments[0]);
672
+ return (options?.properties ?? []).flatMap((prop) => {
673
+ const key = keyOf(prop);
674
+ if (!key || !isHelped(key) || (ts.isPropertyAssignment(prop) && isCallback(prop.initializer))) {
675
+ return [];
676
+ }
677
+ const list = ts.isPropertyAssignment(prop) ? prop.initializer : undefined;
678
+ const callback = sectionCallback(key, list, param, findings, prop);
679
+ return callback && list ? [replaced(list, callback)] : [];
680
+ });
681
+ });
682
+ }
683
+ /** A schema's computed map as what its section returns: each a column `as()` its SQL. */
684
+ function computedSection(value, param) {
685
+ const map = objectLiteral(value);
686
+ const entries = map?.properties.map((prop) => {
687
+ if (!ts.isPropertyAssignment(prop))
688
+ return undefined;
689
+ const options = optionsByKey(prop.initializer);
690
+ if (typeof options === 'string')
691
+ return undefined;
692
+ const computed = options.get('computed');
693
+ const expression = computed && bodyAs(computed, param);
694
+ const built = expression && computedBuilder(options, expression);
695
+ return typeof built === 'string' ? `${prop.name.getText()}: ${built}` : undefined;
696
+ });
697
+ return entries?.every((entry) => entry !== undefined) ? `{ ${entries.join(', ')} }` : undefined;
698
+ }
699
+ /** The variable a call initializes, which names the entity where its `name` is no literal. */
700
+ function variableOf(call) {
701
+ let at = call;
702
+ while (ts.isPropertyAccessExpression(at.parent) || ts.isCallExpression(at.parent))
703
+ at = at.parent;
704
+ return ts.isVariableDeclaration(at.parent) ? at.parent.name : undefined;
705
+ }
706
+ /** A property's full text, its comments kept, its value written `value`. */
707
+ function withValue(prop, value) {
708
+ const full = prop.getFullText();
709
+ const at = prop.getFullStart();
710
+ return full.slice(0, prop.initializer.getStart() - at) + value + full.slice(prop.initializer.getEnd() - at);
711
+ }
712
+ /** The names a builder writes, which the file must not bind otherwise. */
713
+ export const BUILDER_NAMES = new Set(['f', 'Computed']);
714
+ /** Whether the file declares anything this pass rewrites. */
715
+ const REWRITES = /@(?:Field|Id)\(|\bdefineEntity\(\s*\{|@Entity\(\s*\{/;
716
+ /**
717
+ * Writes entity definitions as column builders, `@Field(f.string().nullable())`, and a schema as its columns and a
718
+ * chain of sections. Read off the text the other rewrites produced, so it only moves what is written; whatever it
719
+ * cannot write faithfully is left as it is and reported.
720
+ */
721
+ export function toBuilders(fileName, text, { locate, taken }) {
722
+ if (!REWRITES.test(text)) {
723
+ return { ...unedited(text), unresolved: [] };
724
+ }
725
+ const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
726
+ const findings = [];
727
+ const used = new Set();
728
+ const edits = [];
729
+ const visit = (node) => {
730
+ if (ts.isPropertyDeclaration(node)) {
731
+ edits.push(...decoratorEdits(node, findings, used));
732
+ }
733
+ if (ts.isClassLike(node)) {
734
+ edits.push(...entityEdits(node, findings));
735
+ }
736
+ if (ts.isCallExpression(node) && identifierText(node.expression) === 'defineEntity') {
737
+ const schema = schemaEdits(node, findings, used);
738
+ if (schema.length) {
739
+ edits.push(...schema);
740
+ return;
741
+ }
742
+ }
743
+ ts.forEachChild(node, visit);
744
+ };
745
+ visit(source);
746
+ const located = locate ?? ((at) => locationAt(source, at));
747
+ const unresolved = formatFindings(findings, located);
748
+ if (!edits.length) {
749
+ return { ...unedited(text), unresolved };
750
+ }
751
+ const clash = [...used].find((name) => taken.has(name));
752
+ if (clash) {
753
+ return {
754
+ ...unedited(text),
755
+ unresolved: [...unresolved, `${located(0)}: ${nameClash(clash)}: rename that, then run again`],
756
+ };
757
+ }
758
+ return {
759
+ ...reparsedAgain(fileName, editedText(text, edits), (edited) => importEdits(edited, used, FIELD_DECORATORS)),
760
+ unresolved,
761
+ };
762
+ }
763
+ /**
764
+ * A schema's statement, `const X = defineEntity({...})`, its call written as a chain and the builders it writes, or why
765
+ * it cannot be.
766
+ */
767
+ export function chainedSchema(text) {
768
+ const source = ts.createSourceFile('/schema.ts', text, ts.ScriptTarget.Latest, true);
769
+ const findings = [];
770
+ const used = new Set();
771
+ const edits = [];
772
+ const visit = (node) => {
773
+ if (ts.isCallExpression(node) && identifierText(node.expression) === 'defineEntity') {
774
+ edits.push(...schemaEdits(node, findings, used));
775
+ return;
776
+ }
777
+ ts.forEachChild(node, visit);
778
+ };
779
+ visit(source);
780
+ return findings.length
781
+ ? { reasons: findings.map(({ message }) => message) }
782
+ : { text: applyEdits(text, edits), builders: used };
783
+ }