uql-orm 0.79.0 → 0.80.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 (78) hide show
  1. package/dist/browser/uql-browser.min.js.map +2 -2
  2. package/dist/cockroachdb/cockroachDialect.js +5 -1
  3. package/dist/dialect/abstractDialect.d.ts +1 -31
  4. package/dist/dialect/abstractDialect.js +3 -27
  5. package/dist/dialect/abstractSqlDialect.d.ts +25 -56
  6. package/dist/dialect/abstractSqlDialect.js +78 -145
  7. package/dist/dialect/aliases.d.ts +5 -0
  8. package/dist/dialect/aliases.js +5 -0
  9. package/dist/dialect/mysqlLikeSqlDialect.d.ts +4 -2
  10. package/dist/dialect/mysqlLikeSqlDialect.js +14 -1
  11. package/dist/dialect/operators.d.ts +66 -0
  12. package/dist/dialect/operators.js +129 -0
  13. package/dist/dialect/pgLikeSqlDialect.d.ts +4 -1
  14. package/dist/dialect/pgLikeSqlDialect.js +16 -3
  15. package/dist/entity/decorator/entity.d.ts +6 -1
  16. package/dist/entity/decorator/entity.js +12 -1
  17. package/dist/entity/index.d.ts +1 -1
  18. package/dist/entity/index.js +1 -1
  19. package/dist/entity/metadata/definition.d.ts +6 -1
  20. package/dist/entity/metadata/definition.js +19 -0
  21. package/dist/migrate/codegen/entityTypes.js +1 -2
  22. package/dist/migrate/ddl/mssqlIndexDdl.d.ts +5 -0
  23. package/dist/migrate/ddl/mssqlIndexDdl.js +10 -0
  24. package/dist/migrate/ddl/mssqlTableDdl.d.ts +2 -0
  25. package/dist/migrate/ddl/mssqlTableDdl.js +5 -0
  26. package/dist/migrate/ddl/tableDdl.d.ts +2 -0
  27. package/dist/migrate/ddl/tableDdl.js +4 -0
  28. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +4 -0
  29. package/dist/migrate/generator/mongoSchemaGenerator.js +10 -0
  30. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +13 -1
  31. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +21 -0
  32. package/dist/migrate/introspection/mongoIntrospector.d.ts +3 -1
  33. package/dist/migrate/introspection/mongoIntrospector.js +4 -0
  34. package/dist/migrate/introspection/mssqlIntrospector.d.ts +1 -0
  35. package/dist/migrate/introspection/mssqlIntrospector.js +8 -0
  36. package/dist/migrate/introspection/mysqlIntrospector.d.ts +1 -0
  37. package/dist/migrate/introspection/mysqlIntrospector.js +9 -0
  38. package/dist/migrate/introspection/postgresIntrospector.d.ts +1 -0
  39. package/dist/migrate/introspection/postgresIntrospector.js +10 -0
  40. package/dist/migrate/introspection/sqliteIntrospector.d.ts +1 -0
  41. package/dist/migrate/introspection/sqliteIntrospector.js +3 -0
  42. package/dist/migrate/migrator.d.ts +28 -1
  43. package/dist/migrate/migrator.js +88 -9
  44. package/dist/migrate/schemaGenerator.d.ts +12 -1
  45. package/dist/migrate/schemaGenerator.js +47 -5
  46. package/dist/migrate/storage/databaseStorage.d.ts +4 -0
  47. package/dist/migrate/storage/databaseStorage.js +14 -8
  48. package/dist/migrate/triggerSql.d.ts +24 -0
  49. package/dist/migrate/triggerSql.js +229 -0
  50. package/dist/mongo/mongoDialect.d.ts +0 -21
  51. package/dist/mongo/mongoDialect.js +105 -100
  52. package/dist/mongo/mongodbQuerier.js +17 -1
  53. package/dist/mssql/mssqlDialect.d.ts +18 -7
  54. package/dist/mssql/mssqlDialect.js +77 -33
  55. package/dist/mssql/mssqlQuerier.js +2 -2
  56. package/dist/schema/canonicalType.d.ts +6 -1
  57. package/dist/schema/canonicalType.js +14 -0
  58. package/dist/schema/schemaASTBuilder.d.ts +2 -8
  59. package/dist/schema/schemaASTBuilder.js +6 -20
  60. package/dist/sqlite/sqliteDialect.d.ts +1 -1
  61. package/dist/sqlite/sqliteDialect.js +12 -3
  62. package/dist/type/dialect.d.ts +69 -9
  63. package/dist/type/entity.d.ts +96 -3
  64. package/dist/type/migration.d.ts +17 -0
  65. package/dist/type/query.d.ts +13 -4
  66. package/dist/type/queryWhere.d.ts +4 -2
  67. package/dist/util/field.util.d.ts +9 -1
  68. package/dist/util/field.util.js +14 -2
  69. package/dist/util/fieldOption.util.d.ts +2 -2
  70. package/dist/util/fieldOption.util.js +2 -2
  71. package/dist/util/raw.d.ts +9 -1
  72. package/dist/util/raw.js +36 -11
  73. package/dist/util/sql.util.d.ts +12 -0
  74. package/dist/util/sql.util.js +24 -3
  75. package/dist/util/uqlError.d.ts +2 -0
  76. package/dist/util/uqlError.js +4 -0
  77. package/package.json +4 -4
  78. package/skills/uql-orm/SKILL.md +11 -6
@@ -4,7 +4,7 @@ import { BYTES_PREFIX } from '../dialect/hydrateColumn.js';
4
4
  import { jsonArraySlotArgs, jsonPath, jsonSlotArgs } from '../dialect/jsonSql.js';
5
5
  import { MergeSqlDialect } from '../dialect/mergeSqlDialect.js';
6
6
  import { getMeta } from '../entity/index.js';
7
- import { fieldOptionsToCanonical } from '../schema/canonicalType.js';
7
+ import { canonicalToSql, fieldOptionsToCanonical, resolveColumnCanonicalType } from '../schema/canonicalType.js';
8
8
  import { QueryRaw } from '../type/index.js';
9
9
  import { parseQueryLock } from '../type/index.js';
10
10
  import { isAutoIncrement } from '../util/field.util.js';
@@ -13,10 +13,8 @@ import { escapeSingleQuotes } from '../util/sqlLiteral.js';
13
13
  import { UqlUsageError } from '../util/uqlError.js';
14
14
  /** What SQL Server has. */
15
15
  const MSSQL_FEATURES = {
16
- // Neither object takes an `IF NOT EXISTS`; both need a `sys` catalogue lookup around them, which
17
- // the generator does not emit.
18
- ifNotExists: false,
19
- indexIfNotExists: false,
16
+ // No `IF NOT EXISTS` on an index, so `MsSqlIndexDdl` guards the create with a `sys.indexes` lookup.
17
+ indexIfNotExists: true,
20
18
  schemas: true,
21
19
  dropTableCascade: false,
22
20
  foreignKeyAlter: true,
@@ -40,6 +38,16 @@ const MSSQL_FEATURES = {
40
38
  narrowVectorTypes: false,
41
39
  vectorTuningNeedsTransaction: false,
42
40
  serialDeclaresPrimaryKey: false,
41
+ triggers: {
42
+ preamble: 'SET NOCOUNT ON;',
43
+ assignsRow: false,
44
+ body: 'inline',
45
+ guards: 'beginEnd',
46
+ layout: 'tableFirst',
47
+ rows: 'set',
48
+ scope: 'schema',
49
+ before: false,
50
+ },
43
51
  };
44
52
  /** The `type` `OPENJSON` reports for the JSON scalar an element is compared with; anything else binds as a string. */
45
53
  function openJsonType(value) {
@@ -48,6 +56,14 @@ function openJsonType(value) {
48
56
  }
49
57
  return typeof value === 'boolean' ? 3 : 1;
50
58
  }
59
+ /** The table variable an `OUTPUT` fills, `_uql`-prefixed as every name uql invents is. */
60
+ const OUTPUT_TABLE = '@_uql_output';
61
+ /** The one key column and its field, or nothing on a composite key. */
62
+ function soleId(meta) {
63
+ const [key] = meta.ids;
64
+ const field = meta.ids.length === 1 ? meta.fields[key] : undefined;
65
+ return field && { key, field };
66
+ }
51
67
  /** Microsoft SQL Server 2017 and up. Identifiers are `"`-quoted, the ANSI spelling `tedious` enables. */
52
68
  export class MsSqlDialect extends MergeSqlDialect {
53
69
  features = MSSQL_FEATURES;
@@ -74,6 +90,8 @@ export class MsSqlDialect extends MergeSqlDialect {
74
90
  maxBindValues = 2100;
75
91
  /** `OUTPUT` has no trailing form: it sits between the column list and `VALUES`. */
76
92
  returningPosition = 'after-target';
93
+ /** `OUTPUT` reads the written row off the `INSERTED` pseudo-table. */
94
+ returnedRowPrefix = 'INSERTED.';
77
95
  insertIdSource = 'returning';
78
96
  /** Holds the update key lock across the insert; without it two concurrent upserts of one key race. */
79
97
  mergeTargetHint = ' WITH (HOLDLOCK)';
@@ -96,30 +114,20 @@ export class MsSqlDialect extends MergeSqlDialect {
96
114
  }
97
115
  super.pager(ctx, opts, sorted);
98
116
  }
99
- /**
100
- * `SET IDENTITY_INSERT` around an insert that states a key the engine would generate, which it otherwise
101
- * refuses; turned off in the same batch, since one table per session may hold it.
102
- */
103
117
  insert(ctx, entity, payload, opts) {
104
- const table = this.identityInsertTarget(entity, payload);
105
- if (table) {
106
- ctx.append(`SET IDENTITY_INSERT ${table} ON; `);
107
- }
108
- super.insert(ctx, entity, payload, opts);
109
- if (table) {
110
- ctx.append(`; SET IDENTITY_INSERT ${table} OFF`);
111
- }
118
+ this.writeRows(ctx, getMeta(entity), payload, () => {
119
+ super.insert(ctx, entity, payload, opts);
120
+ ctx.append(';');
121
+ });
112
122
  }
113
123
  /** The table to toggle, or nothing when no record writes a key the engine would have generated. */
114
- identityInsertTarget(entity, payload) {
115
- const meta = getMeta(entity);
116
- const [idKey] = meta.ids;
117
- const field = meta.ids.length === 1 ? meta.fields[idKey] : undefined;
118
- if (!field || !isAutoIncrement(field, true)) {
124
+ identityInsertTarget(meta, payload) {
125
+ const id = soleId(meta);
126
+ if (!id || !isAutoIncrement(id.field, true)) {
119
127
  return undefined;
120
128
  }
121
129
  const records = Array.isArray(payload) ? payload : [payload];
122
- const stated = records.some((record) => record[idKey] !== undefined);
130
+ const stated = records.some((record) => record[id.key] !== undefined);
123
131
  return stated ? this.escapedTableName(meta) : undefined;
124
132
  }
125
133
  /** A `DECIMAL` declared `String`, converted before it crosses the wire, where `tedious` would round it. */
@@ -151,17 +159,44 @@ export class MsSqlDialect extends MergeSqlDialect {
151
159
  placeholder(index) {
152
160
  return `@p${index}`;
153
161
  }
154
- /** `OUTPUT` reads the written row out of the `INSERTED` pseudo-table rather than `RETURNING` it. */
162
+ /**
163
+ * `OUTPUT` reads the written row out of the `INSERTED` pseudo-table rather than `RETURNING` it, and
164
+ * always `INTO` a table variable: SQL Server refuses an `OUTPUT` straight back to the client on a table
165
+ * carrying a trigger, and through a table it works whether one is there or not.
166
+ */
155
167
  returningId(meta) {
156
168
  const expression = this.returningIdExpression(meta);
157
- return expression ? `OUTPUT ${expression}` : '';
169
+ return expression ? `OUTPUT ${expression} INTO ${OUTPUT_TABLE}` : '';
170
+ }
171
+ upsert(ctx, entity, conflictPaths, payload, extraReturning = '') {
172
+ this.writeRows(ctx, getMeta(entity), payload, () => super.upsert(ctx, entity, conflictPaths, payload, extraReturning));
158
173
  }
159
- returningIdExpression(meta) {
160
- const [idKey] = meta.ids;
161
- return meta.ids.length === 1 ? `INSERTED.${this.escapeId(this.columnOf(meta, idKey))} ${this.escapeId('id')}` : '';
174
+ /**
175
+ * A statement writing rows, `write`, wrapped in what SQL Server needs around one. The table variable its
176
+ * `OUTPUT` fills, typed as the id column is, and the read handing that back, uncounted so the batch
177
+ * reports the rows written alone; none on a composite key, which has no id to hand back. And `SET
178
+ * IDENTITY_INSERT` where a row states a key the engine would generate, turned off in the same batch.
179
+ */
180
+ writeRows(ctx, meta, payload, write) {
181
+ const id = soleId(meta);
182
+ const column = this.escapeId('id');
183
+ const identity = this.identityInsertTarget(meta, payload);
184
+ if (id) {
185
+ ctx.append(`DECLARE ${OUTPUT_TABLE} TABLE (${column} ${canonicalToSql(resolveColumnCanonicalType(id.field), this)}); `);
186
+ }
187
+ if (identity) {
188
+ ctx.append(`SET IDENTITY_INSERT ${identity} ON; `);
189
+ }
190
+ write();
191
+ if (identity) {
192
+ ctx.append(` SET IDENTITY_INSERT ${identity} OFF;`);
193
+ }
194
+ if (id) {
195
+ ctx.append(` SET NOCOUNT ON; SELECT ${column} FROM ${OUTPUT_TABLE}; SET NOCOUNT OFF;`);
196
+ }
162
197
  }
163
198
  mergeReturning(expression) {
164
- return `OUTPUT ${expression}`;
199
+ return `OUTPUT ${expression} INTO ${OUTPUT_TABLE}`;
165
200
  }
166
201
  /** A row lock is a hint on the table here, which `rowLocks.placement` says instead of a trailing clause. */
167
202
  lockHint(q) {
@@ -217,9 +252,7 @@ export class MsSqlDialect extends MergeSqlDialect {
217
252
  }
218
253
  /** There is no `CREATE SCHEMA IF NOT EXISTS`, and `CREATE SCHEMA` has to be alone in its batch. */
219
254
  createSchemaSql(schema) {
220
- const literal = escapeSingleQuotes(schema);
221
- const quoted = escapeSingleQuotes(this.escapeId(schema, true));
222
- return `IF SCHEMA_ID(N'${literal}') IS NULL EXEC(N'CREATE SCHEMA ${quoted}')`;
255
+ return `IF SCHEMA_ID(${this.escape(schema)}) IS NULL EXEC(${this.escape(`CREATE SCHEMA ${this.escapeId(schema, true)}`)})`;
223
256
  }
224
257
  /** The estimate the engine already keeps per partition, live without a stats refresh. */
225
258
  estimatedCount(ctx, entity) {
@@ -229,8 +262,19 @@ export class MsSqlDialect extends MergeSqlDialect {
229
262
  ` JOIN sys.schemas s ON s.schema_id = o.schema_id` +
230
263
  ` WHERE p.index_id IN (0, 1) AND o.name = `);
231
264
  ctx.addValue(this.resolveTableAlias(meta));
265
+ // The connection's own default schema where the entity names none, as the introspector reads it.
266
+ const schema = this.resolveSchema(meta);
232
267
  ctx.append(' AND s.name = ');
233
- ctx.addValue(this.resolveSchema(meta) ?? 'dbo');
268
+ if (schema === undefined) {
269
+ ctx.append('SCHEMA_NAME()');
270
+ }
271
+ else {
272
+ ctx.addValue(schema);
273
+ }
274
+ }
275
+ /** Null-safe on every version, `EXCEPT` comparing NULLs as equal: `IS DISTINCT FROM` needs 2022. */
276
+ neExpr(field, ph) {
277
+ return `EXISTS (SELECT ${field} EXCEPT SELECT ${ph})`;
234
278
  }
235
279
  numericCast(expr) {
236
280
  return `TRY_CAST(${expr} AS FLOAT)`;
@@ -25,8 +25,8 @@ export class MsSqlQuerier extends AbstractPoolQuerier {
25
25
  async internalRun(query, values) {
26
26
  const res = await this.#request(values).query(query);
27
27
  return this.buildUpdateResult({
28
- // `rowsAffected` carries one entry per statement, and a `MERGE` upsert emits its `OUTPUT`
29
- // alongside the write, so the counts are summed rather than read at [0].
28
+ // `rowsAffected` carries one entry per statement, and a write runs in a batch of several - the table
29
+ // variable its ids go through, the read handing them back uncounted - so the counts are summed.
30
30
  changes: res.rowsAffected.reduce((total, count) => total + count, 0),
31
31
  rows: decodeWireTypes(res.recordset, res.recordset?.columns),
32
32
  });
@@ -1,6 +1,6 @@
1
1
  import type { AbstractDialect } from '../dialect/abstractDialect.js';
2
2
  import type { VectorCast } from '../dialect/vectorCast.js';
3
- import type { ColumnType, FieldOptions } from '../type/entity.js';
3
+ import type { ColumnType, EntityGetter, FieldMeta, FieldOptions } from '../type/entity.js';
4
4
  import type { CanonicalType, TypeCategory } from './types.js';
5
5
  /** Whether a category is one of the vector types, narrowing it to the cast pgvector names use. */
6
6
  export declare function isVectorCategory(category: TypeCategory | undefined): category is VectorCast;
@@ -51,3 +51,8 @@ export declare function isBreakingTypeChange(from: CanonicalType, to: CanonicalT
51
51
  * Get the UQL ColumnType that best matches a canonical type.
52
52
  */
53
53
  export declare function canonicalToColumnType(type: CanonicalType): ColumnType;
54
+ /**
55
+ * A field's canonical type, taken from the referenced key where the field gave `references` and no
56
+ * `type` (`typeFromReference`), so a foreign key matches the key it points at; `columnType` always wins.
57
+ */
58
+ export declare function resolveColumnCanonicalType(field: FieldMeta, seen?: Set<EntityGetter>): CanonicalType;
@@ -1,4 +1,5 @@
1
1
  // Canonical types, between an engine's SQL types and TypeScript's.
2
+ import { fieldOf, getMeta, soleIdOf } from '../entity/metadata/definition.js';
2
3
  import { QueryRaw } from '../type/index.js';
3
4
  import { columnFamily, isIntegerColumn } from '../util/field.util.js';
4
5
  import { constantSql } from '../util/raw.js';
@@ -456,3 +457,16 @@ export function canonicalToColumnType(type) {
456
457
  return 'sparsevec';
457
458
  }
458
459
  }
460
+ /**
461
+ * A field's canonical type, taken from the referenced key where the field gave `references` and no
462
+ * `type` (`typeFromReference`), so a foreign key matches the key it points at; `columnType` always wins.
463
+ */
464
+ export function resolveColumnCanonicalType(field, seen = new Set()) {
465
+ const hasExplicitType = !!field.columnType || !field.typeFromReference;
466
+ if (!hasExplicitType && field.references && !seen.has(field.references)) {
467
+ seen.add(field.references);
468
+ const referencedMeta = getMeta(field.references());
469
+ return resolveColumnCanonicalType(fieldOf(referencedMeta, soleIdOf(referencedMeta, 'a foreign key')), seen);
470
+ }
471
+ return fieldOptionsToCanonical(field);
472
+ }
@@ -1,8 +1,7 @@
1
- import type { EntityGetter } from '../type/entity.js';
2
- import type { EntityMeta, EntityWhereMeta, FieldMeta, FieldOptions, Type } from '../type/index.js';
1
+ import type { EntityMeta, EntityWhereMeta, FieldOptions, Type } from '../type/index.js';
3
2
  import type { NamingStrategy } from '../type/namingStrategy.js';
4
3
  import { SchemaAST } from './schemaAST.js';
5
- import { type CanonicalType, type ForeignKeyAction } from './types.js';
4
+ import { type ForeignKeyAction } from './types.js';
6
5
  /**
7
6
  * Options for building SchemaAST from entities.
8
7
  */
@@ -34,8 +33,3 @@ export interface BuildSchemaASTOptions {
34
33
  * resolves against a table another entity declares, and an index against the columns of its own.
35
34
  */
36
35
  export declare function buildSchemaAST(entities: readonly Type<object>[], options?: BuildSchemaASTOptions): SchemaAST;
37
- /**
38
- * A field's canonical type, taken from the referenced key where the field gave `references` and no
39
- * `type` (`typeFromReference`), so a foreign key matches the key it points at; `columnType` always wins.
40
- */
41
- export declare function resolveColumnCanonicalType(field: FieldMeta, seen?: Set<EntityGetter>): CanonicalType;
@@ -1,14 +1,12 @@
1
- import { fieldOf, foreignKeysOf, getMeta, soleIdOf } from '../entity/metadata/definition.js';
1
+ import { fieldOf, foreignKeysOf, getMeta } from '../entity/metadata/definition.js';
2
2
  import { declaredIndexes, declaredIndexName, renderIndexColumn } from '../util/ddlExpression.util.js';
3
3
  import { fulltextWeights, textWeightSteps } from '../util/dialect.util.js';
4
- import { isInlinedExpression } from '../util/field.util.js';
5
- import { isSoleIdField } from '../util/field.util.js';
6
- import { isAutoIncrement } from '../util/field.util.js';
4
+ import { isAutoIncrement, isInlinedExpression, isSoleIdField } from '../util/field.util.js';
7
5
  import { definedEntries } from '../util/object.util.js';
8
6
  import { derivedForeignKeyName, derivedIndexName, qualifyName } from '../util/sql.util.js';
9
- import { fieldOptionsToCanonical } from './canonicalType.js';
7
+ import { resolveColumnCanonicalType } from './canonicalType.js';
10
8
  import { createTableNode, SchemaAST } from './schemaAST.js';
11
- import { DEFAULT_FOREIGN_KEY_ACTION, } from './types.js';
9
+ import { DEFAULT_FOREIGN_KEY_ACTION } from './types.js';
12
10
  /**
13
11
  * Build a SchemaAST from entity classes (decorated with `@Entity`, `@Field`, etc.).
14
12
  *
@@ -40,19 +38,6 @@ export function buildSchemaAST(entities, options = {}) {
40
38
  function refuseDdl() {
41
39
  throw new TypeError('building the schema of an entity that declares SQL (a check, a stored computed column, an index expression or predicate) needs a dialect to render it: pass `compileDdl`, as `buildEntityAST` does');
42
40
  }
43
- /**
44
- * A field's canonical type, taken from the referenced key where the field gave `references` and no
45
- * `type` (`typeFromReference`), so a foreign key matches the key it points at; `columnType` always wins.
46
- */
47
- export function resolveColumnCanonicalType(field, seen = new Set()) {
48
- const hasExplicitType = !!field.columnType || !field.typeFromReference;
49
- if (!hasExplicitType && field.references && !seen.has(field.references)) {
50
- seen.add(field.references);
51
- const referencedMeta = getMeta(field.references());
52
- return resolveColumnCanonicalType(fieldOf(referencedMeta, soleIdOf(referencedMeta, 'a foreign key')), seen);
53
- }
54
- return fieldOptionsToCanonical(field);
55
- }
56
41
  /**
57
42
  * Add a table from entity metadata.
58
43
  */
@@ -80,7 +65,8 @@ function addTableFromEntity(ctx, meta) {
80
65
  isPrimaryKey,
81
66
  isAutoIncrement: isAutoIncrement(field, isSoleKey),
82
67
  isUnique: field.unique ?? false,
83
- generatedAs: field.computed && ctx.compileDdl(field.computed, meta.entity),
68
+ // A stamp is filled by a trigger, so it is a column like any other; only `stored: true` generates.
69
+ generatedAs: field.stored === true && field.computed ? ctx.compileDdl(field.computed, meta.entity) : undefined,
84
70
  comment: field.comment,
85
71
  enum: field.enum,
86
72
  table,
@@ -36,7 +36,7 @@ export declare class SqliteDialect extends AbstractSqlDialect {
36
36
  */
37
37
  protected appendDefaultInsertValue(ctx: QueryContext, field: FieldOptions | undefined): void;
38
38
  protected readonly caseInsensitiveMatch = "native";
39
- protected get neOp(): string;
39
+ neExpr(field: string, ph: string): string;
40
40
  normalizeValue(value: unknown): unknown;
41
41
  /**
42
42
  * `OFFSET` is only legal after a `LIMIT` here too, so a bare `$skip` needs one - `-1` being
@@ -17,7 +17,6 @@ function ftsQuery(columns, value) {
17
17
  }
18
18
  /** What SQLite and the engines derived from it have. */
19
19
  export const SQLITE_FEATURES = {
20
- ifNotExists: true,
21
20
  indexIfNotExists: true,
22
21
  schemas: false, // SQLite's namespaces are attached database files, not declared objects
23
22
  dropTableCascade: false,
@@ -41,6 +40,16 @@ export const SQLITE_FEATURES = {
41
40
  narrowVectorTypes: false,
42
41
  vectorTuningNeedsTransaction: false,
43
42
  serialDeclaresPrimaryKey: true,
43
+ triggers: {
44
+ preamble: '',
45
+ assignsRow: false,
46
+ body: 'inline',
47
+ guards: 'clause',
48
+ layout: 'timingFirst',
49
+ rows: 'row',
50
+ scope: 'schema',
51
+ before: true,
52
+ },
44
53
  };
45
54
  export class SqliteDialect extends AbstractSqlDialect {
46
55
  features = SQLITE_FEATURES;
@@ -114,8 +123,8 @@ export class SqliteDialect extends AbstractSqlDialect {
114
123
  // SQLite's `LIKE` already ignores case on both sides, for ASCII - and only ASCII, with or without
115
124
  // `NOCASE`, so folding the pattern here would break the accented text the engine leaves alone.
116
125
  caseInsensitiveMatch = 'native';
117
- get neOp() {
118
- return 'IS NOT';
126
+ neExpr(field, ph) {
127
+ return `${field} IS NOT ${ph}`;
119
128
  }
120
129
  normalizeValue(value) {
121
130
  if (value instanceof Date)
@@ -1,5 +1,5 @@
1
1
  import type { EntityMeta, UpdatePayload } from './entity.js';
2
- import type { Query, QueryConflictPaths, QueryOptions, QueryPage, QuerySearch, RelationQuery } from './query.js';
2
+ import type { Query, QueryConflictPaths, QueryPage, QueryRenderOptions, QuerySearch, RelationQuery } from './query.js';
3
3
  import type { QueryAggMap, QueryAggregate, QueryAggregateOp, QueryGroupMap } from './queryAggregate.js';
4
4
  import type { QueryWhere } from './queryWhere.js';
5
5
  import type { Type } from './utility.js';
@@ -7,7 +7,7 @@ import type { QueryVectorQuery } from './vector.js';
7
7
  /**
8
8
  * comparison options.
9
9
  */
10
- export type QueryComparisonOptions = QueryOptions & {
10
+ export type QueryComparisonOptions = QueryRenderOptions & {
11
11
  /**
12
12
  * Whether this fragment is rendered as an operand of an enclosing `AND`/`OR`/`NOT`. An operand
13
13
  * parenthesizes itself when it emits more than one term, so no fragment ever depends on the
@@ -78,7 +78,6 @@ export type InsertIdSource = 'returning' | 'firstId';
78
78
  * Features of the database engine (SQL syntax layer).
79
79
  */
80
80
  export interface DialectFeatures {
81
- readonly ifNotExists: boolean;
82
81
  readonly indexIfNotExists: boolean;
83
82
  /**
84
83
  * Whether the engine has namespaces a table can sit behind. `false` leaves every table
@@ -176,6 +175,61 @@ export interface SqlDialectFeatures extends DialectFeatures {
176
175
  readonly vectorTuningNeedsTransaction: boolean;
177
176
  /** Whether the serial column type states `PRIMARY KEY` itself, as SQLite's `AUTOINCREMENT` must. */
178
177
  readonly serialDeclaresPrimaryKey: boolean;
178
+ /** How the engine spells a trigger. One value rather than a flag each, as {@link rowLocks} is. */
179
+ readonly triggers: TriggerFeatures;
180
+ }
181
+ /**
182
+ * What a trigger body calls the rows it reads. Row-based engines hand it a record on each side; SQL
183
+ * Server hands it the two tables of the set it touched.
184
+ */
185
+ export type TriggerRowName = 'NEW' | 'OLD' | 'inserted' | 'deleted';
186
+ /** How a dialect spells a trigger, once {@link SqlDialectFeatures.triggers} names its shape. */
187
+ export interface TriggerFeatures {
188
+ /**
189
+ * Where the body lives: a function of its own that the trigger names (the Postgres family), or inside
190
+ * the `CREATE TRIGGER` itself (everywhere else).
191
+ */
192
+ readonly body: 'function' | 'inline';
193
+ /**
194
+ * How it states which rows it fires for: `UPDATE OF` beside a `WHEN` (`'clause'`), or, where there is
195
+ * no usable `WHEN` - the MySQL family, CockroachDB, SQL Server - the same condition wrapping the body,
196
+ * as `IF c THEN ... END IF;` (`'thenEndIf'`) or T-SQL's `IF c BEGIN ... END` (`'beginEnd'`).
197
+ */
198
+ readonly guards: 'clause' | 'thenEndIf' | 'beginEnd';
199
+ /**
200
+ * Whether it fires once per row, with a row on each side, or once per statement over the set it
201
+ * touched. SQL Server is the only one here that is set-based, reading `inserted` and `deleted`.
202
+ */
203
+ readonly rows: 'row' | 'set';
204
+ /**
205
+ * Where a trigger's name is unique, and so what a `DROP` has to name: per table on the Postgres
206
+ * family, which spells `DROP TRIGGER x ON t`, and per schema everywhere else, which spells
207
+ * `DROP TRIGGER x`. Two tables may carry the same trigger name only under `'table'`.
208
+ */
209
+ readonly scope: 'table' | 'schema';
210
+ /**
211
+ * Where the table sits in the statement: after the timing, `BEFORE UPDATE ON t`, or ahead of it and
212
+ * behind an `AS`, `ON t AFTER UPDATE AS` - which is T-SQL's shape and nobody else's.
213
+ */
214
+ readonly layout: 'timingFirst' | 'tableFirst';
215
+ /**
216
+ * What every body opens with, or `''`. T-SQL wants `SET NOCOUNT ON`: a trigger running its own DML
217
+ * otherwise sends a rowcount of its own back, and the client reads that as what the original statement
218
+ * affected. Nothing else here needs a preamble.
219
+ */
220
+ readonly preamble: string;
221
+ /**
222
+ * Whether a body may assign to the row it was handed, `NEW."col" := ...`. SQLite forbids writing `NEW`
223
+ * at all, and SQL Server is handed a set rather than a row, so on both a trigger that fills a column
224
+ * has to restate the row as an `UPDATE` after the write instead.
225
+ */
226
+ readonly assignsRow: boolean;
227
+ /**
228
+ * Whether it can fire before the write, which is what a stamp needs. SQL Server has only `AFTER` and
229
+ * `INSTEAD OF`, and `INSTEAD OF` would make the trigger responsible for performing the write itself,
230
+ * so a `before*` event is refused there rather than silently made to mean something else.
231
+ */
232
+ readonly before: boolean;
179
233
  }
180
234
  /**
181
235
  * What a SQL statement is rendered through, as a `raw` callback and a query context see it:
@@ -186,6 +240,12 @@ export interface SqlQueryDialect {
186
240
  * The SQL dialect name.
187
241
  */
188
242
  readonly dialectName: SqlDialectName;
243
+ /**
244
+ * The engine whose SQL this one also accepts, which is itself unless it is a fork: CockroachDB runs
245
+ * Postgres's PL/pgSQL and MariaDB runs MySQL's. What lets a body, or any other hand-written SQL, be
246
+ * declared once for a family rather than copied per member.
247
+ */
248
+ readonly dialectFamily: SqlDialectName;
189
249
  /**
190
250
  * the escape character for identifiers.
191
251
  */
@@ -193,17 +253,17 @@ export interface SqlQueryDialect {
193
253
  /** What the engine can do. */
194
254
  readonly features: SqlDialectFeatures;
195
255
  /** A read; with `totalAlias`, every row also carries the unpaged match count under that alias. */
196
- find<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, opts?: QueryOptions, totalAlias?: string): void;
256
+ find<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, opts?: QueryRenderOptions, totalAlias?: string): void;
197
257
  /** A count of the records matching the filter, or of those a page of it takes. */
198
- count<E>(ctx: QueryContext, entity: Type<E>, q: QueryPage<E>, opts?: QueryOptions): void;
258
+ count<E>(ctx: QueryContext, entity: Type<E>, q: QueryPage<E>, opts?: QueryRenderOptions): void;
199
259
  /** An insert of one record or many. */
200
- insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryOptions): void;
260
+ insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryRenderOptions): void;
201
261
  /** An update of the records the query matches. */
202
- update<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryOptions): void;
262
+ update<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, payload: UpdatePayload<E>, opts?: QueryRenderOptions): void;
203
263
  /** An upsert of one record or many by their conflict paths. */
204
264
  upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void;
205
265
  /** A delete of the records the query matches, a soft delete where the entity has one. */
206
- delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryOptions): void;
266
+ delete<E>(ctx: QueryContext, entity: Type<E>, q: QuerySearch<E>, opts?: QueryRenderOptions): void;
207
267
  /**
208
268
  * escape an identifier.
209
269
  * @param val the value to be escaped
@@ -237,7 +297,7 @@ export interface SqlQueryDialect {
237
297
  /**
238
298
  * Build an aggregate query.
239
299
  */
240
- aggregate<E, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(ctx: QueryContext, entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): void;
300
+ aggregate<E, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(ctx: QueryContext, entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryRenderOptions): void;
241
301
  /**
242
302
  * Get the placeholder for a parameter at the given index (1-based).
243
303
  * Default: '?' for MySQL/MariaDB/SQLite, '$n' for PostgreSQL.
@@ -1,8 +1,9 @@
1
1
  import type { EnumValues, ForeignKeyAction, IndexType } from '../schema/types.js';
2
+ import type { SqlDialectName } from './dialect.js';
2
3
  import type { FilterOptions, RelationQuery } from './query.js';
3
4
  import type { ColumnRef, QueryRaw, RelationAggregate } from './queryRaw.js';
4
5
  import type { QueryWhere } from './queryWhere.js';
5
- import type { Except, ExactlyOne, IsEqual, IsMany, Json, Scalar, Type, Unpacked, Writable } from './utility.js';
6
+ import type { AtLeastOne, Except, ExactlyOne, IsEqual, IsMany, Json, Scalar, Type, Unpacked, Writable } from './utility.js';
6
7
  import type { VectorDistance, VectorIndexOptions, VectorIndexType } from './vector.js';
7
8
  /** Brands the property an entity is identified by, where it is not `id`, `_id` or `uuid`. */
8
9
  export declare const idKey: unique symbol;
@@ -254,8 +255,12 @@ export type FieldOptions<V = TsTypeOf<FieldType>, E = unknown> = {
254
255
  * subquery a `$count` reads. Both resolve to SQL at registration, so everything downstream sees one.
255
256
  */
256
257
  readonly computed?: ComputedSql<E>;
257
- /** Whether {@link FieldOptions.computed} is a generated column rather than spliced into each read; no query changes either way. */
258
- readonly stored?: boolean;
258
+ /**
259
+ * Where {@link FieldOptions.computed} lives instead of being spliced into each read. `true` makes it a
260
+ * generated column, which takes only an immutable expression; a list of events makes it a stamp a trigger
261
+ * writes on each, whoever writes the row - how `CURRENT_TIMESTAMP` is kept, where `onUpdate` sees only uql's writes.
262
+ */
263
+ readonly stored?: boolean | readonly StampEvent[];
259
264
  readonly updatable?: boolean;
260
265
  readonly eager?: boolean;
261
266
  readonly onInsert?: OnFieldCallback<V>;
@@ -691,11 +696,97 @@ export type EntityMeta<E> = {
691
696
  checks?: EntityCheckMeta<E>[];
692
697
  /** Lifecycle hooks registered via @BeforeInsert, @AfterUpdate, etc. */
693
698
  hooks?: Partial<Record<HookEvent, HookRegistration[]>>;
699
+ /** Triggers the database runs, compiled when the schema is built. */
700
+ triggers?: EntityTriggerMeta<E>[];
694
701
  /** Bumped by every `define*` call, so what is derived from the metadata can tell it changed. */
695
702
  revision: number;
696
703
  /** The revision `getMeta` last finalized, which is what makes finalizing idempotent and re-entrant. */
697
704
  processedAt?: number;
698
705
  };
706
+ /** When the database writes a stamp: as the row is inserted, as it is updated, or both. */
707
+ export type StampEvent = 'insert' | 'update';
708
+ /**
709
+ * The events a trigger fires on: the lifecycle names, minus the upsert pair, which names no event of its
710
+ * own because `ON CONFLICT` fires the insert or the update triggers, and minus `afterLoad`, which is a
711
+ * read. Derived from {@link HookEvent} so the two vocabularies cannot drift.
712
+ */
713
+ export type TriggerEvent = Exclude<HookEvent, 'beforeUpsert' | 'afterUpsert' | 'afterLoad'>;
714
+ /** Both events for one operation, since a trigger's timing never changes which rows it has. */
715
+ type TriggerEventOn<Op extends string> = Extract<TriggerEvent, `before${Op}` | `after${Op}`>;
716
+ /** The events with a row on both sides, the only ones that can say which columns moved. */
717
+ type TriggerUpdateEvent = TriggerEventOn<'Update'>;
718
+ /**
719
+ * The rows an event has, as `where` keys them: the incoming one on an insert, the outgoing one on a
720
+ * delete, both on an update.
721
+ */
722
+ type TriggerRow<Ev extends TriggerEvent> = Ev extends TriggerEventOn<'Insert'> ? '$new' : Ev extends TriggerEventOn<'Delete'> ? '$old' : '$new' | '$old';
723
+ /** One row's refs, rendering `NEW."col"` or `OLD."col"`, or `never` where the event has no such row. */
724
+ type TriggerRowRefs<E, Ev extends TriggerEvent, R extends '$new' | '$old'> = R extends TriggerRow<Ev> ? RefMap<E> : never;
725
+ /**
726
+ * What a trigger runs, over its rows: the incoming row first and the outgoing one second, on every event.
727
+ * The one an event lacks is `never`, so reading it does not compile, and a body reading only the outgoing
728
+ * row - `(_newRow, oldRow)` - serves an update and a delete alike.
729
+ */
730
+ type TriggerBody<E, Ev extends TriggerEvent> = (newRow: TriggerRowRefs<E, Ev, '$new'>, oldRow: TriggerRowRefs<E, Ev, '$old'>) => QueryRaw;
731
+ /**
732
+ * A condition as data: a predicate on each row it names, of the rows the event has, all of which hold.
733
+ * The row is `$`-marked, as the operators inside it are, so it never reads as a field of the entity.
734
+ */
735
+ type TriggerPredicate<E, Ev extends TriggerEvent> = {
736
+ readonly [R in TriggerRow<Ev>]?: EntityPredicate<E>;
737
+ };
738
+ /**
739
+ * The body, the engine's own SQL: one for every engine it reads alike, or a map naming one per engine
740
+ * where they differ, as SQL Server's set-based `inserted`/`deleted` does. A missing entry for the engine
741
+ * in use is refused at `sync`, since an entity is declared without knowing which pool will render it.
742
+ */
743
+ type TriggerRun<E, Ev extends TriggerEvent> = TriggerBody<E, Ev> | Readonly<AtLeastOne<Record<SqlDialectName, TriggerBody<E, Ev>>>>;
744
+ /**
745
+ * A trigger, `{ on: 'beforeUpdate', of: (post) => [post.body], run: (newRow) => raw`...` }`.
746
+ *
747
+ * A list rather than a map keyed by the event, as `checks` and `indexes` are lists: several triggers may
748
+ * share an event, they fire in the order written, and each is named, diffed and dropped by that name.
749
+ */
750
+ export type TriggerOptions<E = unknown> = {
751
+ [Ev in TriggerEvent]: {
752
+ readonly on: Ev;
753
+ /**
754
+ * What to call this trigger within the entity, for a clearer identifier than its event and position.
755
+ * A label, not the identifier: uql prefixes and qualifies what it installs, so it can tell its own
756
+ * objects from hand-written ones and two entities may share a label.
757
+ */
758
+ readonly name?: string;
759
+ /**
760
+ * The columns whose change the trigger waits for, reading as the `UPDATE OF` it renders. Beside it
761
+ * goes a `WHEN` comparing each with `IS DISTINCT FROM`, which is the point of the pair: `UPDATE OF`
762
+ * fires on a column that was merely assigned, and the comparison narrows that to one that moved.
763
+ */
764
+ readonly of?: Ev extends TriggerUpdateEvent ? (refs: RefMap<E>) => readonly ColumnRef<string>[] : never;
765
+ /**
766
+ * A further condition, as the `WHEN` the engine evaluates before entering the body, over the rows the
767
+ * body reads: a predicate on each, `{ $old: { status: 'draft' }, $new: { status: 'published' } }`,
768
+ * rendered on every engine from the one declaration, or SQL off them for what no predicate states.
769
+ */
770
+ readonly where?: TriggerPredicate<E, Ev> | TriggerBody<E, Ev>;
771
+ readonly run: TriggerRun<E, Ev>;
772
+ };
773
+ }[TriggerEvent];
774
+ /**
775
+ * A trigger as entity metadata keeps it: the column callback resolved to keys, and a body widened to
776
+ * take both rows, which the renderer passes whatever the event, each body reading only its own.
777
+ */
778
+ export type EntityTriggerMeta<E = object> = Except<TriggerOptions<E>, 'of' | 'where' | 'run'> & {
779
+ readonly of?: readonly string[];
780
+ readonly where?: TriggerPredicate<E, TriggerUpdateEvent> | TriggerMetaBody<E>;
781
+ readonly run: TriggerMetaBody<E> | Readonly<Partial<Record<SqlDialectName, TriggerMetaBody<E>>>>;
782
+ };
783
+ /**
784
+ * A body as the renderer calls it, with both rows whatever the event. Bivariant, as {@link EntitySql} is,
785
+ * so a body typing the row its event lacks as `never` is still held here.
786
+ */
787
+ export type TriggerMetaBody<E> = {
788
+ run(newRow: RefMap<E>, oldRow: RefMap<E>): QueryRaw;
789
+ }['run'];
699
790
  /**
700
791
  * A table's `CHECK`, `{ where: { balance: { $gte: 0 } } }`, or SQL off the refs,
701
792
  * `{ where: (wallet) => raw`${wallet.spent} <= ${wallet.balance}` }`.
@@ -743,6 +834,8 @@ export type EntityOptions<E = unknown> = {
743
834
  readonly checks?: readonly CheckOptions<E>[];
744
835
  /** Each lifecycle event and the methods it runs, read off the key map: `{ beforeInsert: (post) => [post.stamp] }`. */
745
836
  readonly hooks?: Partial<Record<HookEvent, (keys: KeyMap<E>) => readonly MethodKey<E>[]>>;
837
+ /** Triggers the database runs, in the order written. See {@link TriggerOptions}. */
838
+ readonly triggers?: readonly TriggerOptions<E>[];
746
839
  };
747
840
  /**
748
841
  * Everything an index carries beyond its columns, as the migration builder's `table.index(...)` takes it,