uql-orm 0.82.0 → 0.83.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.
Files changed (57) hide show
  1. package/README.md +116 -5
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/cockroachdb/crdbQuerierPool.js +2 -2
  5. package/dist/dialect/abstractSqlDialect.d.ts +1 -2
  6. package/dist/dialect/abstractSqlDialect.js +15 -10
  7. package/dist/dialect/hydrateColumn.js +2 -12
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -0
  9. package/dist/dialect/mysqlLikeSqlDialect.js +5 -0
  10. package/dist/dialect/operators.d.ts +16 -0
  11. package/dist/dialect/operators.js +47 -4
  12. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -0
  13. package/dist/dialect/pgLikeSqlDialect.js +13 -4
  14. package/dist/http/handler.js +17 -23
  15. package/dist/http/query.d.ts +3 -3
  16. package/dist/http/query.js +6 -3
  17. package/dist/maria/mariadbQuerierPool.js +4 -2
  18. package/dist/migrate/builder/expressions.d.ts +2 -0
  19. package/dist/migrate/builder/expressions.js +18 -9
  20. package/dist/migrate/builder/tableBuilder.js +1 -1
  21. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
  22. package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
  23. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  24. package/dist/migrate/introspection/mongoIntrospector.js +1 -1
  25. package/dist/migrate/introspection/mssqlIntrospector.js +13 -1
  26. package/dist/migrate/introspection/mysqlIntrospector.d.ts +2 -0
  27. package/dist/migrate/introspection/mysqlIntrospector.js +6 -2
  28. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  29. package/dist/migrate/storage/databaseStorage.js +1 -1
  30. package/dist/mongo/mongoDialect.js +12 -24
  31. package/dist/mongo/mongodbQuerier.js +4 -3
  32. package/dist/mssql/mssqlQuerier.d.ts +2 -0
  33. package/dist/mssql/mssqlQuerier.js +8 -5
  34. package/dist/mysql/mysql2QuerierPool.d.ts +1 -0
  35. package/dist/mysql/mysql2QuerierPool.js +20 -2
  36. package/dist/neon/neonQuerierPool.js +2 -2
  37. package/dist/pglite/pgliteQuerierPool.js +10 -4
  38. package/dist/postgres/pgQuerierPool.js +2 -2
  39. package/dist/postgres/{pgNumericTypes.d.ts → pgWireTypes.d.ts} +4 -3
  40. package/dist/postgres/{pgNumericTypes.js → pgWireTypes.js} +7 -3
  41. package/dist/schema/canonicalType.d.ts +3 -0
  42. package/dist/schema/canonicalType.js +31 -9
  43. package/dist/schema/schemaASTDiffer.js +4 -2
  44. package/dist/sqlite/sqliteDialect.d.ts +1 -3
  45. package/dist/sqlite/sqliteDialect.js +3 -6
  46. package/dist/type/entity.d.ts +1 -1
  47. package/dist/type/queryWhere.d.ts +10 -8
  48. package/dist/util/date.d.ts +11 -0
  49. package/dist/util/date.js +19 -0
  50. package/dist/util/dialect.util.d.ts +2 -5
  51. package/dist/util/dialect.util.js +3 -6
  52. package/dist/util/fieldOption.util.d.ts +5 -3
  53. package/dist/util/fieldOption.util.js +6 -5
  54. package/dist/util/sqlLiteral.d.ts +8 -1
  55. package/dist/util/sqlLiteral.js +11 -7
  56. package/package.json +1 -1
  57. package/skills/uql-orm/SKILL.md +1 -1
@@ -1,6 +1,7 @@
1
1
  import { QueryRaw, } from '../type/index.js';
2
+ import { utcTimestamp } from '../util/date.js';
2
3
  import { fulltextConfig, fulltextIndexOver, hasVectorNear, textSearchFields } from '../util/dialect.util.js';
3
- import { escapeSingleQuotes } from '../util/sqlLiteral.js';
4
+ import { escapePgSqlLiteral, escapeSingleQuotes, PG_UTC } from '../util/sqlLiteral.js';
4
5
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
5
6
  import { JSON_PULL_ALIAS, RELATION_ROW_ALIAS } from './aliases.js';
6
7
  import { BYTES_PREFIX } from './hydrateColumn.js';
@@ -128,10 +129,11 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
128
129
  return statements;
129
130
  }
130
131
  normalizeValue(value) {
131
- if (value != null && typeof value === 'object' && Array.isArray(value)) {
132
- return this.driverCapabilities.nativeArrays ? value : toPgArray(value);
132
+ if (Array.isArray(value)) {
133
+ const values = value.map(utcDate);
134
+ return this.driverCapabilities.nativeArrays ? values : toPgArray(values);
133
135
  }
134
- return super.normalizeValue(value);
136
+ return super.normalizeValue(utcDate(value));
135
137
  }
136
138
  placeholder(index) {
137
139
  return `$${index}`;
@@ -240,6 +242,9 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
240
242
  const ph = this.addValue(ctx, values);
241
243
  return negate ? `${operand} <> ALL(${ph})` : `${operand} = ANY(${ph})`;
242
244
  }
245
+ escape(value) {
246
+ return escapePgSqlLiteral(value);
247
+ }
243
248
  numericCast(expr) {
244
249
  return `(${expr})::numeric`;
245
250
  }
@@ -306,6 +311,10 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
306
311
  return this.driverCapabilities.explicitJsonCast ? `(${ph}::text)::${type}` : `${ph}::${type}`;
307
312
  }
308
313
  }
314
+ /** A date as UTC text, which a zoneless `TIMESTAMP` stores as is, where each driver would pick its own zone. */
315
+ function utcDate(value) {
316
+ return value instanceof Date ? utcTimestamp(value, PG_UTC) : value;
317
+ }
309
318
  /**
310
319
  * Converts a JS array to a Postgres array literal string: `{"val1","val2"}`.
311
320
  * Safely handles nesting and escaping of special characters.
@@ -42,13 +42,12 @@ export function createRequestHandler(opts) {
42
42
  };
43
43
  async function run(entity, { op, method, id }, req) {
44
44
  const meta = getMeta(entity);
45
- // QUERY (RFC 10008) carries the JSON query in the body instead of the query string
46
- const rawQuery = method === 'QUERY' ? req.body : req.query;
47
45
  const hookCtx = {
48
46
  meta,
49
47
  op,
50
48
  method,
51
- query: parseQueryParams(rawQuery),
49
+ // QUERY (RFC 10008) carries the JSON query in the body instead of the query string
50
+ query: parseQueryParams(method === 'QUERY' ? req.body : req.query),
52
51
  body: req.body,
53
52
  context: req.context,
54
53
  };
@@ -69,11 +68,9 @@ export function createRequestHandler(opts) {
69
68
  else {
70
69
  await preFilter?.(hookCtx);
71
70
  }
72
- const resp = await dispatch();
73
- if (post) {
74
- await post(hookCtx, resp.body);
75
- }
76
- return resp;
71
+ const envelope = await dispatch();
72
+ await post?.(hookCtx, envelope);
73
+ return { status: 200, body: envelope };
77
74
  });
78
75
  function dispatch() {
79
76
  // read post-hooks so both in-place mutation and reassignment of hookCtx.query apply
@@ -84,59 +81,59 @@ export function createRequestHandler(opts) {
84
81
  case 'findOne':
85
82
  return withQuerier(async (querier) => {
86
83
  const data = await querier.findOne(entity, query);
87
- return ok({ data, count: data ? 1 : 0 });
84
+ return { data, count: data ? 1 : 0 };
88
85
  });
89
86
  case 'count':
90
87
  return withQuerier(async (querier) => {
91
88
  const count = await querier.count(entity, query);
92
- return ok({ data: count, count });
89
+ return { data: count, count };
93
90
  });
94
91
  case 'findOneById':
95
92
  return withQuerier(async (querier) => {
96
93
  const data = await querier.findOne(entity, buildIdQuery(meta, id, query));
97
- return ok({ data, count: data ? 1 : 0 });
94
+ return { data, count: data ? 1 : 0 };
98
95
  });
99
96
  case 'findMany':
100
97
  return withQuerier(async (querier) => {
101
98
  const findManyPromise = querier.findMany(entity, query);
102
99
  const countPromise = flags.count ? querier.count(entity, query) : undefined;
103
100
  const [data, count] = await Promise.all([findManyPromise, countPromise]);
104
- return ok({ data, count });
101
+ return { data, count };
105
102
  });
106
103
  case 'insertOne':
107
104
  return withTransaction(async (querier) => {
108
105
  const data = await querier.insertOne(entity, hookCtx.body);
109
- return ok({ data, count: 1 });
106
+ return { data, count: 1 };
110
107
  });
111
108
  case 'insertMany':
112
109
  return withTransaction(async (querier) => {
113
110
  const data = await querier.insertMany(entity, hookCtx.body);
114
- return ok({ data, count: data.length });
111
+ return { data, count: data.length };
115
112
  });
116
113
  case 'saveOne':
117
114
  return withTransaction(async (querier) => {
118
115
  const data = await querier.saveOne(entity, hookCtx.body);
119
- return ok({ data, count: 1 });
116
+ return { data, count: 1 };
120
117
  });
121
118
  case 'saveMany':
122
119
  return withTransaction(async (querier) => {
123
120
  const data = await querier.saveMany(entity, hookCtx.body);
124
- return ok({ data, count: data.length });
121
+ return { data, count: data.length };
125
122
  });
126
123
  case 'updateOneById':
127
124
  return withTransaction(async (querier) => {
128
125
  const count = await querier.updateMany(entity, buildIdQuery(meta, id, query), hookCtx.body);
129
- return ok({ data: id, count });
126
+ return { data: id, count };
130
127
  });
131
128
  case 'updateMany':
132
129
  return withTransaction(async (querier) => {
133
130
  const count = await querier.updateMany(entity, query, hookCtx.body);
134
- return ok({ data: count, count });
131
+ return { data: count, count };
135
132
  });
136
133
  case 'deleteOneById':
137
134
  return withTransaction(async (querier) => {
138
135
  const count = await querier.deleteMany(entity, buildIdQuery(meta, id, query), { hardDelete });
139
- return ok({ data: id, count });
136
+ return { data: id, count };
140
137
  });
141
138
  case 'deleteMany':
142
139
  return withTransaction(async (querier) => {
@@ -148,15 +145,12 @@ export function createRequestHandler(opts) {
148
145
  ids = founds.map((found) => found[idKey]);
149
146
  count = await querier.deleteMany(entity, { $where: whereIds(meta, ids) }, { hardDelete });
150
147
  }
151
- return ok({ data: ids, count });
148
+ return { data: ids, count };
152
149
  });
153
150
  }
154
151
  }
155
152
  }
156
153
  }
157
- function ok(body) {
158
- return { status: 200, body };
159
- }
160
154
  function buildIdQuery(meta, id, query) {
161
155
  query.$where = whereWith(soleIdOf(meta, 'the HTTP handler'), id, query.$where);
162
156
  return query;
@@ -1,9 +1,9 @@
1
1
  import type { WireQuery } from '../type/index.js';
2
2
  /**
3
- * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
4
- * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
3
+ * Parse raw query-string entries (with JSON-stringified values), or a `QUERY` body, into a UQL query
4
+ * object. Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
5
5
  */
6
- export declare function parseQueryParams<E = unknown>(params?: Record<string, unknown>): WireQuery<E>;
6
+ export declare function parseQueryParams<E = unknown>(params?: unknown): WireQuery<E>;
7
7
  /**
8
8
  * Serialize a UQL query object into a percent-encoded query string where object values
9
9
  * are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.
@@ -4,7 +4,7 @@ import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUER
4
4
  // with it, in the browser bundle, which is on a size budget
5
5
  import { RAW_VALUE } from '../type/queryRaw.js';
6
6
  // the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
7
- import { getKeys, isWhereMap } from '../util/object.util.js';
7
+ import { getKeys, isRecord, isWhereMap } from '../util/object.util.js';
8
8
  // the error class alone, from its own leaf module: `queryError.ts` carries every driver's code map
9
9
  import { UqlUsageError } from '../util/uqlError.js';
10
10
  /**
@@ -30,10 +30,13 @@ const ALLOWED_QUERY_KEYS = new Set([
30
30
  */
31
31
  const REJECTED_QUERY_KEYS = new Set(['$lock']);
32
32
  /**
33
- * Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
34
- * Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
33
+ * Parse raw query-string entries (with JSON-stringified values), or a `QUERY` body, into a UQL query
34
+ * object. Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
35
35
  */
36
36
  export function parseQueryParams(params = {}) {
37
+ if (!isRecord(params)) {
38
+ throw new UqlUsageError('the query must be a JSON object');
39
+ }
37
40
  const query = {};
38
41
  for (const key of getKeys(params)) {
39
42
  if (REJECTED_QUERY_KEYS.has(key)) {
@@ -9,8 +9,10 @@ export class MariadbQuerierPool extends AbstractSqlQuerierPool {
9
9
  constructor(opts, extra) {
10
10
  super(new MariaDialect(dialectOptionsFrom(extra)), extra);
11
11
  // BIGINT stays the driver's `bigint`, which `MariadbQuerier` decodes by the rule every driver here
12
- // shares (`decodeWideNumber`) - not `bigIntAsNumber`, which rounds past 2^53 without a word.
13
- this.pool = createPool(opts);
12
+ // shares (`decodeWideNumber`) - not `bigIntAsNumber`, which rounds past 2^53 without a word. A date
13
+ // reads as its UTC text, which hydration decodes, since the connector would take it for local time
14
+ // whatever `timezone` says; that only sets the session's zone, UTC, so `NOW()` agrees.
15
+ this.pool = createPool({ timezone: 'Z', dateStrings: true, ...opts });
14
16
  // `mariadb` fires 'error' at runtime without declaring it, hence the cast; this makes it visible.
15
17
  attachPoolErrorHandler(this.pool, 'Idle MariaDB pool connection encountered an error', extra?.logger);
16
18
  }
@@ -13,6 +13,8 @@ export type DialectDefaults = {
13
13
  readonly expressions: SqlExpressionMap;
14
14
  /** Column types whose `DEFAULT` this engine takes only as a parenthesized expression. */
15
15
  readonly wrapTypes?: RegExp;
16
+ /** Column types whose `CURRENT_TIMESTAMP` default must repeat their precision, captured by the pattern. */
17
+ readonly preciseTypes?: RegExp;
16
18
  };
17
19
  /**
18
20
  * Looked up by name rather than carried on the dialect, which keeps DDL data out of the query
@@ -15,6 +15,8 @@ const MYSQL = {
15
15
  };
16
16
  /** MySQL 8.0.13+ rejects `DEFAULT 'x'` on these but accepts `DEFAULT ('x')`, whatever the value. */
17
17
  const MYSQL_LARGE_TYPES = /^\s*(TINY|MEDIUM|LONG)?(TEXT|BLOB)|^\s*(JSON|GEOMETRY)\b/i;
18
+ /** A `DATETIME(3)` or `TIMESTAMP(3)`, whose fractional-second precision is captured. */
19
+ const MYSQL_PRECISE_TYPES = /^\s*(?:DATETIME|TIMESTAMP)\((\d)\)/i;
18
20
  /**
19
21
  * Looked up by name rather than carried on the dialect, which keeps DDL data out of the query
20
22
  * bundle - the same split that keeps `CANONICAL_TO_SQL` in `schema/canonicalType.ts`. `uuidv7()` is
@@ -24,8 +26,12 @@ const MYSQL_LARGE_TYPES = /^\s*(TINY|MEDIUM|LONG)?(TEXT|BLOB)|^\s*(JSON|GEOMETRY
24
26
  export const DIALECT_DEFAULTS = {
25
27
  postgres: { expressions: { ...PG, uuidv7: 'uuidv7()' } },
26
28
  cockroachdb: { expressions: PG },
27
- mysql: { expressions: MYSQL, wrapTypes: MYSQL_LARGE_TYPES },
28
- mariadb: { expressions: { ...MYSQL, uuidv7: 'UUID_v7()' }, wrapTypes: MYSQL_LARGE_TYPES },
29
+ mysql: { expressions: MYSQL, wrapTypes: MYSQL_LARGE_TYPES, preciseTypes: MYSQL_PRECISE_TYPES },
30
+ mariadb: {
31
+ expressions: { ...MYSQL, uuidv7: 'UUID_v7()' },
32
+ wrapTypes: MYSQL_LARGE_TYPES,
33
+ preciseTypes: MYSQL_PRECISE_TYPES,
34
+ },
29
35
  sqlite: { expressions: ANSI },
30
36
  // `SYSUTCDATETIME()` over `CURRENT_TIMESTAMP`, which is local time in the server's zone. No
31
37
  // `uuidv7`: `NEWSEQUENTIALID()` is an ordered v4 GUID, so it carries no readable timestamp and
@@ -80,7 +86,7 @@ export const expr = {
80
86
  * the result needs wrapping, which MySQL demands on its large types whatever the value.
81
87
  */
82
88
  export function formatDefaultValue(value, dialect, columnType) {
83
- const sql = defaultLiteral(value, dialect);
89
+ const sql = defaultLiteral(value, dialect, columnType);
84
90
  const { wrapTypes } = DIALECT_DEFAULTS[dialect.dialectName];
85
91
  return columnType !== undefined && wrapTypes?.test(columnType) ? `(${sql})` : sql;
86
92
  }
@@ -114,23 +120,26 @@ export function sameDefault(desired, current, dialect) {
114
120
  * cannot serve stay here: a boolean is `1` where booleans are integers, and a plain object or array
115
121
  * is JSON rather than the throw and the IN-list `escape` gives them.
116
122
  */
117
- function defaultLiteral(value, dialect) {
123
+ function defaultLiteral(value, dialect, columnType) {
118
124
  if (value === undefined || value === null) {
119
125
  return 'NULL';
120
126
  }
121
127
  if (SqlExpression.isExpression(value)) {
122
- return expressionSql(value, dialect);
128
+ return expressionSql(value, dialect, columnType);
123
129
  }
124
130
  if (typeof value === 'boolean') {
125
131
  return dialect.booleanLiteral === 'native' ? (value ? 'TRUE' : 'FALSE') : value ? '1' : '0';
126
132
  }
127
133
  return dialect.escape(typeof value === 'object' && !(value instanceof Date) ? JSON.stringify(value) : value);
128
134
  }
129
- function expressionSql(expression, dialect) {
130
- const { expressions } = DIALECT_DEFAULTS[dialect.dialectName];
131
- const sql = expression.kind === 'raw' ? expression.sql : expressions[expression.kind];
135
+ function expressionSql(expression, dialect, columnType) {
136
+ const { expressions, preciseTypes } = DIALECT_DEFAULTS[dialect.dialectName];
137
+ const raw = expression.kind === 'raw';
138
+ const sql = raw ? expression.sql : expressions[expression.kind];
132
139
  if (sql == null) {
133
140
  throw new UqlUsageError(`${dialect.dialectName} has no '${expression.kind}' default; pass expr.raw(...) with SQL this engine accepts`);
134
141
  }
135
- return sql;
142
+ // A raw default is the caller's SQL, never rewritten.
143
+ const precision = raw || columnType === undefined ? undefined : preciseTypes?.exec(columnType)?.[1];
144
+ return precision === undefined ? sql : sql.replaceAll('CURRENT_TIMESTAMP', `CURRENT_TIMESTAMP(${precision})`);
136
145
  }
@@ -144,7 +144,7 @@ export class TableBuilder {
144
144
  return this.timestampNow('updatedAt');
145
145
  }
146
146
  timestampNow(name) {
147
- return this.add(name, { category: 'timestamp' }, { defaultValue: expr.now() });
147
+ return this.timestamptz(name, { defaultValue: expr.now() });
148
148
  }
149
149
  timestamps() {
150
150
  this.createdAt();
@@ -33,7 +33,7 @@ export declare class MongoSchemaGenerator extends MongoDialect implements Schema
33
33
  generateDropTable(tableName: string): string;
34
34
  /** A collection's indexes: each dropped, then each created, an alter as both. */
35
35
  generateAlterTable(diff: SchemaDiff): string[];
36
- /** MongoDB has no triggers, and a write to an entity declaring one is refused, so there is none to reconcile. */
36
+ /** MongoDB runs no trigger within a write, and a write to an entity declaring one is refused, so there is none to reconcile. */
37
37
  generateTriggers(): string[];
38
38
  generateTriggersDown(): string[];
39
39
  generateTriggerDrops(): string[];
@@ -115,7 +115,7 @@ export class MongoSchemaGenerator extends MongoDialect {
115
115
  ...sides(diff.indexes, 'to').map((index) => this.generateCreateIndex(diff.tableName, index)),
116
116
  ];
117
117
  }
118
- /** MongoDB has no triggers, and a write to an entity declaring one is refused, so there is none to reconcile. */
118
+ /** MongoDB runs no trigger within a write, and a write to an entity declaring one is refused, so there is none to reconcile. */
119
119
  generateTriggers() {
120
120
  return [];
121
121
  }
@@ -10,7 +10,7 @@ export declare class MongoSchemaIntrospector implements SchemaIntrospector {
10
10
  /** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
11
11
  readonly indexFacets: ReadonlySet<IndexFacet>;
12
12
  constructor(pool: QuerierPool);
13
- /** MongoDB has no triggers, so none is ever installed. */
13
+ /** MongoDB runs no trigger within a write, so uql installs none. */
14
14
  ownedTriggers(): Promise<InstalledTriggers>;
15
15
  introspect(tables?: readonly string[]): Promise<SchemaAST>;
16
16
  getTableSchema(tableName: string): Promise<TableSchema | undefined>;
@@ -15,7 +15,7 @@ export class MongoSchemaIntrospector {
15
15
  constructor(pool) {
16
16
  this.pool = pool;
17
17
  }
18
- /** MongoDB has no triggers, so none is ever installed. */
18
+ /** MongoDB runs no trigger within a write, so uql installs none. */
19
19
  async ownedTriggers() {
20
20
  return new Map();
21
21
  }
@@ -1,3 +1,4 @@
1
+ import { defaultTimestampPrecision } from '../../schema/canonicalType.js';
1
2
  import { AbstractSqlSchemaIntrospector, } from './abstractSqlSchemaIntrospector.js';
2
3
  /**
3
4
  * SQL Server schema introspector.
@@ -137,7 +138,7 @@ export class MsSqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
137
138
  const bytes = this.toNumber(row.max_length);
138
139
  return {
139
140
  name: row.column_name,
140
- type: bytes === -1 && CHARACTER_TYPES.has(type) ? `${type}(MAX)` : type,
141
+ type: spelledType(type, bytes, this.toNumber(row.numeric_scale)),
141
142
  nullable: Boolean(row.is_nullable),
142
143
  defaultValue: this.parseDefaultValue(row.column_default),
143
144
  isAutoIncrement: Boolean(row.is_identity),
@@ -183,6 +184,17 @@ export class MsSqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
183
184
  /** The types whose `max_length` is a width rather than a fixed storage size. */
184
185
  const CHARACTER_TYPES = new Set(['CHAR', 'NCHAR', 'VARCHAR', 'NVARCHAR', 'BINARY', 'VARBINARY']);
185
186
  const NUMERIC_TYPES = new Set(['DECIMAL', 'NUMERIC']);
187
+ /** `(MAX)` on an unbounded character type, and a timestamp's fractional digits where not the engine's default. */
188
+ function spelledType(type, bytes, scale) {
189
+ if (bytes === -1 && CHARACTER_TYPES.has(type)) {
190
+ return `${type}(MAX)`;
191
+ }
192
+ return (type === 'DATETIME2' || type === 'DATETIMEOFFSET') &&
193
+ scale !== undefined &&
194
+ scale !== defaultTimestampPrecision('mssql')
195
+ ? `${type}(${scale})`
196
+ : type;
197
+ }
186
198
  /**
187
199
  * A column's declared width from `max_length`, which is bytes: an `N` type holds two a character, a
188
200
  * `VECTOR` is an 8-byte header and four a dimension, and `MAX` is `-1`. Read as bytes, every Unicode
@@ -29,6 +29,7 @@ export declare class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospect
29
29
  }
30
30
  type MysqlColumnRow = {
31
31
  column_name: string;
32
+ data_type: string;
32
33
  column_type: string;
33
34
  is_nullable: string;
34
35
  column_default: string | null;
@@ -36,6 +37,7 @@ type MysqlColumnRow = {
36
37
  extra: string;
37
38
  character_maximum_length: number | bigint | null;
38
39
  numeric_precision: number | bigint | null;
40
+ datetime_precision: number | bigint | null;
39
41
  numeric_scale: number | null;
40
42
  column_comment: string | null;
41
43
  generated_as: string | null;
@@ -47,6 +47,7 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
47
47
  CHARACTER_MAXIMUM_LENGTH as character_maximum_length,
48
48
  NUMERIC_PRECISION as numeric_precision,
49
49
  NUMERIC_SCALE as numeric_scale,
50
+ DATETIME_PRECISION as datetime_precision,
50
51
  COLUMN_KEY as column_key,
51
52
  EXTRA as extra,
52
53
  CASE WHEN EXTRA LIKE '%STORED GENERATED%' THEN GENERATION_EXPRESSION END as generated_as,
@@ -113,7 +114,8 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
113
114
  isUnique: row.column_key === 'UNI',
114
115
  // A `VECTOR`'s is its bytes, four a dimension, which `column_type` already states as dimensions.
115
116
  length: /^vector/i.test(row.column_type) ? undefined : this.toNumber(row.character_maximum_length),
116
- precision: this.toNumber(row.numeric_precision),
117
+ // A timestamp's fractional digits, stated even when 0, which uql's own unstated `DATETIME(3)` is not.
118
+ precision: this.toNumber(TIMESTAMP_TYPES.has(row.data_type) ? row.datetime_precision : row.numeric_precision),
117
119
  scale: this.toNumber(row.numeric_scale),
118
120
  comment: row.column_comment || undefined,
119
121
  generatedAs: row.generated_as ?? undefined,
@@ -146,7 +148,8 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
146
148
  if (normalized === 'NULL') {
147
149
  return null;
148
150
  }
149
- if (normalized === 'CURRENT_TIMESTAMP' || normalized === 'CURRENT_TIMESTAMP()') {
151
+ // Whatever precision it repeats from its column, which the column's own type already states.
152
+ if (/^CURRENT_TIMESTAMP(?:\(\d?\))?$/.test(normalized)) {
150
153
  return 'CURRENT_TIMESTAMP';
151
154
  }
152
155
  if (/^-?\d+(\.\d+)?$/.test(defaultValue)) {
@@ -158,6 +161,7 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
158
161
  return quoted ? unescapeMysqlString(quoted[1]) : literal;
159
162
  }
160
163
  }
164
+ const TIMESTAMP_TYPES = new Set(['datetime', 'timestamp']);
161
165
  /**
162
166
  * MariaDB reads out of the same `information_schema` as MySQL, save for one column type it does not
163
167
  * have: `JSON` there is an alias for `LONGTEXT` plus a `json_valid()` check constraint named after
@@ -75,7 +75,7 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
75
75
  c.is_identity,
76
76
  c.identity_generation,
77
77
  CASE WHEN a.attgenerated = 's' THEN c.generation_expression END AS generated_as,
78
- CASE WHEN c.data_type IN ('USER-DEFINED', 'vector') AND a.atttypmod > -1
78
+ CASE WHEN (c.data_type IN ('USER-DEFINED', 'vector') OR c.data_type LIKE 'timestamp%') AND a.atttypmod > -1
79
79
  THEN format_type(a.atttypid, a.atttypmod) END AS formatted_type,
80
80
  EXISTS (
81
81
  SELECT 1 FROM information_schema.table_constraints tc
@@ -32,7 +32,7 @@ export class DatabaseMigrationStorage {
32
32
  async createTableIfNotExists(querier) {
33
33
  const table = new TableBuilder(this.tableName);
34
34
  table.string('name', { length: 255, primaryKey: true });
35
- table.timestamp('executed_at', { defaultValue: expr.now() });
35
+ table.timestamptz('executed_at', { defaultValue: expr.now() });
36
36
  const generator = new SqlSchemaGenerator(querier.dialect);
37
37
  for (const sql of generator.generateCreateTableFromDefinition(table.build(), { ifNotExists: true })) {
38
38
  await querier.run(sql);
@@ -1,12 +1,12 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, nullsSortField, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
- import { betweenBounds, GROUP_OPS, groupClauses, isGroupOp, whereOperators } from '../dialect/operators.js';
4
+ import { betweenBounds, GROUP_OPS, groupClauses, isGroupOp, LIKE_OPS, likeRegex, whereOperators, } from '../dialect/operators.js';
5
5
  import { aggregateColumnField, groupPathField, resolveGroupJoins, relationSortTerms, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
6
6
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
7
7
  import { COUNT_RESULT_KEY } from '../type/query.js';
8
8
  import { QueryRaw } from '../type/queryRaw.js';
9
- import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
9
+ import { aggregateOf, isSelectList, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
10
10
  import { UqlUsageError } from '../util/uqlError.js';
11
11
  import { decodeBigIntsExcept } from '../util/wideNumber.js';
12
12
  import { textLanguage } from './textLanguage.js';
@@ -71,17 +71,6 @@ function compareCount(count, size) {
71
71
  }
72
72
  return comparisons.length === 1 ? comparisons[0] : { $and: comparisons };
73
73
  }
74
- /** String operators -> { pattern: (v) => regex, caseInsensitive } */
75
- const REGEX_OP_MAP = new Map([
76
- ['$startsWith', { wrap: (v) => `^${v}`, ci: false }],
77
- ['$istartsWith', { wrap: (v) => `^${v}`, ci: true }],
78
- ['$endsWith', { wrap: (v) => `${v}$`, ci: false }],
79
- ['$iendsWith', { wrap: (v) => `${v}$`, ci: true }],
80
- ['$includes', { wrap: (v) => String(v), ci: false }],
81
- ['$iincludes', { wrap: (v) => String(v), ci: true }],
82
- ['$like', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: false }],
83
- ['$ilike', { wrap: (v) => String(v).replace(/%/g, '.*').replace(/_/g, '.'), ci: true }],
84
- ]);
85
74
  /** MongoDB native operators - pass through as-is. */
86
75
  const NATIVE_OPS = new Set([
87
76
  '$all',
@@ -361,11 +350,11 @@ export class MongoDialect extends AbstractDialect {
361
350
  result[op] = val;
362
351
  continue;
363
352
  }
364
- // String/pattern -> regex operators (8 variants including $like/$ilike)
365
- const regexEntry = REGEX_OP_MAP.get(op);
366
- if (regexEntry) {
367
- result['$regex'] = regexEntry.wrap(val);
368
- if (regexEntry.ci)
353
+ // The `$like` family, as the regex matching what its `LIKE` pattern matches on SQL.
354
+ const like = LIKE_OPS.get(op);
355
+ if (like) {
356
+ result['$regex'] = likeRegex(like.pattern(String(val)));
357
+ if (like.insensitive)
369
358
  result['$options'] = 'i';
370
359
  continue;
371
360
  }
@@ -429,17 +418,16 @@ export class MongoDialect extends AbstractDialect {
429
418
  if (!select && !exclude) {
430
419
  return {};
431
420
  }
432
- if (Array.isArray(select)) {
421
+ if (isSelectList(select)) {
433
422
  throw new UqlUsageError('raw $select is not supported on MongoDB');
434
423
  }
435
- const selectMap = asSelectMap(select);
436
424
  // Projected by column, not by field key; `normalizeId` maps them back on the way out.
437
- const projection = normalizeScalarFieldSelection(meta, selectMap, exclude).reduce((acc, key) => {
425
+ const projection = normalizeScalarFieldSelection(meta, select, exclude).reduce((acc, key) => {
438
426
  // A computed field writing SQL leaves the document nothing to project: refused asked for by
439
427
  // name, skipped swept in with the rest. A relation aggregate is on it by now, like any column.
440
428
  const field = meta.fields[key];
441
429
  if (field?.computed && !aggregateOf(field)) {
442
- if (selectMap && key in selectMap) {
430
+ if (select && key in select) {
443
431
  assertReadable(meta, key);
444
432
  }
445
433
  return acc;
@@ -450,7 +438,7 @@ export class MongoDialect extends AbstractDialect {
450
438
  // MongoDB returns `_id` unless it is explicitly excluded, so subtracting the primary key needs
451
439
  // `_id: 0` - the one inclusion/exclusion mix MongoDB allows - or `$exclude: { id: true }` would
452
440
  // have no effect at all.
453
- if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), selectMap, exclude)) {
441
+ if (this.subtractsKey(soleIdOf(meta, 'MongoDB'), select, exclude)) {
454
442
  projection[ID_KEY] = 0;
455
443
  }
456
444
  return projection;
@@ -565,7 +553,7 @@ export class MongoDialect extends AbstractDialect {
565
553
  /** The relation aggregates a read projects or sorts by; its `$where` puts its own on the document. */
566
554
  aggregateKeys(entity, q) {
567
555
  const meta = getMeta(entity);
568
- const projected = normalizeScalarFieldSelection(meta, asSelectMap(q.$select), q.$exclude);
556
+ const projected = normalizeScalarFieldSelection(meta, isSelectList(q.$select) ? undefined : q.$select, q.$exclude);
569
557
  return [...projected, ...Object.keys(q.$sort ?? {})].filter((key) => aggregateOf(meta.fields[key]));
570
558
  }
571
559
  /**
@@ -13,12 +13,13 @@ function asksForNoRows(q) {
13
13
  return q.$limit === 0;
14
14
  }
15
15
  /**
16
- * MongoDB has no triggers, so a write to an entity declaring one - a stamp included - would skip it
17
- * silently. Refused instead, as a query naming SQL is.
16
+ * MongoDB runs no trigger within a write (Atlas Database Triggers fire after the commit), so a write to
17
+ * an entity declaring one - a stamp included - would skip it silently. Refused instead, as a query naming
18
+ * SQL is. The why, in `architecture/triggers.md`.
18
19
  */
19
20
  function refuseTriggers(entity) {
20
21
  if (hasTriggers(getMeta(entity))) {
21
- throw new UqlUsageError(`'${entity.name}' declares triggers, which MongoDB has none of: a write here would skip them. ` +
22
+ throw new UqlUsageError(`'${entity.name}' declares triggers, which MongoDB cannot run within a write: a write here would skip them. ` +
22
23
  'Keep the entity on a SQL engine, or drop its triggers and stamps.');
23
24
  }
24
25
  }
@@ -1,3 +1,4 @@
1
+ import { DateTime2 } from 'mssql';
1
2
  import { AbstractPoolQuerier } from '../querier/abstractPoolQuerier.js';
2
3
  import type { QueryUpdateResult, RawRow, TransactionOptions } from '../type/index.js';
3
4
  /** What `tedious` hands back for one statement, whichever shape it took. */
@@ -17,6 +18,7 @@ type MsSqlRowStream = AsyncIterable<unknown> & {
17
18
  /** The part of an `mssql` `Request` a querier drives. */
18
19
  type MsSqlRequest = {
19
20
  input(name: string, value: unknown): unknown;
21
+ input(name: string, type: typeof DateTime2, value: unknown): unknown;
20
22
  query(command: string): Promise<MsSqlResult>;
21
23
  toReadableStream(): MsSqlRowStream;
22
24
  cancel(): unknown;
@@ -1,4 +1,4 @@
1
- import { ISOLATION_LEVEL } from 'mssql';
1
+ import { DateTime2, ISOLATION_LEVEL } from 'mssql';
2
2
  import { AbstractPoolQuerier } from '../querier/abstractPoolQuerier.js';
3
3
  import { decodeWireTypes } from './mssqlWireTypes.js';
4
4
  /**
@@ -9,13 +9,16 @@ import { decodeWireTypes } from './mssqlWireTypes.js';
9
9
  export class MsSqlQuerier extends AbstractPoolQuerier {
10
10
  #transaction;
11
11
  /**
12
- * Values bind by name, `@p1` upward, matching {@link MsSqlDialect.placeholder}. `tedious` infers
13
- * a type from the JS value, which is why a `Date` and a `Uint8Array` reach it unconverted - the
14
- * inference is right for both, and wrong only for a bare `null`, which it calls `NVarChar`.
12
+ * Values bind by name, `@p1` upward, matching {@link MsSqlDialect.placeholder}. `mssql` infers a type
13
+ * from the JS value, right for a `Uint8Array` and harmlessly wrong for a bare `null` (`NVarChar`), but
14
+ * a `Date` it binds as the legacy `DATETIME`, whose 1/300 s steps no `DATETIME2` column compares equal to.
15
15
  */
16
16
  #request(values) {
17
17
  const request = this.#transaction ? this.#transaction.request() : this.getConn().request();
18
- values?.forEach((value, index) => request.input(`p${index + 1}`, value));
18
+ values?.forEach((value, index) => {
19
+ const name = `p${index + 1}`;
20
+ return value instanceof Date ? request.input(name, DateTime2, value) : request.input(name, value);
21
+ });
19
22
  return request;
20
23
  }
21
24
  async internalAll(query, values) {
@@ -4,6 +4,7 @@ import type { ExtraOptions } from '../type/index.js';
4
4
  import { MySql2Querier } from './mysql2Querier.js';
5
5
  import { MySqlDialect } from './mysqlDialect.js';
6
6
  export declare class MySql2QuerierPool extends AbstractSqlQuerierPool<MySql2Querier, MySqlDialect> {
7
+ #private;
7
8
  readonly pool: Pool;
8
9
  constructor(opts: PoolOptions, extra?: ExtraOptions);
9
10
  getQuerier(): Promise<MySql2Querier>;