uql-orm 0.41.0 → 0.41.1

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.
@@ -7,11 +7,29 @@ type MemberDecorator<V> = (value: undefined, context: ClassFieldDecoratorContext
7
7
  * primary key's own type. Which is what makes `@Field({ references: () => User })` on a `number`, where
8
8
  * `User.id` is a `uuid`, a compile error rather than a column that disagrees with its property.
9
9
  */
10
+ /**
11
+ * The value type the options declare, which the decorated property is then checked against.
12
+ *
13
+ * `enum` narrows it to its own values, so the property must spell out the same set. Only the values
14
+ * the declared `type` admits count, which is what keeps `enum: [2]` off a `String` field.
15
+ */
10
16
  type DeclaredValue<O> = O extends {
11
17
  readonly type: infer T extends FieldType;
12
- } ? TsTypeOf<T> : O extends {
18
+ } ? O extends {
19
+ readonly enum: infer E extends readonly unknown[];
20
+ } ? EnumValue<Extract<E[number], TsTypeOf<T>>, TsTypeOf<T>> : TsTypeOf<T> : O extends {
13
21
  readonly references: EntityGetter<infer E>;
14
22
  } ? IdValue<E> : never;
23
+ /**
24
+ * The enum's members, or a named complaint when they widened.
25
+ *
26
+ * `['a', 'b']` without `as const` infers `string[]`, whose member type is the field's own type and
27
+ * so narrows nothing - the check would be silently off. Resolving to a type no property can hold
28
+ * makes that a compile error that says why, rather than a decoration.
29
+ */
30
+ type EnumValue<Members, Declared> = Declared extends Members ? {
31
+ readonly __enumNeedsAsConst: true;
32
+ } : Members;
15
33
  /**
16
34
  * Declares a persisted field.
17
35
  *
@@ -1,5 +1,5 @@
1
1
  import { SOFT_DELETE_FILTER } from '../../type/index.js';
2
- import { getKeys, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, normalizeIndexWhere, upperFirst, } from '../../util/index.js';
2
+ import { getKeys, ddlText, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst, } from '../../util/index.js';
3
3
  import { ownRegistrations } from '../decorator/bag.js';
4
4
  // Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
5
5
  // evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
@@ -66,7 +66,7 @@ export function defineIndex(entity, index) {
66
66
  meta.indexes.push({
67
67
  ...index,
68
68
  unique: index.unique ?? false,
69
- where: normalizeIndexWhere(index.where),
69
+ where: ddlText(index.where, 'a partial-index predicate'),
70
70
  columns: index.columns.map(normalizeIndexColumn),
71
71
  });
72
72
  return meta;
@@ -124,6 +124,10 @@ export function defineEntity(entity, opts = {}) {
124
124
  // after class decorators return; draining empties the bag, so whichever runs second is a no-op.
125
125
  applyMembers(entity, ownRegistrations(entity));
126
126
  applyMembers(entity, opts);
127
+ // Unnamed checks are named by the generator, as unnamed indexes are.
128
+ for (const check of opts.checks ?? []) {
129
+ (meta.checks ??= []).push({ name: check.name, expression: ddlText(check.expression, 'a check constraint') });
130
+ }
127
131
  for (const index of opts.indexes ?? []) {
128
132
  defineIndex(entity, index);
129
133
  }
@@ -5,7 +5,7 @@
5
5
  * - OperationRecorder: Record operations only (for code generation)
6
6
  * - MigrationBuilder: Execute DDL operations (for integration tests/runtime)
7
7
  */
8
- import { normalizeIndexColumn, normalizeIndexWhere } from '../../util/index.js';
8
+ import { ddlText, normalizeIndexColumn } from '../../util/index.js';
9
9
  import { derivedIndexName } from '../../util/sql.util.js';
10
10
  import { createSchemaGenerator } from '../schemaGenerator.js';
11
11
  import { splitSqlStatements } from './splitSqlStatements.js';
@@ -29,7 +29,7 @@ function createIndexOperation(tableName, columns, options = {}) {
29
29
  ...index,
30
30
  name: name ??
31
31
  derivedIndexName(tableName, entries.map((entry) => entry.column)),
32
- where: normalizeIndexWhere(index.where),
32
+ where: ddlText(index.where, 'a partial-index predicate'),
33
33
  entries,
34
34
  unique: unique ?? false,
35
35
  },
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Fluent API for defining tables in migrations.
5
5
  */
6
- import { normalizeIndexColumn, normalizeIndexWhere } from '../../util/index.js';
6
+ import { ddlText, normalizeIndexColumn } from '../../util/index.js';
7
7
  import { derivedIndexName } from '../../util/sql.util.js';
8
8
  import { ColumnBuilder } from './columnBuilder.js';
9
9
  import { expr } from './expressions.js';
@@ -171,7 +171,7 @@ export class TableBuilder {
171
171
  this._indexes.push({
172
172
  ...rest,
173
173
  name: name ?? `${prefix}_${this._name}_${entries.map((entry) => entry.column).join('_')}`,
174
- where: normalizeIndexWhere(rest.where),
174
+ where: ddlText(rest.where, 'a partial-index predicate'),
175
175
  entries,
176
176
  unique,
177
177
  });
@@ -3,7 +3,7 @@ import { getMeta } from '../entity/index.js';
3
3
  import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategory, sqlToCanonical, } from '../schema/canonicalType.js';
4
4
  import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
5
5
  import { getKeys, isAutoIncrement, qualifyName } from '../util/index.js';
6
- import { derivedForeignKeyName } from '../util/sql.util.js';
6
+ import { derivedCheckName, derivedForeignKeyName } from '../util/sql.util.js';
7
7
  import { formatDefaultValue, SqlExpression } from './builder/expressions.js';
8
8
  import { indexDdlFor } from './ddl/index.js';
9
9
  import { fullColumnDefinitionToNode, tableDefinitionToNode } from './generator/definitionToNode.js';
@@ -247,6 +247,10 @@ export class SqlSchemaGenerator {
247
247
  if (column.isUnique && !column.isPrimaryKey) {
248
248
  def += ' UNIQUE';
249
249
  }
250
+ if (column.enum?.length) {
251
+ const values = column.enum.map((value) => this.dialect.escape(value)).join(', ');
252
+ def += ` CHECK (${this.escapeId(column.name)} IN (${values}))`;
253
+ }
250
254
  def += this.defaultClause(column);
251
255
  if (column.comment) {
252
256
  def += this.generateColumnComment(column.name, column.comment);
@@ -493,6 +497,10 @@ export class SqlSchemaGenerator {
493
497
  const pkCols = table.primaryKey.map((c) => this.escapeId(c.name)).join(', ');
494
498
  constraints.push(`PRIMARY KEY (${pkCols})`);
495
499
  }
500
+ (table.checks ?? []).forEach((check, i) => {
501
+ const name = check.name ?? derivedCheckName(table.name, i + 1);
502
+ constraints.push(`CONSTRAINT ${this.escapeId(name)} CHECK (${check.expression})`);
503
+ });
496
504
  for (const rel of table.outgoingRelations) {
497
505
  if (rel.from.columns.length > 0) {
498
506
  const fromCols = rel.from.columns.map((c) => this.escapeId(c.name)).join(', ');
@@ -19,6 +19,7 @@ export function createTableNode(name, schema, comment) {
19
19
  columns: new Map(),
20
20
  primaryKey: [],
21
21
  indexes: [],
22
+ checks: [],
22
23
  incomingRelations: [],
23
24
  outgoingRelations: [],
24
25
  };
@@ -71,6 +71,7 @@ function addTableFromEntity(ctx, meta) {
71
71
  const tableName = ctx.resolveTableName(meta);
72
72
  const table = createTableNode(tableName, ctx.resolveSchema(meta));
73
73
  const { columns, primaryKey } = table;
74
+ table.checks?.push(...(meta.checks ?? []));
74
75
  // Add columns from fields
75
76
  const fields = meta.fields;
76
77
  for (const key of Object.keys(fields)) {
@@ -94,6 +95,7 @@ function addTableFromEntity(ctx, meta) {
94
95
  isAutoIncrement: field.autoIncrement ?? (isPrimaryKey && type.category === 'integer'),
95
96
  isUnique: field.unique ?? false,
96
97
  comment: field.comment,
98
+ enum: field.enum,
97
99
  table,
98
100
  referencedBy: [],
99
101
  references: undefined,
@@ -40,6 +40,26 @@ export interface CanonicalType {
40
40
  * Actions for foreign key ON DELETE and ON UPDATE clauses.
41
41
  */
42
42
  export type ForeignKeyAction = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
43
+ /**
44
+ * The values a column accepts, rendered as `CHECK (col IN (...))`.
45
+ *
46
+ * Strings and numbers only: those are what `IN (...)` can state, and each is escaped by the
47
+ * dialect's own literal rules, so a number stays bare where a string is quoted.
48
+ */
49
+ export type EnumValues = readonly (string | number)[];
50
+ /**
51
+ * A `CHECK` constraint as the schema holds it, its expression already text. Declared here rather
52
+ * than beside the entity types because a table node also comes from introspection, where there is
53
+ * no entity to have authored one.
54
+ *
55
+ * Only ever compared by presence, never by content: a check is SQL text, and a database reprints
56
+ * text from its parse tree, so `CHECK ("balance" >= 0)` reads back as `CHECK ((balance >= (0)::numeric))`.
57
+ */
58
+ export interface CheckSchema {
59
+ /** Absent when nothing named it, which the generator fills in with `derivedCheckName`. */
60
+ readonly name?: string;
61
+ readonly expression: string;
62
+ }
43
63
  /**
44
64
  * Default action for foreign key ON DELETE and ON UPDATE clauses.
45
65
  */
@@ -84,6 +104,8 @@ export interface ColumnNode {
84
104
  readonly isAutoIncrement: boolean;
85
105
  /** Whether this column has a unique constraint */
86
106
  readonly isUnique: boolean;
107
+ /** The values the column accepts. See {@link EnumValues}. */
108
+ readonly enum?: EnumValues;
87
109
  /** Column comment/description */
88
110
  readonly comment?: string;
89
111
  /** Reference to the parent table */
@@ -115,6 +137,8 @@ export interface TableNode {
115
137
  readonly primaryKey: ColumnNode[];
116
138
  /** Indexes on this table */
117
139
  readonly indexes: IndexNode[];
140
+ /** `CHECK` constraints on this table. Optional: a node can be built without ever naming one. */
141
+ readonly checks?: CheckSchema[];
118
142
  /** Optional table comment */
119
143
  readonly comment?: string;
120
144
  /** Relationships pointing TO this table (other tables referencing this one) */
@@ -1,4 +1,4 @@
1
- import type { ForeignKeyAction, IndexType } from '../schema/types.js';
1
+ import type { CheckSchema, EnumValues, ForeignKeyAction, IndexType } from '../schema/types.js';
2
2
  import type { FilterOptions } from './query.js';
3
3
  import type { QueryRaw } from './queryRaw.js';
4
4
  import type { Except, IsMany, Json, Scalar, Type, Unpacked } from './utility.js';
@@ -292,6 +292,20 @@ export type FieldOptions<V = TsTypeOf<FieldType>> = {
292
292
  * @example `@Field({ references: () => Company, onDelete: 'CASCADE' }) companyId?: string;`
293
293
  */
294
294
  readonly onDelete?: ForeignKeyAction;
295
+ /**
296
+ * The values the column accepts, enforced by the database as well as by TypeScript.
297
+ *
298
+ * Emitted as a column `CHECK (col IN (...))` on every SQL dialect rather than a native enum type:
299
+ * one code path, no separate schema object to order, and adding a value stays an ordinary column
300
+ * change instead of Postgres's irreversible `ALTER TYPE ... ADD VALUE`.
301
+ *
302
+ * Not constrained against the field's own type here: the decorator narrows the property to these
303
+ * values instead, which reports a mismatch where the mistake is rather than as an unrelated
304
+ * `never`. `as const` is what makes them literal, and so what makes any of it check.
305
+ *
306
+ * @example `@Field({ type: String, enum: ['draft', 'paid'] as const })`
307
+ */
308
+ readonly enum?: EnumValues;
295
309
  readonly virtual?: QueryRaw;
296
310
  readonly updatable?: boolean;
297
311
  readonly eager?: boolean;
@@ -699,6 +713,8 @@ export type EntityMeta<E> = {
699
713
  };
700
714
  /** Composite indexes defined via @Index decorator */
701
715
  indexes?: EntityIndexMeta[];
716
+ /** `CHECK` constraints, their expressions already reduced to text. */
717
+ checks?: CheckSchema[];
702
718
  /** Lifecycle hooks registered via @BeforeInsert, @AfterUpdate, etc. */
703
719
  hooks?: Partial<Record<HookEvent, HookRegistration[]>>;
704
720
  processed?: boolean;
@@ -709,6 +725,15 @@ export type EntityMeta<E> = {
709
725
  * Optional `fields`, `relations`, `indexes`, and `hooks` register metadata in one call for
710
726
  * decorator-free setups. Omit them when using `@Field` / `@ManyToOne` / etc.
711
727
  */
728
+ /**
729
+ * A table-level `CHECK`. The expression is `raw` with no interpolation, like an index expression:
730
+ * this is DDL, so there is no placeholder a bound value could go into.
731
+ */
732
+ export type CheckOptions = {
733
+ /** Derived from the table and the constraint's position when absent. */
734
+ readonly name?: string;
735
+ readonly expression: QueryRaw;
736
+ };
712
737
  export type EntityOptions<E = unknown> = {
713
738
  readonly name?: string;
714
739
  /**
@@ -726,6 +751,8 @@ export type EntityOptions<E = unknown> = {
726
751
  readonly [K in RelationKey<E>]?: RelationOptionsFor<E[K]>;
727
752
  };
728
753
  readonly indexes?: readonly EntityIndexInput<FieldKey<E>, E>[];
754
+ /** Table-level `CHECK` constraints. See {@link CheckOptions}. */
755
+ readonly checks?: readonly CheckOptions[];
729
756
  /** Map hook events to method names on the entity class. */
730
757
  readonly hooks?: Partial<Record<HookEvent, readonly MethodKey<E>[]>>;
731
758
  };
@@ -0,0 +1,15 @@
1
+ import { type IndexColumnInput, type IndexColumnSchema, QueryRaw } from '../type/index.js';
2
+ /**
3
+ * SQL bound for DDL, as the text a generator renders. `raw` with no interpolation, or a bare string
4
+ * where one is still accepted: DDL is evaluated once at creation time, so there is no query context
5
+ * for the callback form and no placeholder a `CREATE` statement could bind a value into.
6
+ *
7
+ * `what` names the thing being declared, so the error says which one the caller got wrong.
8
+ */
9
+ export declare function ddlText(value: string | QueryRaw, what: string): string;
10
+ export declare function ddlText(value: string | QueryRaw | undefined, what: string): string | undefined;
11
+ /**
12
+ * Reduces an authored index entry to its normalized form, so the three shapes users write - a column
13
+ * name, an expression, or an options object - reach the dialects as one.
14
+ */
15
+ export declare function normalizeIndexColumn(entry: IndexColumnInput): IndexColumnSchema;
@@ -0,0 +1,27 @@
1
+ import { QueryRaw, RAW_VALUE } from '../type/index.js';
2
+ export function ddlText(value, what) {
3
+ if (!(value instanceof QueryRaw)) {
4
+ return value;
5
+ }
6
+ const sql = value[RAW_VALUE];
7
+ if (typeof sql !== 'string') {
8
+ throw new TypeError(`${what} needs raw() with no interpolation, not a function or a bound value`);
9
+ }
10
+ return sql;
11
+ }
12
+ /**
13
+ * Reduces an authored index entry to its normalized form, so the three shapes users write - a column
14
+ * name, an expression, or an options object - reach the dialects as one.
15
+ */
16
+ export function normalizeIndexColumn(entry) {
17
+ if (typeof entry === 'string') {
18
+ return { column: entry };
19
+ }
20
+ if (entry instanceof QueryRaw) {
21
+ return { column: ddlText(entry, 'an index expression'), expression: true };
22
+ }
23
+ const { column, ...rest } = entry;
24
+ return column instanceof QueryRaw
25
+ ? { ...rest, column: ddlText(column, 'an index expression'), expression: true }
26
+ : { ...rest, column };
27
+ }
@@ -2,7 +2,7 @@ export * from './dialect.util.js';
2
2
  export * from './field.util.js';
3
3
  export * from './filters.util.js';
4
4
  export * from './hook.util.js';
5
- export * from './indexColumn.util.js';
5
+ export * from './ddlExpression.util.js';
6
6
  export * from './logger.js';
7
7
  export * from './object.util.js';
8
8
  export * from './raw.js';
@@ -2,7 +2,7 @@ export * from './dialect.util.js';
2
2
  export * from './field.util.js';
3
3
  export * from './filters.util.js';
4
4
  export * from './hook.util.js';
5
- export * from './indexColumn.util.js';
5
+ export * from './ddlExpression.util.js';
6
6
  export * from './logger.js';
7
7
  export * from './object.util.js';
8
8
  export * from './raw.js';
@@ -24,6 +24,8 @@ export declare function qualifyName(name: string, schema?: string): string;
24
24
  * `table` is the table's own name, never qualified - the result is a single identifier.
25
25
  */
26
26
  export declare function derivedIndexName(table: string, columns: readonly string[]): string;
27
+ /** The constraint name a check gets when nothing named it: `ck_Order_1`, by declaration order. */
28
+ export declare function derivedCheckName(table: string, position: number): string;
27
29
  /** The constraint name a foreign key gets when nothing named it: `fk_Order_customerId`. */
28
30
  export declare function derivedForeignKeyName(table: string, columns: readonly string[]): string;
29
31
  /**
@@ -67,6 +67,10 @@ export function qualifyName(name, schema) {
67
67
  export function derivedIndexName(table, columns) {
68
68
  return `idx_${table}_${columns.join('_')}`;
69
69
  }
70
+ /** The constraint name a check gets when nothing named it: `ck_Order_1`, by declaration order. */
71
+ export function derivedCheckName(table, position) {
72
+ return `ck_${table}_${position}`;
73
+ }
70
74
  /** The constraint name a foreign key gets when nothing named it: `fk_Order_customerId`. */
71
75
  export function derivedForeignKeyName(table, columns) {
72
76
  return `fk_${table}_${columns.join('_')}`;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "JSON-native ORM for Node.js, Bun and Deno. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.41.0",
6
+ "version": "0.41.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -56,7 +56,7 @@
56
56
  "README.md"
57
57
  ],
58
58
  "scripts": {
59
- "prepack": "bun run verify-dist.ts && cp ../../README.md .",
59
+ "prepack": "bun run build && bun run verify-dist.ts && cp ../../README.md .",
60
60
  "postpack": "rm README.md && npm pkg delete gitHead",
61
61
  "compile.browser": "bun build src/browser/index.ts --minify --sourcemap=linked --format=esm --target=browser --outdir=dist/browser --entry-naming 'uql-browser.min.[ext]'",
62
62
  "build": "bun run clean && tsc -b tsconfig.build.json && bun run compile.browser && bun run verify-dist.ts",
@@ -1,8 +0,0 @@
1
- import { type IndexColumnInput, type IndexColumnSchema, QueryRaw } from '../type/index.js';
2
- /**
3
- * Reduces an authored index entry to its normalized form, so the three shapes users write - a column
4
- * name, `raw(expression)`, or an options object - reach the dialects as one.
5
- */
6
- export declare function normalizeIndexColumn(entry: IndexColumnInput): IndexColumnSchema;
7
- /** The partial-index predicate, as authored: `raw` for new code, a bare string for old. */
8
- export declare function normalizeIndexWhere(where: string | QueryRaw | undefined): string | undefined;
@@ -1,30 +0,0 @@
1
- import { QueryRaw, RAW_VALUE } from '../type/index.js';
2
- /**
3
- * Reduces an authored index entry to its normalized form, so the three shapes users write - a column
4
- * name, `raw(expression)`, or an options object - reach the dialects as one.
5
- */
6
- export function normalizeIndexColumn(entry) {
7
- if (typeof entry === 'string') {
8
- return { column: entry };
9
- }
10
- if (entry instanceof QueryRaw) {
11
- return { column: rawSql(entry), expression: true };
12
- }
13
- const { column, ...rest } = entry;
14
- return column instanceof QueryRaw ? { ...rest, column: rawSql(column), expression: true } : { ...rest, column };
15
- }
16
- /** The partial-index predicate, as authored: `raw` for new code, a bare string for old. */
17
- export function normalizeIndexWhere(where) {
18
- return where instanceof QueryRaw ? rawSql(where, 'a partial-index predicate') : where;
19
- }
20
- /**
21
- * Index DDL is evaluated once at creation time, so it cannot take the dialect-aware callback form of
22
- * `raw()` - there is no query context to hand it, and no placeholder a `CREATE INDEX` could bind.
23
- */
24
- function rawSql(value, what = 'an index expression') {
25
- const sql = value[RAW_VALUE];
26
- if (typeof sql !== 'string') {
27
- throw new TypeError(`${what} needs raw() with no interpolation, not a function or a bound value`);
28
- }
29
- return sql;
30
- }