uql-orm 0.87.0 → 0.89.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 (44) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +2 -2
  3. package/dist/cockroachdb/cockroachDialect.js +1 -1
  4. package/dist/dialect/abstractSqlDialect.d.ts +4 -4
  5. package/dist/dialect/abstractSqlDialect.js +8 -4
  6. package/dist/dialect/mysqlLikeSqlDialect.js +2 -5
  7. package/dist/dialect/pgLikeSqlDialect.js +2 -5
  8. package/dist/migrate/ddl/tableDdl.d.ts +5 -0
  9. package/dist/migrate/ddl/tableDdl.js +14 -5
  10. package/dist/migrate/ddl/tableRebuild.d.ts +17 -0
  11. package/dist/migrate/ddl/tableRebuild.js +56 -0
  12. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +3 -1
  13. package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +7 -1
  14. package/dist/migrate/introspection/baseSqlIntrospector.js +4 -1
  15. package/dist/migrate/introspection/sqliteIntrospector.d.ts +3 -3
  16. package/dist/migrate/introspection/sqliteIntrospector.js +9 -7
  17. package/dist/migrate/migrationTarget.js +27 -1
  18. package/dist/migrate/migrator.d.ts +7 -1
  19. package/dist/migrate/migrator.js +49 -6
  20. package/dist/migrate/schemaChange.d.ts +12 -1
  21. package/dist/migrate/schemaChange.js +27 -0
  22. package/dist/migrate/schemaGenerator.d.ts +16 -4
  23. package/dist/migrate/schemaGenerator.js +76 -37
  24. package/dist/migrate/triggerSql.js +41 -47
  25. package/dist/mongo/mongoDialect.js +1 -3
  26. package/dist/mssql/mssqlDialect.js +2 -5
  27. package/dist/schema/indexDifferences.d.ts +2 -1
  28. package/dist/schema/indexDifferences.js +2 -1
  29. package/dist/schema/schemaAST.js +1 -0
  30. package/dist/schema/types.d.ts +5 -1
  31. package/dist/sqlite/sqliteDialect.d.ts +0 -1
  32. package/dist/sqlite/sqliteDialect.js +2 -6
  33. package/dist/type/dialect.d.ts +19 -21
  34. package/dist/type/migration.d.ts +21 -0
  35. package/dist/type/queryRaw.d.ts +11 -4
  36. package/dist/type/queryRaw.js +7 -0
  37. package/dist/type/queryWhere.d.ts +16 -6
  38. package/dist/util/index.d.ts +1 -1
  39. package/dist/util/index.js +1 -1
  40. package/dist/util/raw.js +4 -2
  41. package/dist/util/triggerWrite.d.ts +3 -1
  42. package/dist/util/triggerWrite.js +4 -3
  43. package/package.json +1 -1
  44. package/skills/uql-orm/SKILL.md +2 -2
@@ -7,10 +7,11 @@ import { columnRenames, tableRenameCandidates } from '../schema/schemaASTDiffer.
7
7
  import { hasTriggers } from '../util/field.util.js';
8
8
  import { LoggerWrapper } from '../util/index.js';
9
9
  import { UqlUsageError } from '../util/uqlError.js';
10
+ import { withSqlQuerierForMigrations } from './acquireQuerierForMigrations.js';
10
11
  import { buildMigrationModule } from './codegen/migrationFile.js';
11
12
  import { introspectorFor } from './introspection/registry.js';
12
13
  import { migrationBuilderFor, migrationTargetFor } from './migrationTarget.js';
13
- import { dropped, nonEmpty, reverseDiff, sides } from './schemaChange.js';
14
+ import { dropped, lacksValue, newlyRequired, nonEmpty, reverseDiff, sides, withoutRebuild } from './schemaChange.js';
14
15
  /**
15
16
  * Main class for managing database migrations
16
17
  */
@@ -185,6 +186,7 @@ export class Migrator {
185
186
  async generateFromEntities(name) {
186
187
  const generator = await this.getSchemaGenerator();
187
188
  const { created, altered } = await this.pendingChanges({ renames: true });
189
+ await this.assertFillable(altered);
188
190
  const plan = this.alterPlan(generator, altered, await this.installedTriggers(created));
189
191
  await this.noteChanges(generator, created, altered);
190
192
  const up = [...this.createSchema(generator, created), ...plan.up];
@@ -237,7 +239,7 @@ export class Migrator {
237
239
  */
238
240
  alterPlan(generator, altered, state) {
239
241
  const changing = new Set(altered
240
- .filter((diff) => sides(diff.columns, 'from').length || diff.renamedColumns?.length)
242
+ .filter((diff) => sides(diff.columns, 'from').length || diff.renamedColumns?.length || diff.rebuild)
241
243
  .map((diff) => diff.tableName));
242
244
  const cleared = state.filter(({ entity }) => changing.has(this.tableOf(entity)));
243
245
  const after = state.map((it) => (cleared.includes(it) ? { entity: it.entity, installed: new Map() } : it));
@@ -290,6 +292,35 @@ export class Migrator {
290
292
  `renamed, replace its creation in this migration with \`renameTable('${from}', '${to}')\`.`);
291
293
  }
292
294
  }
295
+ /**
296
+ * Refuses, before anything runs, each column the changes require with no default while rows would hold
297
+ * none: every engine fails on one but MySQL, which fills in a zero. Counted, since an empty table is fine.
298
+ */
299
+ async assertFillable(altered) {
300
+ // A renamed column is identical but for its name, so the one counted is never renamed too.
301
+ const counts = altered.flatMap(({ tableName, columns }) => newlyRequired(columns)
302
+ .filter(({ to }) => lacksValue(to))
303
+ .map(({ from, to }) => ({ tableName, column: to.name, nullable: from })));
304
+ if (!counts.length) {
305
+ return;
306
+ }
307
+ const unfilled = await withSqlQuerierForMigrations(this.pool, 'Migrator', async (querier) => {
308
+ const escapeId = (name) => querier.dialect.escapeId(name);
309
+ const found = [];
310
+ for (const { tableName, column, nullable } of counts) {
311
+ const empty = nullable ? ` WHERE ${escapeId(column)} IS NULL` : '';
312
+ const [{ rows }] = await querier.all(`SELECT COUNT(*) AS ${escapeId('rows')} FROM ${escapeId(tableName)}${empty}`);
313
+ const count = Number(rows);
314
+ if (count) {
315
+ found.push(`"${tableName}"."${column}" is required with no default, and ${count} ${count === 1 ? 'row holds' : 'rows hold'} none`);
316
+ }
317
+ }
318
+ return found;
319
+ });
320
+ if (unfilled.length) {
321
+ throw new UqlUsageError(`${unfilled.join('; ')}. Declare a default, or add the column nullable, fill it, then require it.`);
322
+ }
323
+ }
293
324
  /** The tables the database holds that no entity names, each paired with a new one it is identical to. */
294
325
  async renamedTables(generator, created) {
295
326
  const createdEntities = this.createdEntities(created);
@@ -386,6 +417,7 @@ export class Migrator {
386
417
  const ast = await this.introspectEntities([entity, ...referencedEntities(meta)]);
387
418
  // The table is already there, so its triggers are reconciled rather than carried by a `CREATE`.
388
419
  const altered = this.alterFromEntity(generator, entity, ast.getTable(tableName), options);
420
+ await this.assertFillable(altered);
389
421
  return this.alterPlan(generator, altered, await this.installedTriggers([], [entity])).up;
390
422
  }
391
423
  /** The diff for one entity against the table it already has, and none where the two agree. */
@@ -418,6 +450,7 @@ export class Migrator {
418
450
  }
419
451
  const { created, altered } = await this.pendingChanges();
420
452
  const filtered = altered.map((diff) => this.filterDiff(diff, options));
453
+ await this.assertFillable(filtered);
421
454
  return [
422
455
  ...this.createSchema(generator, created),
423
456
  ...this.alterPlan(generator, filtered, await this.installedTriggers(created)).up,
@@ -442,12 +475,15 @@ export class Migrator {
442
475
  /**
443
476
  * Safe mode only adds: a change with a `from` drops or rebuilds what the table holds, so it is held,
444
477
  * and so is a key whole, which rebuilds an index over every row and fails where a column holds a null.
445
- * Without `drop`, a column's drop is held too.
478
+ * Without `drop`, a column's drop is held too. A rebuilt table applies its diff whole, so holding any
479
+ * part of it holds the rebuild, and only what an `ALTER` adds goes ahead: a plain column, an index.
446
480
  */
447
481
  filterDiff(diff, options) {
448
482
  const safe = options.safe !== false;
483
+ let held = false;
449
484
  const skip = (what, names, fix) => {
450
485
  if (names.length) {
486
+ held = true;
451
487
  this.logger.logSkippedMigration(`[AutoSync] Skipped ${names.length} ${what} in table '${diff.tableName}': ${names.join(', ')} (${fix}).`);
452
488
  }
453
489
  };
@@ -466,13 +502,18 @@ export class Migrator {
466
502
  if (!options.drop) {
467
503
  skip('column drops', dropped(columns).map((column) => column.name), 'drop: false. Use { drop: true } to apply');
468
504
  }
469
- return {
505
+ const filtered = {
470
506
  ...diff,
471
507
  primaryKey: safe ? undefined : diff.primaryKey,
472
508
  columns: options.drop ? columns : nonEmpty((columns ?? []).filter((change) => change.to !== undefined)),
473
509
  indexes: additive('index', diff.indexes, (index) => index.name),
474
510
  foreignKeys: additive('foreign key', diff.foreignKeys, (foreignKey) => foreignKey.name ?? foreignKey.columns.join(', ')),
475
511
  };
512
+ if (!diff.rebuild || !held) {
513
+ return filtered;
514
+ }
515
+ skip('rebuild', [diff.tableName], 'it applies the whole diff, and part of it is held');
516
+ return withoutRebuild(filtered);
476
517
  }
477
518
  /** Runs the statements a generator wrote, in one transaction where the engine takes DDL in one. */
478
519
  async executeSyncStatements(statements, options) {
@@ -599,6 +640,8 @@ function referencedEntities(meta) {
599
640
  return [...fields, ...relations];
600
641
  }
601
642
  /** A diff's column renames, through the builder operation every SQL generator already renders. */
602
- function renameStatements(generator, { tableName, renamedColumns = [] }) {
603
- return renamedColumns.flatMap(({ from, to }) => generator.generateOperation({ type: 'renameColumn', tableName, oldName: from, newName: to }));
643
+ function renameStatements(generator, { tableName, renamedColumns = [], rebuild }) {
644
+ return rebuild
645
+ ? []
646
+ : renamedColumns.flatMap(({ from, to }) => generator.generateOperation({ type: 'renameColumn', tableName, oldName: from, newName: to }));
604
647
  }
@@ -1,4 +1,4 @@
1
- import type { Change, SchemaDiff } from '../type/index.js';
1
+ import type { Change, ColumnSchema, SchemaDiff } from '../type/index.js';
2
2
  /** Each change's end on `side`, where it has one: what a drop half removes (`from`), or an add half creates (`to`). */
3
3
  export declare function sides<T>(changes: readonly Change<T>[] | undefined, side: 'from' | 'to'): T[];
4
4
  /** What the changes add: each `to` with no `from`. */
@@ -10,6 +10,17 @@ export declare function alterations<T>(changes?: readonly Change<T>[]): {
10
10
  readonly from: T;
11
11
  readonly to: T;
12
12
  }[];
13
+ /** Each column `changes` make required on the rows already there: added so, or no longer nullable. */
14
+ export declare function newlyRequired(changes?: readonly Change<ColumnSchema>[]): {
15
+ readonly from?: ColumnSchema;
16
+ readonly to: ColumnSchema;
17
+ }[];
18
+ /** Whether a row already in the table would hold nothing in `column`: required, with no default, and not one the engine fills. */
19
+ export declare function lacksValue(column: ColumnSchema): boolean;
20
+ /** Whether `diff` holds anything an engine that rebuilds tables makes no other way, a key or a foreign key included. */
21
+ export declare function needsRebuild(diff: SchemaDiff): boolean;
22
+ /** `diff` less its rebuild and everything only a rebuild makes: what an `ALTER` can still apply alone. */
23
+ export declare function withoutRebuild(diff: SchemaDiff): SchemaDiff;
13
24
  /** `items`, or nothing where it has none, so an empty change list is left off a diff. */
14
25
  export declare function nonEmpty<T>(items: readonly T[]): readonly T[] | undefined;
15
26
  /** `change` undone: an add becomes a drop, a drop an add, and an alter runs the other way. */
@@ -17,6 +17,32 @@ export function dropped(changes = []) {
17
17
  export function alterations(changes = []) {
18
18
  return changes.flatMap(({ from, to }) => (from === undefined || to === undefined ? [] : [{ from, to }]));
19
19
  }
20
+ /** Each column `changes` make required on the rows already there: added so, or no longer nullable. */
21
+ export function newlyRequired(changes = []) {
22
+ return changes.flatMap(({ from, to }) => (to && !to.nullable && (!from || from.nullable) ? [{ from, to }] : []));
23
+ }
24
+ /** Whether a row already in the table would hold nothing in `column`: required, with no default, and not one the engine fills. */
25
+ export function lacksValue(column) {
26
+ return !column.nullable && column.defaultValue === undefined && !column.generatedAs && !column.isAutoIncrement;
27
+ }
28
+ /** Whether an engine that rebuilds tables makes `change` no other way: a column changed in place, or a stored generated one added. */
29
+ function onlyRebuilt({ from, to }) {
30
+ return from === undefined ? Boolean(to?.generatedAs) : to !== undefined;
31
+ }
32
+ /** Whether `diff` holds anything an engine that rebuilds tables makes no other way, a key or a foreign key included. */
33
+ export function needsRebuild(diff) {
34
+ return Boolean(diff.primaryKey || diff.foreignKeys || diff.columns?.some(onlyRebuilt));
35
+ }
36
+ /** `diff` less its rebuild and everything only a rebuild makes: what an `ALTER` can still apply alone. */
37
+ export function withoutRebuild(diff) {
38
+ return {
39
+ ...diff,
40
+ primaryKey: undefined,
41
+ foreignKeys: undefined,
42
+ columns: nonEmpty((diff.columns ?? []).filter((change) => !onlyRebuilt(change))),
43
+ rebuild: undefined,
44
+ };
45
+ }
20
46
  /** `items`, or nothing where it has none, so an empty change list is left off a diff. */
21
47
  export function nonEmpty(items) {
22
48
  return items.length ? items : undefined;
@@ -34,5 +60,6 @@ export function reverseDiff(diff) {
34
60
  indexes: diff.indexes?.map(swap),
35
61
  foreignKeys: diff.foreignKeys?.map(swap),
36
62
  renamedColumns: diff.renamedColumns?.map(({ from, to }) => ({ from: to, to: from })),
63
+ rebuild: diff.rebuild && { from: diff.rebuild.to, to: diff.rebuild.from },
37
64
  };
38
65
  }
@@ -75,6 +75,11 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
75
75
  * index along with its column, which would leave nothing to name. An alter is its drop, then its add.
76
76
  */
77
77
  generateAlterTable(diff: SchemaDiff): string[];
78
+ /**
79
+ * Each column the changes make required while declaring a default, and that default as SQL: the rows
80
+ * already there hold a null it has to replace, and it is the only value the entity says it may take.
81
+ */
82
+ private defaultFills;
78
83
  /** `ADD CONSTRAINT` for each of `foreignKeys`. */
79
84
  private addForeignKeyStatements;
80
85
  /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
@@ -120,6 +125,12 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
120
125
  * drift detection runs, with types normalized as the engine stores them.
121
126
  */
122
127
  diffSchema(entity: Type<object>, currentTable: TableNode | undefined, desiredAst?: SchemaAST, renamedColumns?: readonly Rename[]): SchemaDiff | undefined;
128
+ /**
129
+ * Both ends of rebuilding `actual` as `desired`. The new table is the entity's, keeping what it cannot
130
+ * know of: the indexes and triggers uql did not make, and foreign keys to tables no entity names. The
131
+ * old one is the engine's own statements, so a rollback restores it exactly, checks included.
132
+ */
133
+ private rebuildOf;
123
134
  diffOptions(): DiffOptions;
124
135
  /** Spread, not copied field by field, so a field the node gains cannot go missing here. */
125
136
  private columnNodeToSchema;
@@ -172,12 +183,13 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
172
183
  * generator added it. MySQL takes no name.
173
184
  */
174
185
  generateDropPrimaryKeySql(tableName: string, constraintName?: string): string;
186
+ /** A stored generated column, which an engine that rebuilds tables takes only in a `CREATE TABLE`. */
187
+ private assertColumnAddable;
175
188
  /**
176
- * A column an `ALTER` can carry. Only a generated one is ever refused, and only where the engine
177
- * takes it in a `CREATE TABLE` but not afterwards.
189
+ * Refuses `what` where the engine makes it only by rebuilding the table, which a migration generated
190
+ * from the entities does and a lone statement of the builder cannot.
178
191
  */
179
- private assertColumnAddable;
180
- private assertPrimaryKeyAlterable;
192
+ private assertAlterable;
181
193
  }
182
194
  /**
183
195
  * The entities as an AST, named by `generator`'s resolvers rather than a naming strategy, which would
@@ -6,14 +6,15 @@ import { diffRelationshipNodes, diffTable } from '../schema/schemaASTDiffer.js';
6
6
  import { isAutoIncrement, qualifyName } from '../util/index.js';
7
7
  import { derivedCheckName, derivedForeignKeyName, derivedPrimaryKeyName, isOwnedName } from '../util/sql.util.js';
8
8
  import { UqlUsageError } from '../util/uqlError.js';
9
- import { sameDefault } from './builder/expressions.js';
9
+ import { formatDefaultValue, sameDefault } from './builder/expressions.js';
10
10
  import { splitSqlStatements } from './builder/splitSqlStatements.js';
11
11
  import { indexDdlFor, tableDdlFor } from './ddl/index.js';
12
12
  import { sizedType } from './ddl/tableDdl.js';
13
+ import { rebuildTable } from './ddl/tableRebuild.js';
13
14
  import { columnForeignKey, columnIndex, fullColumnDefinitionToNode, renderIndexDefinition, tableDefinitionToNode, } from './generator/definitionToNode.js';
14
15
  import { indexNodeToSchema } from './generator/indexNodeToSchema.js';
15
16
  import { assertIndexPredicate } from './indexPredicate.js';
16
- import { added, alterations, dropped, nonEmpty, sides } from './schemaChange.js';
17
+ import { added, alterations, dropped, needsRebuild, newlyRequired, nonEmpty, sides } from './schemaChange.js';
17
18
  import { dropTrigger, renderTrigger, stampTriggers } from './triggerSql.js';
18
19
  /**
19
20
  * Unified SQL schema generator.
@@ -92,7 +93,7 @@ export class SqlSchemaGenerator {
92
93
  const withForeignKeys = options.foreignKeys ?? true;
93
94
  // Inline only where a constraint cannot be added afterwards, which is what makes the cyclic case
94
95
  // work everywhere else.
95
- const inline = withForeignKeys && !this.features.foreignKeyAlter;
96
+ const inline = withForeignKeys && this.features.rebuildsTables;
96
97
  // Namespaces first: a qualified `CREATE TABLE` fails against a schema nobody created, and the
97
98
  // schema is the one part of the layout a migration cannot infer from the table it is making.
98
99
  const statements = this.generateCreateSchemas(tables);
@@ -183,13 +184,21 @@ export class SqlSchemaGenerator {
183
184
  * index along with its column, which would leave nothing to name. An alter is its drop, then its add.
184
185
  */
185
186
  generateAlterTable(diff) {
186
- const { tableName, schema, primaryKey } = diff;
187
- const { columns } = diff;
187
+ const { tableName, schema, primaryKey, columns, rebuild } = diff;
188
+ const fills = this.defaultFills(columns);
189
+ if (rebuild) {
190
+ return rebuildTable(this.dialect, tableName, rebuild, { renames: diff.renamedColumns ?? [], fills });
191
+ }
192
+ const target = this.escapeId(tableName);
188
193
  return [
189
194
  ...sides(diff.foreignKeys, 'from').map((foreignKey) => this.generateDropForeignKeySql(tableName, constraintNameOf(tableName, foreignKey))),
190
195
  ...(primaryKey?.from ? [this.generateDropPrimaryKeySql(tableName, primaryKey.from.name)] : []),
191
196
  ...sides(diff.indexes, 'from').map((index) => this.generateDropIndex(tableName, index.name, schema)),
192
197
  ...added(columns).flatMap((column) => this.addColumnStatements(tableName, column, schema)),
198
+ ...[...fills].map(([column, value]) => {
199
+ const name = this.escapeId(column);
200
+ return `UPDATE ${target} SET ${name} = ${value} WHERE ${name} IS NULL;`;
201
+ }),
193
202
  ...alterations(columns).flatMap(({ from, to }) => this.tableDdl.alterColumn(tableName, to, this.generateColumnDefinitionFromSchema(to), from)),
194
203
  ...dropped(columns).flatMap((column) => this.tableDdl.dropColumn(tableName, column.name)),
195
204
  ...this.addIndexStatements(tableName, sides(diff.indexes, 'to')),
@@ -197,6 +206,15 @@ export class SqlSchemaGenerator {
197
206
  ...this.addForeignKeyStatements(tableName, sides(diff.foreignKeys, 'to')),
198
207
  ];
199
208
  }
209
+ /**
210
+ * Each column the changes make required while declaring a default, and that default as SQL: the rows
211
+ * already there hold a null it has to replace, and it is the only value the entity says it may take.
212
+ */
213
+ defaultFills(columns) {
214
+ return new Map(newlyRequired(columns)
215
+ .filter(({ from, to }) => from && to.defaultValue !== undefined)
216
+ .map(({ to }) => [to.name, formatDefaultValue(to.defaultValue, this.dialect, to.type)]));
217
+ }
200
218
  /** `ADD CONSTRAINT` for each of `foreignKeys`. */
201
219
  addForeignKeyStatements(tableName, foreignKeys) {
202
220
  return foreignKeys.map((foreignKey) => this.generateAddForeignKeySql(tableName, foreignKey));
@@ -212,7 +230,7 @@ export class SqlSchemaGenerator {
212
230
  addColumnStatements(tableName, column, schema) {
213
231
  this.assertColumnAddable(tableName, column);
214
232
  return [
215
- this.tableDdl.addColumn(tableName, this.generateColumnDefinitionFromSchema(column)),
233
+ ...this.tableDdl.addColumnStatements(tableName, column, (it) => this.generateColumnDefinitionFromSchema(it)),
216
234
  ...this.generateColumnCommentStatement(tableName, column, schema),
217
235
  ];
218
236
  }
@@ -339,11 +357,7 @@ export class SqlSchemaGenerator {
339
357
  name: derivedPrimaryKeyName(tableName, keyDiff.expected.columns),
340
358
  },
341
359
  };
342
- // This table's own foreign keys. None where the engine cannot alter one (SQLite, short of rebuilding
343
- // the table), since a difference nothing can apply would throw on every sync; `drift:check` names it.
344
- const relationDiffs = this.features.foreignKeyAlter
345
- ? diffRelationshipNodes(desired.outgoingRelations, currentTable.outgoingRelations, this.diffOptions())
346
- : [];
360
+ const relationDiffs = diffRelationshipNodes(desired.outgoingRelations, currentTable.outgoingRelations, this.diffOptions());
347
361
  const foreignKeys = relationDiffs.map(({ actual, expected }) => ({
348
362
  from: actual && foreignKeyOf(actual),
349
363
  to: expected && foreignKeyOf(expected),
@@ -362,9 +376,38 @@ export class SqlSchemaGenerator {
362
376
  foreignKeys: nonEmpty(foreignKeys),
363
377
  renamedColumns: nonEmpty(renamedColumns ?? []),
364
378
  };
365
- return alter.primaryKey || alter.columns || alter.indexes || alter.foreignKeys || alter.renamedColumns
366
- ? alter
367
- : undefined;
379
+ if (!(alter.primaryKey || alter.columns || alter.indexes || alter.foreignKeys || alter.renamedColumns)) {
380
+ return undefined;
381
+ }
382
+ return this.features.rebuildsTables && needsRebuild(alter)
383
+ ? { ...alter, rebuild: this.rebuildOf(desired, currentTable, indexes.kept, renamedColumns ?? []) }
384
+ : alter;
385
+ }
386
+ /**
387
+ * Both ends of rebuilding `actual` as `desired`. The new table is the entity's, keeping what it cannot
388
+ * know of: the indexes and triggers uql did not make, and foreign keys to tables no entity names. The
389
+ * old one is the engine's own statements, so a rollback restores it exactly, checks included.
390
+ */
391
+ rebuildOf(desired, actual, kept, renames) {
392
+ const definition = actual.definition ?? [];
393
+ const read = new Set(actual.indexes.map((index) => index.name));
394
+ const own = (entry) => entry.kind === 'trigger' && isOwnedName(entry.name);
395
+ const verbatim = (entries) => entries.map((entry) => `${entry.sql};`);
396
+ const stored = (table) => [...table.columns.values()].filter((column) => !column.generatedAs).map((column) => column.name);
397
+ return {
398
+ from: {
399
+ statements: verbatim(definition.filter((entry) => !own(entry))),
400
+ columns: stored(actual).map((name) => renames.find((rename) => rename.to === name)?.from ?? name),
401
+ },
402
+ to: {
403
+ statements: [
404
+ ...this.generateCreateTableFromNode({ ...desired, externalForeignKeys: actual.externalForeignKeys }),
405
+ ...kept.map((index) => this.generateCreateIndexFromNode(index)),
406
+ ...verbatim(definition.filter((entry) => (entry.kind === 'index' && !read.has(entry.name)) || (entry.kind === 'trigger' && !own(entry)))),
407
+ ],
408
+ columns: stored(desired),
409
+ },
410
+ };
368
411
  }
369
412
  diffOptions() {
370
413
  return {
@@ -406,6 +449,9 @@ export class SqlSchemaGenerator {
406
449
  const refTable = this.dialect.escapeQualifiedId(rel.to.table.name, rel.to.table.schema);
407
450
  constraints.push(this.foreignKeyConstraint(table.name, foreignKeyOf(rel), refTable));
408
451
  }
452
+ for (const foreignKey of table.externalForeignKeys) {
453
+ constraints.push(this.foreignKeyConstraint(table.name, foreignKey, this.escapeId(foreignKey.references.table)));
454
+ }
409
455
  const target = this.dialect.escapeQualifiedId(table.name, table.schema);
410
456
  let createSql = `${this.tableDdl.createTable(target, !!options.ifNotExists)} (\n`;
411
457
  createSql += columns.map((col) => ` ${col}`).join(',\n');
@@ -497,9 +543,7 @@ export class SqlSchemaGenerator {
497
543
  }
498
544
  /** `ADD COLUMN`, plus the foreign key and index the column declares, as `CREATE TABLE` lifts them. */
499
545
  generateAddColumnSql(tableName, column) {
500
- this.assertColumnAddable(tableName, column);
501
- const colSql = this.generateColumnFromNode(fullColumnDefinitionToNode(column, tableName));
502
- const statements = [this.tableDdl.addColumn(tableName, colSql)];
546
+ const statements = this.addColumnStatements(tableName, this.columnNodeToSchema(fullColumnDefinitionToNode(column, tableName)));
503
547
  const foreignKey = columnForeignKey(column);
504
548
  if (foreignKey) {
505
549
  statements.push(...this.addForeignKeyStatements(tableName, [foreignKey]));
@@ -508,10 +552,10 @@ export class SqlSchemaGenerator {
508
552
  if (index) {
509
553
  statements.push(this.generateCreateIndex(tableName, index));
510
554
  }
511
- statements.push(...this.generateColumnCommentStatement(tableName, column));
512
555
  return statements;
513
556
  }
514
557
  generateAlterColumnSql(tableName, columnName, column) {
558
+ this.assertAlterable(`Altering the column "${columnName}" of "${tableName}"`);
515
559
  const node = fullColumnDefinitionToNode(column, tableName);
516
560
  return this.tableDdl.alterColumn(tableName, { ...this.columnNodeToSchema(node), name: columnName }, this.generateColumnFromNode(node));
517
561
  }
@@ -536,9 +580,7 @@ export class SqlSchemaGenerator {
536
580
  `ON UPDATE ${foreignKey.onUpdate ?? this.defaultForeignKeyAction}`);
537
581
  }
538
582
  generateAddForeignKeySql(tableName, foreignKey) {
539
- if (!this.features.foreignKeyAlter) {
540
- throw new UqlUsageError(`Dialect ${this.dialect} does not support adding foreign keys to existing tables`);
541
- }
583
+ this.assertAlterable(`Adding a foreign key to "${tableName}"`);
542
584
  const constraint = this.foreignKeyConstraint(tableName, foreignKey, this.escapeId(foreignKey.references.table));
543
585
  return `ALTER TABLE ${this.escapeId(tableName)} ADD ${constraint};`;
544
586
  }
@@ -551,7 +593,7 @@ export class SqlSchemaGenerator {
551
593
  * rather than emitting DDL it will reject.
552
594
  */
553
595
  generateAddPrimaryKeySql(tableName, columns, name) {
554
- this.assertPrimaryKeyAlterable(tableName);
596
+ this.assertAlterable(`Changing the primary key of "${tableName}"`);
555
597
  const constraintName = this.escapeId(name ?? derivedPrimaryKeyName(tableName, columns));
556
598
  const pkCols = columns.map((c) => this.escapeId(c)).join(', ');
557
599
  return `ALTER TABLE ${this.escapeId(tableName)} ADD CONSTRAINT ${constraintName} PRIMARY KEY (${pkCols});`;
@@ -561,7 +603,7 @@ export class SqlSchemaGenerator {
561
603
  * generator added it. MySQL takes no name.
562
604
  */
563
605
  generateDropPrimaryKeySql(tableName, constraintName) {
564
- this.assertPrimaryKeyAlterable(tableName);
606
+ this.assertAlterable(`Changing the primary key of "${tableName}"`);
565
607
  const table = this.escapeId(tableName);
566
608
  if (this.dialect.dropPrimaryKeySyntax === 'DROP PRIMARY KEY') {
567
609
  return `ALTER TABLE ${table} DROP PRIMARY KEY;`;
@@ -572,24 +614,21 @@ export class SqlSchemaGenerator {
572
614
  }
573
615
  return `ALTER TABLE ${table} DROP CONSTRAINT ${this.escapeId(constraintName)};`;
574
616
  }
575
- /**
576
- * A column an `ALTER` can carry. Only a generated one is ever refused, and only where the engine
577
- * takes it in a `CREATE TABLE` but not afterwards.
578
- */
617
+ /** A stored generated column, which an engine that rebuilds tables takes only in a `CREATE TABLE`. */
579
618
  assertColumnAddable(tableName, column) {
580
- if (!column.generatedAs || this.features.generatedColumnAdd) {
581
- return;
619
+ if (column.generatedAs) {
620
+ this.assertAlterable(`Adding the stored column "${column.name}" to "${tableName}"`);
582
621
  }
583
- throw new UqlUsageError(`${this.dialect}: Cannot add the computed column "${column.name}" to the existing table ` +
584
- `"${tableName}" - this database only accepts one in a CREATE TABLE. Drop \`stored\` to have the ` +
585
- 'expression spliced into each statement instead, or recreate the table in a written migration.');
586
622
  }
587
- assertPrimaryKeyAlterable(tableName) {
588
- if (this.features.primaryKeyAlter) {
589
- return;
623
+ /**
624
+ * Refuses `what` where the engine makes it only by rebuilding the table, which a migration generated
625
+ * from the entities does and a lone statement of the builder cannot.
626
+ */
627
+ assertAlterable(what) {
628
+ if (this.features.rebuildsTables) {
629
+ throw new UqlUsageError(`${this.dialect}: ${what} rebuilds the table, which a migration generated from the entities does ` +
630
+ '(`uql-migrate generate:entities`) and a hand-written one cannot.');
590
631
  }
591
- throw new UqlUsageError(`${this.dialect}: Cannot change the primary key of "${tableName}" - this database has no ALTER ` +
592
- 'for it. Recreate the table in a written migration.');
593
632
  }
594
633
  }
595
634
  /**
@@ -1,7 +1,9 @@
1
+ import { TriggerWriteRaw } from '../type/index.js';
1
2
  import { stampEvents } from '../util/field.util.js';
2
3
  import { definedEntries } from '../util/object.util.js';
3
4
  import { raw, refs, rowRefs } from '../util/raw.js';
4
5
  import { ownedName } from '../util/sql.util.js';
6
+ import { written } from '../util/triggerWrite.js';
5
7
  import { UqlUsageError } from '../util/uqlError.js';
6
8
  /**
7
9
  * One trigger for `dialect`, named for its table and label and ending in a hash of its own SQL. That hash
@@ -48,31 +50,35 @@ function triggerStatements(dialect, meta, trigger, name) {
48
50
  const features = dialect.features.triggers;
49
51
  const [timing, operation] = EVENT_PARTS[trigger.on];
50
52
  const before = timing === 'BEFORE';
51
- const body = triggerBody(dialect, meta, trigger, before);
52
- const id = triggerId(dialect, meta, name);
53
- const table = dialect.escapedTableName(meta);
53
+ const { fires } = features;
54
+ const perStatement = fires === 'eachStatement';
54
55
  const names = rowNames(dialect);
55
56
  const rows = [rowRefs(meta.entity, names.$new), rowRefs(meta.entity, names.$old)];
56
- const sql = dialect.compileDdl(body(...rows), meta.entity, { rows: rowsFrom(dialect, meta, operation) });
57
- const guard = triggerGuard(dialect, meta, trigger, rows, names);
58
- const inBody = features.guards !== 'clause';
59
- const guarded = !guard || !inBody
60
- ? sql
61
- : features.guards === 'beginEnd'
62
- ? `IF ${guard}\nBEGIN\n${sql}\nEND`
63
- : `IF ${guard} THEN\n${sql}\nEND IF;`;
64
- // The preamble opens the body, outside the guard: it settles how the batch reports itself rather than
65
- // which rows are touched, so it runs even when the guard keeps the statements from running.
57
+ const filter = triggerFilter(dialect, meta, trigger, rows, names);
58
+ const body = triggerBody(dialect, meta, trigger, before)(...rows);
59
+ if (perStatement && filter && !(body instanceof TriggerWriteRaw)) {
60
+ throw new UqlUsageError(`${dialect.dialectName} fires a trigger once per statement, so 'of' and 'where' narrow only what ` +
61
+ `insertInto, updateTable or deleteFrom reads, and '${meta.entity.name}' has one running its own SQL. ` +
62
+ 'Keep the body to those writes, or filter inserted and deleted in that SQL.');
63
+ }
64
+ const sql = dialect.compileDdl(body, meta.entity, {
65
+ rows: perStatement ? rowsFrom(dialect, meta, operation, names, filter) : undefined,
66
+ });
67
+ // A per-row engine keeps to the selected rows by a guard; a per-statement one narrowed what the body reads.
68
+ const guarded = filter && fires === 'eachRowIf' ? `IF ${filter} THEN\n${sql}\nEND IF;` : sql;
66
69
  const opened = features.preamble ? `${features.preamble}\n${guarded}` : guarded;
67
- const of = features.guards === 'clause' && operation === 'UPDATE' && trigger.of?.length
70
+ const id = triggerId(dialect, meta, name);
71
+ const table = dialect.escapedTableName(meta);
72
+ const byWhen = fires === 'eachRowWhen';
73
+ const of = byWhen && operation === 'UPDATE' && trigger.of?.length
68
74
  ? ` OF ${trigger.of.map((key) => dialect.escapedColumnName(meta, key)).join(', ')}`
69
75
  : '';
70
- const each = features.rows === 'set' ? '' : '\nFOR EACH ROW';
71
- const clause = guard && !inBody ? `\nWHEN (${guard})` : '';
72
- const when = `${timing} ${operation}${of}`;
76
+ const each = perStatement ? '' : '\nFOR EACH ROW';
77
+ const whenClause = filter && byWhen ? `\nWHEN (${filter})` : '';
78
+ const event = `${timing} ${operation}${of}`;
73
79
  const header = features.layout === 'tableFirst'
74
- ? `CREATE TRIGGER ${id}\nON ${table} ${when}${each}${clause}\nAS`
75
- : `CREATE TRIGGER ${id}\n${when} ON ${table}${each}${clause}`;
80
+ ? `CREATE TRIGGER ${id}\nON ${table} ${event}${each}${whenClause}\nAS`
81
+ : `CREATE TRIGGER ${id}\n${event} ON ${table}${each}${whenClause}`;
76
82
  if (features.body !== 'function') {
77
83
  return [`${header}\nBEGIN\n${opened}\nEND`];
78
84
  }
@@ -97,16 +103,10 @@ const EVENT_PARTS = {
97
103
  * PL/pgSQL, MariaDB MySQL's.
98
104
  */
99
105
  function triggerBody(dialect, meta, trigger, before) {
100
- const features = dialect.features.triggers;
101
- if (before && !features.before) {
106
+ if (before && !dialect.features.triggers.before) {
102
107
  throw new UqlUsageError(`${dialect.dialectName} has no BEFORE trigger, only AFTER and INSTEAD OF, so '${trigger.on}' cannot be ` +
103
108
  'rendered there. Use the matching after event, which sees the row already written.');
104
109
  }
105
- if (trigger.where && features.rows === 'set') {
106
- throw new UqlUsageError(`${dialect.dialectName} fires a trigger once per statement, over the rows it touched, so no condition ` +
107
- `can read one row: '${meta.entity.name}' cannot state a trigger 'where' there. Guard inside the body ` +
108
- 'instead, where `inserted` and `deleted` can be read as tables.');
109
- }
110
110
  const { run } = trigger;
111
111
  const body = typeof run === 'function' ? run : (run[dialect.dialectName] ?? run[dialect.dialectFamily]);
112
112
  if (!body) {
@@ -115,8 +115,11 @@ function triggerBody(dialect, meta, trigger, before) {
115
115
  }
116
116
  return body;
117
117
  }
118
- /** The one condition both guards reduce to: any watched column that moved, and whatever `where` asks. */
119
- function triggerGuard(dialect, meta, trigger, rows, names) {
118
+ /**
119
+ * The rows a trigger fires for, as one condition on every engine: any watched column that moved, and
120
+ * whatever `where` asks. Empty where it names neither.
121
+ */
122
+ function triggerFilter(dialect, meta, trigger, rows, names) {
120
123
  const moved = movedColumns(dialect, meta, trigger.of ?? [], names);
121
124
  return [
122
125
  ...(moved ? [moved] : []),
@@ -130,12 +133,12 @@ function plpgsqlFunction(id, block) {
130
133
  }
131
134
  /** What the engine calls the rows it hands a trigger: records on a row-based engine, tables on a set-based one. */
132
135
  function rowNames(dialect) {
133
- return dialect.features.triggers.rows === 'set'
136
+ return dialect.features.triggers.fires === 'eachStatement'
134
137
  ? { $new: 'inserted', $old: 'deleted' }
135
138
  : { $new: 'NEW', $old: 'OLD' };
136
139
  }
137
140
  /**
138
- * The `where` guard as the terms an `AND` joins. A callback writes its own off the rows; a predicate
141
+ * The `where` filter as the terms an `AND` joins. A callback writes its own off the rows; a predicate
139
142
  * renders a term per row it names, spelled verbatim because `NEW` is a record the engine declares. Each
140
143
  * is an `operand` wherever another term sits beside it, bracketing itself if compound, as `$where` does.
141
144
  */
@@ -198,7 +201,7 @@ function stampBody(dialect, meta, key, value, newRow) {
198
201
  const stamped = dialect.compileDdl(value, entity);
199
202
  const differs = raw(({ ctx, escapedPrefix }) => ctx.append(dialect.neExpr(`${escapedPrefix}${dialect.escapedColumnName(meta, key)}`, stamped)));
200
203
  const where = { $and: [...keyed, differs] };
201
- return raw(({ ctx, rows }) => dialect.triggerWrite(ctx, { kind: 'update', entity, set: { [key]: value }, where }, rows));
204
+ return written({ kind: 'update', entity, set: { [key]: value }, where });
202
205
  }
203
206
  /** A dollar quote the body does not contain, so no `$$` in it - a literal, a comment - ends the function early. */
204
207
  function dollarQuote(body) {
@@ -210,7 +213,7 @@ function dollarQuote(body) {
210
213
  }
211
214
  /**
212
215
  * Whether any watched column moved, null-safely, or `undefined` where none is watched: the two records
213
- * compared on a row-based engine, and on a set-based one the same question over a join of its two tables.
216
+ * compared on a row-based engine, the two tables' rows on a set-based one.
214
217
  */
215
218
  function movedColumns(dialect, meta, of, { $new: newName, $old: oldName }) {
216
219
  if (!of.length) {
@@ -220,26 +223,17 @@ function movedColumns(dialect, meta, of, { $new: newName, $old: oldName }) {
220
223
  const column = dialect.escapedColumnName(meta, key);
221
224
  return dialect.neExpr(`${oldName}.${column}`, `${newName}.${column}`);
222
225
  });
223
- const moved = differs.length > 1 ? `(${differs.join(' OR ')})` : differs.join('');
224
- const source = rowsFrom(dialect, meta, 'UPDATE');
225
- return source ? `EXISTS (SELECT 1 ${source} WHERE ${moved})` : moved;
226
+ return differs.length > 1 ? `(${differs.join(' OR ')})` : differs.join('');
226
227
  }
227
228
  /**
228
- * Where a set-based engine's body reads the rows it fires for, as the `FROM` a statement names them in:
229
- * `inserted` on an insert, `deleted` on a delete, and on an update both, joined on the whole key. None on a
230
- * row-based engine, whose body reads `NEW` and `OLD` bare.
229
+ * What a set-based engine's writes read: `inserted` on an insert, `deleted` on a delete, and on an update
230
+ * both, joined on the whole key, narrowed by `filter` to the rows a per-row engine would fire for.
231
231
  */
232
- function rowsFrom(dialect, meta, operation) {
233
- if (dialect.features.triggers.rows !== 'set') {
234
- return undefined;
235
- }
236
- const { $new, $old } = rowNames(dialect);
237
- if (operation !== 'UPDATE') {
238
- return `FROM ${operation === 'INSERT' ? $new : $old}`;
239
- }
232
+ function rowsFrom(dialect, meta, operation, { $new, $old }, filter) {
240
233
  const keyed = meta.ids.map((id) => {
241
234
  const column = dialect.escapedColumnName(meta, id);
242
235
  return `${$new}.${column} = ${$old}.${column}`;
243
236
  });
244
- return `FROM ${$new} JOIN ${$old} ON ${keyed.join(' AND ')}`;
237
+ const from = operation === 'UPDATE' ? `${$new} JOIN ${$old} ON ${keyed.join(' AND ')}` : operation === 'INSERT' ? $new : $old;
238
+ return { from, where: filter || undefined };
245
239
  }
@@ -19,9 +19,7 @@ export const mongoDialectFeatures = {
19
19
  indexIfNotExists: false,
20
20
  schemas: false, // the connection picks the database, and a collection name takes no dot
21
21
  dropTableCascade: false,
22
- foreignKeyAlter: false,
23
- primaryKeyAlter: false,
24
- generatedColumnAdd: false,
22
+ rebuildsTables: false,
25
23
  commentSyntax: 'none',
26
24
  vectorIndexRequiresNotNull: false,
27
25
  vectorSupportsLength: false,