@mikro-orm/sql 7.1.9-dev.2 → 7.1.9-dev.20

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
  }
@@ -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
  }
@@ -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
  }
@@ -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
  }
@@ -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.1.9-dev.2",
3
+ "version": "7.1.9-dev.20",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
@@ -53,7 +53,7 @@
53
53
  "@mikro-orm/core": "^7.1.8"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.1.9-dev.2"
56
+ "@mikro-orm/core": "7.1.9-dev.20"
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
  }
@@ -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) {
@@ -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
@@ -176,6 +182,8 @@ export declare abstract class SchemaHelper {
176
182
  createRoutine(_routine: SqlRoutineDef): string;
177
183
  dropRoutine(_routine: SqlRoutineDef): string;
178
184
  getAllRoutines(_connection: AbstractSqlConnection, _schemas?: string[]): Promise<SqlRoutineDef[]>;
185
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
186
+ protected normalizeTriggerBody(body: string): string;
179
187
  /** 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
188
  protected wrapRoutineBody(body: string): string;
181
189
  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
@@ -861,7 +871,7 @@ export class SchemaHelper {
861
871
  const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
862
872
  const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
863
873
  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`;
874
+ return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${this.normalizeTriggerBody(trigger.body)} end`;
865
875
  }
866
876
  /**
867
877
  * Generates SQL to drop a database trigger from a table.
@@ -885,6 +895,11 @@ export class SchemaHelper {
885
895
  async getAllRoutines(_connection, _schemas = []) {
886
896
  return [];
887
897
  }
898
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
899
+ normalizeTriggerBody(body) {
900
+ const trimmed = stripStatementNewlines(body).trim();
901
+ return /;\s*$/.test(trimmed) ? trimmed : `${trimmed};`;
902
+ }
888
903
  /** 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
904
  wrapRoutineBody(body) {
890
905
  const trimmed = stripStatementNewlines(body).trim();
@@ -54,6 +54,8 @@ export declare class SqlSchemaGenerator extends AbstractSchemaGenerator<Abstract
54
54
  wrap?: boolean;
55
55
  ctx?: Transaction;
56
56
  }): Promise<void>;
57
+ /** Whether the statement has to be the first one in a query batch, e.g. `create trigger` on MSSQL. */
58
+ protected startsBatch(_statement: string): boolean;
57
59
  dropTableIfExists(name: string, schema?: string): Promise<void>;
58
60
  private wrapSchema;
59
61
  private append;
@@ -443,14 +443,19 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
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;
@@ -471,6 +476,10 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
471
476
  });
472
477
  await Utils.runSerial(statements, stmt => this.driver.execute(stmt));
473
478
  }
479
+ /** Whether the statement has to be the first one in a query batch, e.g. `create trigger` on MSSQL. */
480
+ startsBatch(_statement) {
481
+ return false;
482
+ }
474
483
  async dropTableIfExists(name, schema) {
475
484
  const sql = this.helper.dropTableIfExists(name, schema);
476
485
  return this.execute(sql);