@mikro-orm/sql 7.2.0-dev.1 → 7.2.0-dev.10

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.
@@ -152,11 +152,10 @@ type ContextFilterKeys<Context> = {
152
152
  type RawFilterKeys<RawAliases extends string> = {
153
153
  [K in RawAliases]?: AliasedFilterValue;
154
154
  };
155
- type NestedFilterCondition<Entity, RootAlias extends string, Context, RawAliases extends string> = ObjectQuery<Entity> & (IsNever<RootAlias> extends true ? {} : string extends RootAlias ? {} : RootAliasFilterKeys<RootAlias, Entity>) & ([Context] extends [never] ? {} : ContextFilterKeys<Context>) & (IsNever<RawAliases> extends true ? {} : string extends RawAliases ? {} : RawFilterKeys<RawAliases>);
156
155
  type GroupOperators<RootAlias extends string, Context, Entity, RawAliases extends string> = {
157
- $and?: NestedFilterCondition<Entity, RootAlias, Context, RawAliases>[];
158
- $or?: NestedFilterCondition<Entity, RootAlias, Context, RawAliases>[];
159
- $not?: NestedFilterCondition<Entity, RootAlias, Context, RawAliases>;
156
+ $and?: QBFilterQuery<Entity, RootAlias, Context, RawAliases>[];
157
+ $or?: QBFilterQuery<Entity, RootAlias, Context, RawAliases>[];
158
+ $not?: QBFilterQuery<Entity, RootAlias, Context, RawAliases>;
160
159
  };
161
160
  export type AliasedFilterCondition<RootAlias extends string, Context, Entity, RawAliases extends string = never> = (IsNever<RootAlias> extends true ? {} : string extends RootAlias ? {} : RootAliasFilterKeys<RootAlias, Entity>) & ([Context] extends [never] ? {} : ContextFilterKeys<Context>) & (IsNever<RawAliases> extends true ? {} : string extends RawAliases ? {} : RawFilterKeys<RawAliases>) & GroupOperators<RootAlias, Context, Entity, RawAliases>;
162
161
  export type QBFilterQuery<Entity, RootAlias extends string = never, Context = never, RawAliases extends string = never> = FilterObject<Entity> & AliasedFilterCondition<RootAlias, Context, Entity, RawAliases>;
@@ -506,6 +505,17 @@ export declare class QueryBuilder<Entity extends object = AnyEntity, RootAlias e
506
505
  * @internal
507
506
  */
508
507
  applyJoinedFilters(em: EntityManager, filterOptions: FilterOptions | undefined): Promise<void>;
508
+ /**
509
+ * The `on` clause of `condJoin` — its explicit join condition or a filter condition merged into
510
+ * it — can reference the alias of any join in its subtree, both auto-joins created while
511
+ * processing the condition and pre-existing joined paths, all of which render after `condJoin`
512
+ * and would be forward alias references (issues #7681, #8090, #8099). When that happens, fold the
513
+ * subtree into `condJoin`, so it renders as a single parenthesized join group and every alias
514
+ * shares the scope of the outer `on` clause.
515
+ */
516
+ private nestReferencedJoins;
517
+ private getJoinSubtree;
518
+ private condReferencesAlias;
509
519
  withSubQuery(subQuery: RawQueryFragment | NativeQueryBuilder, alias: string): this;
510
520
  /**
511
521
  * 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
@@ -989,8 +1030,12 @@ export class DatabaseTable {
989
1030
  // mysql stores decimal defaults padded to scale (`0` → `0.00`); collapse to canonical numeric form
990
1031
  // so the metadata-side (`0`) and introspection-side (`0.00`) snapshots agree
991
1032
  let defaultValue = c.default ?? null;
992
- if (defaultValue != null && c.mappedType instanceof DecimalType && Number.isFinite(+defaultValue)) {
993
- defaultValue = this.#platform.formatDecimal(defaultValue, c.scale).toString();
1033
+ if (defaultValue != null && c.mappedType instanceof DecimalType) {
1034
+ // string defaults like `default: '0.00'` are quoted in metadata, so strip the quotes first
1035
+ const unquoted = defaultValue.replace(/^'(.*)'$/, '$1');
1036
+ if (Number.isFinite(+unquoted)) {
1037
+ defaultValue = this.#platform.formatDecimal(unquoted, c.scale).toString();
1038
+ }
994
1039
  }
995
1040
  const normalized = {
996
1041
  name: c.name,
@@ -89,6 +89,7 @@ export declare class SchemaComparator {
89
89
  private diffViewExpression;
90
90
  private diffTrigger;
91
91
  parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
92
+ private parseDecimalDefault;
92
93
  hasSameDefaultValue(from: Column, to: Column): boolean;
93
94
  private mapColumnToProperty;
94
95
  private log;
@@ -820,6 +820,10 @@ export class SchemaComparator {
820
820
  if (!!index1.clustered !== !!index2.clustered) {
821
821
  return false;
822
822
  }
823
+ // Compare the index access method (e.g. `using gin` on PostgreSQL); unset means the platform default
824
+ if (this.#helper.getIndexAccessMethod(index1) !== this.#helper.getIndexAccessMethod(index2)) {
825
+ return false;
826
+ }
823
827
  // Compare WHERE predicate of partial indexes structurally (whitespace/quoting/casing
824
828
  // are normalized via the same helper used for check constraints).
825
829
  if (this.diffExpression(index1.where ?? '', index2.where ?? '')) {
@@ -897,9 +901,13 @@ export class SchemaComparator {
897
901
  // lookbehind: only strip a real charset introducer, never an underscore inside a literal like 'a_b'
898
902
  ?.replace(/(?<![\w'])_\w+'(.*?)'/g, '$1')
899
903
  .replace(/!=/g, '<>')
900
- .replace(/in\s*\((.*?)\)/gi, '= any (array[$1])')
904
+ // `\b` keeps this from firing inside identifiers like `min(...)`
905
+ .replace(/\bin\s*\((.*?)\)/gi, '= any (array[$1])')
901
906
  // MySQL normalizes count(*) to count(0)
902
907
  .replace(/\bcount\s*\(\s*0\s*\)/gi, 'count(*)')
908
+ // multi word type names in casts, the generic `::\w+` below only covers single word ones
909
+ // the precision is kept, so `timestamptz(3)` and `timestamp(3) with time zone` leave the same residue
910
+ .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
911
  // Remove quotes first so we can process identifiers
904
912
  .replace(/['"`]/g, '')
905
913
  // MySQL adds table/alias prefixes to columns (e.g., a.name or table_name.column vs just column)
@@ -907,12 +915,18 @@ export class SchemaComparator {
907
915
  .replace(/\b\w+\.(\w+)/g, '$1')
908
916
  // Normalize JOIN syntax: inner join -> join (equivalent in SQL)
909
917
  .replace(/\binner\s+join\b/gi, 'join')
918
+ // PostgreSQL names an unaliased bare function call after the function itself,
919
+ // so `max(created_at)` comes back as `max(created_at) AS max`
920
+ // the lookahead skips table function column alias lists like `unnest(a) AS unnest(c)`, which are meaningful
921
+ .replace(/\b(\w+)\s*\(((?:[^()]|\([^()]*\))*)\)\s+as\s+\1\b(?!\s*\()/gi, '$1($2)')
910
922
  // Remove redundant column aliases like `title AS title` -> `title`
911
923
  .replace(/\b(\w+)\s+as\s+\1\b/gi, '$1')
912
924
  // Remove AS keyword (optional in SQL, MySQL may add/remove it)
913
925
  .replace(/\bas\b/gi, '')
914
926
  // Remove remaining special chars, parentheses, type casts, asterisks, and normalize whitespace
915
- .replace(/[()\n[\]*]|::\w+| +/g, '')
927
+ // tabs and CRs included — the schema generator trims every line before executing the DDL,
928
+ // so indentation and CRLF endings can never come back from introspection
929
+ .replace(/[()\n\r\t[\]*]|::\w+| +/g, '')
916
930
  .replace(/anyarray\[(.*)]/gi, '$1')
917
931
  .toLowerCase()
918
932
  // PostgreSQL adds default aliases to aggregate functions (e.g., count(*) AS count)
@@ -979,6 +993,10 @@ export class SchemaComparator {
979
993
  const val = defaultValue.replace(/^(_\w+\\)?'(.*?)\\?'$/, '$2').replace(/^\(?'(.*?)'\)?$/, '$1');
980
994
  return parseJsonSafe(val);
981
995
  }
996
+ parseDecimalDefault(defaultValue) {
997
+ const value = +('' + defaultValue).replace(/^'(.+)'$/, '$1');
998
+ return Number.isFinite(value) ? value : null;
999
+ }
982
1000
  hasSameDefaultValue(from, to) {
983
1001
  if (from.default == null ||
984
1002
  from.default.toString().toLowerCase() === 'null' ||
@@ -1004,10 +1022,15 @@ export class SchemaComparator {
1004
1022
  const defaultValueTo = to.default.toLowerCase().replace('current_timestamp', 'now').replace(/\(\)$/, '');
1005
1023
  return defaultValueFrom === defaultValueTo;
1006
1024
  }
1007
- // mysql stores decimal defaults padded to scale (`0` → `0.00`); compare numerically so the
1008
- // entity-side raw literal and the introspected padded form don't churn the no-op migration
1009
- if (to.mappedType instanceof DecimalType && Number.isFinite(+from.default) && Number.isFinite(+to.default)) {
1010
- return (this.#platform.formatDecimal(from.default, to.scale) === this.#platform.formatDecimal(to.default, to.scale));
1025
+ // mysql pads decimal defaults to scale (`0` → `0.00`) and postgres reports them unquoted
1026
+ // while metadata keeps them quoted; compare numerically so neither churns a no-op migration
1027
+ if (to.mappedType instanceof DecimalType) {
1028
+ const defaultValueFrom = this.parseDecimalDefault(from.default);
1029
+ const defaultValueTo = this.parseDecimalDefault(to.default);
1030
+ if (defaultValueFrom != null && defaultValueTo != null) {
1031
+ return (this.#platform.formatDecimal(defaultValueFrom, to.scale) ===
1032
+ this.#platform.formatDecimal(defaultValueTo, to.scale));
1033
+ }
1011
1034
  }
1012
1035
  if (from.default && to.default) {
1013
1036
  return from.default.toString().toLowerCase() === to.default.toString().toLowerCase();
@@ -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
@@ -64,6 +70,13 @@ export declare abstract class SchemaHelper {
64
70
  * Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
65
71
  */
66
72
  protected getCreateIndexSuffix(_index: IndexDef): string;
73
+ /**
74
+ * Normalized index access method (e.g. `gin` on PostgreSQL), empty string when the
75
+ * platform default applies. Used for both DDL emission and index diffing.
76
+ */
77
+ getIndexAccessMethod(_index: IndexDef): string;
78
+ /** Emits the access method between the table name and the column list (e.g. ` using gin`). */
79
+ protected getIndexAccessMethodClause(index: IndexDef): string;
67
80
  /**
68
81
  * Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
69
82
  * return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
@@ -118,6 +131,8 @@ export declare abstract class SchemaHelper {
118
131
  getAddColumnsSQL(table: DatabaseTable, columns: Column[]): string[];
119
132
  getDropColumnsSQL(tableName: string, columns: Column[], schemaName?: string): string;
120
133
  hasNonDefaultPrimaryKeyName(table: DatabaseTable): boolean;
134
+ /** Returns the `constraint <name> ` prefix for a primary key definition, empty when the server assigns the default name on its own. */
135
+ protected getPrimaryKeyConstraintPrefix(table: DatabaseTable, index: IndexDef): string;
121
136
  castColumn(name: string, type: string): string;
122
137
  alterTableColumn(column: Column, table: DatabaseTable, changedProperties: Set<string>): string[];
123
138
  /** Returns the bare `collate <name>` clause for column DDL. Overridden by PostgreSQL to quote the identifier. */
@@ -137,6 +152,8 @@ export declare abstract class SchemaHelper {
137
152
  }[];
138
153
  }[], safe: boolean): string[];
139
154
  getChangeColumnCommentSQL(tableName: string, to: Column, schemaName?: string): string;
155
+ /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
156
+ protected hasInlineColumnComment(): boolean;
140
157
  getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
141
158
  protected mapIndexes(indexes: IndexDef[]): Promise<IndexDef[]>;
142
159
  mapForeignKeys(fks: any[], tableName: string, schemaName?: string): Dictionary;
@@ -176,6 +193,8 @@ export declare abstract class SchemaHelper {
176
193
  createRoutine(_routine: SqlRoutineDef): string;
177
194
  dropRoutine(_routine: SqlRoutineDef): string;
178
195
  getAllRoutines(_connection: AbstractSqlConnection, _schemas?: string[]): Promise<SqlRoutineDef[]>;
196
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
197
+ protected normalizeTriggerBody(body: string): string;
179
198
  /** 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
199
  protected wrapRoutineBody(body: string): string;
181
200
  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
@@ -157,13 +167,14 @@ export class SchemaHelper {
157
167
  tableName = this.quote(tableName);
158
168
  const keyName = this.quote(index.keyName);
159
169
  const defer = index.deferMode ? ` deferrable initially ${index.deferMode}` : '';
160
- let sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}`;
170
+ const using = this.getIndexAccessMethodClause(index);
171
+ let sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}${using}`;
161
172
  if (index.unique && index.constraint) {
162
173
  sql = `alter table ${tableName} add constraint ${keyName} unique`;
163
174
  }
164
175
  if (index.columnNames.some(column => column.includes('.'))) {
165
176
  // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
166
- sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}`;
177
+ sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}${using}`;
167
178
  const columns = this.platform.getJsonIndexDefinition(index);
168
179
  return `${sql} (${columns.join(', ')})${this.getCreateIndexSuffix(index)}${this.getIndexWhereClause(index)}${defer}`;
169
180
  }
@@ -182,6 +193,18 @@ export class SchemaHelper {
182
193
  getCreateIndexSuffix(_index) {
183
194
  return '';
184
195
  }
196
+ /**
197
+ * Normalized index access method (e.g. `gin` on PostgreSQL), empty string when the
198
+ * platform default applies. Used for both DDL emission and index diffing.
199
+ */
200
+ getIndexAccessMethod(_index) {
201
+ return '';
202
+ }
203
+ /** Emits the access method between the table name and the column list (e.g. ` using gin`). */
204
+ getIndexAccessMethodClause(index) {
205
+ const method = this.getIndexAccessMethod(index);
206
+ return method ? ` using ${method}` : '';
207
+ }
185
208
  /**
186
209
  * Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
187
210
  * return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
@@ -436,7 +459,8 @@ export class SchemaHelper {
436
459
  this.append(ret, this.alterTableColumn(column, diff.fromTable, changedProperties));
437
460
  }
438
461
  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))) {
462
+ if (this.hasInlineColumnComment() &&
463
+ ['type', 'nullable', 'autoincrement', 'unsigned', 'default', 'enumItems', 'collation'].some(t => changedProperties.has(t))) {
440
464
  continue; // will be handled via column update
441
465
  }
442
466
  ret.push(this.getChangeColumnCommentSQL(tableName, column, schemaName));
@@ -489,7 +513,13 @@ export class SchemaHelper {
489
513
  return `add ${this.createTableColumn(column, table)}`;
490
514
  })
491
515
  .join(', ');
492
- return [`alter table ${table.getQuotedName()} ${adds}`];
516
+ const ret = [`alter table ${table.getQuotedName()} ${adds}`];
517
+ if (!this.hasInlineColumnComment()) {
518
+ for (const column of columns.filter(column => column.comment)) {
519
+ ret.push(this.getChangeColumnCommentSQL(table.name, column, table.schema));
520
+ }
521
+ }
522
+ return ret;
493
523
  }
494
524
  getDropColumnsSQL(tableName, columns, schemaName) {
495
525
  const name = this.quote(this.getTableName(tableName, schemaName));
@@ -504,6 +534,10 @@ export class SchemaHelper {
504
534
  const defaultName = this.platform.getDefaultPrimaryName(table.name, pkIndex.columnNames);
505
535
  return pkIndex?.keyName !== defaultName;
506
536
  }
537
+ /** Returns the `constraint <name> ` prefix for a primary key definition, empty when the server assigns the default name on its own. */
538
+ getPrimaryKeyConstraintPrefix(table, index) {
539
+ return this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${this.quote(index.keyName)} ` : '';
540
+ }
507
541
  /* v8 ignore next */
508
542
  castColumn(name, type) {
509
543
  return '';
@@ -608,6 +642,10 @@ export class SchemaHelper {
608
642
  getChangeColumnCommentSQL(tableName, to, schemaName) {
609
643
  return '';
610
644
  }
645
+ /** Whether the column comment is part of the column declaration, as opposed to a separate statement. */
646
+ hasInlineColumnComment() {
647
+ return false;
648
+ }
611
649
  async getNamespaces(connection, ctx) {
612
650
  return [];
613
651
  }
@@ -745,7 +783,7 @@ export class SchemaHelper {
745
783
  const primaryKey = table.getPrimaryKey();
746
784
  const createPrimary = !table.getColumns().some(c => c.autoincrement && c.primary) || this.hasNonDefaultPrimaryKeyName(table);
747
785
  if (createPrimary && primaryKey) {
748
- const name = this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${this.quote(primaryKey.keyName)} ` : '';
786
+ const name = this.getPrimaryKeyConstraintPrefix(table, primaryKey);
749
787
  sql += `, ${name}primary key (${primaryKey.columnNames.map(c => this.quote(c)).join(', ')})`;
750
788
  }
751
789
  sql += ')';
@@ -828,7 +866,7 @@ export class SchemaHelper {
828
866
  const columns = index.columnNames.map(c => this.quote(c)).join(', ');
829
867
  const defer = index.deferMode ? ` deferrable initially ${index.deferMode}` : '';
830
868
  if (index.primary) {
831
- const keyName = this.hasNonDefaultPrimaryKeyName(table) ? `constraint ${index.keyName} ` : '';
869
+ const keyName = this.getPrimaryKeyConstraintPrefix(table, index);
832
870
  return `alter table ${table.getQuotedName()} add ${keyName}primary key (${columns})${defer}`;
833
871
  }
834
872
  if (index.type === 'fulltext') {
@@ -861,7 +899,7 @@ export class SchemaHelper {
861
899
  const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
862
900
  const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
863
901
  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`;
902
+ return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${this.normalizeTriggerBody(trigger.body)} end`;
865
903
  }
866
904
  /**
867
905
  * Generates SQL to drop a database trigger from a table.
@@ -885,6 +923,11 @@ export class SchemaHelper {
885
923
  async getAllRoutines(_connection, _schemas = []) {
886
924
  return [];
887
925
  }
926
+ /** Flattens internal `;\n` so the statement splitter doesn't tear the DDL, and ensures exactly one trailing `;` for the enclosing `begin ... end` block. */
927
+ normalizeTriggerBody(body) {
928
+ const trimmed = stripStatementNewlines(body).trim();
929
+ return /;\s*$/.test(trimmed) ? trimmed : `${trimmed};`;
930
+ }
888
931
  /** 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
932
  wrapRoutineBody(body) {
890
933
  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;