uql-orm 0.35.0 → 0.36.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.
package/README.md CHANGED
@@ -32,7 +32,7 @@
32
32
  npm install uql-orm pg # or mysql2, mariadb, better-sqlite3, mongodb, @tursodatabase/serverless, @libsql/client
33
33
  ```
34
34
 
35
- That is the whole install ([setup](https://uql-orm.dev/getting-started)), and the [imperative API](https://uql-orm.dev/entities/imperative) skips decorators altogether.
35
+ That is the whole install ([setup](https://uql-orm.dev/getting-started)). No compiler flags and no `reflect-metadata`; the decorators are the [TC39 standard spec](https://uql-orm.dev/entities/basic), and plain classes work too, via [`defineEntity`](https://uql-orm.dev/entities/imperative).
36
36
 
37
37
  <a href="https://uql-orm.dev">
38
38
  <picture>
@@ -1,66 +1,65 @@
1
+ import type { AbstractSqlDialect } from '../../dialect/index.js';
2
+ import type { SqlDialectName } from '../../type/index.js';
1
3
  /**
2
- * SQL Expressions
3
- *
4
- * Type-safe SQL expressions for default values and other uses.
5
- * Provides an `expr` helper with common expressions.
4
+ * A {@link ColumnSchema.defaultValue} that is SQL rather than a literal. Kinds are symbolic: the
5
+ * dialect names the spelling and {@link formatDefaultValue} renders one at DDL time.
6
+ */
7
+ export type SqlExpressionKind = 'now' | 'currentDate' | 'currentTime' | 'uuid' | 'uuidv7' | 'onUpdateNow' | 'raw';
8
+ /** Every kind's DDL spelling, `null` where an engine has none. `raw` carries its own text instead. */
9
+ export type SqlExpressionMap = Readonly<Record<Exclude<SqlExpressionKind, 'raw'>, string | null>>;
10
+ /** How one engine renders a DDL default. A new per-dialect rule is a field here, not a second table. */
11
+ export type DialectDefaults = {
12
+ /** Spelling of each kind, `null` where the engine has none. */
13
+ readonly expressions: SqlExpressionMap;
14
+ /** Column types whose `DEFAULT` this engine takes only as a parenthesized expression. */
15
+ readonly wrapTypes?: RegExp;
16
+ };
17
+ /**
18
+ * Looked up by name rather than carried on the dialect, which keeps DDL data out of the query
19
+ * bundle - the same split that keeps `CANONICAL_TO_SQL` in `schema/canonicalType.ts`. `uuidv7()` is
20
+ * Postgres 18+ and `UUID_v7()` MariaDB 11.7+; a server below those rejects it itself, the version
21
+ * not being knowable here.
6
22
  */
23
+ export declare const DIALECT_DEFAULTS: Readonly<Record<SqlDialectName, DialectDefaults>>;
7
24
  /**
8
- * Represents a raw SQL expression (not a literal value).
25
+ * A DDL default that is SQL rather than a literal. A class, not a plain object, so a JSON default
26
+ * cannot masquerade as one: `defaultValue` accepts `unknown`.
9
27
  */
10
28
  export declare class SqlExpression {
11
- readonly sql: string;
12
- constructor(sql: string);
13
- toString(): string;
14
- /**
15
- * Check if a value is a SQL expression.
16
- */
29
+ readonly kind: SqlExpressionKind;
30
+ readonly sql?: string | undefined;
31
+ /** `sql` is set only for the `raw` kind, which carries its own text verbatim. */
32
+ constructor(kind: SqlExpressionKind, sql?: string | undefined);
17
33
  static isExpression(value: unknown): value is SqlExpression;
18
34
  }
19
35
  /**
20
- * Helper object for common SQL expressions.
21
- * Use in migrations: `t.timestamp('createdAt').defaultValue(expr.now())`
36
+ * Symbolic DDL defaults. Each builds a token the dialect spells at DDL time, so `expr.uuid()` is
37
+ * `gen_random_uuid()` on Postgres and `UUID()` on MySQL from one migration.
38
+ *
39
+ * Use in migrations: `t.timestamp('createdAt', { defaultValue: expr.now() })`
22
40
  */
23
41
  export declare const expr: {
42
+ /** Current timestamp. */
43
+ now: () => SqlExpression;
44
+ /** Current date. */
45
+ currentDate: () => SqlExpression;
46
+ /** Current time. */
47
+ currentTime: () => SqlExpression;
48
+ /** Generated UUID. SQLite has no built-in one and throws; pass `expr.raw` there. */
49
+ uuid: () => SqlExpression;
24
50
  /**
25
- * Current timestamp (CURRENT_TIMESTAMP).
26
- */
27
- now(): SqlExpression;
28
- /**
29
- * Current date (CURRENT_DATE).
30
- */
31
- currentDate(): SqlExpression;
32
- /**
33
- * Current time (CURRENT_TIME).
34
- */
35
- currentTime(): SqlExpression;
36
- /**
37
- * Generate a UUID. Postgres only - use `expr.mysqlUuid()` on MySQL/MariaDB.
38
- */
39
- uuid(): SqlExpression;
40
- /**
41
- * Generate a UUID on MySQL/MariaDB.
42
- */
43
- mysqlUuid(): SqlExpression;
44
- /**
45
- * Raw SQL expression.
46
- * Use for custom expressions not covered by helpers.
47
- */
48
- raw(sql: string): SqlExpression;
49
- /**
50
- * Empty JSON object. Postgres only - the `::jsonb` cast is not portable.
51
- */
52
- emptyObject(): SqlExpression;
53
- /**
54
- * Empty JSON array. Postgres only - the `::jsonb` cast is not portable.
55
- */
56
- emptyArray(): SqlExpression;
57
- /**
58
- * MySQL: ON UPDATE CURRENT_TIMESTAMP.
51
+ * Time-ordered UUID, which indexes far better than a random one as a key. Postgres 18+ and
52
+ * MariaDB 11.7+ only; MySQL, SQLite and CockroachDB have no such function and throw.
59
53
  */
60
- onUpdateNow(): SqlExpression;
54
+ uuidv7: () => SqlExpression;
55
+ /** MySQL's `ON UPDATE CURRENT_TIMESTAMP`. Throws on dialects without it. */
56
+ onUpdateNow: () => SqlExpression;
57
+ /** SQL taken verbatim, for anything the kinds above do not cover. Not portable by definition. */
58
+ raw: (sql: string) => SqlExpression;
61
59
  };
62
60
  /**
63
- * Format a default value for SQL.
64
- * Handles SqlExpression vs literal values.
61
+ * The one place a DDL default becomes SQL: an expression through the dialect's own spelling, anything
62
+ * else as a literal, so `true` is `1` where booleans are integers. `columnType` decides only whether
63
+ * the result needs wrapping, which MySQL demands on its large types whatever the value.
65
64
  */
66
- export declare function formatDefaultValue(value: unknown): string;
65
+ export declare function formatDefaultValue(value: unknown, dialect: AbstractSqlDialect, columnType?: string): string;
@@ -1,117 +1,120 @@
1
+ const ANSI = {
2
+ now: 'CURRENT_TIMESTAMP',
3
+ currentDate: 'CURRENT_DATE',
4
+ currentTime: 'CURRENT_TIME',
5
+ uuid: null,
6
+ uuidv7: null,
7
+ onUpdateNow: null,
8
+ };
9
+ const PG = { ...ANSI, uuid: 'gen_random_uuid()' };
10
+ const MYSQL = {
11
+ ...ANSI,
12
+ uuid: 'UUID()',
13
+ onUpdateNow: 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
14
+ };
15
+ /** MySQL 8.0.13+ rejects `DEFAULT 'x'` on these but accepts `DEFAULT ('x')`, whatever the value. */
16
+ const MYSQL_LARGE_TYPES = /^\s*(TINY|MEDIUM|LONG)?(TEXT|BLOB)|^\s*(JSON|GEOMETRY)\b/i;
1
17
  /**
2
- * SQL Expressions
3
- *
4
- * Type-safe SQL expressions for default values and other uses.
5
- * Provides an `expr` helper with common expressions.
18
+ * Looked up by name rather than carried on the dialect, which keeps DDL data out of the query
19
+ * bundle - the same split that keeps `CANONICAL_TO_SQL` in `schema/canonicalType.ts`. `uuidv7()` is
20
+ * Postgres 18+ and `UUID_v7()` MariaDB 11.7+; a server below those rejects it itself, the version
21
+ * not being knowable here.
6
22
  */
23
+ export const DIALECT_DEFAULTS = {
24
+ postgres: { expressions: { ...PG, uuidv7: 'uuidv7()' } },
25
+ cockroachdb: { expressions: PG },
26
+ mysql: { expressions: MYSQL, wrapTypes: MYSQL_LARGE_TYPES },
27
+ mariadb: { expressions: { ...MYSQL, uuidv7: 'UUID_v7()' }, wrapTypes: MYSQL_LARGE_TYPES },
28
+ sqlite: { expressions: ANSI },
29
+ };
7
30
  /**
8
- * Represents a raw SQL expression (not a literal value).
31
+ * A DDL default that is SQL rather than a literal. A class, not a plain object, so a JSON default
32
+ * cannot masquerade as one: `defaultValue` accepts `unknown`.
9
33
  */
10
34
  export class SqlExpression {
35
+ kind;
11
36
  sql;
12
- constructor(sql) {
37
+ /** `sql` is set only for the `raw` kind, which carries its own text verbatim. */
38
+ constructor(kind, sql) {
39
+ this.kind = kind;
13
40
  this.sql = sql;
14
41
  }
15
- toString() {
16
- return this.sql;
17
- }
18
- /**
19
- * Check if a value is a SQL expression.
20
- */
21
42
  static isExpression(value) {
22
43
  return value instanceof SqlExpression;
23
44
  }
24
45
  }
25
46
  /**
26
- * Helper object for common SQL expressions.
27
- * Use in migrations: `t.timestamp('createdAt').defaultValue(expr.now())`
47
+ * Symbolic DDL defaults. Each builds a token the dialect spells at DDL time, so `expr.uuid()` is
48
+ * `gen_random_uuid()` on Postgres and `UUID()` on MySQL from one migration.
49
+ *
50
+ * Use in migrations: `t.timestamp('createdAt', { defaultValue: expr.now() })`
28
51
  */
29
52
  export const expr = {
53
+ /** Current timestamp. */
54
+ now: () => new SqlExpression('now'),
55
+ /** Current date. */
56
+ currentDate: () => new SqlExpression('currentDate'),
57
+ /** Current time. */
58
+ currentTime: () => new SqlExpression('currentTime'),
59
+ /** Generated UUID. SQLite has no built-in one and throws; pass `expr.raw` there. */
60
+ uuid: () => new SqlExpression('uuid'),
30
61
  /**
31
- * Current timestamp (CURRENT_TIMESTAMP).
32
- */
33
- now() {
34
- return new SqlExpression('CURRENT_TIMESTAMP');
35
- },
36
- /**
37
- * Current date (CURRENT_DATE).
38
- */
39
- currentDate() {
40
- return new SqlExpression('CURRENT_DATE');
41
- },
42
- /**
43
- * Current time (CURRENT_TIME).
44
- */
45
- currentTime() {
46
- return new SqlExpression('CURRENT_TIME');
47
- },
48
- /**
49
- * Generate a UUID. Postgres only - use `expr.mysqlUuid()` on MySQL/MariaDB.
50
- */
51
- uuid() {
52
- return new SqlExpression('gen_random_uuid()');
53
- },
54
- /**
55
- * Generate a UUID on MySQL/MariaDB.
56
- */
57
- mysqlUuid() {
58
- return new SqlExpression('UUID()');
59
- },
60
- /**
61
- * Raw SQL expression.
62
- * Use for custom expressions not covered by helpers.
63
- */
64
- raw(sql) {
65
- return new SqlExpression(sql);
66
- },
67
- /**
68
- * Empty JSON object. Postgres only - the `::jsonb` cast is not portable.
69
- */
70
- emptyObject() {
71
- return new SqlExpression("'{}'::jsonb");
72
- },
73
- /**
74
- * Empty JSON array. Postgres only - the `::jsonb` cast is not portable.
62
+ * Time-ordered UUID, which indexes far better than a random one as a key. Postgres 18+ and
63
+ * MariaDB 11.7+ only; MySQL, SQLite and CockroachDB have no such function and throw.
75
64
  */
76
- emptyArray() {
77
- return new SqlExpression("'[]'::jsonb");
78
- },
79
- /**
80
- * MySQL: ON UPDATE CURRENT_TIMESTAMP.
81
- */
82
- onUpdateNow() {
83
- return new SqlExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
84
- },
65
+ uuidv7: () => new SqlExpression('uuidv7'),
66
+ /** MySQL's `ON UPDATE CURRENT_TIMESTAMP`. Throws on dialects without it. */
67
+ onUpdateNow: () => new SqlExpression('onUpdateNow'),
68
+ /** SQL taken verbatim, for anything the kinds above do not cover. Not portable by definition. */
69
+ raw: (sql) => new SqlExpression('raw', sql),
85
70
  };
86
71
  /**
87
- * Format a default value for SQL.
88
- * Handles SqlExpression vs literal values.
72
+ * The one place a DDL default becomes SQL: an expression through the dialect's own spelling, anything
73
+ * else as a literal, so `true` is `1` where booleans are integers. `columnType` decides only whether
74
+ * the result needs wrapping, which MySQL demands on its large types whatever the value.
89
75
  */
90
- export function formatDefaultValue(value) {
76
+ export function formatDefaultValue(value, dialect, columnType) {
77
+ const sql = defaultLiteral(value, dialect);
78
+ const { wrapTypes } = DIALECT_DEFAULTS[dialect.dialectName];
79
+ return columnType !== undefined && wrapTypes?.test(columnType) ? `(${sql})` : sql;
80
+ }
81
+ /**
82
+ * Quoting is the dialect's `escape`, so a backslash in a default is escaped the way the engine reads
83
+ * it - MySQL takes `'a\b'` as a backspace where Postgres takes it literally. Only the cases `escape`
84
+ * cannot serve stay here: a boolean is `1` where booleans are integers, and a plain object or array
85
+ * is JSON rather than the throw and the IN-list `escape` gives them.
86
+ */
87
+ function defaultLiteral(value, dialect) {
91
88
  if (value === undefined || value === null) {
92
89
  return 'NULL';
93
90
  }
94
91
  if (SqlExpression.isExpression(value)) {
95
- return value.sql;
96
- }
97
- if (typeof value === 'string') {
98
- // Escape single quotes for string literals
99
- const escaped = value.replace(/'/g, "''");
100
- return `'${escaped}'`;
101
- }
102
- if (typeof value === 'number') {
103
- return String(value);
92
+ return expressionSql(value, dialect);
104
93
  }
105
94
  if (typeof value === 'boolean') {
106
- return value ? 'TRUE' : 'FALSE';
95
+ return dialect.booleanLiteral === 'native' ? (value ? 'TRUE' : 'FALSE') : value ? '1' : '0';
107
96
  }
108
97
  if (value instanceof Date) {
109
- return `'${value.toISOString()}'`;
98
+ return dialect.escape(ddlTimestamp(value));
110
99
  }
111
100
  if (typeof value === 'object') {
112
- // JSON value
113
- const json = JSON.stringify(value).replace(/'/g, "''");
114
- return `'${json}'`;
101
+ return dialect.escape(JSON.stringify(value));
102
+ }
103
+ return dialect.escape(value);
104
+ }
105
+ /**
106
+ * `YYYY-MM-DD HH:mm:ss.SSS` in UTC. Not `toISOString`, whose `T` and `Z` MySQL rejects outright
107
+ * ("Invalid default value"), and not `escape`'s local-time form, which would make the DDL depend on
108
+ * the machine that generated it.
109
+ */
110
+ function ddlTimestamp(date) {
111
+ return date.toISOString().replace('T', ' ').replace('Z', '');
112
+ }
113
+ function expressionSql(expression, dialect) {
114
+ const { expressions } = DIALECT_DEFAULTS[dialect.dialectName];
115
+ const sql = expression.kind === 'raw' ? expression.sql : expressions[expression.kind];
116
+ if (sql == null) {
117
+ throw new TypeError(`${dialect.dialectName} has no '${expression.kind}' default; pass expr.raw(...) with SQL this engine accepts`);
115
118
  }
116
- return String(value);
119
+ return sql;
117
120
  }
@@ -40,6 +40,7 @@ export declare class TableBuilder implements ITableBuilder {
40
40
  private add;
41
41
  createdAt(): IColumnBuilder;
42
42
  updatedAt(): IColumnBuilder;
43
+ private timestampNow;
43
44
  timestamps(): void;
44
45
  primaryKey(columns: string[]): this;
45
46
  unique(columns: readonly IndexColumnInput[], options?: string | IndexOptions): this;
@@ -137,10 +137,13 @@ export class TableBuilder {
137
137
  return column;
138
138
  }
139
139
  createdAt() {
140
- return this.add('createdAt', { category: 'timestamp' }, { defaultValue: expr.now() });
140
+ return this.timestampNow('createdAt');
141
141
  }
142
142
  updatedAt() {
143
- return this.add('updatedAt', { category: 'timestamp' }, { defaultValue: expr.now() });
143
+ return this.timestampNow('updatedAt');
144
+ }
145
+ timestampNow(name) {
146
+ return this.add(name, { category: 'timestamp' }, { defaultValue: expr.now() });
144
147
  }
145
148
  timestamps() {
146
149
  this.createdAt();
@@ -76,6 +76,9 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
76
76
  * rules are the same everywhere, and having them written twice is how the two paths drifted.
77
77
  */
78
78
  private renderColumn;
79
+ /** ` DEFAULT <sql>`, or nothing where the column declares none. Empty rather than `DEFAULT NULL`
80
+ * so an absent default stays absent - `defaultValue: null` is the way to ask for one. */
81
+ private defaultClause;
79
82
  getSqlType(field: FieldOptions, fieldType?: unknown): string;
80
83
  /**
81
84
  * Generate ALTER COLUMN statements (database-specific)
@@ -85,10 +88,6 @@ export declare class SqlSchemaGenerator implements SqlDdlGenerator {
85
88
  * Generate column comment clause (if supported)
86
89
  */
87
90
  generateColumnComment(columnName: string, comment: string): string;
88
- /**
89
- * Format a default value for SQL
90
- */
91
- formatDefaultValue(value: unknown): string;
92
91
  /**
93
92
  * Compare an entity with a database table node and return the differences.
94
93
  */
@@ -4,7 +4,7 @@ import { areTypesEqual, canonicalToSql, fieldOptionsToCanonical, isVectorCategor
4
4
  import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
5
5
  import { getKeys, isAutoIncrement, qualifyName } from '../util/index.js';
6
6
  import { derivedForeignKeyName } from '../util/sql.util.js';
7
- import { formatDefaultValue } from './builder/expressions.js';
7
+ import { formatDefaultValue, SqlExpression } from './builder/expressions.js';
8
8
  import { fullColumnDefinitionToNode, tableDefinitionToNode } from './generator/definitionToNode.js';
9
9
  import { indexNodeToSchema } from './generator/indexNodeToSchema.js';
10
10
  /**
@@ -243,14 +243,19 @@ export class SqlSchemaGenerator {
243
243
  if (column.isUnique && !column.isPrimaryKey) {
244
244
  def += ' UNIQUE';
245
245
  }
246
- if (column.defaultValue !== undefined) {
247
- def += ` DEFAULT ${this.formatDefaultValue(column.defaultValue)}`;
248
- }
246
+ def += this.defaultClause(column);
249
247
  if (column.comment) {
250
248
  def += this.generateColumnComment(column.name, column.comment);
251
249
  }
252
250
  return def;
253
251
  }
252
+ /** ` DEFAULT <sql>`, or nothing where the column declares none. Empty rather than `DEFAULT NULL`
253
+ * so an absent default stays absent - `defaultValue: null` is the way to ask for one. */
254
+ defaultClause(column) {
255
+ return column.defaultValue === undefined
256
+ ? ''
257
+ : ` DEFAULT ${formatDefaultValue(column.defaultValue, this.dialect, column.type)}`;
258
+ }
254
259
  getSqlType(field, fieldType) {
255
260
  // If field has a reference, inherit type from the target primary key
256
261
  if (field.references) {
@@ -288,7 +293,7 @@ export class SqlSchemaGenerator {
288
293
  statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} SET NOT NULL;`);
289
294
  }
290
295
  if (column.defaultValue !== undefined) {
291
- statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} SET DEFAULT ${this.formatDefaultValue(column.defaultValue)};`);
296
+ statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} SET${this.defaultClause(column)};`);
292
297
  }
293
298
  else {
294
299
  statements.push(`ALTER TABLE ${table} ALTER COLUMN ${colName} DROP DEFAULT;`);
@@ -307,15 +312,6 @@ export class SqlSchemaGenerator {
307
312
  }
308
313
  return '';
309
314
  }
310
- /**
311
- * Format a default value for SQL
312
- */
313
- formatDefaultValue(value) {
314
- if (this.dialect.booleanLiteral === 'integer' && typeof value === 'boolean') {
315
- return value ? '1' : '0';
316
- }
317
- return formatDefaultValue(value);
318
- }
319
315
  /**
320
316
  * Compare an entity with a database table node and return the differences.
321
317
  */
@@ -467,9 +463,12 @@ export class SqlSchemaGenerator {
467
463
  return true;
468
464
  if (current === undefined || desired === undefined)
469
465
  return current === desired;
470
- const normalize = (val) => {
471
- if (val === null)
466
+ const normalize = (value) => {
467
+ if (value === null)
472
468
  return 'null';
469
+ // Render first: the desired side may be a symbolic expression, the current side is always the
470
+ // engine's own text, and `{"kind":"now"}` matches no spelling of `CURRENT_TIMESTAMP`.
471
+ const val = SqlExpression.isExpression(value) ? formatDefaultValue(value, this.dialect) : value;
473
472
  if (typeof val === 'string') {
474
473
  let s = val.replace(/::[a-z_]+(\s+[a-z_]+)*(\[\])?$/i, '');
475
474
  s = s.replace(/^'(.*)'$/, '$1');
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.35.0",
6
+ "version": "0.36.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"