uql-orm 0.45.0 → 0.46.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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/dist/dialect/abstractSqlDialect.d.ts +9 -2
  3. package/dist/dialect/abstractSqlDialect.js +25 -16
  4. package/dist/dialect/mysqlLikeSqlDialect.js +2 -1
  5. package/dist/dialect/pgLikeSqlDialect.js +2 -1
  6. package/dist/entity/metadata/definition.js +8 -1
  7. package/dist/migrate/builder/columnBuilder.d.ts +14 -0
  8. package/dist/migrate/builder/columnBuilder.js +28 -9
  9. package/dist/migrate/builder/tableBuilder.js +8 -19
  10. package/dist/migrate/builder/types.d.ts +16 -33
  11. package/dist/migrate/codegen/entityCodeGenerator.js +4 -5
  12. package/dist/migrate/codegen/fieldOptionsSource.d.ts +2 -0
  13. package/dist/migrate/codegen/fieldOptionsSource.js +49 -33
  14. package/dist/migrate/codegen/indexDecoratorSource.js +1 -9
  15. package/dist/migrate/codegen/sourceLiteral.d.ts +12 -0
  16. package/dist/migrate/codegen/sourceLiteral.js +17 -0
  17. package/dist/migrate/generator/definitionToNode.d.ts +21 -0
  18. package/dist/migrate/generator/definitionToNode.js +47 -25
  19. package/dist/migrate/introspection/baseSqlIntrospector.d.ts +4 -2
  20. package/dist/migrate/introspection/baseSqlIntrospector.js +11 -11
  21. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  22. package/dist/migrate/introspection/mongoIntrospector.js +2 -2
  23. package/dist/migrate/introspection/sqliteIntrospector.d.ts +5 -0
  24. package/dist/migrate/introspection/sqliteIntrospector.js +6 -2
  25. package/dist/migrate/schemaGenerator.d.ts +48 -2
  26. package/dist/migrate/schemaGenerator.js +120 -38
  27. package/dist/mongo/mongoDialect.js +2 -1
  28. package/dist/schema/schemaAST.d.ts +34 -3
  29. package/dist/schema/schemaAST.js +4 -9
  30. package/dist/schema/schemaASTBuilder.js +5 -2
  31. package/dist/schema/schemaASTDiffer.js +8 -4
  32. package/dist/schema/types.d.ts +2 -0
  33. package/dist/sqlite/sqliteDialect.js +2 -1
  34. package/dist/type/dialect.d.ts +21 -2
  35. package/dist/type/entity.d.ts +21 -0
  36. package/dist/type/migration.d.ts +11 -11
  37. package/dist/util/dialect.util.js +5 -4
  38. package/dist/util/field.util.d.ts +23 -0
  39. package/dist/util/field.util.js +28 -0
  40. package/dist/util/fieldOption.util.d.ts +16 -3
  41. package/dist/util/fieldOption.util.js +23 -4
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -53,7 +53,7 @@ The query is just JSON: build it dynamically, store it, diff it, or send it from
53
53
  - **One API, everywhere it runs.** PostgreSQL, PGlite, CockroachDB, MySQL, MariaDB, SQLite, Turso, libSQL, Neon, Cloudflare D1, Bun's native SQL, and even MongoDB. The same code on Node 24+, Bun, Deno, [Cloudflare Workers](https://uql-orm.dev/cloudflare-d1), [AWS Lambda and Vercel](https://uql-orm.dev/serverless), and [the browser](https://uql-orm.dev/browser), with no native binaries on the `fetch`-based drivers.
54
54
  - **Relations without N+1.** [`$populate`](https://uql-orm.dev/querying/relations) loads a to-many with one query for all parents, not one per parent. Nothing is lazy, so nothing fires behind your back in a serializer.
55
55
  - **Migrations you read before they run.** Edit an entity, run `uql-migrate generate:entities`, review the SQL in the PR like any other file. [`drift:check`](https://uql-orm.dev/migrations) catches a database that no longer matches.
56
- - **Raw SQL when you want it.** [`raw()`](https://uql-orm.dev/querying/raw-sql) fits anywhere in a query, [virtual fields](https://uql-orm.dev/entities/virtual-fields) are sub-queries you can filter on, and a migration can be plain SQL.
56
+ - **Raw SQL when you want it.** [`raw()`](https://uql-orm.dev/querying/raw-sql) fits anywhere in a query, [computed fields](https://uql-orm.dev/entities/computed-fields) are expressions you can filter on, and a migration can be plain SQL.
57
57
  - **Light.** Zero runtime dependencies, under 280 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
58
58
  - **The hard things are built in.** [Semantic and vector search](https://uql-orm.dev/ai-semantic-search), [multi-tenant filters you cannot bypass by accident](https://uql-orm.dev/multi-tenancy), [soft-delete with restore](https://uql-orm.dev/entities/soft-delete), [streaming](https://uql-orm.dev/querying/streaming), and [a REST API from your entities](https://uql-orm.dev/http).
59
59
  - **The fastest ORM.** On a full PostgreSQL round trip it adds the least over hand-written driver code of any ORM in our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark), by 2.6-3x over the next closest and roughly 10x over the slowest, on Bun, Node and Deno alike. The same benchmark [scores the types](https://github.com/rogerpadilla/ts-orm-benchmark#type-safety) by writing ten ordinary mistakes in six ORMs' APIs and compiling them: UQL is the only one that catches all ten.
@@ -171,6 +171,13 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
171
171
  * `NOT (... <=> ...)` - instead of having to fall back to a form that takes none.
172
172
  */
173
173
  protected resolveOperandField<E>(ctx: QueryContext, entity: Type<E>, key: string, opts: QueryOptions): string;
174
+ /**
175
+ * The expression an inlined computed field stands for, or nothing when the field is a real column.
176
+ *
177
+ * Every clause that names such a field needs the expression itself, never the output alias: an
178
+ * alias exists only when the field was also selected, which `$where` and `$sort` cannot assume.
179
+ */
180
+ private inlinedOperand;
174
181
  compareFieldOperator<E, K extends keyof QueryWhereFieldOperatorMap<E>>(ctx: QueryContext, entity: Type<E>, key: FieldKey<E>, op: K, val: QueryWhereFieldOperatorMap<E>[K], opts?: QueryOptions): void;
175
182
  /**
176
183
  * `<operand> <op> <value>` for every operator that needs nothing but its left-hand SQL, or
@@ -261,8 +268,8 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
261
268
  */
262
269
  private collectSortTerms;
263
270
  /**
264
- * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
265
- * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
271
+ * The `ORDER BY` operand for one key. A key that is not a field of `meta` - a `raw()` projection, a
272
+ * `$agg` alias - is an output alias, which is never table-qualified and needs no resolving.
266
273
  */
267
274
  private sortColumn;
268
275
  pager(ctx: QueryContext, opts: QueryPager): void;
@@ -1,5 +1,6 @@
1
1
  import { getMeta, soleIdOf } from '../entity/index.js';
2
2
  import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, VECTOR_QUERY_KEYS, } from '../type/index.js';
3
+ import { computedExpression, isInlinedExpression } from '../util/field.util.js';
3
4
  import { asSelectMap, assertNonNegativeInteger, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, columnFamily, isJsonUpdateOp, isOperatorMap, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parentJoins, targetKeyColumns, parseGroupMap, parseRelationSize, parseSortByCount, populatesRelations, raw, someValue, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
4
5
  import { escapeAnsiSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
5
6
  import { COUNT_ALIAS, DISTINCT_DERIVED_ALIAS, JSON_ELEM_ALIAS_PREFIX } from './aliases.js';
@@ -160,9 +161,9 @@ export class AbstractSqlDialect extends VectorSqlDialect {
160
161
  const field = meta.fields[key];
161
162
  if (!field)
162
163
  return;
163
- if (field.virtual) {
164
+ if (isInlinedExpression(field)) {
164
165
  this.getRawValue(ctx, {
165
- value: field.virtual.as(key),
166
+ value: computedExpression(field).as(key),
166
167
  prefix: opts.prefix,
167
168
  escapedPrefix,
168
169
  autoPrefixAlias: opts.autoPrefixAlias,
@@ -485,15 +486,23 @@ export class AbstractSqlDialect extends VectorSqlDialect {
485
486
  */
486
487
  resolveOperandField(ctx, entity, key, opts) {
487
488
  const field = getMeta(entity).fields[key];
488
- const virtual = field?.virtual;
489
- if (virtual) {
490
- return this.buildFragment(ctx, (fragmentCtx) => this.getRawValue(fragmentCtx, {
491
- value: virtual,
492
- prefix: opts.prefix,
493
- escapedPrefix: this.escapeId(opts.prefix, true, true),
494
- }));
495
- }
496
- return this.columnWithPrefix(key, field, opts.prefix);
489
+ return this.inlinedOperand(ctx, field, opts.prefix) ?? this.columnWithPrefix(key, field, opts.prefix);
490
+ }
491
+ /**
492
+ * The expression an inlined computed field stands for, or nothing when the field is a real column.
493
+ *
494
+ * Every clause that names such a field needs the expression itself, never the output alias: an
495
+ * alias exists only when the field was also selected, which `$where` and `$sort` cannot assume.
496
+ */
497
+ inlinedOperand(ctx, field, prefix) {
498
+ const inlined = field && isInlinedExpression(field) ? computedExpression(field) : undefined;
499
+ return inlined
500
+ ? this.buildFragment(ctx, (fragmentCtx) => this.getRawValue(fragmentCtx, {
501
+ value: inlined,
502
+ prefix,
503
+ escapedPrefix: this.escapeId(prefix, true, true),
504
+ }))
505
+ : undefined;
497
506
  }
498
507
  compareFieldOperator(ctx, entity, key, op, val, opts = {}) {
499
508
  const field = this.resolveOperandField(ctx, entity, key, opts);
@@ -767,17 +776,17 @@ export class AbstractSqlDialect extends VectorSqlDialect {
767
776
  : this.buildFragment(ctx, (fragmentCtx) => this.appendVectorSort(fragmentCtx, meta, key, value)));
768
777
  continue;
769
778
  }
770
- columns.push(this.sortColumn(meta, key, prefix) + this.resolveSortDirection(value));
779
+ columns.push(this.sortColumn(ctx, meta, key, prefix) + this.resolveSortDirection(value));
771
780
  }
772
781
  }
773
782
  /**
774
- * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
775
- * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
783
+ * The `ORDER BY` operand for one key. A key that is not a field of `meta` - a `raw()` projection, a
784
+ * `$agg` alias - is an output alias, which is never table-qualified and needs no resolving.
776
785
  */
777
- sortColumn(meta, key, prefix) {
786
+ sortColumn(ctx, meta, key, prefix) {
778
787
  const field = meta.fields[key];
779
788
  if (field) {
780
- return field.virtual ? this.escapeId(key) : this.columnWithPrefix(key, field, prefix);
789
+ return this.inlinedOperand(ctx, field, prefix) ?? this.columnWithPrefix(key, field, prefix);
781
790
  }
782
791
  const json = this.resolveJsonDotPath(meta, key, prefix);
783
792
  return json ? this.jsonPathExpr(json.column, json.jsonPath, 'text') : this.escapeId(key);
@@ -32,7 +32,8 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
32
32
  renameColumn: true,
33
33
  foreignKeyAlter: true,
34
34
  primaryKeyAlter: true,
35
- columnComment: true,
35
+ generatedColumnAdd: true,
36
+ commentSyntax: 'inline',
36
37
  vectorIndexRequiresNotNull: false,
37
38
  vectorSupportsLength: false,
38
39
  supportsTimestamptz: false,
@@ -29,7 +29,8 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
29
29
  renameColumn: true,
30
30
  foreignKeyAlter: true,
31
31
  primaryKeyAlter: true,
32
- columnComment: false,
32
+ generatedColumnAdd: true,
33
+ commentSyntax: 'statement',
33
34
  vectorIndexRequiresNotNull: false,
34
35
  vectorSupportsLength: true,
35
36
  supportsTimestamptz: true,
@@ -1,4 +1,5 @@
1
1
  import { SOFT_DELETE_FILTER } from '../../type/index.js';
2
+ import { isInlinedExpression } from '../../util/field.util.js';
2
3
  import { entityName, fieldOptionConflict, getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
3
4
  import { ownRegistrations } from '../decorator/bag.js';
4
5
  /**
@@ -16,7 +17,13 @@ function globalMap(key) {
16
17
  const metas = globalMap('uql-orm/entity/metadata/v1');
17
18
  export function defineField(entity, key, opts = {}) {
18
19
  const meta = ensureWritableMeta(entity);
19
- if (!opts.type && !opts.references && !opts.virtual) {
20
+ if (opts.virtual !== undefined && opts.computed !== undefined) {
21
+ throw new TypeError(`'${entity.name}.${key}' gives both 'virtual' and 'computed'. They are one option under two names - ` +
22
+ "keep 'computed'; 'npx uql-codemod' rewrites the other.");
23
+ }
24
+ // A stored computed column is a real column and still needs a type; only an inlined one is exempt,
25
+ // its expression being spliced in rather than declared.
26
+ if (!opts.type && !opts.references && !isInlinedExpression(opts)) {
20
27
  throw new TypeError(`'${entity.name}.${key}' needs a 'type'. Declare it - '@Field({ type: String })' - or point the field ` +
21
28
  "at another entity with 'references', which resolves the column type from its primary key.");
22
29
  }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Fluent API for defining columns in migrations.
5
5
  */
6
+ import type { EnumValues } from '../../schema/types.js';
6
7
  import type { CanonicalType, ForeignKeyAction } from '../../schema/types.js';
7
8
  import type { BaseColumnOptions, FullColumnDefinition, IColumnBuilder, IForeignKeyBuilder } from './types.js';
8
9
  /**
@@ -17,6 +18,8 @@ export declare class ColumnBuilder implements IColumnBuilder, IForeignKeyBuilder
17
18
  private _primaryKey;
18
19
  private _autoIncrement;
19
20
  private _unique;
21
+ private _enum?;
22
+ private _generatedAs?;
20
23
  private _comment?;
21
24
  private _index?;
22
25
  private _foreignKey?;
@@ -45,6 +48,17 @@ export declare class ColumnBuilder implements IColumnBuilder, IForeignKeyBuilder
45
48
  * Add a unique constraint.
46
49
  */
47
50
  unique(): this;
51
+ /**
52
+ * Make the column one the database computes: `GENERATED ALWAYS AS (<sql>) STORED`.
53
+ *
54
+ * Takes the SQL as text, since a `CREATE TABLE` has nowhere to bind a value into - the same reason
55
+ * a check expression and a partial-index predicate do.
56
+ */
57
+ computed(sql: string): this;
58
+ /**
59
+ * Constrain the column to these values, as a `CHECK (col IN (...))` - what `@Field({ enum })` emits.
60
+ */
61
+ enum(values: EnumValues): this;
48
62
  /**
49
63
  * Add a comment to the column.
50
64
  */
@@ -15,6 +15,8 @@ export class ColumnBuilder {
15
15
  _primaryKey;
16
16
  _autoIncrement;
17
17
  _unique;
18
+ _enum;
19
+ _generatedAs;
18
20
  _comment;
19
21
  _index;
20
22
  _foreignKey;
@@ -35,8 +37,7 @@ export class ColumnBuilder {
35
37
  // Handle inline references option
36
38
  if (options.references) {
37
39
  this._foreignKey = {
38
- table: options.references.table,
39
- columns: [options.references.column ?? 'id'],
40
+ references: { table: options.references.table, columns: [options.references.column ?? 'id'] },
40
41
  onDelete: options.references.onDelete ?? 'NO ACTION',
41
42
  onUpdate: options.references.onUpdate ?? 'NO ACTION',
42
43
  };
@@ -85,6 +86,23 @@ export class ColumnBuilder {
85
86
  this._unique = true;
86
87
  return this;
87
88
  }
89
+ /**
90
+ * Make the column one the database computes: `GENERATED ALWAYS AS (<sql>) STORED`.
91
+ *
92
+ * Takes the SQL as text, since a `CREATE TABLE` has nowhere to bind a value into - the same reason
93
+ * a check expression and a partial-index predicate do.
94
+ */
95
+ computed(sql) {
96
+ this._generatedAs = sql;
97
+ return this;
98
+ }
99
+ /**
100
+ * Constrain the column to these values, as a `CHECK (col IN (...))` - what `@Field({ enum })` emits.
101
+ */
102
+ enum(values) {
103
+ this._enum = values;
104
+ return this;
105
+ }
88
106
  /**
89
107
  * Add a comment to the column.
90
108
  */
@@ -113,8 +131,7 @@ export class ColumnBuilder {
113
131
  */
114
132
  references(table, column = 'id') {
115
133
  this._foreignKey = {
116
- table,
117
- columns: [column],
134
+ references: { table, columns: [column] },
118
135
  onDelete: 'NO ACTION',
119
136
  onUpdate: 'NO ACTION',
120
137
  };
@@ -125,7 +142,7 @@ export class ColumnBuilder {
125
142
  */
126
143
  onDelete(action) {
127
144
  if (this._foreignKey) {
128
- this._foreignKey.onDelete = action;
145
+ this._foreignKey = { ...this._foreignKey, onDelete: action };
129
146
  }
130
147
  return this;
131
148
  }
@@ -134,7 +151,7 @@ export class ColumnBuilder {
134
151
  */
135
152
  onUpdate(action) {
136
153
  if (this._foreignKey) {
137
- this._foreignKey.onUpdate = action;
154
+ this._foreignKey = { ...this._foreignKey, onUpdate: action };
138
155
  }
139
156
  return this;
140
157
  }
@@ -147,9 +164,11 @@ export class ColumnBuilder {
147
164
  type: this._type,
148
165
  nullable: this._nullable,
149
166
  defaultValue: this._defaultValue,
150
- primaryKey: this._primaryKey,
151
- autoIncrement: this._autoIncrement,
152
- unique: this._unique,
167
+ isPrimaryKey: this._primaryKey,
168
+ isAutoIncrement: this._autoIncrement,
169
+ isUnique: this._unique,
170
+ enum: this._enum,
171
+ generatedAs: this._generatedAs,
153
172
  comment: this._comment,
154
173
  index: this._index,
155
174
  foreignKey: this._foreignKey,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { ddlText, normalizeIndexColumn } from '../../util/index.js';
7
7
  import { derivedIndexName } from '../../util/sql.util.js';
8
+ import { columnForeignKey, columnIndex } from '../generator/definitionToNode.js';
8
9
  import { ColumnBuilder } from './columnBuilder.js';
9
10
  import { expr } from './expressions.js';
10
11
  /**
@@ -193,18 +194,11 @@ export class TableBuilder {
193
194
  build() {
194
195
  // Build all columns from builders
195
196
  const columns = this._columnBuilders.map((cb) => cb.build());
196
- // Collect column-level indexes
197
+ // Collect column-level indexes, skipping any a table-level one already names.
197
198
  for (const col of columns) {
198
- if (col.index) {
199
- const indexName = typeof col.index === 'string' ? col.index : derivedIndexName(this._name, [col.name]);
200
- // Only add if not already in table-level indexes
201
- if (!this._indexes.some((idx) => idx.name === indexName)) {
202
- this._indexes.push({
203
- name: indexName,
204
- entries: [{ column: col.name }],
205
- unique: col.unique,
206
- });
207
- }
199
+ const index = columnIndex(this._name, col);
200
+ if (index && !this._indexes.some((idx) => idx.name === index.name)) {
201
+ this._indexes.push(index);
208
202
  }
209
203
  }
210
204
  // Build foreign keys
@@ -213,14 +207,9 @@ export class TableBuilder {
213
207
  .filter((fk) => fk !== undefined);
214
208
  // Collect column-level foreign keys
215
209
  for (const col of columns) {
216
- if (col.foreignKey) {
217
- foreignKeys.push({
218
- name: col.foreignKey.name,
219
- columns: [col.name],
220
- references: { table: col.foreignKey.table, columns: col.foreignKey.columns },
221
- onDelete: col.foreignKey.onDelete,
222
- onUpdate: col.foreignKey.onUpdate,
223
- });
210
+ const foreignKey = columnForeignKey(col);
211
+ if (foreignKey) {
212
+ foreignKeys.push(foreignKey);
224
213
  }
225
214
  }
226
215
  return {
@@ -4,7 +4,7 @@
4
4
  * Type definitions for the fluent migration builder API.
5
5
  * Enables type-safe migrations without raw SQL.
6
6
  */
7
- import type { CanonicalType, ForeignKeyAction } from '../../schema/types.js';
7
+ import type { ColumnNode, EnumValues, ForeignKeyAction } from '../../schema/types.js';
8
8
  import type { IndexColumnInput, IndexOptions, IndexSchema } from '../../type/index.js';
9
9
  import type { ForeignKeySchema } from '../../type/migration.js';
10
10
  /**
@@ -68,41 +68,20 @@ export interface VectorColumnOptions extends BaseColumnOptions {
68
68
  dimensions?: number;
69
69
  }
70
70
  /**
71
- * Base options for a column definition.
71
+ * A column as the builder describes one: {@link ColumnNode} without the graph links a DTO cannot carry.
72
+ *
73
+ * Derived rather than restated, so the two cannot drift. A column gained `enum` and this shape was
74
+ * simply missing it, which is why a hand-written `createTable` could never constrain one. A field
75
+ * added to the node now reaches here, and failing to render it is a compile error rather than a
76
+ * column that quietly loses half its declaration.
72
77
  */
73
- export interface ColumnDefinition {
74
- /** Column name */
75
- name: string;
76
- /** Canonical type */
77
- type: CanonicalType;
78
- /** Whether the column is nullable */
79
- nullable: boolean;
80
- /** Default value or expression */
81
- defaultValue?: unknown;
82
- /** Whether this is a primary key */
83
- primaryKey: boolean;
84
- /** Whether this column auto-increments */
85
- autoIncrement: boolean;
86
- /** Whether this column has a unique constraint */
87
- unique: boolean;
88
- /** Column comment */
89
- comment?: string;
90
- }
78
+ export type ColumnDefinition = Omit<ColumnNode, 'table' | 'referencedBy' | 'references'>;
91
79
  /**
92
- * Foreign key definition for a column.
80
+ * The foreign key a single column declares: {@link ForeignKeySchema} without the local columns, which
81
+ * are the column itself. Derived for the reason {@link ColumnDefinition} is - restated, the two spelled
82
+ * their target differently and every hand-off between them had to translate.
93
83
  */
94
- export interface ForeignKeyDefinition {
95
- /** Constraint name */
96
- name?: string;
97
- /** Referenced table */
98
- table: string;
99
- /** Referenced column(s) */
100
- columns: string[];
101
- /** Action on delete */
102
- onDelete: ForeignKeyAction;
103
- /** Action on update */
104
- onUpdate: ForeignKeyAction;
105
- }
84
+ export type ForeignKeyDefinition = Omit<ForeignKeySchema, 'columns'>;
106
85
  /**
107
86
  * Full column definition including foreign key.
108
87
  */
@@ -259,6 +238,10 @@ export interface IColumnBuilder {
259
238
  autoIncrement(): this;
260
239
  /** Add unique constraint */
261
240
  unique(): this;
241
+ /** Constrain the column to these values, as a `CHECK (col IN (...))`. */
242
+ enum(values: EnumValues): this;
243
+ /** Make the column one the database computes, as `GENERATED ALWAYS AS (<sql>) STORED`. */
244
+ computed(sql: string): this;
262
245
  /** Add comment */
263
246
  comment(text: string): this;
264
247
  /** Add index */
@@ -12,7 +12,7 @@
12
12
  import { canonicalToTypeScript } from '../../schema/canonicalType.js';
13
13
  import { DEFAULT_FOREIGN_KEY_ACTION, } from '../../schema/types.js';
14
14
  import { camelCase, pascalCase, singularize } from '../../util/string.util.js';
15
- import { buildFieldOptionsSource } from './fieldOptionsSource.js';
15
+ import { buildFieldOptionsSource, fieldNeedsRaw } from './fieldOptionsSource.js';
16
16
  import { buildIndexDecoratorSource, indexNeedsRaw, isPlainFieldIndex } from './indexDecoratorSource.js';
17
17
  /**
18
18
  * Generates TypeScript entity code from SchemaAST.
@@ -75,11 +75,13 @@ export class EntityCodeGenerator {
75
75
  buildImports(table) {
76
76
  const uqlImports = new Set(['Entity', 'Field']);
77
77
  const relatedImports = [];
78
- // Check for Id decorator
79
78
  for (const col of table.columns.values()) {
80
79
  if (col.isPrimaryKey) {
81
80
  uqlImports.add('Id');
82
81
  }
82
+ if (fieldNeedsRaw(col)) {
83
+ uqlImports.add('raw');
84
+ }
83
85
  }
84
86
  // Check for relation decorators
85
87
  if (this.options.includeRelations) {
@@ -145,9 +147,6 @@ export class EntityCodeGenerator {
145
147
  lines.push(' /**');
146
148
  lines.push(` * @sync-added ${new Date().toISOString().split('T')[0]}`);
147
149
  lines.push(` * Column: ${col.name} (${this.formatTypeDescription(col.type)})`);
148
- if (col.comment) {
149
- lines.push(` * ${col.comment}`);
150
- }
151
150
  lines.push(' */');
152
151
  }
153
152
  // Decorator
@@ -8,3 +8,5 @@ import type { ColumnNode } from '../../schema/types.js';
8
8
  * file from scratch.
9
9
  */
10
10
  export declare function buildFieldOptionsSource(col: ColumnNode, propertyName: string, indexName?: string): string;
11
+ /** Whether the field's decorator needs `raw` imported, the way {@link indexNeedsRaw} does for an index. */
12
+ export declare function fieldNeedsRaw(col: ColumnNode): boolean;
@@ -1,4 +1,41 @@
1
1
  import { canonicalToColumnType } from '../../schema/canonicalType.js';
2
+ import { quoted, rawTag } from './sourceLiteral.js';
3
+ /**
4
+ * What each field of a {@link ColumnNode} contributes to `@Field({ ... })`, in emit order, and `null`
5
+ * where nothing does.
6
+ *
7
+ * The `satisfies` is the point: a field the node gains cannot reach here without someone answering
8
+ * whether an entity generated from a database keeps it. Written as a hand-rolled `if` chain, this had
9
+ * already dropped `comment` - introspection reads one on Postgres and MySQL, and regenerating an
10
+ * entity threw it away.
11
+ */
12
+ const OPTION_SOURCE = {
13
+ // Without this the entity maps to a column named after the property, which for anything the
14
+ // transformer rewrote - every `user_id` - is a column the database does not have.
15
+ name: (col, { propertyName }) => (propertyName === col.name ? [] : [`name: ${quoted(col.name)}`]),
16
+ type: (col) => {
17
+ const columnType = canonicalToColumnType(col.type);
18
+ return [
19
+ ...(columnType ? [`columnType: ${quoted(columnType)}`] : []),
20
+ ...(col.type.length && col.type.category === 'string' ? [`length: ${col.type.length}`] : []),
21
+ ...(col.type.precision === undefined ? [] : [`precision: ${col.type.precision}`]),
22
+ ...(col.type.precision !== undefined && col.type.scale !== undefined ? [`scale: ${col.type.scale}`] : []),
23
+ ];
24
+ },
25
+ nullable: (col) => (col.nullable ? ['nullable: true'] : []),
26
+ isUnique: (col) => (col.isUnique ? ['unique: true'] : []),
27
+ enum: (col) => col.enum ? [`enum: [${col.enum.map((it) => (typeof it === 'number' ? it : quoted(it))).join(', ')}]`] : [],
28
+ defaultValue: (col) => col.defaultValue === undefined ? [] : [`defaultValue: ${defaultValueSource(col.defaultValue)}`],
29
+ generatedAs: (col) => (col.generatedAs ? [`computed: ${rawTag(col.generatedAs)}`, 'stored: true'] : []),
30
+ comment: (col) => (col.comment ? [`comment: ${quoted(col.comment)}`] : []),
31
+ // A key is `@Id`, and a numeric one generates by that alone. Neither is an option to write out.
32
+ isPrimaryKey: null,
33
+ isAutoIncrement: null,
34
+ // Graph links. A foreign key becomes a relation decorator, emitted beside the field rather than in it.
35
+ table: null,
36
+ references: null,
37
+ referencedBy: null,
38
+ };
2
39
  /**
3
40
  * A column's `@Field({ ... })` options as source, or `''` when it needs none.
4
41
  *
@@ -8,47 +45,26 @@ import { canonicalToColumnType } from '../../schema/canonicalType.js';
8
45
  * file from scratch.
9
46
  */
10
47
  export function buildFieldOptionsSource(col, propertyName, indexName) {
11
- const options = [];
12
- // Without this the entity maps to a column named after the property, which for anything the
13
- // transformer rewrote - every `user_id` - is a column the database does not have.
14
- if (propertyName !== col.name) {
15
- options.push(`name: '${col.name}'`);
16
- }
17
- const columnType = canonicalToColumnType(col.type);
18
- if (columnType) {
19
- options.push(`columnType: '${columnType}'`);
20
- }
21
- if (col.type.length && col.type.category === 'string') {
22
- options.push(`length: ${col.type.length}`);
23
- }
24
- if (col.type.precision !== undefined) {
25
- options.push(`precision: ${col.type.precision}`);
26
- if (col.type.scale !== undefined) {
27
- options.push(`scale: ${col.type.scale}`);
28
- }
29
- }
30
- if (col.nullable) {
31
- options.push('nullable: true');
32
- }
33
- if (col.isUnique) {
34
- options.push('unique: true');
35
- }
36
- if (col.defaultValue !== undefined) {
37
- options.push(`defaultValue: ${formatDefaultValueSource(col.defaultValue)}`);
38
- }
39
- if (indexName) {
40
- options.push(`index: '${indexName}'`);
41
- }
48
+ const context = { propertyName, indexName };
49
+ const options = [
50
+ ...Object.values(OPTION_SOURCE).flatMap((source) => source?.(col, context) ?? []),
51
+ // Not a column field: the index is a table-level object the field only borrows.
52
+ ...(indexName ? [`index: ${quoted(indexName)}`] : []),
53
+ ];
42
54
  return options.length > 0 ? `{ ${options.join(', ')} }` : '';
43
55
  }
56
+ /** Whether the field's decorator needs `raw` imported, the way {@link indexNeedsRaw} does for an index. */
57
+ export function fieldNeedsRaw(col) {
58
+ return col.generatedAs !== undefined;
59
+ }
44
60
  /**
45
61
  * A default value as source. Strings stay single-quoted, expressions included: `defaultValue: 'now()'`
46
62
  * is what reaches the DDL. The generator used to branch on `CURRENT_TIMESTAMP`/`NEXTVAL`/`(` first, but
47
63
  * both branches emitted a quoted string and only the fallthrough escaped embedded quotes.
48
64
  */
49
- function formatDefaultValueSource(value) {
65
+ function defaultValueSource(value) {
50
66
  if (typeof value === 'string') {
51
- return `'${value.replace(/'/g, "\\'")}'`;
67
+ return quoted(value);
52
68
  }
53
69
  if (typeof value === 'boolean' || typeof value === 'number') {
54
70
  return value.toString();
@@ -1,3 +1,4 @@
1
+ import { rawTag } from './sourceLiteral.js';
1
2
  /**
2
3
  * A vector index carries its metric in the operator class pgvector names after it
3
4
  * (`vector_cosine_ops`), which is the only place introspection can recover it from. `@Index` requires
@@ -94,12 +95,3 @@ function indexEntrySource(entry, propertyName) {
94
95
  }
95
96
  return `{ column: '${propertyName(entry.column)}', ${modifiers.join(', ')} }`;
96
97
  }
97
- /**
98
- * SQL as a `raw` tagged template. A database reprints an expression as arbitrary text, and exactly
99
- * three sequences can end or interpolate a template literal, so escaping those is the whole job.
100
- * Newlines need none, which keeps a multi-line expression readable in the generated entity.
101
- */
102
- function rawTag(sql) {
103
- const escaped = sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
104
- return `raw\`${escaped}\``;
105
- }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A string as single-quoted source. Introspected text is arbitrary - a comment or a default
3
+ * expression can hold a quote or a backslash - and only escaping both keeps the generated file
4
+ * parsing.
5
+ */
6
+ export declare function quoted(text: string): string;
7
+ /**
8
+ * SQL as a `raw` tagged template. A database reprints an expression as arbitrary text, and exactly
9
+ * three sequences can end or interpolate a template literal, so escaping those is the whole job.
10
+ * Newlines need none, which keeps a multi-line expression readable in the generated entity.
11
+ */
12
+ export declare function rawTag(sql: string): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A string as single-quoted source. Introspected text is arbitrary - a comment or a default
3
+ * expression can hold a quote or a backslash - and only escaping both keeps the generated file
4
+ * parsing.
5
+ */
6
+ export function quoted(text) {
7
+ return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
8
+ }
9
+ /**
10
+ * SQL as a `raw` tagged template. A database reprints an expression as arbitrary text, and exactly
11
+ * three sequences can end or interpolate a template literal, so escaping those is the whole job.
12
+ * Newlines need none, which keeps a multi-line expression readable in the generated entity.
13
+ */
14
+ export function rawTag(sql) {
15
+ const escaped = sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
16
+ return `raw\`${escaped}\``;
17
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ColumnNode, TableNode } from '../../schema/types.js';
2
+ import type { ForeignKeySchema, IndexSchema } from '../../type/migration.js';
2
3
  import type { FullColumnDefinition, TableDefinition } from '../builder/types.js';
3
4
  /**
4
5
  * A migration builder's table definition as the AST nodes the generators render from, so a hand-written
@@ -6,4 +7,24 @@ import type { FullColumnDefinition, TableDefinition } from '../builder/types.js'
6
7
  * not generator methods: nothing here consults the dialect.
7
8
  */
8
9
  export declare function tableDefinitionToNode(def: TableDefinition): TableNode;
10
+ /**
11
+ * A builder's column as the AST node the generators render from.
12
+ *
13
+ * The shared half is spread, not copied field by field: `ColumnDefinition` *is* a `ColumnNode` minus
14
+ * the graph links, so spreading it and adding those back is a node by construction. Listed one by one,
15
+ * the copy silently dropped whatever the node gained next - `enum` first, and the type had no way to
16
+ * say so. The two builder-only keys are destructured off: `index` and `foreignKey` are lifted onto the
17
+ * table by `columnIndex`/`columnForeignKey`, which is the path that renders them.
18
+ *
19
+ * No `references` node either: `SchemaAST.addRelationship` sets that one.
20
+ */
9
21
  export declare function fullColumnDefinitionToNode(col: FullColumnDefinition, tableName: string): ColumnNode;
22
+ /**
23
+ * The index a column-level `index` declares, or nothing.
24
+ *
25
+ * Shared with `TableBuilder.build`, which lifts these into the table it is creating: written twice,
26
+ * `addColumn` had no lift at all and silently emitted a column with no index.
27
+ */
28
+ export declare function columnIndex(tableName: string, col: FullColumnDefinition): IndexSchema | undefined;
29
+ /** The foreign key a column-level `references` declares, or nothing. Shared for the same reason. */
30
+ export declare function columnForeignKey(col: FullColumnDefinition): ForeignKeySchema | undefined;