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.
- package/dist/browser/uql-browser.min.js.map +2 -2
- package/dist/cockroachdb/cockroachDialect.js +5 -1
- package/dist/dialect/abstractDialect.d.ts +1 -31
- package/dist/dialect/abstractDialect.js +3 -27
- package/dist/dialect/abstractSqlDialect.d.ts +25 -56
- package/dist/dialect/abstractSqlDialect.js +78 -145
- package/dist/dialect/aliases.d.ts +5 -0
- package/dist/dialect/aliases.js +5 -0
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +4 -2
- package/dist/dialect/mysqlLikeSqlDialect.js +14 -1
- package/dist/dialect/operators.d.ts +66 -0
- package/dist/dialect/operators.js +129 -0
- package/dist/dialect/pgLikeSqlDialect.d.ts +4 -1
- package/dist/dialect/pgLikeSqlDialect.js +16 -3
- package/dist/entity/decorator/entity.d.ts +6 -1
- package/dist/entity/decorator/entity.js +12 -1
- package/dist/entity/index.d.ts +1 -1
- package/dist/entity/index.js +1 -1
- package/dist/entity/metadata/definition.d.ts +6 -1
- package/dist/entity/metadata/definition.js +19 -0
- package/dist/migrate/codegen/entityTypes.js +1 -2
- package/dist/migrate/ddl/mssqlIndexDdl.d.ts +5 -0
- package/dist/migrate/ddl/mssqlIndexDdl.js +10 -0
- package/dist/migrate/ddl/mssqlTableDdl.d.ts +2 -0
- package/dist/migrate/ddl/mssqlTableDdl.js +5 -0
- package/dist/migrate/ddl/tableDdl.d.ts +2 -0
- package/dist/migrate/ddl/tableDdl.js +4 -0
- package/dist/migrate/generator/mongoSchemaGenerator.d.ts +4 -0
- package/dist/migrate/generator/mongoSchemaGenerator.js +10 -0
- package/dist/migrate/introspection/abstractSqlSchemaIntrospector.d.ts +13 -1
- package/dist/migrate/introspection/abstractSqlSchemaIntrospector.js +21 -0
- package/dist/migrate/introspection/mongoIntrospector.d.ts +3 -1
- package/dist/migrate/introspection/mongoIntrospector.js +4 -0
- package/dist/migrate/introspection/mssqlIntrospector.d.ts +1 -0
- package/dist/migrate/introspection/mssqlIntrospector.js +8 -0
- package/dist/migrate/introspection/mysqlIntrospector.d.ts +1 -0
- package/dist/migrate/introspection/mysqlIntrospector.js +9 -0
- package/dist/migrate/introspection/postgresIntrospector.d.ts +1 -0
- package/dist/migrate/introspection/postgresIntrospector.js +10 -0
- package/dist/migrate/introspection/sqliteIntrospector.d.ts +1 -0
- package/dist/migrate/introspection/sqliteIntrospector.js +3 -0
- package/dist/migrate/migrator.d.ts +28 -1
- package/dist/migrate/migrator.js +88 -9
- package/dist/migrate/schemaGenerator.d.ts +12 -1
- package/dist/migrate/schemaGenerator.js +47 -5
- package/dist/migrate/storage/databaseStorage.d.ts +4 -0
- package/dist/migrate/storage/databaseStorage.js +14 -8
- package/dist/migrate/triggerSql.d.ts +24 -0
- package/dist/migrate/triggerSql.js +229 -0
- package/dist/mongo/mongoDialect.d.ts +0 -21
- package/dist/mongo/mongoDialect.js +105 -100
- package/dist/mongo/mongodbQuerier.js +17 -1
- package/dist/mssql/mssqlDialect.d.ts +18 -7
- package/dist/mssql/mssqlDialect.js +77 -33
- package/dist/mssql/mssqlQuerier.js +2 -2
- package/dist/schema/canonicalType.d.ts +6 -1
- package/dist/schema/canonicalType.js +14 -0
- package/dist/schema/schemaASTBuilder.d.ts +2 -8
- package/dist/schema/schemaASTBuilder.js +6 -20
- package/dist/sqlite/sqliteDialect.d.ts +1 -1
- package/dist/sqlite/sqliteDialect.js +12 -3
- package/dist/type/dialect.d.ts +69 -9
- package/dist/type/entity.d.ts +96 -3
- package/dist/type/migration.d.ts +17 -0
- package/dist/type/query.d.ts +13 -4
- package/dist/type/queryWhere.d.ts +4 -2
- package/dist/util/field.util.d.ts +9 -1
- package/dist/util/field.util.js +14 -2
- package/dist/util/fieldOption.util.d.ts +2 -2
- package/dist/util/fieldOption.util.js +2 -2
- package/dist/util/raw.d.ts +9 -1
- package/dist/util/raw.js +36 -11
- package/dist/util/sql.util.d.ts +12 -0
- package/dist/util/sql.util.js +24 -3
- package/dist/util/uqlError.d.ts +2 -0
- package/dist/util/uqlError.js +4 -0
- package/package.json +4 -4
- 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
|
|
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;
|
package/dist/migrate/migrator.js
CHANGED
|
@@ -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
|
|
185
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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' ?
|
|
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
|
-
...
|
|
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
|
|
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
|
|
461
|
-
let createSql =
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
}
|