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
@@ -63,6 +63,33 @@ export declare class Migrator {
63
63
  * Generate a migration based on entity schema differences
64
64
  */
65
65
  generateFromEntities(name: string): Promise<string>;
66
+ /**
67
+ * Each entity on a table this plan does not create - a new one carries its triggers in its `CREATE` -
68
+ * beside the triggers uql has installed there, read once per schema. Kept only where either side has
69
+ * any: one declaring none on a table holding none has nothing to reconcile.
70
+ */
71
+ private installedTriggers;
72
+ /**
73
+ * The alters, with the triggers reconciled around them. Postgres refuses to retype or drop a column a
74
+ * trigger names, so every trigger on a table whose columns change comes off before the alters and what
75
+ * its entity declares goes back on after. `down` is lazy: SQLite cannot express every alter's inverse.
76
+ */
77
+ private alterPlan;
78
+ /**
79
+ * Each entity's triggers taken from what the catalogue holds to what it declares. Against the catalogue
80
+ * rather than a diff, because a trigger hangs off a table whose columns may be unchanged: a body edited
81
+ * on a settled table appears in no diff at all.
82
+ */
83
+ private reconcileTriggers;
84
+ /**
85
+ * The inverse: what the reconcile created dropped, and what it dropped restored as the engine reprints
86
+ * it. Read off the catalogue rather than recorded by uql, and exactly right for restoring one.
87
+ */
88
+ private revertedTriggers;
89
+ /** The entities whose tables are among `created`. */
90
+ private createdEntities;
91
+ /** The table `entity` maps to, as a diff names it. */
92
+ private tableOf;
66
93
  /**
67
94
  * Get all schema differences between entities and database
68
95
  */
@@ -82,7 +109,7 @@ export declare class Migrator {
82
109
  * settle instead of colliding; an existing one still pays for introspection, as a diff needs columns.
83
110
  */
84
111
  private planEntity;
85
- /** The same for one entity against the table it already has, and nothing where the two agree. */
112
+ /** The diff for one entity against the table it already has, and none where the two agree. */
86
113
  private alterFromEntity;
87
114
  /** The configured entities, with `entity` among them however the migrator was built. */
88
115
  private entitiesWith;
@@ -3,6 +3,7 @@ import { basename, extname, join } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { getEntities, getMeta } from '../entity/index.js';
5
5
  import { SchemaAST } from '../schema/index.js';
6
+ import { hasTriggers } from '../util/field.util.js';
6
7
  import { LoggerWrapper } from '../util/index.js';
7
8
  import { buildMigrationModule } from './codegen/migrationFile.js';
8
9
  import { introspectorFor } from './introspection/registry.js';
@@ -181,17 +182,17 @@ export class Migrator {
181
182
  async generateFromEntities(name) {
182
183
  const generator = await this.getSchemaGenerator();
183
184
  const { created, altered } = await this.pendingChanges();
184
- const up = [
185
- ...this.createSchema(generator, created),
186
- ...altered.flatMap((diff) => generator.generateAlterTable(diff)),
187
- ];
185
+ const plan = this.alterPlan(generator, altered, await this.installedTriggers(created));
186
+ const up = [...this.createSchema(generator, created), ...plan.up];
188
187
  if (up.length === 0) {
189
188
  this.logger.logInfo('No schema changes detected.');
190
189
  return '';
191
190
  }
192
191
  // Diff by diff in reverse, each rolled back in the order its generator wrote it.
193
192
  const down = [
194
- ...altered.toReversed().flatMap((diff) => generator.generateAlterTableDown(diff)),
193
+ ...plan.down(),
194
+ // A table's drop takes its triggers along, but not the function the Postgres family keeps each body in.
195
+ ...this.createdEntities(created).flatMap((entity) => generator.generateTriggersDown(entity)),
195
196
  ...created.toReversed().map((tableName) => generator.generateDropTable(tableName, { ifExists: true })),
196
197
  ];
197
198
  const { emit } = this.target.source;
@@ -203,6 +204,81 @@ export class Migrator {
203
204
  this.logger.logInfo(`Created migration from entities: ${filePath}`);
204
205
  return filePath;
205
206
  }
207
+ /**
208
+ * Each entity on a table this plan does not create - a new one carries its triggers in its `CREATE` -
209
+ * beside the triggers uql has installed there, read once per schema. Kept only where either side has
210
+ * any: one declaring none on a table holding none has nothing to reconcile.
211
+ */
212
+ async installedTriggers(created, entities = this.entities) {
213
+ const { dialect } = this.pool;
214
+ const fresh = new Set(created);
215
+ // Tables this plan creates are left out: their `CREATE` carries their triggers, and asking the
216
+ // catalogue about a table that is not there yet fails outright on some engines.
217
+ const wanted = entities.filter((entity) => !fresh.has(this.tableOf(entity)));
218
+ const bySchema = new Map();
219
+ const state = [];
220
+ for (const entity of wanted) {
221
+ const meta = getMeta(entity);
222
+ const schema = dialect.resolveSchema(meta);
223
+ let owned = bySchema.get(schema);
224
+ if (!owned) {
225
+ owned = await this.schemaIntrospectorFor(schema).ownedTriggers();
226
+ bySchema.set(schema, owned);
227
+ }
228
+ const installed = owned.get(dialect.resolveTableAlias(meta)) ?? new Map();
229
+ // An installed trigger alone keeps it: an entity that stopped declaring one has it to drop.
230
+ if (installed.size || hasTriggers(meta)) {
231
+ state.push({ entity, installed });
232
+ }
233
+ }
234
+ return state;
235
+ }
236
+ /**
237
+ * The alters, with the triggers reconciled around them. Postgres refuses to retype or drop a column a
238
+ * trigger names, so every trigger on a table whose columns change comes off before the alters and what
239
+ * its entity declares goes back on after. `down` is lazy: SQLite cannot express every alter's inverse.
240
+ */
241
+ alterPlan(generator, altered, state) {
242
+ const changing = new Set(altered.filter((diff) => diff.columnsToAlter?.length || diff.columnsToDrop?.length).map((diff) => diff.tableName));
243
+ const cleared = state.filter(({ entity }) => changing.has(this.tableOf(entity)));
244
+ const after = state.map((it) => (cleared.includes(it) ? { entity: it.entity, installed: new Map() } : it));
245
+ return {
246
+ up: [
247
+ ...cleared.flatMap(({ entity, installed }) => generator.generateTriggerDrops(entity, [...installed.keys()])),
248
+ ...altered.flatMap((diff) => generator.generateAlterTable(diff)),
249
+ ...this.reconcileTriggers(generator, after),
250
+ ],
251
+ down: () => [
252
+ ...this.revertedTriggers(generator, after),
253
+ ...altered.toReversed().flatMap((diff) => generator.generateAlterTableDown(diff)),
254
+ ...cleared.flatMap(({ installed }) => [...installed.values()].flat().map((sql) => `${sql};`)),
255
+ ],
256
+ };
257
+ }
258
+ /**
259
+ * Each entity's triggers taken from what the catalogue holds to what it declares. Against the catalogue
260
+ * rather than a diff, because a trigger hangs off a table whose columns may be unchanged: a body edited
261
+ * on a settled table appears in no diff at all.
262
+ */
263
+ reconcileTriggers(generator, state) {
264
+ return state.flatMap(({ entity, installed }) => generator.generateTriggers(entity, installed));
265
+ }
266
+ /**
267
+ * The inverse: what the reconcile created dropped, and what it dropped restored as the engine reprints
268
+ * it. Read off the catalogue rather than recorded by uql, and exactly right for restoring one.
269
+ */
270
+ revertedTriggers(generator, state) {
271
+ return state.flatMap(({ entity, installed }) => generator.generateTriggersDown(entity, installed));
272
+ }
273
+ /** The entities whose tables are among `created`. */
274
+ createdEntities(created) {
275
+ const fresh = new Set(created);
276
+ return this.entities.filter((entity) => fresh.has(this.tableOf(entity)));
277
+ }
278
+ /** The table `entity` maps to, as a diff names it. */
279
+ tableOf(entity) {
280
+ return this.pool.dialect.resolveTableName(getMeta(entity));
281
+ }
206
282
  /**
207
283
  * Get all schema differences between entities and database
208
284
  */
@@ -269,14 +345,16 @@ export class Migrator {
269
345
  }
270
346
  // With the tables it references, which its foreign keys resolve against.
271
347
  const ast = await this.introspectEntities([entity, ...referencedEntities(meta)]);
272
- return this.alterFromEntity(generator, entity, ast.getTable(tableName), options);
348
+ // The table is already there, so its triggers are reconciled rather than carried by a `CREATE`.
349
+ const altered = this.alterFromEntity(generator, entity, ast.getTable(tableName), options);
350
+ return this.alterPlan(generator, altered, await this.installedTriggers([], [entity])).up;
273
351
  }
274
- /** The same for one entity against the table it already has, and nothing where the two agree. */
352
+ /** The diff for one entity against the table it already has, and none where the two agree. */
275
353
  alterFromEntity(generator, entity, table, options) {
276
354
  // Spanning the set for the reason `planEntity` spells out: a foreign key needs the table it
277
355
  // points at, which a sync of one entity outside the configured list would not otherwise have.
278
356
  const diff = generator.diffSchema(entity, table, generator.buildAST?.(this.entitiesWith(entity)));
279
- return diff?.type === 'alter' ? generator.generateAlterTable(this.filterDiff(diff, options)) : [];
357
+ return diff?.type === 'alter' ? [this.filterDiff(diff, options)] : [];
280
358
  }
281
359
  /** The configured entities, with `entity` among them however the migrator was built. */
282
360
  entitiesWith(entity) {
@@ -300,9 +378,10 @@ export class Migrator {
300
378
  return this.planEntity(generator, options.entity, options);
301
379
  }
302
380
  const { created, altered } = await this.pendingChanges();
381
+ const filtered = altered.map((diff) => this.filterDiff(diff, options));
303
382
  return [
304
383
  ...this.createSchema(generator, created),
305
- ...altered.flatMap((diff) => generator.generateAlterTable(this.filterDiff(diff, options))),
384
+ ...this.alterPlan(generator, filtered, await this.installedTriggers(created)).up,
306
385
  ];
307
386
  }
308
387
  /** The new tables, created together so a foreign key between them, cyclic included, resolves. Empty in, empty out. */
@@ -3,7 +3,7 @@ import type { SchemaAST } from '../schema/schemaAST.js';
3
3
  import { type BuildSchemaASTOptions } from '../schema/schemaASTBuilder.js';
4
4
  import { type DiffOptions } from '../schema/schemaASTDiffer.js';
5
5
  import type { CanonicalType, ColumnNode, ForeignKeyAction, IndexNode, TableNode } from '../schema/types.js';
6
- import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, EntityWhereMeta, FieldMeta, FieldOptions, ForeignKeySchema, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../type/index.js';
6
+ import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, InstalledTriggers, EntityWhereMeta, FieldMeta, FieldOptions, ForeignKeySchema, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../type/index.js';
7
7
  import type { AnyMigrationOperation, FullColumnDefinition, IndexDefinition, TableDefinition } from './builder/types.js';
8
8
  import { type IndexDdl, type TableDdl } from './ddl/index.js';
9
9
  /**
@@ -43,6 +43,17 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
43
43
  * cyclic. SQLite keeps them inline: it cannot add one later, and resolves a forward reference lazily.
44
44
  */
45
45
  generateCreateSchema(entities: readonly Type<object>[], options?: CreateSchemaOptions): string[];
46
+ /**
47
+ * The installed triggers on `entity`'s table it does not declare, and the declared ones not installed,
48
+ * compared by name alone: a name carries a hash of the trigger's SQL, so an edited one is a new name.
49
+ * Every trigger counts - the ones it authored, and one per event each stamp names.
50
+ */
51
+ private triggerChanges;
52
+ generateTriggers(entity: Type<object>, installed?: InstalledTriggers): string[];
53
+ generateTriggersDown(entity: Type<object>, installed?: InstalledTriggers): string[];
54
+ /** `dropped` taken off `entity`'s table and `created` put on, which is a reconcile read either way. */
55
+ private swapTriggers;
56
+ generateTriggerDrops(entity: Type<object>, names: readonly string[]): string[];
46
57
  /**
47
58
  * One statement per distinct schema the tables being created live in, in first-seen order. Only
48
59
  * the tables actually being created, so a narrowed `only` does not declare namespaces it is not
@@ -1,10 +1,10 @@
1
1
  import { getMeta } from '../entity/index.js';
2
- import { canonicalToSql, engineType, isVectorCategory } from '../schema/canonicalType.js';
2
+ import { canonicalToSql, engineType, isVectorCategory, resolveColumnCanonicalType } from '../schema/canonicalType.js';
3
3
  import { indexChanges } from '../schema/indexDifferences.js';
4
- import { buildSchemaAST, resolveColumnCanonicalType } from '../schema/schemaASTBuilder.js';
4
+ import { buildSchemaAST } from '../schema/schemaASTBuilder.js';
5
5
  import { diffRelationshipNodes, diffTable } from '../schema/schemaASTDiffer.js';
6
6
  import { isAutoIncrement, qualifyName } from '../util/index.js';
7
- import { derivedCheckName, derivedForeignKeyName, derivedPrimaryKeyName } from '../util/sql.util.js';
7
+ import { derivedCheckName, derivedForeignKeyName, derivedPrimaryKeyName, isOwnedName } from '../util/sql.util.js';
8
8
  import { formatDefaultValue, SqlExpression } from './builder/expressions.js';
9
9
  import { splitSqlStatements } from './builder/splitSqlStatements.js';
10
10
  import { indexDdlFor, tableDdlFor } from './ddl/index.js';
@@ -12,6 +12,7 @@ import { sizedType } from './ddl/tableDdl.js';
12
12
  import { columnForeignKey, columnIndex, fullColumnDefinitionToNode, renderIndexDefinition, tableDefinitionToNode, } from './generator/definitionToNode.js';
13
13
  import { indexNodeToSchema } from './generator/indexNodeToSchema.js';
14
14
  import { assertIndexPredicate } from './indexPredicate.js';
15
+ import { dropTrigger, renderTrigger, stampTriggers } from './triggerSql.js';
15
16
  /**
16
17
  * Unified SQL schema generator.
17
18
  * Parameterized by dialect to handle Postgres, MySQL, MariaDB, and SQLite.
@@ -98,8 +99,49 @@ export class SqlSchemaGenerator {
98
99
  statements.push(...this.addForeignKeyStatements(qualifyName(table.name, table.schema), table.outgoingRelations.map(foreignKeyOf)));
99
100
  }
100
101
  }
102
+ // Triggers last: each needs its own table, and a body may read any other the same schema just made.
103
+ const made = new Set(tables.map((table) => qualifyName(table.name, table.schema)));
104
+ statements.push(...entities
105
+ .filter((entity) => made.has(this.resolveTableName(getMeta(entity))))
106
+ .flatMap((entity) => this.generateTriggers(entity)));
101
107
  return statements;
102
108
  }
109
+ /**
110
+ * The installed triggers on `entity`'s table it does not declare, and the declared ones not installed,
111
+ * compared by name alone: a name carries a hash of the trigger's SQL, so an edited one is a new name.
112
+ * Every trigger counts - the ones it authored, and one per event each stamp names.
113
+ */
114
+ triggerChanges(entity, installed) {
115
+ const meta = getMeta(entity);
116
+ const triggers = [...(meta.triggers ?? []), ...stampTriggers(this.dialect, meta)];
117
+ const rendered = triggers.map((trigger, i) => renderTrigger(this.dialect, meta, trigger, i));
118
+ const declared = new Set(rendered.map((trigger) => trigger.name));
119
+ return {
120
+ stale: [...installed]
121
+ .filter(([name]) => isOwnedName(name) && !declared.has(name))
122
+ .map(([name, statements]) => ({ name, statements })),
123
+ missing: rendered.filter((trigger) => !installed.has(trigger.name)),
124
+ };
125
+ }
126
+ generateTriggers(entity, installed = new Map()) {
127
+ const { stale, missing } = this.triggerChanges(entity, installed);
128
+ return this.swapTriggers(entity, stale, missing);
129
+ }
130
+ generateTriggersDown(entity, installed = new Map()) {
131
+ const { stale, missing } = this.triggerChanges(entity, installed);
132
+ return this.swapTriggers(entity, missing, stale);
133
+ }
134
+ /** `dropped` taken off `entity`'s table and `created` put on, which is a reconcile read either way. */
135
+ swapTriggers(entity, dropped, created) {
136
+ return [
137
+ ...this.generateTriggerDrops(entity, dropped.map((trigger) => trigger.name)),
138
+ ...created.flatMap((trigger) => trigger.statements.map((sql) => `${sql};`)),
139
+ ];
140
+ }
141
+ generateTriggerDrops(entity, names) {
142
+ const meta = getMeta(entity);
143
+ return names.filter(isOwnedName).flatMap((name) => dropTrigger(this.dialect, meta, name).map((sql) => `${sql};`));
144
+ }
103
145
  /**
104
146
  * One statement per distinct schema the tables being created live in, in first-seen order. Only
105
147
  * the tables actually being created, so a narrowed `only` does not declare namespaces it is not
@@ -457,8 +499,8 @@ export class SqlSchemaGenerator {
457
499
  const refTable = this.dialect.escapeQualifiedId(rel.to.table.name, rel.to.table.schema);
458
500
  constraints.push(this.foreignKeyConstraint(table.name, foreignKeyOf(rel), refTable));
459
501
  }
460
- const ifNotExists = options.ifNotExists && this.features.ifNotExists ? 'IF NOT EXISTS ' : '';
461
- let createSql = `CREATE TABLE ${ifNotExists}${this.dialect.escapeQualifiedId(table.name, table.schema)} (\n`;
502
+ const target = this.dialect.escapeQualifiedId(table.name, table.schema);
503
+ let createSql = `${this.tableDdl.createTable(target, !!options.ifNotExists)} (\n`;
462
504
  createSql += columns.map((col) => ` ${col}`).join(',\n');
463
505
  if (constraints.length > 0) {
464
506
  createSql += ',\n';
@@ -13,6 +13,10 @@ export declare class DatabaseMigrationStorage implements MigrationStorage {
13
13
  tableName?: string;
14
14
  });
15
15
  ensureStorage(): Promise<void>;
16
+ /**
17
+ * Rendered by the schema generator rather than written out, so each engine gets its own spelling: SQL
18
+ * Server has no `CREATE TABLE IF NOT EXISTS`, and its `TIMESTAMP` is a row version that takes no default.
19
+ */
16
20
  private createTableIfNotExists;
17
21
  executed(): Promise<string[]>;
18
22
  /**
@@ -1,4 +1,7 @@
1
1
  import { withSqlQuerierForMigrations } from '../acquireQuerierForMigrations.js';
2
+ import { expr } from '../builder/expressions.js';
3
+ import { TableBuilder } from '../builder/tableBuilder.js';
4
+ import { SqlSchemaGenerator } from '../schemaGenerator.js';
2
5
  /** Where executed migrations are recorded when the config does not name a table. */
3
6
  export const DEFAULT_MIGRATIONS_TABLE = 'uql_migrations';
4
7
  /**
@@ -22,15 +25,18 @@ export class DatabaseMigrationStorage {
22
25
  this.storageInitialized = true;
23
26
  });
24
27
  }
28
+ /**
29
+ * Rendered by the schema generator rather than written out, so each engine gets its own spelling: SQL
30
+ * Server has no `CREATE TABLE IF NOT EXISTS`, and its `TIMESTAMP` is a row version that takes no default.
31
+ */
25
32
  async createTableIfNotExists(querier) {
26
- const { dialect } = querier;
27
- const sql = /*sql*/ `
28
- CREATE TABLE IF NOT EXISTS ${dialect.escapeId(this.tableName)} (
29
- ${dialect.escapeId('name')} VARCHAR(255) PRIMARY KEY,
30
- ${dialect.escapeId('executed_at')} TIMESTAMP DEFAULT CURRENT_TIMESTAMP
31
- )
32
- `;
33
- await querier.run(sql);
33
+ const table = new TableBuilder(this.tableName);
34
+ table.string('name', { length: 255, primaryKey: true });
35
+ table.timestamp('executed_at', { defaultValue: expr.now() });
36
+ const generator = new SqlSchemaGenerator(querier.dialect);
37
+ for (const sql of generator.generateCreateTableFromDefinition(table.build(), { ifNotExists: true })) {
38
+ await querier.run(sql);
39
+ }
34
40
  }
35
41
  async executed() {
36
42
  await this.ensureStorage();
@@ -0,0 +1,24 @@
1
+ import type { AbstractSqlDialect } from '../dialect/index.js';
2
+ import type { EntityMeta, EntityTriggerMeta } from '../type/index.js';
3
+ /** A trigger as uql installs it: the identifier, and the statements creating it under that identifier. */
4
+ export type RenderedTrigger = {
5
+ readonly name: string;
6
+ readonly statements: readonly string[];
7
+ };
8
+ /**
9
+ * One trigger for `dialect`, named for its table and label and ending in a hash of its own SQL. That hash
10
+ * is the whole of change detection: a trigger is in place exactly when its name is installed, and an
11
+ * edited one is a new name, created while the old one drops as no longer declared.
12
+ */
13
+ export declare function renderTrigger<E>(dialect: AbstractSqlDialect, meta: EntityMeta<E>, trigger: EntityTriggerMeta<E>, position: number): RenderedTrigger;
14
+ /**
15
+ * What removes one trigger: the trigger, named with its table only where the engine scopes the name to
16
+ * one, and on the Postgres family the function holding its body, which dropping a trigger leaves behind.
17
+ */
18
+ export declare function dropTrigger<E>(dialect: AbstractSqlDialect, meta: EntityMeta<E>, name: string): string[];
19
+ /**
20
+ * The triggers a stamp needs: one per event it names, each assigning the field's expression to its
21
+ * column. Generated rather than authored, so unlike an authored body it renders on every engine from
22
+ * one declaration - and not at all on the MySQL family, whose columns stamp themselves.
23
+ */
24
+ export declare function stampTriggers<E>(dialect: AbstractSqlDialect, meta: EntityMeta<E>): EntityTriggerMeta<E>[];
@@ -0,0 +1,229 @@
1
+ import { stampEvents } from '../util/field.util.js';
2
+ import { definedEntries } from '../util/object.util.js';
3
+ import { raw, rowColumn, rowRefs } from '../util/raw.js';
4
+ import { ownedName } from '../util/sql.util.js';
5
+ /**
6
+ * One trigger for `dialect`, named for its table and label and ending in a hash of its own SQL. That hash
7
+ * is the whole of change detection: a trigger is in place exactly when its name is installed, and an
8
+ * edited one is a new name, created while the old one drops as no longer declared.
9
+ */
10
+ export function renderTrigger(dialect, meta, trigger, position) {
11
+ const table = dialect.resolveTableAlias(meta);
12
+ const label = trigger.name ?? `${trigger.on}_${position}`;
13
+ const draft = triggerStatements(dialect, meta, trigger, label);
14
+ const name = ownedName(table, label, draft.join('\n'));
15
+ return { name, statements: triggerStatements(dialect, meta, trigger, name) };
16
+ }
17
+ /**
18
+ * What removes one trigger: the trigger, named with its table only where the engine scopes the name to
19
+ * one, and on the Postgres family the function holding its body, which dropping a trigger leaves behind.
20
+ */
21
+ export function dropTrigger(dialect, meta, name) {
22
+ const { scope, body } = dialect.features.triggers;
23
+ const table = dialect.escapeId(dialect.resolveTableName(meta));
24
+ return [
25
+ `DROP TRIGGER IF EXISTS ${triggerId(dialect, meta, name)}${scope === 'table' ? ` ON ${table}` : ''}`,
26
+ ...(body === 'function' ? [`DROP FUNCTION IF EXISTS ${schemaObjectId(dialect, meta, name)}()`] : []),
27
+ ];
28
+ }
29
+ /**
30
+ * A trigger's name as its statements spell it: bare where the engine keeps the name under its table,
31
+ * since Postgres refuses a schema there, and in the table's schema where the engine keeps names per
32
+ * schema, or MySQL would look for it in the connection's database and SQL Server in its default schema.
33
+ */
34
+ function triggerId(dialect, meta, name) {
35
+ return dialect.features.triggers.scope === 'table' ? dialect.escapeId(name) : schemaObjectId(dialect, meta, name);
36
+ }
37
+ /** A name in the schema of `meta`'s table, where uql keeps whatever it installs beside it. */
38
+ function schemaObjectId(dialect, meta, name) {
39
+ return dialect.escapeQualifiedId(name, dialect.resolveSchema(meta));
40
+ }
41
+ /**
42
+ * The statements creating one trigger under `name`. Every difference between engines is read off
43
+ * `dialect.features.triggers` rather than branched on a dialect name, so a seventh engine states its
44
+ * shape and renders here unchanged.
45
+ */
46
+ function triggerStatements(dialect, meta, trigger, name) {
47
+ const features = dialect.features.triggers;
48
+ // The event, read apart once: `beforeUpdate` is `BEFORE` and `UPDATE`, and everything else follows.
49
+ const before = trigger.on.startsWith('before');
50
+ const operation = trigger.on.slice(before ? 6 : 5).toUpperCase();
51
+ const body = triggerBody(dialect, meta, trigger, before);
52
+ const id = triggerId(dialect, meta, name);
53
+ const table = dialect.escapeId(dialect.resolveTableName(meta));
54
+ const column = (key) => dialect.escapeId(dialect.columnOf(meta, key));
55
+ const names = rowNames(dialect);
56
+ const rows = [rowRefs(names.$new), rowRefs(names.$old)];
57
+ const sql = dialect.compileDdl(body(...rows), meta.entity);
58
+ const guard = triggerGuard(dialect, meta, trigger, rows, names, column);
59
+ const inBody = features.guards !== 'clause';
60
+ const guarded = !guard || !inBody
61
+ ? sql
62
+ : features.guards === 'beginEnd'
63
+ ? `IF ${guard}\nBEGIN\n${sql}\nEND`
64
+ : `IF ${guard} THEN\n${sql}\nEND IF;`;
65
+ // The preamble opens the body, outside the guard: it settles how the batch reports itself rather than
66
+ // which rows are touched, so it runs even when the guard keeps the statements from running.
67
+ const opened = features.preamble ? `${features.preamble}\n${guarded}` : guarded;
68
+ const of = features.guards === 'clause' && operation === 'UPDATE' && trigger.of?.length
69
+ ? ` OF ${trigger.of.map(column).join(', ')}`
70
+ : '';
71
+ const each = features.rows === 'set' ? '' : '\nFOR EACH ROW';
72
+ const clause = guard && !inBody ? `\nWHEN (${guard})` : '';
73
+ const timing = `${before ? 'BEFORE' : 'AFTER'} ${operation}${of}`;
74
+ const header = features.layout === 'tableFirst'
75
+ ? `CREATE TRIGGER ${id}\nON ${table} ${timing}${each}${clause}\nAS`
76
+ : `CREATE TRIGGER ${id}\n${timing} ON ${table}${each}${clause}`;
77
+ if (features.body !== 'function') {
78
+ return [`${header}\nBEGIN\n${opened}\nEND`];
79
+ }
80
+ // A `BEFORE` trigger returning NULL discards the write, so it hands back the row it leaves behind;
81
+ // after the write the value is ignored.
82
+ const returned = before ? (operation === 'DELETE' ? names.$old : names.$new) : 'NULL';
83
+ const fn = schemaObjectId(dialect, meta, name);
84
+ return [plpgsqlFunction(fn, `BEGIN\n${opened}\nRETURN ${returned};\nEND`), `${header}\nEXECUTE FUNCTION ${fn}()`];
85
+ }
86
+ /**
87
+ * The body for the engine in use, refusing first what the engine cannot render at all. One body serves
88
+ * every engine; in a map, one written for the family serves its forks: CockroachDB runs Postgres's
89
+ * PL/pgSQL, MariaDB MySQL's.
90
+ */
91
+ function triggerBody(dialect, meta, trigger, before) {
92
+ const features = dialect.features.triggers;
93
+ if (before && !features.before) {
94
+ throw new TypeError(`${dialect.dialectName} has no BEFORE trigger, only AFTER and INSTEAD OF, so '${trigger.on}' cannot be ` +
95
+ 'rendered there. Use the matching after event, which sees the row already written.');
96
+ }
97
+ if (trigger.where && features.rows === 'set') {
98
+ throw new TypeError(`${dialect.dialectName} fires a trigger once per statement, over the rows it touched, so no condition ` +
99
+ `can read one row: '${meta.entity.name}' cannot state a trigger 'where' there. Guard inside the body ` +
100
+ 'instead, where `inserted` and `deleted` can be read as tables.');
101
+ }
102
+ const { run } = trigger;
103
+ const body = typeof run === 'function' ? run : (run[dialect.dialectName] ?? run[dialect.dialectFamily]);
104
+ if (!body) {
105
+ throw new TypeError(`'${meta.entity.name}' has a trigger with no body for ${dialect.dialectName}, the engine in use. ` +
106
+ 'Write one for it, or one body for every engine.');
107
+ }
108
+ return body;
109
+ }
110
+ /** The one condition both guards reduce to: any watched column that moved, and whatever `where` asks. */
111
+ function triggerGuard(dialect, meta, trigger, rows, names, column) {
112
+ const moved = movedColumns(dialect, meta, trigger.of ?? [], names, column);
113
+ return [
114
+ ...(moved ? [moved] : []),
115
+ ...(trigger.where ? condition(dialect, meta, trigger.where, rows, names, Boolean(moved)) : []),
116
+ ].join(' AND ');
117
+ }
118
+ /** The PL/pgSQL function holding a body. `OR REPLACE`, since a dropped table leaves its function behind. */
119
+ function plpgsqlFunction(id, block) {
120
+ const quote = dollarQuote(block);
121
+ return `CREATE OR REPLACE FUNCTION ${id}() RETURNS trigger AS ${quote}\n${block} ${quote} LANGUAGE plpgsql`;
122
+ }
123
+ /** What the engine calls the rows it hands a trigger: records on a row-based engine, tables on a set-based one. */
124
+ function rowNames(dialect) {
125
+ return dialect.features.triggers.rows === 'set'
126
+ ? { $new: 'inserted', $old: 'deleted' }
127
+ : { $new: 'NEW', $old: 'OLD' };
128
+ }
129
+ /**
130
+ * The `where` guard as the terms an `AND` joins. A callback writes its own off the rows; a predicate
131
+ * renders a term per row it names, spelled verbatim because `NEW` is a record the engine declares. Each
132
+ * is an `operand` wherever another term sits beside it, bracketing itself if compound, as `$where` does.
133
+ */
134
+ function condition(dialect, meta, where, rows, names, operand) {
135
+ if (typeof where === 'function') {
136
+ const sql = dialect.compileDdl(where(...rows), meta.entity);
137
+ return [operand ? `(${sql})` : sql];
138
+ }
139
+ const predicates = definedEntries(where);
140
+ return predicates.map(([row, predicate]) => {
141
+ const ctx = dialect.createContext({ inlineValues: true });
142
+ const escapedPrefix = `${names[row]}.`;
143
+ dialect.where(ctx, meta.entity, predicate, {
144
+ clause: false,
145
+ operand: operand || predicates.length > 1,
146
+ escapedPrefix,
147
+ });
148
+ return ctx.sql;
149
+ });
150
+ }
151
+ /**
152
+ * The triggers a stamp needs: one per event it names, each assigning the field's expression to its
153
+ * column. Generated rather than authored, so unlike an authored body it renders on every engine from
154
+ * one declaration - and not at all on the MySQL family, whose columns stamp themselves.
155
+ */
156
+ export function stampTriggers(dialect, meta) {
157
+ const features = dialect.features.triggers;
158
+ // Restating the row has to wait for it to be there, so those engines stamp after the write.
159
+ const after = !features.assignsRow;
160
+ const newName = rowNames(dialect).$new;
161
+ return definedEntries(meta.fields).flatMap(([key, field]) => {
162
+ const events = stampEvents(field);
163
+ const { computed } = field;
164
+ if (!events?.length || !computed) {
165
+ return [];
166
+ }
167
+ return events.map((event) => ({
168
+ on: STAMP_EVENTS[after ? 'after' : 'before'][event],
169
+ // The event is part of the name: a stamp on both writes installs two triggers, and one identifier
170
+ // between them would have the second replace the first rather than sit beside it.
171
+ name: `${key}_${event}`,
172
+ run: () => stampBody(dialect, meta, key, computed, newName),
173
+ }));
174
+ });
175
+ }
176
+ /** The trigger event a stamp fires on, by when the engine lets it write and what it stamps. */
177
+ const STAMP_EVENTS = {
178
+ before: { insert: 'beforeInsert', update: 'beforeUpdate' },
179
+ after: { insert: 'afterInsert', update: 'afterUpdate' },
180
+ };
181
+ /** The one statement a stamp runs, in whichever of the two shapes the engine leaves open. */
182
+ function stampBody(dialect, meta, key, value, newName) {
183
+ const features = dialect.features.triggers;
184
+ const read = (member) => rowColumn(newName, member);
185
+ if (features.assignsRow) {
186
+ const target = read(key);
187
+ return features.body === 'function' ? raw `${target} := ${value};` : raw `SET ${target} = ${value};`;
188
+ }
189
+ const table = dialect.escapeId(dialect.resolveTableName(meta));
190
+ const column = dialect.escapeId(dialect.columnOf(meta, key));
191
+ const keyed = meta.ids
192
+ .map((id) => raw `${text(`${table}.${dialect.escapeId(dialect.columnOf(meta, id))}`)} = ${read(id)}`)
193
+ .reduce((all, part) => raw `${all} AND ${part}`);
194
+ // A set-based engine hands the rows as a table, which an `UPDATE` has to name in a `FROM` before its
195
+ // condition can read one: `inserted."id"` binds to nothing on its own.
196
+ const from = features.rows === 'set' ? ` FROM ${newName}` : '';
197
+ // Only where the stamp still differs: this `UPDATE` fires the trigger again, and with recursive triggers
198
+ // on, the restatement it runs then finds nothing left to change instead of recursing without end.
199
+ const differs = dialect.neExpr(`${table}.${column}`, dialect.compileDdl(value, meta.entity));
200
+ return raw `UPDATE ${text(table)} SET ${text(column)} = ${value}${text(from)} WHERE ${keyed} AND ${text(differs)};`;
201
+ }
202
+ /** A dollar quote the body does not contain, so no `$$` in it - a literal, a comment - ends the function early. */
203
+ function dollarQuote(body) {
204
+ let tag = '$uql$';
205
+ for (let i = 1; body.includes(tag); i++) {
206
+ tag = `$uql${i}$`;
207
+ }
208
+ return tag;
209
+ }
210
+ /** SQL already written out, for the identifiers a statement splices rather than binds. */
211
+ function text(sql) {
212
+ return raw((opts) => opts.ctx.append(sql));
213
+ }
214
+ /**
215
+ * Whether any watched column moved, null-safely, or `undefined` where none is watched: the two records
216
+ * compared on a row-based engine, and on a set-based one the same question over a join of its two tables.
217
+ */
218
+ function movedColumns(dialect, meta, of, { $new: newName, $old: oldName }, column) {
219
+ if (!of.length) {
220
+ return undefined;
221
+ }
222
+ const differs = of.map((key) => dialect.neExpr(`${oldName}.${column(key)}`, `${newName}.${column(key)}`));
223
+ const moved = differs.length > 1 ? `(${differs.join(' OR ')})` : differs.join('');
224
+ if (dialect.features.triggers.rows === 'row') {
225
+ return moved;
226
+ }
227
+ const keyed = meta.ids.map((id) => `${newName}.${column(id)} = ${oldName}.${column(id)}`).join(' AND ');
228
+ return `EXISTS (SELECT 1 FROM ${newName} JOIN ${oldName} ON ${keyed} WHERE ${moved})`;
229
+ }