@mikro-orm/sql 7.1.9-dev.9 → 7.1.9

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.
@@ -56,6 +56,15 @@ export declare abstract class AbstractSqlConnection extends Connection {
56
56
  commit(ctx: ControlledTransaction<any, any>, eventBroadcaster?: TransactionEventBroadcaster, loggerContext?: LogContext): Promise<void>;
57
57
  /** Rolls back the transaction or rolls back to the savepoint. */
58
58
  rollback(ctx: ControlledTransaction<any, any>, eventBroadcaster?: TransactionEventBroadcaster, loggerContext?: LogContext): Promise<void>;
59
+ /**
60
+ * Waits until the transaction's connection has no query in flight. Kysely runs `rollback` straight
61
+ * on that connection instead of going through its connection provider, so a rollback caused by an
62
+ * aborted query would otherwise be sent while the aborted query is still running. That not only
63
+ * queues the rollback behind it on the server, it also overwrites the query id Kysely compares
64
+ * against before firing the `'cancel query'`/`'kill session'` control statement — the control
65
+ * statement is then discarded as stale and the abort never reaches the database.
66
+ */
67
+ private waitForIdleTransaction;
59
68
  private prepareQuery;
60
69
  /** Executes a SQL query and returns the result based on the method: `'all'` for rows, `'get'` for single row, `'run'` for affected count. */
61
70
  execute<T extends QueryResult | EntityData<AnyEntity> | EntityData<AnyEntity>[] = EntityData<AnyEntity>[]>(query: string | NativeQueryBuilder | RawQueryFragment, params?: readonly unknown[], method?: 'all' | 'get' | 'run', ctx?: Transaction, loggerContext?: LoggingOptions): Promise<T>;
@@ -110,7 +110,15 @@ export class AbstractSqlConnection extends Connection {
110
110
  return ret;
111
111
  }
112
112
  catch (error) {
113
- await this.rollback(trx, options.eventBroadcaster, options.loggerContext);
113
+ // A failing rollback must not mask why the transaction failed in the first place — the
114
+ // `'kill session'` abort strategy tears the connection down, so the rollback that follows can
115
+ // only ever report the dead connection.
116
+ try {
117
+ await this.rollback(trx, options.eventBroadcaster, options.loggerContext);
118
+ }
119
+ catch (rollbackError) {
120
+ this.logger.warn('query', `Failed to roll back transaction: ${rollbackError.message}`);
121
+ }
114
122
  throw error;
115
123
  }
116
124
  }
@@ -138,18 +146,8 @@ export class AbstractSqlConnection extends Connection {
138
146
  trxBuilder = trxBuilder.setAccessMode('read only');
139
147
  }
140
148
  const trx = await trxBuilder.execute();
141
- if (options.ctx) {
142
- const ctx = options.ctx;
143
- ctx.index ??= 0;
144
- const savepointName = `trx${ctx.index + 1}`;
145
- Reflect.defineProperty(trx, 'index', { value: ctx.index + 1 });
146
- Reflect.defineProperty(trx, 'savepointName', { value: savepointName });
147
- this.logQuery(this.platform.getSavepointSQL(savepointName), options.loggerContext);
148
- }
149
- else {
150
- for (const query of this.platform.getBeginTransactionSQL(options)) {
151
- this.logQuery(query, options.loggerContext);
152
- }
149
+ for (const query of this.platform.getBeginTransactionSQL(options)) {
150
+ this.logQuery(query, options.loggerContext);
153
151
  }
154
152
  await options.eventBroadcaster?.dispatchEvent(EventType.afterTransactionStart, trx);
155
153
  return trx;
@@ -173,6 +171,7 @@ export class AbstractSqlConnection extends Connection {
173
171
  /** Rolls back the transaction or rolls back to the savepoint. */
174
172
  async rollback(ctx, eventBroadcaster, loggerContext) {
175
173
  await eventBroadcaster?.dispatchEvent(EventType.beforeTransactionRollback, ctx);
174
+ await this.waitForIdleTransaction(ctx);
176
175
  if ('savepointName' in ctx) {
177
176
  await ctx.rollbackToSavepoint(ctx.savepointName).execute();
178
177
  this.logQuery(this.platform.getRollbackToSavepointSQL(ctx.savepointName), loggerContext);
@@ -183,6 +182,17 @@ export class AbstractSqlConnection extends Connection {
183
182
  }
184
183
  await eventBroadcaster?.dispatchEvent(EventType.afterTransactionRollback, ctx);
185
184
  }
185
+ /**
186
+ * Waits until the transaction's connection has no query in flight. Kysely runs `rollback` straight
187
+ * on that connection instead of going through its connection provider, so a rollback caused by an
188
+ * aborted query would otherwise be sent while the aborted query is still running. That not only
189
+ * queues the rollback behind it on the server, it also overwrites the query id Kysely compares
190
+ * against before firing the `'cancel query'`/`'kill session'` control statement — the control
191
+ * statement is then discarded as stale and the abort never reaches the database.
192
+ */
193
+ async waitForIdleTransaction(ctx) {
194
+ await ctx.getExecutor().provideConnection(async () => undefined);
195
+ }
186
196
  prepareQuery(query, params = []) {
187
197
  if (query instanceof NativeQueryBuilder) {
188
198
  query = query.toRaw();
@@ -604,7 +604,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
604
604
  let pk;
605
605
  if (meta.primaryKeys.length > 1) {
606
606
  // owner has composite pk
607
- pk = Utils.getPrimaryKeyCond(data, meta.primaryKeys);
607
+ pk = Utils.getOrderedPrimaryKeys(data, meta);
608
608
  }
609
609
  else {
610
610
  /* v8 ignore next */
@@ -887,10 +887,9 @@ export class AbstractSqlDriver extends DatabaseDriver {
887
887
  }
888
888
  const res = await this.execute(sql, params, 'run', options.ctx, withAbortContext(options.loggerContext, options));
889
889
  let pk;
890
- /* v8 ignore next */
891
890
  if (pks.length > 1) {
892
891
  // owner has composite pk
893
- pk = data.map(d => Utils.getPrimaryKeyCond(d, pks));
892
+ pk = data.map(d => Utils.getOrderedPrimaryKeys(d, meta));
894
893
  }
895
894
  else {
896
895
  res.row ??= {};
@@ -949,8 +948,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
949
948
  }
950
949
  res = await this.rethrow(qb.execute('run', false));
951
950
  }
952
- /* v8 ignore next */
953
- const pk = pks.map(pk => Utils.extractPK(data[pk] || where, meta));
951
+ const pk = Utils.getOrderedPrimaryKeys({ ...where, ...data }, meta);
954
952
  await this.processManyToMany(meta, pk, collections, true, options);
955
953
  return res;
956
954
  }
@@ -1072,7 +1070,8 @@ export class AbstractSqlDriver extends DatabaseDriver {
1072
1070
  ? `(${pks.map(() => '?').join(', ')})`
1073
1071
  : `(${pks.map(pk => `${this.platform.quoteIdentifier(pk)} = ?`).join(' and ')})`;
1074
1072
  const conds = where.map(cond => {
1075
- if (Utils.isPlainObject(cond) && Utils.getObjectKeysSize(cond) === 1) {
1073
+ // with multiple PK columns the condition is looked up by property name, so it needs to stay an object
1074
+ if (pks.length === 1 && Utils.isPlainObject(cond) && Utils.getObjectKeysSize(cond) === 1) {
1076
1075
  cond = Object.values(cond)[0];
1077
1076
  }
1078
1077
  if (pks.length > 1) {
@@ -1100,7 +1099,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1100
1099
  sql += conds.join(' or ');
1101
1100
  }
1102
1101
  if (this.platform.usesReturningStatement() && returning.size > 0) {
1103
- const returningFields = Utils.flatten([...returning].map(prop => meta.properties[prop].fieldNames));
1102
+ const returningFields = Utils.flatten([...returning].map(prop => (meta.properties[prop] ?? meta.root.properties[prop]).fieldNames));
1104
1103
  /* v8 ignore next */
1105
1104
  sql +=
1106
1105
  returningFields.length > 0
@@ -1112,7 +1111,8 @@ export class AbstractSqlDriver extends DatabaseDriver {
1112
1111
  }
1113
1112
  const res = await this.rethrow(this.execute(sql, params, 'run', options.ctx, withAbortContext(options.loggerContext, options)));
1114
1113
  for (let i = 0; i < collections.length; i++) {
1115
- await this.processManyToMany(meta, where[i], collections[i], false, options);
1114
+ const pk = Utils.getOrderedPrimaryKeys(where[i], meta);
1115
+ await this.processManyToMany(meta, pk, collections[i], false, options);
1116
1116
  }
1117
1117
  return res;
1118
1118
  }
@@ -1316,7 +1316,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1316
1316
  }
1317
1317
  }
1318
1318
  }
1319
- return this.buildPivotResultMap(owners, res, pivotProp2.name, pivotProp1.name);
1319
+ return this.buildPivotResultMap(owners, res, pivotProp2.name, pivotProp1.name, ownerMeta);
1320
1320
  }
1321
1321
  /**
1322
1322
  * Load from a polymorphic M:N pivot table.
@@ -1337,10 +1337,15 @@ export class AbstractSqlDriver extends DatabaseDriver {
1337
1337
  async loadPolymorphicPivotOwnerSide(prop, owners, where, orderBy, ctx, options, pivotJoin, inverseProp) {
1338
1338
  const pivotMeta = this.metadata.get(prop.pivotEntity);
1339
1339
  const targetMeta = prop.targetMeta;
1340
- // Build condition: discriminator = 'post' AND {discriminator} IN (...)
1340
+ // `prop.discriminator` spans all owner FK columns but carries no target metadata, so a composite
1341
+ // owner PK neither expands to a tuple condition nor gets mapped back; the virtual M:1 relation
1342
+ // to this discriminator's owner describes the same columns as an actual relation
1343
+ const ownerMeta = this.metadata.get(pivotMeta.polymorphicDiscriminatorMap[prop.discriminatorValue]);
1344
+ const ownerProp = pivotMeta.properties[`${prop.discriminator}_${ownerMeta.tableName}`];
1345
+ // Build condition: discriminator = 'post' AND {owner} IN (...)
1341
1346
  const cond = {
1342
1347
  [prop.discriminatorColumn]: prop.discriminatorValue,
1343
- [prop.discriminator]: { $in: owners.length === 1 && owners[0].length === 1 ? owners.map(o => o[0]) : owners },
1348
+ [ownerProp.name]: { $in: owners.length === 1 && owners[0].length === 1 ? owners.map(o => o[0]) : owners },
1344
1349
  };
1345
1350
  if (!Utils.isEmpty(where)) {
1346
1351
  cond[inverseProp.name] = { ...where };
@@ -1351,9 +1356,13 @@ export class AbstractSqlDriver extends DatabaseDriver {
1351
1356
  const childExclude = !Utils.isEmpty(options?.exclude)
1352
1357
  ? options.exclude.map(f => `${inverseProp.name}.${f}`)
1353
1358
  : [];
1359
+ // the owner relation is virtual, so its FK columns have to be selected via the pivot props that
1360
+ // cover them; only the first owner of a shared pivot gets the flat prop named after the
1361
+ // discriminator, and it keeps that owner's columns, so later owners get per-column props instead
1362
+ const ownerFields = Utils.unique(prop.joinColumns.map(col => (pivotMeta.properties[col] ? col : prop.discriminator)));
1354
1363
  const fields = pivotJoin
1355
- ? [inverseProp.name, prop.discriminator, prop.discriminatorColumn]
1356
- : [inverseProp.name, prop.discriminator, prop.discriminatorColumn, ...childFields];
1364
+ ? [inverseProp.name, ...ownerFields, prop.discriminatorColumn]
1365
+ : [inverseProp.name, ...ownerFields, prop.discriminatorColumn, ...childFields];
1357
1366
  const res = await this.find(pivotMeta.class, cond, {
1358
1367
  ctx,
1359
1368
  ...options,
@@ -1374,7 +1383,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1374
1383
  _populateWhere: 'infer',
1375
1384
  populateFilter: this.wrapPopulateFilter(options, inverseProp.name),
1376
1385
  });
1377
- return this.buildPivotResultMap(owners, res, prop.discriminator, inverseProp.name);
1386
+ return this.buildPivotResultMap(owners, res, ownerProp.name, inverseProp.name, ownerMeta);
1378
1387
  }
1379
1388
  /**
1380
1389
  * Load from inverse side of polymorphic M:N (e.g., Tag -> Posts)
@@ -1423,7 +1432,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
1423
1432
  _populateWhere: 'infer',
1424
1433
  populateFilter: this.wrapPopulateFilter(options, ownerRelationName),
1425
1434
  });
1426
- return this.buildPivotResultMap(owners, res, tagProp.name, ownerRelationName);
1435
+ return this.buildPivotResultMap(owners, res, tagProp.name, ownerRelationName, tagProp.targetMeta);
1427
1436
  }
1428
1437
  /**
1429
1438
  * Load a union-target polymorphic M:N pivot (e.g. Post.attachments -> Image | Video).
@@ -1519,19 +1528,23 @@ export class AbstractSqlDriver extends DatabaseDriver {
1519
1528
  }
1520
1529
  }
1521
1530
  const result = orphanedRows.size > 0 ? pivotRows.filter(r => !orphanedRows.has(r)) : pivotRows;
1522
- return this.buildPivotResultMap(owners, result, ownerProp.name, prop.discriminator);
1531
+ return this.buildPivotResultMap(owners, result, ownerProp.name, prop.discriminator, ownerMeta);
1523
1532
  }
1524
1533
  /**
1525
1534
  * Build a map from owner PKs to their related entities from pivot table results.
1526
1535
  */
1527
- buildPivotResultMap(owners, results, keyProp, valueProp) {
1536
+ buildPivotResultMap(owners, results, keyProp, valueProp, ownerMeta) {
1528
1537
  const map = {};
1529
1538
  for (const owner of owners) {
1530
1539
  const key = Utils.getPrimaryKeyHash(owner);
1531
1540
  map[key] = [];
1532
1541
  }
1533
1542
  for (const item of results) {
1534
- const key = Utils.getPrimaryKeyHash(Utils.asArray(item[keyProp]));
1543
+ const fk = item[keyProp];
1544
+ // the owner PKs are always flat, while the pivot FK follows the owner PK structure,
1545
+ // so a PK built from a relation to another composite PK entity needs flattening too
1546
+ const pks = ownerMeta && fk != null ? Utils.getOrderedPrimaryKeys(fk, ownerMeta) : Utils.asArray(fk);
1547
+ const key = Utils.getPrimaryKeyHash(pks);
1535
1548
  const entity = item[valueProp];
1536
1549
  if (map[key]) {
1537
1550
  map[key].push(entity);
@@ -1657,8 +1670,22 @@ export class AbstractSqlDriver extends DatabaseDriver {
1657
1670
  const [propName, ref] = hint.field.split(':', 2);
1658
1671
  return { propName, ref, children: hint.children };
1659
1672
  });
1673
+ // with `fixedOrder` the pivot PK is the order column, which is not guaranteed to be unique when the
1674
+ // pivot table is managed externally, so we disambiguate the rows by their FKs on top of the PK
1675
+ // (including virtual ones, as the owner FK of a polymorphic pivot is only mapped via non-persisted relations)
1676
+ const pivotRelations = meta.pivotTable && !meta.compositePK ? meta.relations.filter(p => p.kind === ReferenceKind.MANY_TO_ONE) : [];
1660
1677
  for (const item of rawResults) {
1661
- const pk = Utils.getCompositeKeyHash(item, meta);
1678
+ let pk = Utils.getCompositeKeyHash(item, meta);
1679
+ if (pivotRelations.length > 0) {
1680
+ pk = Utils.getPrimaryKeyHash([
1681
+ pk,
1682
+ ...pivotRelations.flatMap(p => {
1683
+ const value = item[p.name];
1684
+ // composite FKs are mapped to an array of values, which `extractPK` does not accept
1685
+ return (Array.isArray(value) ? value : Utils.extractPK(value, p.targetMeta));
1686
+ }),
1687
+ ]);
1688
+ }
1662
1689
  if (map[pk]) {
1663
1690
  for (const { propName } of hints) {
1664
1691
  if (!item[propName]) {
@@ -2087,7 +2114,20 @@ export class AbstractSqlDriver extends DatabaseDriver {
2087
2114
  const ret = {};
2088
2115
  for (const prop of meta.relations) {
2089
2116
  if (prop.kind === ReferenceKind.MANY_TO_MANY && data[prop.name]) {
2090
- ret[prop.name] = data[prop.name].map((item) => Utils.asArray(item));
2117
+ // union targets are validated to have a single PK column, so a pivot row is always keyed
2118
+ // by exactly `[discriminator, pk]` - anything else cannot address a target table
2119
+ const discriminators = QueryHelper.isUnionTargetPolymorphic(prop)
2120
+ ? Object.keys(prop.discriminatorMap)
2121
+ : undefined;
2122
+ ret[prop.name] = data[prop.name].map((item) => {
2123
+ const values = Utils.asArray(item);
2124
+ if (discriminators && !(values.length === 2 && discriminators.includes('' + values[0]))) {
2125
+ throw new Error(`Cannot resolve the discriminator value of ${meta.className}.${prop.name} from '${values.join(', ')}', ` +
2126
+ `as the same primary key can exist in any of the target tables. ` +
2127
+ `Pass the target as a [discriminator, ...primaryKey] tuple, e.g. ${JSON.stringify([discriminators[0], ...values])}.`);
2128
+ }
2129
+ return values;
2130
+ });
2091
2131
  delete data[prop.name];
2092
2132
  }
2093
2133
  }
@@ -168,7 +168,7 @@ export class MsSqlNativeQueryBuilder extends NativeQueryBuilder {
168
168
  const fields = this.options.groupBy.map(field => this.quote(field));
169
169
  this.parts.push(`group by ${fields.join(', ')}`);
170
170
  }
171
- if (this.options.having) {
171
+ if (this.options.having?.sql.trim()) {
172
172
  this.parts.push(`having ${this.options.having.sql}`);
173
173
  this.params.push(...this.options.having.params);
174
174
  }
@@ -53,6 +53,7 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
53
53
  getPreAlterTable(tableDiff: TableDifference, safe: boolean): string[];
54
54
  getRenameColumnSQL(tableName: string, oldColumnName: string, to: Column): string;
55
55
  getRenameIndexSQL(tableName: string, index: IndexDef, oldIndexName: string): string[];
56
+ protected hasInlineColumnComment(): boolean;
56
57
  getChangeColumnCommentSQL(tableName: string, to: Column, schemaName?: string): string;
57
58
  alterTableColumn(column: Column, table: DatabaseTable, changedProperties: Set<string>): string[];
58
59
  private getColumnDeclarationSQL;
@@ -322,7 +322,7 @@ export class MySqlSchemaHelper extends SchemaHelper {
322
322
  const ret = [];
323
323
  for (const event of trigger.events) {
324
324
  const name = trigger.events.length > 1 ? `${trigger.name}_${event}` : trigger.name;
325
- ret.push(`create trigger ${this.quote(name)} ${timing} ${event.toUpperCase()} on ${table.getQuotedName()} for each ROW begin ${trigger.body}; end`);
325
+ ret.push(`create trigger ${this.quote(name)} ${timing} ${event.toUpperCase()} on ${table.getQuotedName()} for each ROW begin ${this.normalizeTriggerBody(trigger.body)} end`);
326
326
  }
327
327
  return ret.join(';\n');
328
328
  }
@@ -540,6 +540,9 @@ export class MySqlSchemaHelper extends SchemaHelper {
540
540
  const keyName = this.quote(index.keyName);
541
541
  return [`alter table ${tableName} rename index ${oldIndexName} to ${keyName}`];
542
542
  }
543
+ hasInlineColumnComment() {
544
+ return true;
545
+ }
543
546
  getChangeColumnCommentSQL(tableName, to, schemaName) {
544
547
  tableName = this.quote(tableName);
545
548
  const columnName = this.quote(to.name);
@@ -234,7 +234,7 @@ export class OracleNativeQueryBuilder extends NativeQueryBuilder {
234
234
  const fields = this.options.groupBy.map(field => this.quote(field));
235
235
  this.parts.push(`group by ${fields.join(', ')}`);
236
236
  }
237
- if (this.options.having) {
237
+ if (this.options.having?.sql.trim()) {
238
238
  this.parts.push(`having ${this.options.having.sql}`);
239
239
  this.params.push(...this.options.having.params);
240
240
  }
@@ -513,7 +513,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
513
513
  const when = trigger.when ? `\n when (${trigger.when})` : '';
514
514
  const fnName = this.getSchemaQualifiedTriggerFnName(table, trigger);
515
515
  const triggerName = this.platform.quoteIdentifier(trigger.name);
516
- const fnSql = `create or replace function ${fnName}() returns trigger as $$ begin ${trigger.body}; end; $$ language plpgsql`;
516
+ const fnSql = `create or replace function ${fnName}() returns trigger as $$ begin ${this.normalizeTriggerBody(trigger.body)} end; $$ language plpgsql`;
517
517
  const triggerSql = `create trigger ${triggerName} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} execute function ${fnName}()`;
518
518
  return `${fnSql};\n${triggerSql}`;
519
519
  }
@@ -561,7 +561,7 @@ export class SqliteSchemaHelper extends SchemaHelper {
561
561
  for (const event of trigger.events) {
562
562
  const name = trigger.events.length > 1 ? `${trigger.name}_${event}` : trigger.name;
563
563
  const when = trigger.when ? `\n when ${trigger.when}` : '';
564
- ret.push(`create trigger ${this.quote(name)} ${timing} ${event.toUpperCase()} on ${table.getQuotedName()} for each ${forEach}${when} begin ${trigger.body}; end`);
564
+ ret.push(`create trigger ${this.quote(name)} ${timing} ${event.toUpperCase()} on ${table.getQuotedName()} for each ${forEach}${when} begin ${this.normalizeTriggerBody(trigger.body)} end`);
565
565
  }
566
566
  return ret.join(';\n');
567
567
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.1.9-dev.9",
3
+ "version": "7.1.9",
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",
@@ -50,10 +50,10 @@
50
50
  "kysely": "0.29.4"
51
51
  },
52
52
  "devDependencies": {
53
- "@mikro-orm/core": "^7.1.8"
53
+ "@mikro-orm/core": "^7.1.9"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.1.9-dev.9"
56
+ "@mikro-orm/core": "7.1.9"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -308,7 +308,7 @@ export class NativeQueryBuilder {
308
308
  const fields = this.options.groupBy.map(field => this.quote(field));
309
309
  this.parts.push(`group by ${fields.join(', ')}`);
310
310
  }
311
- if (this.options.having) {
311
+ if (this.options.having?.sql.trim()) {
312
312
  this.parts.push(`having ${this.options.having.sql}`);
313
313
  this.params.push(...this.options.having.params);
314
314
  }
@@ -414,7 +414,8 @@ export class QueryBuilderHelper {
414
414
  }
415
415
  if (k === '$not') {
416
416
  const res = this._appendQueryCondition(type, cond[k]);
417
- parts.push(`not (${res.sql})`);
417
+ // negating a vacuously true condition (e.g. an empty `$and`) matches nothing
418
+ parts.push(res.sql ? `not (${res.sql})` : '1 = 0');
418
419
  res.params.forEach(p => params.push(p));
419
420
  continue;
420
421
  }
@@ -920,7 +920,9 @@ export class SchemaComparator {
920
920
  // Remove AS keyword (optional in SQL, MySQL may add/remove it)
921
921
  .replace(/\bas\b/gi, '')
922
922
  // Remove remaining special chars, parentheses, type casts, asterisks, and normalize whitespace
923
- .replace(/[()\n[\]*]|::\w+| +/g, '')
923
+ // tabs and CRs included — the schema generator trims every line before executing the DDL,
924
+ // so indentation and CRLF endings can never come back from introspection
925
+ .replace(/[()\n\r\t[\]*]|::\w+| +/g, '')
924
926
  .replace(/anyarray\[(.*)]/gi, '$1')
925
927
  .toLowerCase()
926
928
  // PostgreSQL adds default aliases to aggregate functions (e.g., count(*) AS count)
@@ -4,7 +4,13 @@ import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
4
4
  import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef, SqlRoutineDef } from '../typings.js';
5
5
  import type { DatabaseSchema } from './DatabaseSchema.js';
6
6
  import type { DatabaseTable } from './DatabaseTable.js';
7
- /** Flattens `;\n` boundaries so the schema-generator's statement splitter doesn't break the routine DDL apart. Other whitespace is preserved. */
7
+ /**
8
+ * Flattens `;\n` boundaries and drops blank lines so the schema-generator's statement splitter
9
+ * doesn't break the routine or trigger DDL apart — it treats both as statement/group separators.
10
+ * Blank lines go first, otherwise a `;` followed by one would keep its newline. Like
11
+ * `normalizeViewDefinition`, this is not string-literal aware, so a blank line inside a multi-line
12
+ * literal is dropped too. Other whitespace is preserved.
13
+ */
8
14
  export declare function stripStatementNewlines(body: string): string;
9
15
  /**
10
16
  * Strips SQL line comments and blank lines from a view definition. Comments are dropped by the
@@ -137,6 +143,8 @@ export declare abstract class SchemaHelper {
137
143
  }[];
138
144
  }[], safe: boolean): string[];
139
145
  getChangeColumnCommentSQL(tableName: string, to: Column, schemaName?: string): string;
146
+ /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
147
+ protected hasInlineColumnComment(): boolean;
140
148
  getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
141
149
  protected mapIndexes(indexes: IndexDef[]): Promise<IndexDef[]>;
142
150
  mapForeignKeys(fks: any[], tableName: string, schemaName?: string): Dictionary;
@@ -176,6 +184,8 @@ export declare abstract class SchemaHelper {
176
184
  createRoutine(_routine: SqlRoutineDef): string;
177
185
  dropRoutine(_routine: SqlRoutineDef): string;
178
186
  getAllRoutines(_connection: AbstractSqlConnection, _schemas?: string[]): Promise<SqlRoutineDef[]>;
187
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
188
+ protected normalizeTriggerBody(body: string): string;
179
189
  /** Wraps the body in `BEGIN ... END` if not already, and flattens internal `;\n` so the schema-generator's statement splitter doesn't tear the DDL. */
180
190
  protected wrapRoutineBody(body: string): string;
181
191
  protected stripRoutineBody(body: string): string;
@@ -1,7 +1,17 @@
1
1
  import { isRaw, Utils, } from '@mikro-orm/core';
2
- /** Flattens `;\n` boundaries so the schema-generator's statement splitter doesn't break the routine DDL apart. Other whitespace is preserved. */
2
+ /**
3
+ * Flattens `;\n` boundaries and drops blank lines so the schema-generator's statement splitter
4
+ * doesn't break the routine or trigger DDL apart — it treats both as statement/group separators.
5
+ * Blank lines go first, otherwise a `;` followed by one would keep its newline. Like
6
+ * `normalizeViewDefinition`, this is not string-literal aware, so a blank line inside a multi-line
7
+ * literal is dropped too. Other whitespace is preserved.
8
+ */
3
9
  export function stripStatementNewlines(body) {
4
- return body.replace(/;[\t ]*\r?\n/g, '; ');
10
+ return body
11
+ .split('\n')
12
+ .filter(line => line.trim() !== '')
13
+ .join('\n')
14
+ .replace(/;[\t ]*\r?\n/g, '; ');
5
15
  }
6
16
  /**
7
17
  * Strips SQL line comments and blank lines from a view definition. Comments are dropped by the
@@ -436,7 +446,8 @@ export class SchemaHelper {
436
446
  this.append(ret, this.alterTableColumn(column, diff.fromTable, changedProperties));
437
447
  }
438
448
  for (const { column, changedProperties } of Object.values(diff.changedColumns).filter(diff => diff.changedProperties.has('comment'))) {
439
- if (['type', 'nullable', 'autoincrement', 'unsigned', 'default', 'enumItems', 'collation'].some(t => changedProperties.has(t))) {
449
+ if (this.hasInlineColumnComment() &&
450
+ ['type', 'nullable', 'autoincrement', 'unsigned', 'default', 'enumItems', 'collation'].some(t => changedProperties.has(t))) {
440
451
  continue; // will be handled via column update
441
452
  }
442
453
  ret.push(this.getChangeColumnCommentSQL(tableName, column, schemaName));
@@ -489,7 +500,13 @@ export class SchemaHelper {
489
500
  return `add ${this.createTableColumn(column, table)}`;
490
501
  })
491
502
  .join(', ');
492
- return [`alter table ${table.getQuotedName()} ${adds}`];
503
+ const ret = [`alter table ${table.getQuotedName()} ${adds}`];
504
+ if (!this.hasInlineColumnComment()) {
505
+ for (const column of columns.filter(column => column.comment)) {
506
+ ret.push(this.getChangeColumnCommentSQL(table.name, column, table.schema));
507
+ }
508
+ }
509
+ return ret;
493
510
  }
494
511
  getDropColumnsSQL(tableName, columns, schemaName) {
495
512
  const name = this.quote(this.getTableName(tableName, schemaName));
@@ -608,6 +625,10 @@ export class SchemaHelper {
608
625
  getChangeColumnCommentSQL(tableName, to, schemaName) {
609
626
  return '';
610
627
  }
628
+ /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
629
+ hasInlineColumnComment() {
630
+ return false;
631
+ }
611
632
  async getNamespaces(connection, ctx) {
612
633
  return [];
613
634
  }
@@ -861,7 +882,7 @@ export class SchemaHelper {
861
882
  const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
862
883
  const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
863
884
  const when = trigger.when ? ` when (${trigger.when})` : '';
864
- return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${trigger.body}; end`;
885
+ return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${this.normalizeTriggerBody(trigger.body)} end`;
865
886
  }
866
887
  /**
867
888
  * Generates SQL to drop a database trigger from a table.
@@ -885,6 +906,11 @@ export class SchemaHelper {
885
906
  async getAllRoutines(_connection, _schemas = []) {
886
907
  return [];
887
908
  }
909
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
910
+ normalizeTriggerBody(body) {
911
+ const trimmed = stripStatementNewlines(body).trim();
912
+ return /;\s*$/.test(trimmed) ? trimmed : `${trimmed};`;
913
+ }
888
914
  /** Wraps the body in `BEGIN ... END` if not already, and flattens internal `;\n` so the schema-generator's statement splitter doesn't tear the DDL. */
889
915
  wrapRoutineBody(body) {
890
916
  const trimmed = stripStatementNewlines(body).trim();
@@ -54,6 +54,10 @@ export declare class SqlSchemaGenerator extends AbstractSchemaGenerator<Abstract
54
54
  wrap?: boolean;
55
55
  ctx?: Transaction;
56
56
  }): Promise<void>;
57
+ /** Splits the SQL on the separator, keeping the separators that fall inside a string literal. */
58
+ private splitOutsideLiterals;
59
+ /** Whether the statement has to be the first one in a query batch, e.g. `create trigger` on MSSQL. */
60
+ protected startsBatch(_statement: string): boolean;
57
61
  dropTableIfExists(name: string, schema?: string): Promise<void>;
58
62
  private wrapSchema;
59
63
  private append;
@@ -439,18 +439,23 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
439
439
  }
440
440
  async execute(sql, options = {}) {
441
441
  options.wrap ??= false;
442
- const lines = this.wrapSchema(sql, options).split('\n');
442
+ const lines = this.splitOutsideLiterals(this.wrapSchema(sql, options), '\n');
443
443
  const groups = [];
444
444
  let i = 0;
445
445
  for (const line of lines) {
446
- if (line.trim() === '') {
446
+ const stmt = line.trim();
447
+ if (stmt === '') {
447
448
  if (groups[i]?.length > 0) {
448
449
  i++;
449
450
  }
450
451
  continue;
451
452
  }
453
+ // same boundary an empty line creates, for statements that have to start their own batch
454
+ if (groups[i]?.length > 0 && this.startsBatch(stmt)) {
455
+ i++;
456
+ }
452
457
  groups[i] ??= [];
453
- groups[i].push(line.trim());
458
+ groups[i].push(stmt);
454
459
  }
455
460
  if (groups.length === 0) {
456
461
  return;
@@ -463,14 +468,37 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
463
468
  return;
464
469
  }
465
470
  const statements = groups.flatMap(group => {
466
- return group
467
- .join('\n')
468
- .split(';\n')
471
+ return this.splitOutsideLiterals(group.join('\n'), ';\n')
469
472
  .map(s => s.trim())
470
473
  .filter(s => s);
471
474
  });
472
475
  await Utils.runSerial(statements, stmt => this.driver.execute(stmt));
473
476
  }
477
+ /** Splits the SQL on the separator, keeping the separators that fall inside a string literal. */
478
+ splitOutsideLiterals(sql, separator) {
479
+ const [idOpen, idClose] = this.platform.quoteIdentifier('');
480
+ // mysql escapes quotes as `\'`, the other dialects double them, which pairs up on its own
481
+ const esc = this.platform.quoteValue(`'`).includes(`\\'`) ? '\\\\.|' : '';
482
+ // complete literals, quoted identifiers and `--` comments, so that an apostrophe inside an
483
+ // identifier or a comment is not mistaken for one opening a literal
484
+ const tokens = new RegExp(`'(?:${esc}[^'])*'|\\${idOpen}[^\\${idClose}]*\\${idClose}|--[^\n]*`, 'g');
485
+ const parts = [];
486
+ for (const chunk of sql.split(separator)) {
487
+ const prev = parts.at(-1);
488
+ // whatever quote is left once the complete tokens are gone opened a literal we are still inside of
489
+ if (prev?.replace(tokens, '').includes(`'`)) {
490
+ parts[parts.length - 1] = prev + separator + chunk;
491
+ }
492
+ else {
493
+ parts.push(chunk);
494
+ }
495
+ }
496
+ return parts;
497
+ }
498
+ /** Whether the statement has to be the first one in a query batch, e.g. `create trigger` on MSSQL. */
499
+ startsBatch(_statement) {
500
+ return false;
501
+ }
474
502
  async dropTableIfExists(name, schema) {
475
503
  const sql = this.helper.dropTableIfExists(name, schema);
476
504
  return this.execute(sql);