@mikro-orm/sql 7.1.0-dev.1 → 7.1.0-dev.11

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.
@@ -159,6 +159,41 @@ export class QueryBuilder {
159
159
  insert(data) {
160
160
  return this.init(QueryType.INSERT, data);
161
161
  }
162
+ /**
163
+ * Creates an INSERT ... SELECT query that copies rows from the source query.
164
+ *
165
+ * Column resolution (3 tiers):
166
+ * 1. No explicit select on source, no explicit columns → all cloneable columns derived from entity metadata
167
+ * 2. Explicit select on source, no explicit columns → columns derived from selected field names
168
+ * 3. Explicit `columns` option → user-provided column list
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * // Clone all fields (columns auto-derived from metadata)
173
+ * const source = em.createQueryBuilder(User).where({ id: 1 });
174
+ * await em.createQueryBuilder(User).insertFrom(source).execute();
175
+ *
176
+ * // Clone with overrides via raw() aliases
177
+ * const source = em.createQueryBuilder(User)
178
+ * .select(['name', raw("'new@email.com'").as('email')])
179
+ * .where({ id: 1 });
180
+ * await em.createQueryBuilder(User).insertFrom(source).execute();
181
+ *
182
+ * // Explicit columns for full control
183
+ * await em.createQueryBuilder(User)
184
+ * .insertFrom(source, { columns: ['name', 'email'] })
185
+ * .execute();
186
+ * ```
187
+ */
188
+ insertFrom(subQuery, options) {
189
+ this.ensureNotFinalized();
190
+ this.#state.type = QueryType.INSERT;
191
+ this.#state.insertSubQuery = subQuery;
192
+ if (options?.columns) {
193
+ this.#state.insertColumns = Utils.asArray(options.columns);
194
+ }
195
+ return this;
196
+ }
162
197
  /**
163
198
  * Creates an UPDATE query with the given data.
164
199
  * Use `where()` to specify which rows to update.
@@ -686,6 +721,12 @@ export class QueryBuilder {
686
721
  hasFlag(flag) {
687
722
  return this.#state.flags.has(flag);
688
723
  }
724
+ /** @internal */
725
+ setPartitionLimit(opts) {
726
+ this.ensureNotFinalized();
727
+ this.#state.partitionLimit = opts;
728
+ return this;
729
+ }
689
730
  cache(config = true) {
690
731
  this.ensureNotFinalized();
691
732
  this.#state.cache = config;
@@ -789,12 +830,18 @@ export class QueryBuilder {
789
830
  if (this.#state.lockMode) {
790
831
  this.helper.getLockSQL(qb, this.#state.lockMode, this.#state.lockTables, this.#state.joins);
791
832
  }
792
- this.processReturningStatement(qb, this.mainAlias.meta, this.#state.data, this.#state.returning);
833
+ this.processReturningStatement(qb, this.mainAlias.meta, this.#state.insertSubQuery ? undefined : this.#state.data, this.#state.returning);
834
+ if (this.#state.partitionLimit) {
835
+ return (this.#query.qb = this.wrapPartitionLimitSubQuery(qb));
836
+ }
793
837
  return (this.#query.qb = qb);
794
838
  }
795
839
  processReturningStatement(qb, meta, data, returning) {
796
840
  const usesReturningStatement = this.platform.usesReturningStatement() || this.platform.usesOutputStatement();
797
- if (!meta || !data || !usesReturningStatement) {
841
+ if (!meta || !usesReturningStatement) {
842
+ return;
843
+ }
844
+ if (!data && !this.#state.insertSubQuery) {
798
845
  return;
799
846
  }
800
847
  // always respect explicit returning hint
@@ -805,13 +852,13 @@ export class QueryBuilder {
805
852
  if (this.type === QueryType.INSERT) {
806
853
  const returningProps = meta.hydrateProps
807
854
  .filter(prop => prop.returning || (prop.persist !== false && ((prop.primary && prop.autoincrement) || prop.defaultRaw)))
808
- .filter(prop => !(prop.name in data));
855
+ .filter(prop => !data || !(prop.name in data));
809
856
  if (returningProps.length > 0) {
810
857
  qb.returning(Utils.flatten(returningProps.map(prop => prop.fieldNames)));
811
858
  }
812
859
  return;
813
860
  }
814
- if (this.type === QueryType.UPDATE) {
861
+ if (this.type === QueryType.UPDATE && data) {
815
862
  const returningProps = meta.hydrateProps.filter(prop => prop.fieldNames && isRaw(data[prop.fieldNames[0]]));
816
863
  if (returningProps.length > 0) {
817
864
  qb.returning(returningProps.flatMap((prop) => {
@@ -1603,7 +1650,14 @@ export class QueryBuilder {
1603
1650
  break;
1604
1651
  }
1605
1652
  case QueryType.INSERT:
1606
- qb.insert(this.#state.data);
1653
+ if (this.#state.insertSubQuery) {
1654
+ const columns = this.resolveInsertFromColumns();
1655
+ const compiled = this.#state.insertSubQuery.toQuery();
1656
+ qb.insertSelect(columns, raw(compiled.sql, compiled.params));
1657
+ }
1658
+ else {
1659
+ qb.insert(this.#state.data);
1660
+ }
1607
1661
  break;
1608
1662
  case QueryType.UPDATE:
1609
1663
  qb.update(this.#state.data);
@@ -1619,6 +1673,78 @@ export class QueryBuilder {
1619
1673
  }
1620
1674
  return qb;
1621
1675
  }
1676
+ /**
1677
+ * Resolves the INSERT column list for `insertFrom()`.
1678
+ *
1679
+ * Tier 1: Explicit `insertColumns` from `options.columns` → map property names to field names
1680
+ * Tier 2: Source QB has explicit select fields → derive from those
1681
+ * Tier 3: Derive from target entity metadata (all cloneable columns), auto-populate source select
1682
+ */
1683
+ resolveInsertFromColumns() {
1684
+ const meta = this.mainAlias.meta;
1685
+ const subQuery = this.#state.insertSubQuery;
1686
+ // Tier 1: explicit columns
1687
+ if (this.#state.insertColumns?.length) {
1688
+ return this.#state.insertColumns.flatMap(col => {
1689
+ const prop = meta?.properties[col];
1690
+ return prop?.fieldNames ?? [col];
1691
+ });
1692
+ }
1693
+ // Tier 2: source QB has explicit select fields
1694
+ const sourceFields = subQuery.state.fields;
1695
+ if (sourceFields && subQuery.state.type === QueryType.SELECT) {
1696
+ return sourceFields
1697
+ .filter((field) => typeof field === 'string' || isRaw(field))
1698
+ .flatMap((field) => {
1699
+ if (typeof field === 'string') {
1700
+ // Strip alias prefix like 'a0.'
1701
+ const bare = field.replace(/^\w+\./, '');
1702
+ const prop = meta?.properties[bare];
1703
+ return prop?.fieldNames ?? [bare];
1704
+ }
1705
+ // RawQueryFragment with alias: raw('...').as('name')
1706
+ const alias = String(field.params[field.params.length - 1]);
1707
+ const prop = meta?.properties[alias];
1708
+ return prop?.fieldNames ?? [alias];
1709
+ });
1710
+ }
1711
+ // Tier 3: derive from metadata — all cloneable columns
1712
+ const cloneableProps = this.getCloneableProps(meta);
1713
+ const selectFields = [];
1714
+ const columns = [];
1715
+ for (const prop of cloneableProps) {
1716
+ for (const fieldName of prop.fieldNames) {
1717
+ columns.push(fieldName);
1718
+ selectFields.push(fieldName);
1719
+ }
1720
+ }
1721
+ // Auto-populate source select with matching fields
1722
+ if (!sourceFields) {
1723
+ subQuery.select(selectFields);
1724
+ }
1725
+ return columns;
1726
+ }
1727
+ /** Returns properties that are safe to clone (persistable, non-PK, non-generated). */
1728
+ getCloneableProps(meta) {
1729
+ return meta.props.filter(prop => {
1730
+ if (prop.persist === false) {
1731
+ return false;
1732
+ }
1733
+ if (prop.primary) {
1734
+ return false;
1735
+ }
1736
+ if (!prop.fieldNames?.length) {
1737
+ return false;
1738
+ }
1739
+ if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind)) {
1740
+ return false;
1741
+ }
1742
+ if (prop.kind === ReferenceKind.EMBEDDED && !prop.object) {
1743
+ return false;
1744
+ }
1745
+ return true;
1746
+ });
1747
+ }
1622
1748
  applyDiscriminatorCondition() {
1623
1749
  const meta = this.mainAlias.meta;
1624
1750
  if (meta.root.inheritanceType !== 'sti' || !meta.discriminatorValue) {
@@ -1782,6 +1908,9 @@ export class QueryBuilder {
1782
1908
  (this.#state.limit > 0 || this.#state.offset > 0)) {
1783
1909
  this.wrapPaginateSubQuery(meta);
1784
1910
  }
1911
+ if (this.#state.partitionLimit) {
1912
+ this.preparePartitionLimit();
1913
+ }
1785
1914
  if (meta &&
1786
1915
  (this.#state.flags.has(QueryFlag.UPDATE_SUB_QUERY) || this.#state.flags.has(QueryFlag.DELETE_SUB_QUERY))) {
1787
1916
  this.wrapModifySubQuery(meta);
@@ -2001,6 +2130,40 @@ export class QueryBuilder {
2001
2130
  [Utils.getPrimaryKeyHash(meta.primaryKeys)]: { $in: raw(sql, params) },
2002
2131
  });
2003
2132
  }
2133
+ /**
2134
+ * Wraps the inner query (which has ROW_NUMBER in SELECT) with an outer query
2135
+ * that filters by the __rn column to apply per-parent limiting.
2136
+ */
2137
+ wrapPartitionLimitSubQuery(innerQb) {
2138
+ const { limit, offset = 0 } = this.#state.partitionLimit;
2139
+ const rnCol = this.platform.quoteIdentifier('__rn');
2140
+ innerQb.as(this.mainAlias.aliasName);
2141
+ const outerQb = this.platform.createNativeQueryBuilder();
2142
+ outerQb.select('*').from(innerQb);
2143
+ outerQb.where(`${rnCol} > ? and ${rnCol} <= ?`, [offset, offset + limit]);
2144
+ outerQb.orderBy(rnCol);
2145
+ return outerQb;
2146
+ }
2147
+ /**
2148
+ * Adds ROW_NUMBER() OVER (PARTITION BY ...) to the SELECT list and prepares
2149
+ * the query state for per-parent limiting. The actual wrapping into a subquery
2150
+ * with __rn filtering happens in getNativeQuery().
2151
+ */
2152
+ preparePartitionLimit() {
2153
+ const { partitionBy } = this.#state.partitionLimit;
2154
+ // `partitionBy` is always a declared property name, so mapper returns a string here.
2155
+ const partitionCol = this.helper.mapper(partitionBy, this.type, undefined, null);
2156
+ const quotedPartition = partitionCol
2157
+ .split('.')
2158
+ .map(e => this.platform.quoteIdentifier(e))
2159
+ .join('.');
2160
+ const queryOrder = this.helper.getQueryOrder(this.type, this.#state.orderBy, this.#state.populateMap, this.#state.collation);
2161
+ const orderBySql = queryOrder.length > 0 ? Utils.unique(queryOrder).join(', ') : quotedPartition;
2162
+ const rnAlias = this.platform.quoteIdentifier('__rn');
2163
+ this.#state.fields.push(raw(`row_number() over (partition by ${quotedPartition} order by ${orderBySql}) as ${rnAlias}`));
2164
+ // Moved into the OVER clause; outer query re-applies via wrapPartitionLimitSubQuery
2165
+ this.#state.orderBy = [];
2166
+ }
2004
2167
  /**
2005
2168
  * Computes the set of populate paths from the _populate hints.
2006
2169
  */
@@ -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>;