@mikro-orm/sql 7.2.0-dev.3 → 7.2.0-dev.5

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,23 @@ 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
+ // flat hash, so nested composite PK values keep their own separators and cannot collide
1679
+ let pk = Utils.getCompositeKeyHash(item, meta, false, undefined, true);
1680
+ if (pivotRelations.length > 0) {
1681
+ pk = Utils.getPrimaryKeyHash([
1682
+ pk,
1683
+ ...pivotRelations.flatMap(p => {
1684
+ const value = item[p.name];
1685
+ // composite FKs are mapped to an array of values, which `extractPK` does not accept
1686
+ return (Array.isArray(value) ? Utils.flatten(value, true) : Utils.extractPK(value, p.targetMeta));
1687
+ }),
1688
+ ]);
1689
+ }
1662
1690
  if (map[pk]) {
1663
1691
  for (const { propName } of hints) {
1664
1692
  if (!item[propName]) {
@@ -2087,7 +2115,20 @@ export class AbstractSqlDriver extends DatabaseDriver {
2087
2115
  const ret = {};
2088
2116
  for (const prop of meta.relations) {
2089
2117
  if (prop.kind === ReferenceKind.MANY_TO_MANY && data[prop.name]) {
2090
- ret[prop.name] = data[prop.name].map((item) => Utils.asArray(item));
2118
+ // union targets are validated to have a single PK column, so a pivot row is always keyed
2119
+ // by exactly `[discriminator, pk]` - anything else cannot address a target table
2120
+ const discriminators = QueryHelper.isUnionTargetPolymorphic(prop)
2121
+ ? Object.keys(prop.discriminatorMap)
2122
+ : undefined;
2123
+ ret[prop.name] = data[prop.name].map((item) => {
2124
+ const values = Utils.asArray(item);
2125
+ if (discriminators && !(values.length === 2 && discriminators.includes('' + values[0]))) {
2126
+ throw new Error(`Cannot resolve the discriminator value of ${meta.className}.${prop.name} from '${values.join(', ')}', ` +
2127
+ `as the same primary key can exist in any of the target tables. ` +
2128
+ `Pass the target as a [discriminator, ...primaryKey] tuple, e.g. ${JSON.stringify([discriminators[0], ...values])}.`);
2129
+ }
2130
+ return values;
2131
+ });
2091
2132
  delete data[prop.name];
2092
2133
  }
2093
2134
  }
@@ -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
  }
@@ -492,7 +492,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
492
492
  // SchemaHelper.createCheck).
493
493
  const m = /^check \(\((.*)\)\)$/is.exec(check.expression);
494
494
  const single = m ? null : /^check \((.*)\)$/is.exec(check.expression);
495
- const def = m ? m[1].replace(/\((.*?)\)::\w+/g, '$1') : single ? single[1] : check.expression;
495
+ const def = m ? m[1].replace(/\(([^()]*)\)::\w+/g, '$1') : single ? single[1] : check.expression;
496
496
  ret[key].push({
497
497
  name: check.name,
498
498
  columnName: check.column_name,
@@ -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
  }
@@ -7,4 +7,7 @@ export declare class BaseSqliteConnection extends AbstractSqlConnection {
7
7
  skipOnConnect?: boolean;
8
8
  }): Promise<void>;
9
9
  protected attachDatabases(): Promise<void>;
10
+ /** Per-connection state, lost whenever the underlying connection is recreated, so it has to be replayed. */
11
+ protected getConnectionSetupSql(): Promise<string[]>;
12
+ private getAttachDatabasesSql;
10
13
  }
@@ -1,5 +1,6 @@
1
1
  import { CompiledQuery } from 'kysely';
2
2
  import { AbstractSqlConnection } from '../../AbstractSqlConnection.js';
3
+ const FOREIGN_KEYS_PRAGMA = 'pragma foreign_keys = on';
3
4
  export class BaseSqliteConnection extends AbstractSqlConnection {
4
5
  createKyselyDialect(options) {
5
6
  throw new Error('No SQLite dialect configured. Pass a Kysely dialect via the `driverOptions` config option, ' +
@@ -7,19 +8,28 @@ export class BaseSqliteConnection extends AbstractSqlConnection {
7
8
  }
8
9
  async connect(options) {
9
10
  await super.connect(options);
10
- await this.getClient().executeQuery(CompiledQuery.raw('pragma foreign_keys = on'));
11
+ await this.getClient().executeQuery(CompiledQuery.raw(FOREIGN_KEYS_PRAGMA));
11
12
  await this.attachDatabases();
12
13
  }
13
14
  async attachDatabases() {
15
+ for (const sql of await this.getAttachDatabasesSql()) {
16
+ await this.execute(sql);
17
+ }
18
+ }
19
+ /** Per-connection state, lost whenever the underlying connection is recreated, so it has to be replayed. */
20
+ async getConnectionSetupSql() {
21
+ return [FOREIGN_KEYS_PRAGMA, ...(await this.getAttachDatabasesSql())];
22
+ }
23
+ async getAttachDatabasesSql() {
14
24
  const attachDatabases = this.config.get('attachDatabases');
15
25
  if (!attachDatabases?.length) {
16
- return;
26
+ return [];
17
27
  }
18
28
  const { fs } = await import('@mikro-orm/core/fs-utils');
19
29
  const baseDir = this.config.get('baseDir');
20
- for (const db of attachDatabases) {
30
+ return attachDatabases.map(db => {
21
31
  const path = fs.absolutePath(db.path, baseDir);
22
- await this.execute(`attach database '${path}' as ${this.platform.quoteIdentifier(db.name)}`);
23
- }
32
+ return `attach database '${path}' as ${this.platform.quoteIdentifier(db.name)}`;
33
+ });
24
34
  }
25
35
  }
@@ -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.2.0-dev.3",
3
+ "version": "7.2.0-dev.5",
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.7"
53
+ "@mikro-orm/core": "^7.1.11"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.2.0-dev.3"
56
+ "@mikro-orm/core": "7.2.0-dev.5"
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
  }
@@ -506,6 +506,17 @@ export declare class QueryBuilder<Entity extends object = AnyEntity, RootAlias e
506
506
  * @internal
507
507
  */
508
508
  applyJoinedFilters(em: EntityManager, filterOptions: FilterOptions | undefined): Promise<void>;
509
+ /**
510
+ * The `on` clause of `condJoin` — its explicit join condition or a filter condition merged into
511
+ * it — can reference the alias of any join in its subtree, both auto-joins created while
512
+ * processing the condition and pre-existing joined paths, all of which render after `condJoin`
513
+ * and would be forward alias references (issues #7681, #8090, #8099). When that happens, fold the
514
+ * subtree into `condJoin`, so it renders as a single parenthesized join group and every alias
515
+ * shares the scope of the outer `on` clause.
516
+ */
517
+ private nestReferencedJoins;
518
+ private getJoinSubtree;
519
+ private condReferencesAlias;
509
520
  withSubQuery(subQuery: RawQueryFragment | NativeQueryBuilder, alias: string): this;
510
521
  /**
511
522
  * Adds a WHERE clause to the query using an object condition.
@@ -433,6 +433,7 @@ export class QueryBuilder {
433
433
  else {
434
434
  join.cond = { ...cond };
435
435
  }
436
+ this.nestReferencedJoins(join);
436
437
  // For polymorphic LEFT JOIN filters, add a WHERE condition to enforce the filter
437
438
  // only for rows matching this target's discriminator value. This ensures rows pointing
438
439
  // to other polymorphic targets are not excluded.
@@ -456,6 +457,45 @@ export class QueryBuilder {
456
457
  }
457
458
  }
458
459
  }
460
+ /**
461
+ * The `on` clause of `condJoin` — its explicit join condition or a filter condition merged into
462
+ * it — can reference the alias of any join in its subtree, both auto-joins created while
463
+ * processing the condition and pre-existing joined paths, all of which render after `condJoin`
464
+ * and would be forward alias references (issues #7681, #8090, #8099). When that happens, fold the
465
+ * subtree into `condJoin`, so it renders as a single parenthesized join group and every alias
466
+ * shares the scope of the outer `on` clause.
467
+ */
468
+ nestReferencedJoins(condJoin) {
469
+ // m:n pivot joins might not have the target join entry created
470
+ if (!condJoin) {
471
+ return;
472
+ }
473
+ const subtree = this.getJoinSubtree(condJoin);
474
+ if (!subtree.some(j => this.condReferencesAlias(condJoin.cond, j.alias))) {
475
+ return;
476
+ }
477
+ for (const j of subtree) {
478
+ const parent = j.ownerAlias === condJoin.alias ? condJoin : subtree.find(p => p.alias === j.ownerAlias);
479
+ if (!parent.nested?.has(j)) {
480
+ const nested = (parent.nested ??= new Set());
481
+ j.type = j.type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
482
+ nested.add(j);
483
+ }
484
+ }
485
+ }
486
+ getJoinSubtree(join) {
487
+ const children = Object.values(this.#state.joins).filter(j => j !== join && j.ownerAlias === join.alias);
488
+ return children.flatMap(j => [j, ...this.getJoinSubtree(j)]);
489
+ }
490
+ condReferencesAlias(cond, alias) {
491
+ if (Array.isArray(cond)) {
492
+ return cond.some(c => this.condReferencesAlias(c, alias));
493
+ }
494
+ if (Utils.isPlainObject(cond)) {
495
+ return Object.entries(cond).some(([key, value]) => key.startsWith(`${alias}.`) || this.condReferencesAlias(value, alias));
496
+ }
497
+ return false;
498
+ }
459
499
  withSubQuery(subQuery, alias) {
460
500
  this.ensureNotFinalized();
461
501
  if (isRaw(subQuery)) {
@@ -1104,7 +1144,9 @@ export class QueryBuilder {
1104
1144
  }
1105
1145
  if (stack.length > 0) {
1106
1146
  const merged = this.driver.mergeJoinedResult(stack, this.mainAlias.meta, joinedProps);
1107
- yield this.mapResult(merged[0], options.mapResults);
1147
+ for (const row of merged) {
1148
+ yield this.mapResult(row, options.mapResults);
1149
+ }
1108
1150
  }
1109
1151
  }
1110
1152
  /**
@@ -1412,7 +1454,6 @@ export class QueryBuilder {
1412
1454
  aliased: [QueryType.SELECT, QueryType.COUNT].includes(this.type),
1413
1455
  });
1414
1456
  const criteriaNode = CriteriaNodeFactory.createNode(this.metadata, prop.targetMeta.class, cond);
1415
- const joinCountBefore = Object.keys(this.#state.joins).length;
1416
1457
  cond = criteriaNode.process(this, { ignoreBranching: true, alias });
1417
1458
  let aliasedName = `${fromAlias}.${prop.name}#${alias}`;
1418
1459
  path ??= `${Object.values(this.#state.joins).find(j => j.alias === fromAlias)?.path ?? Utils.className(entityName)}.${prop.name}`;
@@ -1442,20 +1483,7 @@ export class QueryBuilder {
1442
1483
  this.#state.joins[aliasedName] = this.helper.joinManyToOneReference(prop, ownerAlias, alias, type, cond, schema);
1443
1484
  this.#state.joins[aliasedName].path ??= path;
1444
1485
  }
1445
- // auto-joins added by cond processing that depend on the new alias would otherwise produce a
1446
- // forward reference (the auto-join's ON refers to alias, while alias's ON refers back to it);
1447
- // fold them into the new join so both aliases share scope in the outer ON clause (issue #7681)
1448
- const condJoin = this.#state.joins[aliasedName];
1449
- const joinKeys = Object.keys(this.#state.joins);
1450
- for (let i = joinCountBefore; i < joinKeys.length; i++) {
1451
- const j = this.#state.joins[joinKeys[i]];
1452
- if (j === condJoin || j.ownerAlias !== alias) {
1453
- continue;
1454
- }
1455
- const nested = (condJoin.nested ??= new Set());
1456
- j.type = j.type === JoinType.innerJoin ? JoinType.nestedInnerJoin : JoinType.nestedLeftJoin;
1457
- nested.add(j);
1458
- }
1486
+ this.nestReferencedJoins(this.#state.joins[aliasedName]);
1459
1487
  return { prop, key: aliasedName };
1460
1488
  }
1461
1489
  prepareFields(fields, type = 'where', schema) {
@@ -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
  }
@@ -785,6 +786,10 @@ export class QueryBuilderHelper {
785
786
  appendGroupCondition(type, operator, subCondition) {
786
787
  const parts = [];
787
788
  const params = [];
789
+ // an empty disjunction is false, same as `$in: []`, while an empty conjunction is vacuously true
790
+ if (operator === '$or' && subCondition.length === 0) {
791
+ return { sql: '1 = 0', params };
792
+ }
788
793
  // single sub-condition can be ignored to reduce nesting of parens
789
794
  if (subCondition.length === 1 || operator === '$and') {
790
795
  for (const sub of subCondition) {
@@ -49,6 +49,8 @@ export declare class DatabaseTable {
49
49
  getEntityDeclaration(namingStrategy: NamingStrategy, schemaHelper: SchemaHelper, scalarPropertiesForRelations: 'always' | 'never' | 'smart'): EntityMetadata;
50
50
  private foreignKeysToProps;
51
51
  private findFkIndex;
52
+ /** Advanced options require an entity-level declaration, as the property-level `index`/`unique` cannot carry them. */
53
+ private hasAdvancedIndexOptions;
52
54
  private getIndexProperties;
53
55
  private getSafeBaseNameForFkProp;
54
56
  /**
@@ -219,6 +219,8 @@ export class DatabaseTable {
219
219
  skippedColumnNames.includes(index.columnNames[0]) || // Non-composite indexes for skipped columns are to be mapped as entity decorators.
220
220
  index.deferMode ||
221
221
  index.expression ||
222
+ index.where ||
223
+ this.hasAdvancedIndexOptions(index) ||
222
224
  !(index.columnNames[0] in columnFks)) && // Trivial non-composite indexes for scalar props are to be mapped to the column.
223
225
  // ignore indexes that don't have all column names (this can happen in sqlite where there is no way to infer this for expressions)
224
226
  !(index.columnNames.some(col => !col) && !index.expression));
@@ -254,14 +256,7 @@ export class DatabaseTable {
254
256
  }
255
257
  }
256
258
  // An index is trivial if it has no special options that require entity-level declaration
257
- const hasAdvancedOptions = index.columns?.length ||
258
- index.include?.length ||
259
- index.fillFactor ||
260
- index.type ||
261
- index.invisible ||
262
- index.disabled ||
263
- index.clustered;
264
- const isTrivial = !index.deferMode && !index.expression && !index.where && !hasAdvancedOptions;
259
+ const isTrivial = !index.deferMode && !index.expression && !index.where && !this.hasAdvancedIndexOptions(index);
265
260
  if (isTrivial) {
266
261
  // Index is for FK. Map to the FK prop and move on.
267
262
  const fkForIndex = fkIndexes.get(index);
@@ -299,6 +294,19 @@ export class DatabaseTable {
299
294
  }
300
295
  schema.addIndex(ret);
301
296
  }
297
+ for (const check of this.getChecks()) {
298
+ // skip checks that were consumed by enum conversion — the enum property recreates an
299
+ // equivalent check under the conventional name during discovery (only on platforms that
300
+ // emulate enums via check constraints; mysql/mariadb enums are native and recreate nothing)
301
+ const enumItems = check.columnName ? this.getColumn(check.columnName)?.enumItems : undefined;
302
+ if (this.#platform.usesEnumCheckConstraints() &&
303
+ enumItems?.length &&
304
+ (check.expression === this.#platform.getEnumCheckConstraintExpression(check.columnName, enumItems) ||
305
+ check.name === this.#platform.getIndexName(this.name, [check.columnName], 'check'))) {
306
+ continue;
307
+ }
308
+ schema.meta.checks.push({ name: check.name, expression: check.expression });
309
+ }
302
310
  const addedStandaloneFkPropsBasedOnColumn = new Set();
303
311
  const nonSkippedColumns = this.getColumns().filter(column => !skippedColumnNames.includes(column.name));
304
312
  for (const column of nonSkippedColumns) {
@@ -507,7 +515,9 @@ export class DatabaseTable {
507
515
  findFkIndex(currentFk) {
508
516
  const fkColumnsLength = currentFk.columnNames.length;
509
517
  const possibleIndexes = this.#indexes.filter(index => {
510
- return (index.columnNames.length === fkColumnsLength &&
518
+ return (!index.where &&
519
+ !this.hasAdvancedIndexOptions(index) &&
520
+ index.columnNames.length === fkColumnsLength &&
511
521
  !currentFk.columnNames.some((columnName, i) => index.columnNames[i] !== columnName));
512
522
  });
513
523
  possibleIndexes.sort((a, b) => {
@@ -521,13 +531,31 @@ export class DatabaseTable {
521
531
  });
522
532
  return possibleIndexes.at(0);
523
533
  }
534
+ /** Advanced options require an entity-level declaration, as the property-level `index`/`unique` cannot carry them. */
535
+ hasAdvancedIndexOptions(index) {
536
+ return !!(index.columns?.length ||
537
+ index.include?.length ||
538
+ index.fillFactor ||
539
+ index.type ||
540
+ index.invisible ||
541
+ index.disabled ||
542
+ index.clustered);
543
+ }
524
544
  getIndexProperties(index, columnFks, fksOnColumnProps, fksOnStandaloneProps, namingStrategy) {
525
- const propBaseNames = new Set();
545
+ const propBaseNames = new Map();
526
546
  const columnNames = index.columnNames;
527
547
  const l = columnNames.length;
528
548
  if (columnNames.some(col => !col)) {
529
549
  return;
530
550
  }
551
+ const addPropBaseName = (baseName, position) => {
552
+ const positions = propBaseNames.get(baseName);
553
+ if (positions) {
554
+ positions.last = position;
555
+ return;
556
+ }
557
+ propBaseNames.set(baseName, { first: position, last: position });
558
+ };
531
559
  for (let i = 0; i < l; ++i) {
532
560
  const columnName = columnNames[i];
533
561
  // The column is not involved with FKs.
@@ -538,14 +566,14 @@ export class DatabaseTable {
538
566
  }
539
567
  // It has a prop named after it.
540
568
  // Add it and move on.
541
- propBaseNames.add(columnName);
569
+ addPropBaseName(columnName, i);
542
570
  continue;
543
571
  }
544
572
  // If the prop named after the column has a FK and the FK's columns are a subset of this index,
545
573
  // include this prop and move on.
546
574
  const columnPropFk = fksOnColumnProps.get(columnName);
547
575
  if (columnPropFk && !columnPropFk.columnNames.some(fkColumnName => !columnNames.includes(fkColumnName))) {
548
- propBaseNames.add(columnName);
576
+ addPropBaseName(columnName, i);
549
577
  continue;
550
578
  }
551
579
  // If there is at least one standalone FK featuring this column,
@@ -557,7 +585,7 @@ export class DatabaseTable {
557
585
  continue;
558
586
  }
559
587
  if (!fk.columnNames.some(fkColumnName => !columnNames.includes(fkColumnName))) {
560
- propBaseNames.add(propName);
588
+ addPropBaseName(propName, i);
561
589
  propAdded = true;
562
590
  }
563
591
  }
@@ -568,7 +596,10 @@ export class DatabaseTable {
568
596
  // Break the whole prop creation.
569
597
  return;
570
598
  }
571
- return Array.from(propBaseNames).map(baseName => this.getPropertyName(namingStrategy, baseName, fksOnColumnProps.get(baseName)));
599
+ // Props sharing their first column would otherwise follow FK discovery order, so break ties on the last one.
600
+ return Array.from(propBaseNames)
601
+ .sort(([, a], [, b]) => a.first - b.first || a.last - b.last)
602
+ .map(([baseName]) => this.getPropertyName(namingStrategy, baseName, fksOnColumnProps.get(baseName)));
572
603
  }
573
604
  getSafeBaseNameForFkProp(namingStrategy, currentFk, fks, columnName) {
574
605
  if (columnName &&
@@ -694,9 +725,19 @@ export class DatabaseTable {
694
725
  const prop = this.getPropertyName(namingStrategy, column.name, fk);
695
726
  const persist = !(column.name in columnFks && typeof fk === 'undefined');
696
727
  const index = compositeFkIndexes[prop] ||
697
- this.#indexes.find(idx => idx.columnNames[0] === column.name && !idx.composite && !idx.unique && !idx.primary);
728
+ this.#indexes.find(idx => idx.columnNames[0] === column.name &&
729
+ !idx.composite &&
730
+ !idx.unique &&
731
+ !idx.primary &&
732
+ !idx.where &&
733
+ !this.hasAdvancedIndexOptions(idx));
698
734
  const unique = compositeFkUniques[prop] ||
699
- this.#indexes.find(idx => idx.columnNames[0] === column.name && !idx.composite && idx.unique && !idx.primary);
735
+ this.#indexes.find(idx => idx.columnNames[0] === column.name &&
736
+ !idx.composite &&
737
+ idx.unique &&
738
+ !idx.primary &&
739
+ !idx.where &&
740
+ !this.hasAdvancedIndexOptions(idx));
700
741
  const kind = this.getReferenceKind(fk, unique);
701
742
  const runtimeType = this.getPropertyTypeForColumn(namingStrategy, column, fk);
702
743
  const type = fk
@@ -897,9 +897,13 @@ export class SchemaComparator {
897
897
  // lookbehind: only strip a real charset introducer, never an underscore inside a literal like 'a_b'
898
898
  ?.replace(/(?<![\w'])_\w+'(.*?)'/g, '$1')
899
899
  .replace(/!=/g, '<>')
900
- .replace(/in\s*\((.*?)\)/gi, '= any (array[$1])')
900
+ // `\b` keeps this from firing inside identifiers like `min(...)`
901
+ .replace(/\bin\s*\((.*?)\)/gi, '= any (array[$1])')
901
902
  // MySQL normalizes count(*) to count(0)
902
903
  .replace(/\bcount\s*\(\s*0\s*\)/gi, 'count(*)')
904
+ // multi word type names in casts, the generic `::\w+` below only covers single word ones
905
+ // the precision is kept, so `timestamptz(3)` and `timestamp(3) with time zone` leave the same residue
906
+ .replace(/::\s*(?:character\s+varying|bit\s+varying|double\s+precision|(?:timestamp|time)\b(\s*\(\d+\))?(?:\s+with(?:out)?\s+time\s+zone)?)/gi, '$1')
903
907
  // Remove quotes first so we can process identifiers
904
908
  .replace(/['"`]/g, '')
905
909
  // MySQL adds table/alias prefixes to columns (e.g., a.name or table_name.column vs just column)
@@ -907,12 +911,18 @@ export class SchemaComparator {
907
911
  .replace(/\b\w+\.(\w+)/g, '$1')
908
912
  // Normalize JOIN syntax: inner join -> join (equivalent in SQL)
909
913
  .replace(/\binner\s+join\b/gi, 'join')
914
+ // PostgreSQL names an unaliased bare function call after the function itself,
915
+ // so `max(created_at)` comes back as `max(created_at) AS max`
916
+ // the lookahead skips table function column alias lists like `unnest(a) AS unnest(c)`, which are meaningful
917
+ .replace(/\b(\w+)\s*\(((?:[^()]|\([^()]*\))*)\)\s+as\s+\1\b(?!\s*\()/gi, '$1($2)')
910
918
  // Remove redundant column aliases like `title AS title` -> `title`
911
919
  .replace(/\b(\w+)\s+as\s+\1\b/gi, '$1')
912
920
  // Remove AS keyword (optional in SQL, MySQL may add/remove it)
913
921
  .replace(/\bas\b/gi, '')
914
922
  // Remove remaining special chars, parentheses, type casts, asterisks, and normalize whitespace
915
- .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, '')
916
926
  .replace(/anyarray\[(.*)]/gi, '$1')
917
927
  .toLowerCase()
918
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
@@ -118,6 +124,8 @@ export declare abstract class SchemaHelper {
118
124
  getAddColumnsSQL(table: DatabaseTable, columns: Column[]): string[];
119
125
  getDropColumnsSQL(tableName: string, columns: Column[], schemaName?: string): string;
120
126
  hasNonDefaultPrimaryKeyName(table: DatabaseTable): boolean;
127
+ /** Returns the `constraint <name> ` prefix for a primary key definition, empty when the server assigns the default name on its own. */
128
+ protected getPrimaryKeyConstraintPrefix(table: DatabaseTable, index: IndexDef): string;
121
129
  castColumn(name: string, type: string): string;
122
130
  alterTableColumn(column: Column, table: DatabaseTable, changedProperties: Set<string>): string[];
123
131
  /** Returns the bare `collate <name>` clause for column DDL. Overridden by PostgreSQL to quote the identifier. */
@@ -137,6 +145,8 @@ export declare abstract class SchemaHelper {
137
145
  }[];
138
146
  }[], safe: boolean): string[];
139
147
  getChangeColumnCommentSQL(tableName: string, to: Column, schemaName?: string): string;
148
+ /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
149
+ protected hasInlineColumnComment(): boolean;
140
150
  getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
141
151
  protected mapIndexes(indexes: IndexDef[]): Promise<IndexDef[]>;
142
152
  mapForeignKeys(fks: any[], tableName: string, schemaName?: string): Dictionary;
@@ -176,6 +186,8 @@ export declare abstract class SchemaHelper {
176
186
  createRoutine(_routine: SqlRoutineDef): string;
177
187
  dropRoutine(_routine: SqlRoutineDef): string;
178
188
  getAllRoutines(_connection: AbstractSqlConnection, _schemas?: string[]): Promise<SqlRoutineDef[]>;
189
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
190
+ protected normalizeTriggerBody(body: string): string;
179
191
  /** 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
192
  protected wrapRoutineBody(body: string): string;
181
193
  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));
@@ -504,6 +521,10 @@ export class SchemaHelper {
504
521
  const defaultName = this.platform.getDefaultPrimaryName(table.name, pkIndex.columnNames);
505
522
  return pkIndex?.keyName !== defaultName;
506
523
  }
524
+ /** Returns the `constraint <name> ` prefix for a primary key definition, empty when the server assigns the default name on its own. */
525
+ getPrimaryKeyConstraintPrefix(table, index) {
526
+ return this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${this.quote(index.keyName)} ` : '';
527
+ }
507
528
  /* v8 ignore next */
508
529
  castColumn(name, type) {
509
530
  return '';
@@ -608,6 +629,10 @@ export class SchemaHelper {
608
629
  getChangeColumnCommentSQL(tableName, to, schemaName) {
609
630
  return '';
610
631
  }
632
+ /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
633
+ hasInlineColumnComment() {
634
+ return false;
635
+ }
611
636
  async getNamespaces(connection, ctx) {
612
637
  return [];
613
638
  }
@@ -745,7 +770,7 @@ export class SchemaHelper {
745
770
  const primaryKey = table.getPrimaryKey();
746
771
  const createPrimary = !table.getColumns().some(c => c.autoincrement && c.primary) || this.hasNonDefaultPrimaryKeyName(table);
747
772
  if (createPrimary && primaryKey) {
748
- const name = this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${this.quote(primaryKey.keyName)} ` : '';
773
+ const name = this.getPrimaryKeyConstraintPrefix(table, primaryKey);
749
774
  sql += `, ${name}primary key (${primaryKey.columnNames.map(c => this.quote(c)).join(', ')})`;
750
775
  }
751
776
  sql += ')';
@@ -828,7 +853,7 @@ export class SchemaHelper {
828
853
  const columns = index.columnNames.map(c => this.quote(c)).join(', ');
829
854
  const defer = index.deferMode ? ` deferrable initially ${index.deferMode}` : '';
830
855
  if (index.primary) {
831
- const keyName = this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${index.keyName} ` : '';
856
+ const keyName = this.getPrimaryKeyConstraintPrefix(table, index);
832
857
  return `alter table ${table.getQuotedName()} add ${keyName}primary key (${columns})${defer}`;
833
858
  }
834
859
  if (index.type === 'fulltext') {
@@ -861,7 +886,7 @@ export class SchemaHelper {
861
886
  const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
862
887
  const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
863
888
  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`;
889
+ return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${this.normalizeTriggerBody(trigger.body)} end`;
865
890
  }
866
891
  /**
867
892
  * Generates SQL to drop a database trigger from a table.
@@ -885,6 +910,11 @@ export class SchemaHelper {
885
910
  async getAllRoutines(_connection, _schemas = []) {
886
911
  return [];
887
912
  }
913
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
914
+ normalizeTriggerBody(body) {
915
+ const trimmed = stripStatementNewlines(body).trim();
916
+ return /;\s*$/.test(trimmed) ? trimmed : `${trimmed};`;
917
+ }
888
918
  /** 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
919
  wrapRoutineBody(body) {
890
920
  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);
package/typings.d.ts CHANGED
@@ -368,9 +368,9 @@ type MaybeGenerated<TValue, TOptions, TProcessOnCreate extends boolean> = TOptio
368
368
  } ? TValue | null : TOptions extends {
369
369
  autoincrement: true;
370
370
  } ? Generated<TValue> : TOptions extends {
371
- default: true;
371
+ default: unknown;
372
372
  } ? Generated<TValue> : TOptions extends {
373
- defaultRaw: true;
373
+ defaultRaw: unknown;
374
374
  } ? Generated<TValue> : TProcessOnCreate extends false ? TValue : TOptions extends {
375
375
  onCreate: Function;
376
376
  } ? Generated<TValue> : TValue;