@mikro-orm/sql 7.1.0-dev.7 → 7.1.0-dev.8

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.
@@ -1,5 +1,5 @@
1
1
  import { type Dictionary, type Transaction, type Type } from '@mikro-orm/core';
2
- import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference } from '../../typings.js';
2
+ import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef } from '../../typings.js';
3
3
  import type { AbstractSqlConnection } from '../../AbstractSqlConnection.js';
4
4
  import { SchemaHelper } from '../../schema/SchemaHelper.js';
5
5
  import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
@@ -32,6 +32,9 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
32
32
  protected appendMySqlIndexSuffix(sql: string, index: IndexDef): string;
33
33
  getAllColumns(connection: AbstractSqlConnection, tables: Table[], ctx?: Transaction): Promise<Dictionary<Column[]>>;
34
34
  getAllChecks(connection: AbstractSqlConnection, tables: Table[], ctx?: Transaction): Promise<Dictionary<CheckDef[]>>;
35
+ /** Generates SQL to create MySQL triggers. MySQL requires one trigger per event. */
36
+ createTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
37
+ getAllTriggers(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<SqlTriggerDef[]>>;
35
38
  getAllForeignKeys(connection: AbstractSqlConnection, tables: Table[], ctx?: Transaction): Promise<Dictionary<Dictionary<ForeignKey>>>;
36
39
  getPreAlterTable(tableDiff: TableDifference, safe: boolean): string[];
37
40
  getRenameColumnSQL(tableName: string, oldColumnName: string, to: Column): string;
@@ -64,11 +64,15 @@ export class MySqlSchemaHelper extends SchemaHelper {
64
64
  const checks = await this.getAllChecks(connection, tables, ctx);
65
65
  const fks = await this.getAllForeignKeys(connection, tables, ctx);
66
66
  const enums = await this.getAllEnumDefinitions(connection, tables, ctx);
67
+ const triggers = await this.getAllTriggers(connection, tables);
67
68
  for (const t of tables) {
68
69
  const key = this.getTableKey(t);
69
70
  const table = schema.addTable(t.table_name, t.schema_name, t.table_comment);
70
71
  const pks = await this.getPrimaryKeys(connection, indexes[key], table.name, table.schema);
71
72
  table.init(columns[key], indexes[key], checks[key], pks, fks[key], enums[key]);
73
+ if (triggers[key]) {
74
+ table.setTriggers(triggers[key]);
75
+ }
72
76
  }
73
77
  }
74
78
  async getAllIndexes(connection, tables, ctx) {
@@ -264,6 +268,85 @@ export class MySqlSchemaHelper extends SchemaHelper {
264
268
  }
265
269
  return ret;
266
270
  }
271
+ /** Generates SQL to create MySQL triggers. MySQL requires one trigger per event. */
272
+ createTrigger(table, trigger) {
273
+ if (trigger.expression) {
274
+ return trigger.expression;
275
+ }
276
+ /* v8 ignore next 3 */
277
+ if (trigger.timing === 'instead of') {
278
+ throw new Error(`MySQL does not support INSTEAD OF triggers. Use BEFORE or AFTER for trigger "${trigger.name}".`);
279
+ }
280
+ /* v8 ignore next 5 */
281
+ if (trigger.forEach === 'statement') {
282
+ throw new Error(`MySQL does not support FOR EACH STATEMENT triggers. Use FOR EACH ROW for trigger "${trigger.name}".`);
283
+ }
284
+ const timing = trigger.timing.toUpperCase();
285
+ const ret = [];
286
+ for (const event of trigger.events) {
287
+ const name = trigger.events.length > 1 ? `${trigger.name}_${event}` : trigger.name;
288
+ ret.push(`create trigger ${this.quote(name)} ${timing} ${event.toUpperCase()} on ${table.getQuotedName()} for each ROW begin ${trigger.body}; end`);
289
+ }
290
+ return ret.join(';\n');
291
+ }
292
+ async getAllTriggers(connection, tables) {
293
+ const names = tables.map(t => this.platform.quoteValue(t.table_name)).join(', ');
294
+ const sql = `select trigger_name as trigger_name, event_object_table as table_name, nullif(event_object_schema, schema()) as schema_name,
295
+ event_manipulation as event, action_timing as timing,
296
+ action_orientation as for_each, action_statement as body
297
+ from information_schema.triggers
298
+ where event_object_schema = database()
299
+ and event_object_table in (${names})
300
+ order by trigger_name, event_manipulation`;
301
+ const allTriggers = await connection.execute(sql);
302
+ const ret = {};
303
+ // First pass: collect all raw trigger names per table to detect multi-event groups.
304
+ // A base name is only used for grouping if multiple triggers share it (e.g. trg_multi_insert + trg_multi_update).
305
+ const namesByTable = new Map();
306
+ for (const row of allTriggers) {
307
+ const key = this.getTableKey(row);
308
+ namesByTable.set(key, [...(namesByTable.get(key) ?? []), row.trigger_name]);
309
+ }
310
+ const triggerMap = new Map();
311
+ for (const row of allTriggers) {
312
+ const key = this.getTableKey(row);
313
+ const eventLower = row.event.toLowerCase();
314
+ const tableNames = namesByTable.get(key) ?? [];
315
+ // Only strip event suffix when another trigger with the same base exists for this table
316
+ const candidateBase = row.trigger_name.endsWith(`_${eventLower}`)
317
+ ? row.trigger_name.slice(0, -eventLower.length - 1)
318
+ : null;
319
+ const baseName = candidateBase && tableNames.some(n => n !== row.trigger_name && n.startsWith(`${candidateBase}_`))
320
+ ? candidateBase
321
+ : row.trigger_name;
322
+ const dedupeKey = `${key}:${baseName}`;
323
+ if (triggerMap.has(dedupeKey)) {
324
+ const existing = triggerMap.get(dedupeKey);
325
+ const event = eventLower;
326
+ if (!existing.events.includes(event)) {
327
+ existing.events.push(event);
328
+ }
329
+ continue;
330
+ }
331
+ ret[key] ??= [];
332
+ // Strip BEGIN/END wrapper from MySQL action_statement
333
+ let body = row.body ?? '';
334
+ const beginEndMatch = /^\s*begin\s+([\s\S]*)\s*end\s*$/i.exec(body);
335
+ if (beginEndMatch) {
336
+ body = beginEndMatch[1].trim().replace(/;\s*$/, '');
337
+ }
338
+ const trigger = {
339
+ name: baseName,
340
+ timing: row.timing.toLowerCase(),
341
+ events: [eventLower],
342
+ forEach: (row.for_each ?? 'row').toLowerCase(),
343
+ body,
344
+ };
345
+ ret[key].push(trigger);
346
+ triggerMap.set(dedupeKey, trigger);
347
+ }
348
+ return ret;
349
+ }
267
350
  async getAllForeignKeys(connection, tables, ctx) {
268
351
  const sql = `select k.constraint_name as constraint_name, nullif(k.table_schema, schema()) as schema_name, k.table_name as table_name, k.column_name as column_name, k.referenced_table_name as referenced_table_name, k.referenced_column_name as referenced_column_name, c.update_rule as update_rule, c.delete_rule as delete_rule
269
352
  from information_schema.key_column_usage k
@@ -1,7 +1,7 @@
1
1
  import { type Dictionary, type Transaction } from '@mikro-orm/core';
2
2
  import { SchemaHelper } from '../../schema/SchemaHelper.js';
3
3
  import type { AbstractSqlConnection } from '../../AbstractSqlConnection.js';
4
- import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference } from '../../typings.js';
4
+ import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef } from '../../typings.js';
5
5
  import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
6
6
  import type { DatabaseTable } from '../../schema/DatabaseTable.js';
7
7
  export declare class PostgreSqlSchemaHelper extends SchemaHelper {
@@ -49,6 +49,13 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
49
49
  items: string[];
50
50
  }>, ctx?: Transaction): Promise<Dictionary<Column[]>>;
51
51
  getAllChecks(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, ctx?: Transaction): Promise<Dictionary<CheckDef[]>>;
52
+ /** Generates SQL to create a PostgreSQL trigger and its associated function. */
53
+ createTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
54
+ /** Generates SQL to drop a PostgreSQL trigger and its associated function. */
55
+ dropTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
56
+ private getSchemaQualifiedTriggerFnName;
57
+ getAllTriggers(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>): Promise<Dictionary<SqlTriggerDef[]>>;
58
+ private getTriggersSQL;
52
59
  getAllForeignKeys(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, ctx?: Transaction): Promise<Dictionary<Dictionary<ForeignKey>>>;
53
60
  getNativeEnumDefinitions(connection: AbstractSqlConnection, schemas: string[], ctx?: Transaction): Promise<Dictionary<{
54
61
  name: string;
@@ -118,6 +118,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
118
118
  const indexes = await this.getAllIndexes(connection, tables, ctx);
119
119
  const checks = await this.getAllChecks(connection, tablesBySchema, ctx);
120
120
  const fks = await this.getAllForeignKeys(connection, tablesBySchema, ctx);
121
+ const triggers = await this.getAllTriggers(connection, tablesBySchema);
121
122
  for (const t of tables) {
122
123
  const key = this.getTableKey(t);
123
124
  const table = schema.addTable(t.table_name, t.schema_name, t.table_comment);
@@ -126,6 +127,9 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
126
127
  if (columns[key]) {
127
128
  table.init(columns[key], indexes[key], checks[key], pks, fks[key], enums);
128
129
  }
130
+ if (triggers[key]) {
131
+ table.setTriggers(triggers[key]);
132
+ }
129
133
  }
130
134
  }
131
135
  async getAllIndexes(connection, tables, ctx) {
@@ -375,6 +379,95 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
375
379
  }
376
380
  return ret;
377
381
  }
382
+ /** Generates SQL to create a PostgreSQL trigger and its associated function. */
383
+ createTrigger(table, trigger) {
384
+ if (trigger.expression) {
385
+ return trigger.expression;
386
+ }
387
+ const timing = trigger.timing.toUpperCase();
388
+ const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
389
+ const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
390
+ const when = trigger.when ? `\n when (${trigger.when})` : '';
391
+ const fnName = this.getSchemaQualifiedTriggerFnName(table, trigger);
392
+ const triggerName = this.platform.quoteIdentifier(trigger.name);
393
+ const fnSql = `create or replace function ${fnName}() returns trigger as $$ begin ${trigger.body}; end; $$ language plpgsql`;
394
+ const triggerSql = `create trigger ${triggerName} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} execute function ${fnName}()`;
395
+ return `${fnSql};\n${triggerSql}`;
396
+ }
397
+ /** Generates SQL to drop a PostgreSQL trigger and its associated function. */
398
+ dropTrigger(table, trigger) {
399
+ const triggerName = this.platform.quoteIdentifier(trigger.name);
400
+ const fnName = this.getSchemaQualifiedTriggerFnName(table, trigger);
401
+ return `drop trigger if exists ${triggerName} on ${table.getQuotedName()};\ndrop function if exists ${fnName}()`;
402
+ }
403
+ getSchemaQualifiedTriggerFnName(table, trigger) {
404
+ const rawName = `${table.name}_${trigger.name}_fn`;
405
+ const defaultSchema = this.platform.getDefaultSchemaName();
406
+ if (table.schema && table.schema !== defaultSchema) {
407
+ return `${this.platform.quoteIdentifier(table.schema)}.${this.platform.quoteIdentifier(rawName)}`;
408
+ }
409
+ return this.platform.quoteIdentifier(rawName);
410
+ }
411
+ async getAllTriggers(connection, tablesBySchemas) {
412
+ const sql = this.getTriggersSQL(tablesBySchemas);
413
+ const allTriggers = await connection.execute(sql);
414
+ const ret = {};
415
+ const triggerMap = new Map();
416
+ for (const row of allTriggers) {
417
+ const key = this.getTableKey(row);
418
+ const dedupeKey = `${key}:${row.trigger_name}`;
419
+ if (triggerMap.has(dedupeKey)) {
420
+ // Same trigger with multiple events — merge events
421
+ const existing = triggerMap.get(dedupeKey);
422
+ const event = row.event.toLowerCase();
423
+ if (!existing.events.includes(event)) {
424
+ existing.events.push(event);
425
+ }
426
+ continue;
427
+ }
428
+ ret[key] ??= [];
429
+ // prosrc includes the full function body between $$ delimiters (e.g. " begin RETURN NEW; end;")
430
+ // Strip the begin/end wrapper to get just the trigger body for round-trip comparison
431
+ let body = (row.function_body ?? '').trim();
432
+ const beginEndMatch = /^\s*begin\s+([\s\S]*?)\s*end;?\s*$/i.exec(body);
433
+ if (beginEndMatch) {
434
+ body = beginEndMatch[1].trim().replace(/;\s*$/, '');
435
+ }
436
+ const trigger = {
437
+ name: row.trigger_name,
438
+ timing: row.timing.toLowerCase(),
439
+ events: [row.event.toLowerCase()],
440
+ forEach: row.for_each.toLowerCase(),
441
+ body,
442
+ when: row.when_clause ?? undefined,
443
+ };
444
+ ret[key].push(trigger);
445
+ triggerMap.set(dedupeKey, trigger);
446
+ }
447
+ return ret;
448
+ }
449
+ getTriggersSQL(tablesBySchemas) {
450
+ const conditions = [];
451
+ for (const [schema, tables] of tablesBySchemas) {
452
+ const names = tables.map(t => this.platform.quoteValue(t.table_name)).join(', ');
453
+ const schemaName = this.platform.quoteValue(schema ?? this.platform.getDefaultSchemaName());
454
+ conditions.push(`(t.event_object_schema = ${schemaName} and t.event_object_table in (${names}))`);
455
+ }
456
+ // Function lookup uses the '{table}_{trigger}_fn' convention from createTrigger().
457
+ // External triggers with different function names will have NULL body;
458
+ // use the `expression` escape hatch for those.
459
+ return `select t.trigger_name, t.event_object_schema as schema_name, t.event_object_table as table_name,
460
+ t.event_manipulation as event, t.action_timing as timing,
461
+ t.action_orientation as for_each,
462
+ t.action_condition as when_clause,
463
+ pg_get_functiondef(p.oid) as function_def,
464
+ p.prosrc as function_body
465
+ from information_schema.triggers t
466
+ left join pg_namespace n on n.nspname = t.event_object_schema
467
+ left join pg_proc p on p.proname = t.event_object_table || '_' || t.trigger_name || '_fn' and p.pronamespace = n.oid
468
+ where (${conditions.join(' or ')})
469
+ order by t.trigger_name, t.event_manipulation`;
470
+ }
378
471
  async getAllForeignKeys(connection, tablesBySchemas, ctx) {
379
472
  const sql = `select nsp1.nspname schema_name, cls1.relname table_name, nsp2.nspname referenced_schema_name,
380
473
  cls2.relname referenced_table_name, a.attname column_name, af.attname referenced_column_name, conname constraint_name,
@@ -1,7 +1,7 @@
1
1
  import { type Connection, type Transaction } from '@mikro-orm/core';
2
2
  import type { AbstractSqlConnection } from '../../AbstractSqlConnection.js';
3
3
  import { SchemaHelper } from '../../schema/SchemaHelper.js';
4
- import type { Column, IndexDef, Table, TableDifference } from '../../typings.js';
4
+ import type { Column, IndexDef, Table, TableDifference, SqlTriggerDef } from '../../typings.js';
5
5
  import type { DatabaseTable } from '../../schema/DatabaseTable.js';
6
6
  import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
7
7
  export declare class SqliteSchemaHelper extends SchemaHelper {
@@ -64,5 +64,9 @@ export declare class SqliteSchemaHelper extends SchemaHelper {
64
64
  */
65
65
  getReferencedTableName(referencedTableName: string, schema?: string): string;
66
66
  alterTable(diff: TableDifference, safe?: boolean): string[];
67
+ /** Generates SQL to create SQLite triggers. SQLite requires one trigger per event. */
68
+ createTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
69
+ private getTableTriggers;
70
+ private parseTriggerDDL;
67
71
  private getAlterTempTableSQL;
68
72
  }
@@ -101,7 +101,9 @@ export class SqliteSchemaHelper extends SchemaHelper {
101
101
  const pks = await this.getPrimaryKeys(connection, indexes, table.name, table.schema, ctx);
102
102
  const fks = await this.getForeignKeys(connection, table.name, table.schema, ctx);
103
103
  const enums = await this.getEnumDefinitions(connection, table.name, table.schema, ctx);
104
+ const triggers = await this.getTableTriggers(connection, table.name);
104
105
  table.init(cols, indexes, checks, pks, fks, enums);
106
+ table.setTriggers(triggers);
105
107
  }
106
108
  }
107
109
  createTable(table, alter) {
@@ -140,6 +142,9 @@ export class SqliteSchemaHelper extends SchemaHelper {
140
142
  for (const index of table.getIndexes()) {
141
143
  this.append(ret, this.createIndex(index, table));
142
144
  }
145
+ for (const trigger of table.getTriggers()) {
146
+ this.append(ret, this.createTrigger(table, trigger));
147
+ }
143
148
  return ret;
144
149
  }
145
150
  createTableColumn(column, table, _changedProperties) {
@@ -501,8 +506,102 @@ export class SqliteSchemaHelper extends SchemaHelper {
501
506
  this.append(ret, this.getRenameIndexSQL(diff.name, index, oldIndexName));
502
507
  }
503
508
  }
509
+ for (const trigger of Object.values(diff.removedTriggers)) {
510
+ this.append(ret, this.dropTrigger(diff.toTable, trigger));
511
+ }
512
+ for (const trigger of Object.values(diff.changedTriggers)) {
513
+ this.append(ret, this.dropTrigger(diff.toTable, trigger));
514
+ this.append(ret, this.createTrigger(diff.toTable, trigger));
515
+ }
516
+ for (const trigger of Object.values(diff.addedTriggers)) {
517
+ this.append(ret, this.createTrigger(diff.toTable, trigger));
518
+ }
504
519
  return ret;
505
520
  }
521
+ /** Generates SQL to create SQLite triggers. SQLite requires one trigger per event. */
522
+ createTrigger(table, trigger) {
523
+ if (trigger.expression) {
524
+ return trigger.expression;
525
+ }
526
+ const timing = trigger.timing.toUpperCase();
527
+ const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
528
+ const ret = [];
529
+ for (const event of trigger.events) {
530
+ const name = trigger.events.length > 1 ? `${trigger.name}_${event}` : trigger.name;
531
+ const when = trigger.when ? `\n when ${trigger.when}` : '';
532
+ ret.push(`create trigger ${this.quote(name)} ${timing} ${event.toUpperCase()} on ${table.getQuotedName()} for each ${forEach}${when} begin ${trigger.body}; end`);
533
+ }
534
+ return ret.join(';\n');
535
+ }
536
+ async getTableTriggers(connection, tableName) {
537
+ const rows = await connection.execute(`select name, sql from sqlite_master where type = 'trigger' and tbl_name = ?`, [tableName]);
538
+ // First pass: parse all triggers and collect names to detect multi-event groups
539
+ const parsedRows = [];
540
+ for (const row of rows) {
541
+ /* v8 ignore next 3 */
542
+ if (!row.sql) {
543
+ continue;
544
+ }
545
+ const parsed = this.parseTriggerDDL(row.sql, row.name);
546
+ if (parsed) {
547
+ parsedRows.push({ name: row.name, parsed });
548
+ }
549
+ }
550
+ const allNames = parsedRows.map(r => r.name);
551
+ const triggers = [];
552
+ const triggerMap = new Map();
553
+ for (const { name, parsed } of parsedRows) {
554
+ // Only strip event suffix when another trigger with the same base exists
555
+ const eventLower = parsed.events[0];
556
+ const candidateBase = name.endsWith(`_${eventLower}`) ? name.slice(0, -eventLower.length - 1) : null;
557
+ const baseName = candidateBase && allNames.some(n => n !== name && n.startsWith(`${candidateBase}_`)) ? candidateBase : name;
558
+ if (triggerMap.has(baseName)) {
559
+ const existing = triggerMap.get(baseName);
560
+ if (!existing.events.includes(parsed.events[0])) {
561
+ existing.events.push(parsed.events[0]);
562
+ }
563
+ continue;
564
+ }
565
+ const trigger = { ...parsed, name: baseName };
566
+ triggers.push(trigger);
567
+ triggerMap.set(baseName, trigger);
568
+ }
569
+ return triggers;
570
+ }
571
+ parseTriggerDDL(sql, name) {
572
+ // Split at the last top-level BEGIN to separate header from body,
573
+ // so that a WHEN clause containing the word "begin" in a string literal doesn't confuse parsing.
574
+ const beginIdx = sql.search(/\bbegin\b(?=[^]*$)/i);
575
+ /* v8 ignore next 3 */
576
+ if (beginIdx === -1) {
577
+ return null;
578
+ }
579
+ const header = sql.slice(0, beginIdx);
580
+ const bodyPart = sql.slice(beginIdx);
581
+ const headerMatch = /create\s+trigger\s+["`]?\w+["`]?\s+(before|after|instead\s+of)\s+(insert|update|delete)\s+on\s+["`]?\w+["`]?\s*(?:for\s+each\s+(row|statement))?\s*(?:when\s+([\s\S]*?))?\s*$/i.exec(header);
582
+ /* v8 ignore next 3 */
583
+ if (!headerMatch) {
584
+ return null;
585
+ }
586
+ const bodyMatch = /^begin\s+([\s\S]*?)\s*end/i.exec(bodyPart);
587
+ /* v8 ignore next 3 */
588
+ if (!bodyMatch) {
589
+ return null;
590
+ }
591
+ const timing = headerMatch[1].toLowerCase();
592
+ const event = headerMatch[2].toLowerCase();
593
+ const forEach = (headerMatch[3]?.toLowerCase() ?? 'row');
594
+ const when = headerMatch[4]?.trim() || undefined;
595
+ const body = bodyMatch[1].trim().replace(/;\s*$/, '');
596
+ return {
597
+ name,
598
+ timing,
599
+ events: [event],
600
+ forEach,
601
+ body,
602
+ when,
603
+ };
604
+ }
506
605
  getAlterTempTableSQL(changedTable) {
507
606
  const tempName = `${changedTable.toTable.name}__temp_alter`;
508
607
  const quotedName = this.quote(changedTable.toTable.name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.1.0-dev.7",
3
+ "version": "7.1.0-dev.8",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
@@ -53,7 +53,7 @@
53
53
  "@mikro-orm/core": "^7.0.11"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.1.0-dev.7"
56
+ "@mikro-orm/core": "7.1.0-dev.8"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -220,6 +220,20 @@ export class DatabaseSchema {
220
220
  columnName,
221
221
  });
222
222
  }
223
+ for (const trigger of meta.triggers) {
224
+ const body = isRaw(trigger.body)
225
+ ? platform.formatQuery(trigger.body.sql, trigger.body.params)
226
+ : trigger.body;
227
+ table.addTrigger({
228
+ name: trigger.name,
229
+ timing: trigger.timing,
230
+ events: trigger.events,
231
+ forEach: trigger.forEach ?? 'row',
232
+ body: body ?? '',
233
+ when: trigger.when,
234
+ expression: trigger.expression,
235
+ });
236
+ }
223
237
  }
224
238
  return schema;
225
239
  }
@@ -1,6 +1,6 @@
1
1
  import { type Configuration, type DeferMode, type Dictionary, type EntityMetadata, type EntityProperty, type IndexCallback, type NamingStrategy } from '@mikro-orm/core';
2
2
  import type { SchemaHelper } from './SchemaHelper.js';
3
- import type { CheckDef, Column, ForeignKey, IndexDef } from '../typings.js';
3
+ import type { CheckDef, Column, ForeignKey, IndexDef, SqlTriggerDef } from '../typings.js';
4
4
  import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
5
5
  /**
6
6
  * @internal
@@ -22,11 +22,14 @@ export declare class DatabaseTable {
22
22
  removeColumn(name: string): void;
23
23
  getIndexes(): IndexDef[];
24
24
  getChecks(): CheckDef[];
25
+ getTriggers(): SqlTriggerDef[];
25
26
  /** @internal */
26
27
  setIndexes(indexes: IndexDef[]): void;
27
28
  /** @internal */
28
29
  setChecks(checks: CheckDef[]): void;
29
30
  /** @internal */
31
+ setTriggers(triggers: SqlTriggerDef[]): void;
32
+ /** @internal */
30
33
  setForeignKeys(fks: Dictionary<ForeignKey>): void;
31
34
  init(cols: Column[], indexes: IndexDef[] | undefined, checks: CheckDef[] | undefined, pks: string[], fks?: Dictionary<ForeignKey>, enums?: Dictionary<string[]>): void;
32
35
  addColumn(column: Column): void;
@@ -47,6 +50,8 @@ export declare class DatabaseTable {
47
50
  hasIndex(indexName: string): boolean;
48
51
  getCheck(checkName: string): CheckDef | undefined;
49
52
  hasCheck(checkName: string): boolean;
53
+ getTrigger(triggerName: string): SqlTriggerDef | undefined;
54
+ hasTrigger(triggerName: string): boolean;
50
55
  getPrimaryKey(): IndexDef | undefined;
51
56
  hasPrimaryKey(): boolean;
52
57
  private getForeignKeyDeclaration;
@@ -78,5 +83,6 @@ export declare class DatabaseTable {
78
83
  clustered?: boolean;
79
84
  }, type: 'index' | 'unique' | 'primary'): void;
80
85
  addCheck(check: CheckDef): void;
86
+ addTrigger(trigger: SqlTriggerDef): void;
81
87
  toJSON(): Dictionary;
82
88
  }
@@ -8,6 +8,7 @@ export class DatabaseTable {
8
8
  #columns = {};
9
9
  #indexes = [];
10
10
  #checks = [];
11
+ #triggers = [];
11
12
  #foreignKeys = {};
12
13
  #platform;
13
14
  nativeEnums = {}; // for postgres
@@ -35,6 +36,9 @@ export class DatabaseTable {
35
36
  getChecks() {
36
37
  return this.#checks;
37
38
  }
39
+ getTriggers() {
40
+ return this.#triggers;
41
+ }
38
42
  /** @internal */
39
43
  setIndexes(indexes) {
40
44
  this.#indexes = indexes;
@@ -44,6 +48,10 @@ export class DatabaseTable {
44
48
  this.#checks = checks;
45
49
  }
46
50
  /** @internal */
51
+ setTriggers(triggers) {
52
+ this.#triggers = triggers;
53
+ }
54
+ /** @internal */
47
55
  setForeignKeys(fks) {
48
56
  this.#foreignKeys = fks;
49
57
  }
@@ -598,6 +606,12 @@ export class DatabaseTable {
598
606
  hasCheck(checkName) {
599
607
  return !!this.getCheck(checkName);
600
608
  }
609
+ getTrigger(triggerName) {
610
+ return this.#triggers.find(t => t.name === triggerName);
611
+ }
612
+ hasTrigger(triggerName) {
613
+ return !!this.getTrigger(triggerName);
614
+ }
601
615
  getPrimaryKey() {
602
616
  return this.#indexes.find(i => i.primary);
603
617
  }
@@ -885,6 +899,9 @@ export class DatabaseTable {
885
899
  addCheck(check) {
886
900
  this.#checks.push(check);
887
901
  }
902
+ addTrigger(trigger) {
903
+ this.#triggers.push(trigger);
904
+ }
888
905
  toJSON() {
889
906
  const columns = this.#columns;
890
907
  const columnsMapped = Utils.keys(columns).reduce((o, col) => {
@@ -929,6 +946,7 @@ export class DatabaseTable {
929
946
  columns: columnsMapped,
930
947
  indexes: this.#indexes,
931
948
  checks: this.#checks,
949
+ triggers: this.#triggers,
932
950
  foreignKeys: this.#foreignKeys,
933
951
  nativeEnums: this.nativeEnums,
934
952
  comment: this.comment,
@@ -69,6 +69,7 @@ export declare class SchemaComparator {
69
69
  * @see https://github.com/mikro-orm/mikro-orm/issues/7308
70
70
  */
71
71
  private diffViewExpression;
72
+ private diffTrigger;
72
73
  parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
73
74
  hasSameDefaultValue(from: Column, to: Column): boolean;
74
75
  private mapColumnToProperty;
@@ -176,14 +176,17 @@ export class SchemaComparator {
176
176
  addedForeignKeys: {},
177
177
  addedIndexes: {},
178
178
  addedChecks: {},
179
+ addedTriggers: {},
179
180
  changedColumns: {},
180
181
  changedForeignKeys: {},
181
182
  changedIndexes: {},
182
183
  changedChecks: {},
184
+ changedTriggers: {},
183
185
  removedColumns: {},
184
186
  removedForeignKeys: {},
185
187
  removedIndexes: {},
186
188
  removedChecks: {},
189
+ removedTriggers: {},
187
190
  renamedColumns: {},
188
191
  renamedIndexes: {},
189
192
  fromTable,
@@ -309,6 +312,33 @@ export class SchemaComparator {
309
312
  tableDifferences.changedChecks[check.name] = toTableCheck;
310
313
  changes++;
311
314
  }
315
+ const fromTableTriggers = fromTable.getTriggers();
316
+ const toTableTriggers = toTable.getTriggers();
317
+ for (const trigger of toTableTriggers) {
318
+ if (fromTable.hasTrigger(trigger.name)) {
319
+ continue;
320
+ }
321
+ tableDifferences.addedTriggers[trigger.name] = trigger;
322
+ this.log(`trigger ${trigger.name} added to table ${tableDifferences.name}`, { trigger });
323
+ changes++;
324
+ }
325
+ for (const trigger of fromTableTriggers) {
326
+ if (!toTable.hasTrigger(trigger.name)) {
327
+ tableDifferences.removedTriggers[trigger.name] = trigger;
328
+ this.log(`trigger ${trigger.name} removed from table ${tableDifferences.name}`);
329
+ changes++;
330
+ continue;
331
+ }
332
+ const toTableTrigger = toTable.getTrigger(trigger.name);
333
+ if (this.diffTrigger(trigger, toTableTrigger)) {
334
+ this.log(`trigger ${trigger.name} changed in table ${tableDifferences.name}`, {
335
+ fromTableTrigger: trigger,
336
+ toTableTrigger,
337
+ });
338
+ tableDifferences.changedTriggers[trigger.name] = toTableTrigger;
339
+ changes++;
340
+ }
341
+ }
312
342
  const fromForeignKeys = { ...fromTable.getForeignKeys() };
313
343
  const toForeignKeys = { ...toTable.getForeignKeys() };
314
344
  for (const fromConstraint of Object.values(fromForeignKeys)) {
@@ -714,6 +744,30 @@ export class SchemaComparator {
714
744
  }
715
745
  return true;
716
746
  }
747
+ diffTrigger(from, to) {
748
+ // Raw DDL expression cannot be meaningfully compared to introspected
749
+ // trigger metadata, so skip diffing when the metadata side uses it.
750
+ if (to.expression) {
751
+ // Both sides have expression — compare the raw DDL directly
752
+ if (from.expression) {
753
+ return this.diffExpression(from.expression, to.expression);
754
+ }
755
+ // Only metadata side has expression — the raw DDL cannot be compared to
756
+ // introspected metadata. Changes to the expression value won't be detected;
757
+ // drop and recreate the trigger manually to apply expression changes.
758
+ return false;
759
+ }
760
+ if (from.timing !== to.timing || from.forEach !== to.forEach) {
761
+ return true;
762
+ }
763
+ if ([...from.events].sort().join(',') !== [...to.events].sort().join(',')) {
764
+ return true;
765
+ }
766
+ if ((from.when ?? '') !== (to.when ?? '')) {
767
+ return true;
768
+ }
769
+ return this.diffExpression(from.body, to.body);
770
+ }
717
771
  parseJsonDefault(defaultValue) {
718
772
  /* v8 ignore next */
719
773
  if (!defaultValue) {
@@ -1,7 +1,7 @@
1
1
  import { type Connection, type Dictionary, type Options, type Transaction, type RawQueryFragment } from '@mikro-orm/core';
2
2
  import type { AbstractSqlConnection } from '../AbstractSqlConnection.js';
3
3
  import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
4
- import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference } from '../typings.js';
4
+ import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef } from '../typings.js';
5
5
  import type { DatabaseSchema } from './DatabaseSchema.js';
6
6
  import type { DatabaseTable } from './DatabaseTable.js';
7
7
  /** Base class for database-specific schema helpers. Provides SQL generation for DDL operations. */
@@ -84,6 +84,16 @@ export declare abstract class SchemaHelper {
84
84
  getReferencedTableName(referencedTableName: string, schema?: string): string;
85
85
  createIndex(index: IndexDef, table: DatabaseTable, createPrimary?: boolean): string;
86
86
  createCheck(table: DatabaseTable, check: CheckDef): string;
87
+ /**
88
+ * Generates SQL to create a database trigger on a table.
89
+ * Override in driver-specific helpers for custom DDL (e.g., PostgreSQL function wrapping).
90
+ */
91
+ createTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
92
+ /**
93
+ * Generates SQL to drop a database trigger from a table.
94
+ * Override in driver-specific helpers for custom DDL.
95
+ */
96
+ dropTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
87
97
  /** @internal */
88
98
  getTableName(table: string, schema?: string): string;
89
99
  getTablesGroupedBySchemas(tables: Table[]): Map<string | undefined, Table[]>;
@@ -204,6 +204,12 @@ export class SchemaHelper {
204
204
  for (const check of Object.values(diff.changedChecks)) {
205
205
  ret.push(this.dropConstraint(diff.name, check.name));
206
206
  }
207
+ for (const trigger of Object.values(diff.removedTriggers)) {
208
+ ret.push(this.dropTrigger(diff.toTable, trigger));
209
+ }
210
+ for (const trigger of Object.values(diff.changedTriggers)) {
211
+ ret.push(this.dropTrigger(diff.toTable, trigger));
212
+ }
207
213
  /* v8 ignore next */
208
214
  if (!safe && Object.values(diff.removedColumns).length > 0) {
209
215
  ret.push(this.getDropColumnsSQL(tableName, Object.values(diff.removedColumns), schemaName));
@@ -263,6 +269,12 @@ export class SchemaHelper {
263
269
  for (const check of Object.values(diff.changedChecks)) {
264
270
  ret.push(this.createCheck(diff.toTable, check));
265
271
  }
272
+ for (const trigger of Object.values(diff.addedTriggers)) {
273
+ ret.push(this.createTrigger(diff.toTable, trigger));
274
+ }
275
+ for (const trigger of Object.values(diff.changedTriggers)) {
276
+ ret.push(this.createTrigger(diff.toTable, trigger));
277
+ }
266
278
  if ('changedComment' in diff) {
267
279
  ret.push(this.alterTableComment(diff.toTable, diff.changedComment));
268
280
  }
@@ -521,6 +533,9 @@ export class SchemaHelper {
521
533
  for (const check of table.getChecks()) {
522
534
  this.append(ret, this.createCheck(table, check));
523
535
  }
536
+ for (const trigger of table.getTriggers()) {
537
+ this.append(ret, this.createTrigger(table, trigger));
538
+ }
524
539
  }
525
540
  return ret;
526
541
  }
@@ -600,6 +615,33 @@ export class SchemaHelper {
600
615
  createCheck(table, check) {
601
616
  return `alter table ${table.getQuotedName()} add constraint ${this.quote(check.name)} check (${check.expression})`;
602
617
  }
618
+ /**
619
+ * Generates SQL to create a database trigger on a table.
620
+ * Override in driver-specific helpers for custom DDL (e.g., PostgreSQL function wrapping).
621
+ */
622
+ /* v8 ignore next 10 */
623
+ createTrigger(table, trigger) {
624
+ if (trigger.expression) {
625
+ return trigger.expression;
626
+ }
627
+ const timing = trigger.timing.toUpperCase();
628
+ const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
629
+ const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
630
+ const when = trigger.when ? ` when (${trigger.when})` : '';
631
+ return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${trigger.body}; end`;
632
+ }
633
+ /**
634
+ * Generates SQL to drop a database trigger from a table.
635
+ * Override in driver-specific helpers for custom DDL.
636
+ */
637
+ dropTrigger(table, trigger) {
638
+ if (trigger.events.length > 1) {
639
+ return trigger.events
640
+ .map(event => `drop trigger if exists ${this.quote(`${trigger.name}_${event}`)}`)
641
+ .join(';\n');
642
+ }
643
+ return `drop trigger if exists ${this.quote(trigger.name)}`;
644
+ }
603
645
  /** @internal */
604
646
  getTableName(table, schema) {
605
647
  if (schema && schema !== this.platform.getDefaultSchemaName()) {
@@ -300,11 +300,18 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
300
300
  for (const check of newTable.getChecks()) {
301
301
  this.append(sql, this.helper.createCheck(newTable, check));
302
302
  }
303
+ for (const trigger of newTable.getTriggers()) {
304
+ this.append(sql, this.helper.createTrigger(newTable, trigger));
305
+ }
303
306
  this.append(ret, sql, true);
304
307
  }
305
308
  }
306
309
  if (options.dropTables && !options.safe) {
307
310
  for (const table of Object.values(schemaDiff.removedTables)) {
311
+ // Drop triggers before the table so driver-specific cleanup runs (e.g. PostgreSQL function removal)
312
+ for (const trigger of table.getTriggers()) {
313
+ this.append(ret, this.helper.dropTrigger(table, trigger));
314
+ }
308
315
  this.append(ret, this.helper.dropTableIfExists(table.name, table.schema));
309
316
  }
310
317
  if (Utils.hasObjectKeys(schemaDiff.removedTables)) {
package/typings.d.ts CHANGED
@@ -108,6 +108,16 @@ export interface CheckDef<T = unknown> {
108
108
  definition?: string;
109
109
  columnName?: string;
110
110
  }
111
+ /** Resolved trigger definition for schema operations (all callbacks resolved to strings). */
112
+ export interface SqlTriggerDef {
113
+ name: string;
114
+ timing: 'before' | 'after' | 'instead of';
115
+ events: ('insert' | 'update' | 'delete' | 'truncate')[];
116
+ forEach: 'row' | 'statement';
117
+ body: string;
118
+ when?: string;
119
+ expression?: string;
120
+ }
111
121
  export interface ColumnDifference {
112
122
  oldColumnName: string;
113
123
  column: Column;
@@ -130,6 +140,9 @@ export interface TableDifference {
130
140
  addedChecks: Dictionary<CheckDef>;
131
141
  changedChecks: Dictionary<CheckDef>;
132
142
  removedChecks: Dictionary<CheckDef>;
143
+ addedTriggers: Dictionary<SqlTriggerDef>;
144
+ changedTriggers: Dictionary<SqlTriggerDef>;
145
+ removedTriggers: Dictionary<SqlTriggerDef>;
133
146
  addedForeignKeys: Dictionary<ForeignKey>;
134
147
  changedForeignKeys: Dictionary<ForeignKey>;
135
148
  removedForeignKeys: Dictionary<ForeignKey>;