@mikro-orm/sql 7.1.0-dev.3 → 7.1.0-dev.30

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.
Files changed (47) hide show
  1. package/AbstractSqlConnection.d.ts +1 -1
  2. package/AbstractSqlConnection.js +2 -2
  3. package/AbstractSqlDriver.d.ts +19 -1
  4. package/AbstractSqlDriver.js +215 -16
  5. package/AbstractSqlPlatform.d.ts +15 -3
  6. package/AbstractSqlPlatform.js +25 -7
  7. package/PivotCollectionPersister.js +13 -2
  8. package/SqlEntityManager.d.ts +5 -1
  9. package/SqlEntityManager.js +36 -1
  10. package/SqlMikroORM.d.ts +23 -0
  11. package/SqlMikroORM.js +23 -0
  12. package/dialects/mysql/BaseMySqlPlatform.d.ts +1 -0
  13. package/dialects/mysql/BaseMySqlPlatform.js +3 -0
  14. package/dialects/mysql/MySqlSchemaHelper.d.ts +13 -3
  15. package/dialects/mysql/MySqlSchemaHelper.js +145 -21
  16. package/dialects/oracledb/OracleDialect.d.ts +1 -1
  17. package/dialects/oracledb/OracleDialect.js +2 -1
  18. package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +3 -0
  19. package/dialects/postgresql/BasePostgreSqlPlatform.js +28 -6
  20. package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +31 -1
  21. package/dialects/postgresql/PostgreSqlSchemaHelper.js +230 -5
  22. package/dialects/sqlite/SqlitePlatform.d.ts +1 -0
  23. package/dialects/sqlite/SqlitePlatform.js +4 -0
  24. package/dialects/sqlite/SqliteSchemaHelper.d.ts +9 -2
  25. package/dialects/sqlite/SqliteSchemaHelper.js +148 -19
  26. package/index.d.ts +2 -0
  27. package/index.js +2 -0
  28. package/package.json +4 -4
  29. package/plugin/transformer.d.ts +11 -3
  30. package/plugin/transformer.js +138 -29
  31. package/query/CriteriaNode.d.ts +1 -1
  32. package/query/CriteriaNode.js +2 -2
  33. package/query/ObjectCriteriaNode.js +1 -1
  34. package/query/QueryBuilder.d.ts +36 -0
  35. package/query/QueryBuilder.js +63 -1
  36. package/schema/DatabaseSchema.js +26 -4
  37. package/schema/DatabaseTable.d.ts +20 -1
  38. package/schema/DatabaseTable.js +182 -31
  39. package/schema/SchemaComparator.d.ts +10 -0
  40. package/schema/SchemaComparator.js +104 -1
  41. package/schema/SchemaHelper.d.ts +63 -1
  42. package/schema/SchemaHelper.js +235 -6
  43. package/schema/SqlSchemaGenerator.d.ts +2 -2
  44. package/schema/SqlSchemaGenerator.js +16 -9
  45. package/schema/partitioning.d.ts +13 -0
  46. package/schema/partitioning.js +326 -0
  47. package/typings.d.ts +34 -2
@@ -1,4 +1,5 @@
1
1
  import { DecimalType, EntitySchema, isRaw, ReferenceKind, t, Type, UnknownType, Utils, } from '@mikro-orm/core';
2
+ import { toEntityPartitionBy } from './partitioning.js';
2
3
  /**
3
4
  * @internal
4
5
  */
@@ -8,10 +9,19 @@ export class DatabaseTable {
8
9
  #columns = {};
9
10
  #indexes = [];
10
11
  #checks = [];
12
+ #triggers = [];
11
13
  #foreignKeys = {};
12
14
  #platform;
13
15
  nativeEnums = {}; // for postgres
14
16
  comment;
17
+ partitioning;
18
+ /**
19
+ * Effective collation the column defaults to when no explicit `COLLATE` is set on a column.
20
+ * For MySQL/MariaDB this is the table collation; for PostgreSQL and MSSQL this is the database default;
21
+ * SQLite has no configurable default. Used by `SchemaComparator.diffCollation` to avoid flapping
22
+ * when a property explicitly names the default collation.
23
+ */
24
+ collation;
15
25
  constructor(platform, name, schema) {
16
26
  this.name = name;
17
27
  this.schema = schema;
@@ -35,6 +45,16 @@ export class DatabaseTable {
35
45
  getChecks() {
36
46
  return this.#checks;
37
47
  }
48
+ getPartitioning() {
49
+ return this.partitioning;
50
+ }
51
+ /** @internal */
52
+ setPartitioning(partitioning) {
53
+ this.partitioning = partitioning;
54
+ }
55
+ getTriggers() {
56
+ return this.#triggers;
57
+ }
38
58
  /** @internal */
39
59
  setIndexes(indexes) {
40
60
  this.#indexes = indexes;
@@ -44,6 +64,10 @@ export class DatabaseTable {
44
64
  this.#checks = checks;
45
65
  }
46
66
  /** @internal */
67
+ setTriggers(triggers) {
68
+ this.#triggers = triggers;
69
+ }
70
+ /** @internal */
47
71
  setForeignKeys(fks) {
48
72
  this.#foreignKeys = fks;
49
73
  }
@@ -51,6 +75,7 @@ export class DatabaseTable {
51
75
  this.#indexes = indexes;
52
76
  this.#checks = checks;
53
77
  this.#foreignKeys = fks;
78
+ const helper = this.#platform.getSchemaHelper();
54
79
  this.#columns = cols.reduce((o, v) => {
55
80
  const index = indexes.filter(i => i.columnNames[0] === v.name);
56
81
  v.primary = v.primary || pks.includes(v.name);
@@ -59,6 +84,11 @@ export class DatabaseTable {
59
84
  v.mappedType = this.#platform.getMappedType(type);
60
85
  v.default = v.default?.toString().startsWith('nextval(') ? null : v.default;
61
86
  v.enumItems ??= enums[v.name] || [];
87
+ // recover length from the declared type so introspection matches `addColumnFromProperty`;
88
+ // scoped to types with `getDefaultLength` to skip mysql's `tinyint(1)` boolean width
89
+ if (v.length == null && v.type && helper && typeof v.mappedType.getDefaultLength !== 'undefined') {
90
+ v.length = helper.inferLengthFromColumnType(v.type);
91
+ }
62
92
  o[v.name] = v;
63
93
  return o;
64
94
  }, {});
@@ -68,7 +98,9 @@ export class DatabaseTable {
68
98
  }
69
99
  addColumnFromProperty(prop, meta, config) {
70
100
  prop.fieldNames?.forEach((field, idx) => {
71
- const type = prop.enum ? 'enum' : prop.columnTypes[idx];
101
+ // numeric enums fall through to the underlying numeric type no platform emits a CHECK we could parse back
102
+ const isStringEnum = !!prop.nativeEnumName || !!prop.items?.every(item => typeof item === 'string');
103
+ const type = prop.enum && isStringEnum ? 'enum' : prop.columnTypes[idx];
72
104
  const mappedType = this.#platform.getMappedType(type);
73
105
  if (mappedType instanceof DecimalType) {
74
106
  const match = /\w+\((\d+), ?(\d+)\)/.exec(prop.columnTypes[idx]);
@@ -105,6 +137,7 @@ export class DatabaseTable {
105
137
  default: prop.defaultRaw,
106
138
  enumItems: prop.nativeEnumName || prop.items?.every(i => typeof i === 'string') ? prop.items : undefined,
107
139
  comment: prop.comment,
140
+ collation: prop.collation,
108
141
  extra: prop.extra,
109
142
  ignoreSchemaChanges: prop.ignoreSchemaChanges,
110
143
  };
@@ -177,6 +210,7 @@ export class DatabaseTable {
177
210
  const { fksOnColumnProps, fksOnStandaloneProps, columnFks, fkIndexes, nullableForeignKeys, skippedColumnNames } = this.foreignKeysToProps(namingStrategy, scalarPropertiesForRelations);
178
211
  const name = namingStrategy.getEntityName(this.name, this.schema);
179
212
  const schema = new EntitySchema({ name, collection: this.name, schema: this.schema, comment: this.comment });
213
+ schema.meta.partitionBy = toEntityPartitionBy(this.partitioning, this.name, this.schema);
180
214
  const compositeFkIndexes = {};
181
215
  const compositeFkUniques = {};
182
216
  const potentiallyUnmappedIndexes = this.#indexes.filter(index => !index.primary && // Skip primary index. Whether it's in use by scalar column or FK, it's already mapped.
@@ -196,6 +230,7 @@ export class DatabaseTable {
196
230
  name: index.keyName,
197
231
  deferMode: index.deferMode,
198
232
  expression: index.expression,
233
+ where: index.where,
199
234
  // Advanced index options - convert column names to property names
200
235
  columns: index.columns?.map(col => ({
201
236
  ...col,
@@ -226,7 +261,7 @@ export class DatabaseTable {
226
261
  index.invisible ||
227
262
  index.disabled ||
228
263
  index.clustered;
229
- const isTrivial = !index.deferMode && !index.expression && !hasAdvancedOptions;
264
+ const isTrivial = !index.deferMode && !index.expression && !index.where && !hasAdvancedOptions;
230
265
  if (isTrivial) {
231
266
  // Index is for FK. Map to the FK prop and move on.
232
267
  const fkForIndex = fkIndexes.get(index);
@@ -598,6 +633,12 @@ export class DatabaseTable {
598
633
  hasCheck(checkName) {
599
634
  return !!this.getCheck(checkName);
600
635
  }
636
+ getTrigger(triggerName) {
637
+ return this.#triggers.find(t => t.name === triggerName);
638
+ }
639
+ hasTrigger(triggerName) {
640
+ return !!this.getTrigger(triggerName);
641
+ }
601
642
  getPrimaryKey() {
602
643
  return this.#indexes.find(i => i.primary);
603
644
  }
@@ -632,6 +673,7 @@ export class DatabaseTable {
632
673
  columnOptions.scale = column.scale;
633
674
  columnOptions.extra = column.extra;
634
675
  columnOptions.comment = column.comment;
676
+ columnOptions.collation = column.collation;
635
677
  columnOptions.enum = !!column.enumItems?.length;
636
678
  columnOptions.items = column.enumItems;
637
679
  }
@@ -698,6 +740,7 @@ export class DatabaseTable {
698
740
  scale: column.scale,
699
741
  extra: column.extra,
700
742
  comment: column.comment,
743
+ collation: column.collation,
701
744
  index: index ? index.keyName : undefined,
702
745
  unique: unique ? unique.keyName : undefined,
703
746
  enum: !!column.enumItems?.length,
@@ -862,16 +905,25 @@ export class DatabaseTable {
862
905
  if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
863
906
  throw new Error(`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`);
864
907
  }
908
+ // The `expression` escape hatch takes the full index definition as raw SQL; combining it
909
+ // with `where` is dialect-dependent (PG/Oracle/MySQL drop `where`, MSSQL appends it) so we
910
+ // reject the combination up-front and ask users to inline the predicate into `expression`.
911
+ if (index.expression && index.where != null) {
912
+ throw new Error(`Index '${name}' on entity '${meta.className}': cannot combine \`expression\` with \`where\` — inline the WHERE clause into the \`expression\` escape hatch, or drop \`expression\` and use structured \`properties\` + \`where\`.`);
913
+ }
914
+ const where = this.processIndexWhere(index.where, meta);
865
915
  this.#indexes.push({
866
916
  keyName: name,
867
917
  columnNames: properties,
868
918
  composite: properties.length > 1,
869
- // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
870
- constraint: type !== 'index' && !properties.some((d) => d.includes('.')),
919
+ // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them.
920
+ // Partial indexes (`where`) must use CREATE [UNIQUE] INDEX form — constraints can't carry predicates.
921
+ constraint: type !== 'index' && !properties.some((d) => d.includes('.')) && !where,
871
922
  primary: type === 'primary',
872
923
  unique: type !== 'index',
873
924
  type: index.type,
874
925
  expression: this.processIndexExpression(name, index.expression, meta),
926
+ where,
875
927
  options: index.options,
876
928
  deferMode: index.deferMode,
877
929
  columns,
@@ -882,56 +934,155 @@ export class DatabaseTable {
882
934
  clustered: index.clustered,
883
935
  });
884
936
  }
937
+ processIndexWhere(where, meta) {
938
+ if (where == null) {
939
+ return undefined;
940
+ }
941
+ // The driver is always an `AbstractSqlDriver` here — `DatabaseTable` is only instantiated
942
+ // by SQL-side schema code, so the platform's config-bound driver is guaranteed to be one.
943
+ const driver = this.#platform.getConfig().getDriver();
944
+ return driver.renderPartialIndexWhere(meta.class, where);
945
+ }
885
946
  addCheck(check) {
886
947
  this.#checks.push(check);
887
948
  }
949
+ addTrigger(trigger) {
950
+ this.#triggers.push(trigger);
951
+ }
888
952
  toJSON() {
889
953
  const columns = this.#columns;
890
- const columnsMapped = Utils.keys(columns).reduce((o, col) => {
954
+ // locale-independent comparison so the snapshot is stable across machines
955
+ const byString = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
956
+ const sortedColumnKeys = Utils.keys(columns).sort(byString);
957
+ // mirror `DatabaseTable.init()`: derive `primary`/`unique` from index membership
958
+ // so metadata (which keeps `primary: false` on composite PK columns) and introspection agree
959
+ const primaryColumns = new Set();
960
+ const uniqueColumns = new Set();
961
+ for (const idx of this.#indexes) {
962
+ if (idx.primary) {
963
+ idx.columnNames.forEach(c => primaryColumns.add(c));
964
+ }
965
+ if (idx.unique && !idx.primary && idx.columnNames.length > 0) {
966
+ uniqueColumns.add(idx.columnNames[0]);
967
+ }
968
+ }
969
+ // integer/float widths live in the type name (`int2`/`int4`/`float8`) — drop redundant precision/scale
970
+ const isFixedPrecisionFamily = (mappedType) => mappedType instanceof t.integer ||
971
+ mappedType instanceof t.smallint ||
972
+ mappedType instanceof t.tinyint ||
973
+ mappedType instanceof t.mediumint ||
974
+ mappedType instanceof t.bigint ||
975
+ mappedType instanceof t.float ||
976
+ mappedType instanceof t.double;
977
+ const supportsUnsigned = this.#platform.supportsUnsigned();
978
+ const columnsMapped = sortedColumnKeys.reduce((o, col) => {
891
979
  const c = columns[col];
980
+ // omit `autoincrement` from options so `serial` (metadata) and `int4` (introspection) collapse the same
981
+ const rawType = c.type?.toLowerCase();
982
+ const normOptions = { length: c.length, precision: c.precision, scale: c.scale };
983
+ const type = this.#platform.normalizeColumnType(c.type ?? '', normOptions)?.toLowerCase() || rawType;
984
+ const fixedPrecision = isFixedPrecisionFamily(c.mappedType);
892
985
  const normalized = {
893
986
  name: c.name,
894
- type: c.type,
895
- unsigned: !!c.unsigned,
987
+ type,
988
+ unsigned: supportsUnsigned && !!c.unsigned,
896
989
  autoincrement: !!c.autoincrement,
897
- primary: !!c.primary,
990
+ primary: primaryColumns.has(c.name) || !!c.primary,
898
991
  nullable: !!c.nullable,
899
- unique: !!c.unique,
900
- length: c.length ?? null,
901
- precision: c.precision ?? null,
902
- scale: c.scale ?? null,
992
+ unique: uniqueColumns.has(c.name) || !!c.unique,
993
+ length: c.length || null,
994
+ precision: fixedPrecision ? null : (c.precision ?? null),
995
+ scale: fixedPrecision ? null : (c.scale ?? null),
903
996
  default: c.default ?? null,
904
997
  comment: c.comment ?? null,
998
+ collation: c.collation ?? null,
905
999
  enumItems: c.enumItems ?? [],
906
1000
  mappedType: Utils.keys(t).find(k => t[k] === c.mappedType.constructor),
907
1001
  };
908
- if (c.generated) {
909
- normalized.generated = c.generated;
910
- }
911
- if (c.nativeEnumName) {
912
- normalized.nativeEnumName = c.nativeEnumName;
913
- }
914
- if (c.extra) {
915
- normalized.extra = c.extra;
916
- }
917
- if (c.ignoreSchemaChanges) {
918
- normalized.ignoreSchemaChanges = c.ignoreSchemaChanges;
919
- }
920
- if (c.defaultConstraint) {
921
- normalized.defaultConstraint = c.defaultConstraint;
1002
+ for (const field of [
1003
+ 'generated',
1004
+ 'nativeEnumName',
1005
+ 'extra',
1006
+ 'ignoreSchemaChanges',
1007
+ 'defaultConstraint',
1008
+ ]) {
1009
+ if (c[field]) {
1010
+ normalized[field] = c[field];
1011
+ }
922
1012
  }
923
1013
  o[col] = normalized;
924
1014
  return o;
925
1015
  }, {});
1016
+ const normalizeIndex = (idx) => {
1017
+ const out = {
1018
+ columnNames: idx.columnNames,
1019
+ composite: !!idx.composite,
1020
+ // PK indexes are always backed by a constraint — force it so postgres introspection matches
1021
+ constraint: !!idx.constraint || !!idx.primary,
1022
+ keyName: idx.keyName,
1023
+ primary: !!idx.primary,
1024
+ unique: !!idx.unique,
1025
+ };
1026
+ const optional = [
1027
+ 'expression',
1028
+ 'type',
1029
+ 'deferMode',
1030
+ 'columns',
1031
+ 'include',
1032
+ 'fillFactor',
1033
+ 'invisible',
1034
+ 'disabled',
1035
+ 'clustered',
1036
+ ];
1037
+ for (const field of optional) {
1038
+ if (idx[field] != null && idx[field] !== false) {
1039
+ out[field] = idx[field];
1040
+ }
1041
+ }
1042
+ return out;
1043
+ };
1044
+ const normalizeFk = (fk) => {
1045
+ const isNoAction = (rule) => !rule || rule.toLowerCase() === 'no action';
1046
+ // JSON.stringify drops undefined properties — let them through instead of guarding
1047
+ return {
1048
+ columnNames: fk.columnNames,
1049
+ constraintName: fk.constraintName,
1050
+ localTableName: fk.localTableName,
1051
+ referencedColumnNames: fk.referencedColumnNames,
1052
+ referencedTableName: fk.referencedTableName,
1053
+ updateRule: isNoAction(fk.updateRule) ? undefined : fk.updateRule,
1054
+ deleteRule: isNoAction(fk.deleteRule) ? undefined : fk.deleteRule,
1055
+ deferMode: fk.deferMode,
1056
+ };
1057
+ };
1058
+ const normalizeCheck = (check) => {
1059
+ const out = { name: check.name };
1060
+ if (typeof check.expression === 'string') {
1061
+ out.expression = check.expression;
1062
+ }
1063
+ for (const field of ['definition', 'columnName']) {
1064
+ if (check[field]) {
1065
+ out[field] = check[field];
1066
+ }
1067
+ }
1068
+ return out;
1069
+ };
1070
+ const sortedIndexes = [...this.#indexes].sort((a, b) => byString(a.keyName, b.keyName)).map(normalizeIndex);
1071
+ const sortedChecks = [...this.#checks].sort((a, b) => byString(a.name, b.name)).map(normalizeCheck);
1072
+ const sortedTriggers = [...this.#triggers].sort((a, b) => byString(a.name, b.name));
1073
+ const sortedForeignKeys = Object.fromEntries(Object.entries(this.#foreignKeys)
1074
+ .sort(([a], [b]) => byString(a, b))
1075
+ .map(([k, v]) => [k, normalizeFk(v)]));
926
1076
  return {
927
1077
  name: this.name,
928
1078
  schema: this.schema,
929
1079
  columns: columnsMapped,
930
- indexes: this.#indexes,
931
- checks: this.#checks,
932
- foreignKeys: this.#foreignKeys,
933
- nativeEnums: this.nativeEnums,
934
- comment: this.comment,
1080
+ indexes: sortedIndexes,
1081
+ checks: sortedChecks,
1082
+ triggers: sortedTriggers,
1083
+ foreignKeys: sortedForeignKeys,
1084
+ // emit `comment` even when unset so introspection (which always reads it) matches metadata
1085
+ comment: this.comment ?? null,
935
1086
  };
936
1087
  }
937
1088
  }
@@ -39,6 +39,15 @@ export declare class SchemaComparator {
39
39
  diffColumn(fromColumn: Column, toColumn: Column, fromTable: DatabaseTable, logging?: boolean): Set<string>;
40
40
  diffEnumItems(items1?: string[], items2?: string[]): boolean;
41
41
  diffComment(comment1?: string, comment2?: string): boolean;
42
+ /**
43
+ * `from` is the introspected DB state, `to` is the target metadata. A column-level `COLLATE`
44
+ * clause naming the table/database default is just verbose syntax for inheriting that default,
45
+ * so both sides are normalized — anything matching `tableDefault` collapses to `undefined` and
46
+ * compares equal to "no explicit collation". Comparison is case-insensitive on dialects that
47
+ * treat collation identifiers as case-insensitive (MySQL/MSSQL/SQLite); PostgreSQL's
48
+ * `pg_collation.collname` is case-sensitive and is compared verbatim.
49
+ */
50
+ diffCollation(fromCollation?: string, toCollation?: string, tableDefault?: string): boolean;
42
51
  /**
43
52
  * Finds the difference between the indexes index1 and index2.
44
53
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
@@ -69,6 +78,7 @@ export declare class SchemaComparator {
69
78
  * @see https://github.com/mikro-orm/mikro-orm/issues/7308
70
79
  */
71
80
  private diffViewExpression;
81
+ private diffTrigger;
72
82
  parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
73
83
  hasSameDefaultValue(from: Column, to: Column): boolean;
74
84
  private mapColumnToProperty;
@@ -1,5 +1,6 @@
1
1
  import { ArrayType, BooleanType, DateTimeType, inspect, JsonType, parseJsonSafe, Utils, } from '@mikro-orm/core';
2
2
  import { DatabaseTable } from './DatabaseTable.js';
3
+ import { diffPartitioning } from './partitioning.js';
3
4
  /**
4
5
  * Compares two Schemas and return an instance of SchemaDifference.
5
6
  */
@@ -176,14 +177,17 @@ export class SchemaComparator {
176
177
  addedForeignKeys: {},
177
178
  addedIndexes: {},
178
179
  addedChecks: {},
180
+ addedTriggers: {},
179
181
  changedColumns: {},
180
182
  changedForeignKeys: {},
181
183
  changedIndexes: {},
182
184
  changedChecks: {},
185
+ changedTriggers: {},
183
186
  removedColumns: {},
184
187
  removedForeignKeys: {},
185
188
  removedIndexes: {},
186
189
  removedChecks: {},
190
+ removedTriggers: {},
187
191
  renamedColumns: {},
188
192
  renamedIndexes: {},
189
193
  fromTable,
@@ -197,6 +201,17 @@ export class SchemaComparator {
197
201
  });
198
202
  changes++;
199
203
  }
204
+ if (diffPartitioning(fromTable.getPartitioning(), toTable.getPartitioning(), this.#platform.getDefaultSchemaName())) {
205
+ tableDifferences.changedPartitioning = {
206
+ from: fromTable.getPartitioning(),
207
+ to: toTable.getPartitioning(),
208
+ };
209
+ this.log(`table partitioning changed for ${tableDifferences.name}`, {
210
+ fromPartitioning: fromTable.getPartitioning(),
211
+ toPartitioning: toTable.getPartitioning(),
212
+ });
213
+ changes++;
214
+ }
200
215
  const fromTableColumns = fromTable.getColumns();
201
216
  const toTableColumns = toTable.getColumns();
202
217
  // See if all the columns in "from" table exist in "to" table
@@ -263,6 +278,19 @@ export class SchemaComparator {
263
278
  if (!this.diffIndex(index, toTableIndex)) {
264
279
  continue;
265
280
  }
281
+ // Constraint-vs-index form mismatch needs drop+create with the OLD form's drop SQL,
282
+ // which `changedIndexes` (uses new form only) can't do. Primary keys stay on the
283
+ // changed path which emits `add primary key`.
284
+ if (!index.primary && !!index.constraint !== !!toTableIndex.constraint) {
285
+ tableDifferences.removedIndexes[index.keyName] = index;
286
+ tableDifferences.addedIndexes[index.keyName] = toTableIndex;
287
+ this.log(`index ${index.keyName} changed form in table ${tableDifferences.name}`, {
288
+ fromTableIndex: index,
289
+ toTableIndex,
290
+ });
291
+ changes += 2;
292
+ continue;
293
+ }
266
294
  tableDifferences.changedIndexes[index.keyName] = toTableIndex;
267
295
  this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
268
296
  fromTableIndex: index,
@@ -309,6 +337,33 @@ export class SchemaComparator {
309
337
  tableDifferences.changedChecks[check.name] = toTableCheck;
310
338
  changes++;
311
339
  }
340
+ const fromTableTriggers = fromTable.getTriggers();
341
+ const toTableTriggers = toTable.getTriggers();
342
+ for (const trigger of toTableTriggers) {
343
+ if (fromTable.hasTrigger(trigger.name)) {
344
+ continue;
345
+ }
346
+ tableDifferences.addedTriggers[trigger.name] = trigger;
347
+ this.log(`trigger ${trigger.name} added to table ${tableDifferences.name}`, { trigger });
348
+ changes++;
349
+ }
350
+ for (const trigger of fromTableTriggers) {
351
+ if (!toTable.hasTrigger(trigger.name)) {
352
+ tableDifferences.removedTriggers[trigger.name] = trigger;
353
+ this.log(`trigger ${trigger.name} removed from table ${tableDifferences.name}`);
354
+ changes++;
355
+ continue;
356
+ }
357
+ const toTableTrigger = toTable.getTrigger(trigger.name);
358
+ if (this.diffTrigger(trigger, toTableTrigger)) {
359
+ this.log(`trigger ${trigger.name} changed in table ${tableDifferences.name}`, {
360
+ fromTableTrigger: trigger,
361
+ toTableTrigger,
362
+ });
363
+ tableDifferences.changedTriggers[trigger.name] = toTableTrigger;
364
+ changes++;
365
+ }
366
+ }
312
367
  const fromForeignKeys = { ...fromTable.getForeignKeys() };
313
368
  const toForeignKeys = { ...toTable.getForeignKeys() };
314
369
  for (const fromConstraint of Object.values(fromForeignKeys)) {
@@ -516,6 +571,11 @@ export class SchemaComparator {
516
571
  log(`'comment' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
517
572
  changedProperties.add('comment');
518
573
  }
574
+ if (!(fromColumn.ignoreSchemaChanges?.includes('collation') || toColumn.ignoreSchemaChanges?.includes('collation')) &&
575
+ this.diffCollation(fromColumn.collation, toColumn.collation, fromTable.collation)) {
576
+ log(`'collation' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
577
+ changedProperties.add('collation');
578
+ }
519
579
  const isNonNativeEnumArray = !(fromColumn.nativeEnumName || toColumn.nativeEnumName) &&
520
580
  (fromColumn.mappedType instanceof ArrayType || toColumn.mappedType instanceof ArrayType);
521
581
  if (!isNonNativeEnumArray && this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)) {
@@ -537,12 +597,26 @@ export class SchemaComparator {
537
597
  // eslint-disable-next-line eqeqeq
538
598
  return comment1 != comment2 && !(comment1 == null && comment2 === '') && !(comment2 == null && comment1 === '');
539
599
  }
600
+ /**
601
+ * `from` is the introspected DB state, `to` is the target metadata. A column-level `COLLATE`
602
+ * clause naming the table/database default is just verbose syntax for inheriting that default,
603
+ * so both sides are normalized — anything matching `tableDefault` collapses to `undefined` and
604
+ * compares equal to "no explicit collation". Comparison is case-insensitive on dialects that
605
+ * treat collation identifiers as case-insensitive (MySQL/MSSQL/SQLite); PostgreSQL's
606
+ * `pg_collation.collname` is case-sensitive and is compared verbatim.
607
+ */
608
+ diffCollation(fromCollation, toCollation, tableDefault) {
609
+ const fold = this.#platform.caseInsensitiveCollationNames() ? (s) => s.toLowerCase() : (s) => s;
610
+ const norm = (c) => c && tableDefault && fold(c) === fold(tableDefault) ? undefined : c == null ? undefined : fold(c);
611
+ return norm(fromCollation) !== norm(toCollation);
612
+ }
540
613
  /**
541
614
  * Finds the difference between the indexes index1 and index2.
542
615
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
543
616
  */
544
617
  diffIndex(index1, index2) {
545
- // if one of them is a custom expression or full text index, compare only by name
618
+ // Opaque raw expressions (`expression` escape hatch) and full-text indexes can't be
619
+ // compared structurally — fall back to name-only matching.
546
620
  if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
547
621
  return index1.keyName !== index2.keyName;
548
622
  }
@@ -593,6 +667,11 @@ export class SchemaComparator {
593
667
  if (!!index1.clustered !== !!index2.clustered) {
594
668
  return false;
595
669
  }
670
+ // Compare WHERE predicate of partial indexes structurally (whitespace/quoting/casing
671
+ // are normalized via the same helper used for check constraints).
672
+ if (this.diffExpression(index1.where ?? '', index2.where ?? '')) {
673
+ return false;
674
+ }
596
675
  if (!index1.unique && !index1.primary) {
597
676
  // this is a special case: If the current key is neither primary or unique, any unique or
598
677
  // primary key will always have the same effect for the index and there cannot be any constraint
@@ -714,6 +793,30 @@ export class SchemaComparator {
714
793
  }
715
794
  return true;
716
795
  }
796
+ diffTrigger(from, to) {
797
+ // Raw DDL expression cannot be meaningfully compared to introspected
798
+ // trigger metadata, so skip diffing when the metadata side uses it.
799
+ if (to.expression) {
800
+ // Both sides have expression — compare the raw DDL directly
801
+ if (from.expression) {
802
+ return this.diffExpression(from.expression, to.expression);
803
+ }
804
+ // Only metadata side has expression — the raw DDL cannot be compared to
805
+ // introspected metadata. Changes to the expression value won't be detected;
806
+ // drop and recreate the trigger manually to apply expression changes.
807
+ return false;
808
+ }
809
+ if (from.timing !== to.timing || from.forEach !== to.forEach) {
810
+ return true;
811
+ }
812
+ if ([...from.events].sort().join(',') !== [...to.events].sort().join(',')) {
813
+ return true;
814
+ }
815
+ if ((from.when ?? '') !== (to.when ?? '')) {
816
+ return true;
817
+ }
818
+ return this.diffExpression(from.body, to.body);
819
+ }
717
820
  parseJsonDefault(defaultValue) {
718
821
  /* v8 ignore next */
719
822
  if (!defaultValue) {
@@ -1,7 +1,7 @@
1
1
  import { type Connection, type Dictionary, type Options, type Transaction, type RawQueryFragment } from '@mikro-orm/core';
2
2
  import type { AbstractSqlConnection } from '../AbstractSqlConnection.js';
3
3
  import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
4
- import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference } from '../typings.js';
4
+ import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef } from '../typings.js';
5
5
  import type { DatabaseSchema } from './DatabaseSchema.js';
6
6
  import type { DatabaseTable } from './DatabaseTable.js';
7
7
  /** Base class for database-specific schema helpers. Provides SQL generation for DDL operations. */
@@ -16,6 +16,14 @@ export declare abstract class SchemaHelper {
16
16
  enableForeignKeysSQL(): string;
17
17
  /** Returns SQL to append to schema migration scripts (e.g., re-enabling FK checks). */
18
18
  getSchemaEnd(disableForeignKeys?: boolean): string;
19
+ /** Sets the current schema for the session (e.g. `SET search_path`). */
20
+ getSetSchemaSQL(_schema: string): string;
21
+ /** Whether the driver supports setting a runtime schema per migration run. */
22
+ supportsMigrationSchema(): boolean;
23
+ /** Restores the session's schema to the connection's default after a migration. */
24
+ getResetSchemaSQL(_defaultSchema: string): string;
25
+ /** Returns `undefined` for schemaless drivers, throws for drivers that have schemas but no session switch. */
26
+ resolveMigrationSchema(schema: string | undefined): string | undefined;
19
27
  finalizeTable(table: DatabaseTable, charset: string, collate?: string): string;
20
28
  appendComments(table: DatabaseTable): string[];
21
29
  supportsSchemaConstraints(): boolean;
@@ -31,6 +39,8 @@ export declare abstract class SchemaHelper {
31
39
  getListTablesSQL(): string;
32
40
  /** Retrieves all tables from the database. */
33
41
  getAllTables(connection: AbstractSqlConnection, schemas?: string[], ctx?: Transaction): Promise<Table[]>;
42
+ /** Checks whether a specific table exists in a given schema (not the connection's current schema). */
43
+ tableExists(connection: AbstractSqlConnection, tableName: string, schemaName: string | undefined, ctx?: Transaction): Promise<boolean>;
34
44
  getListViewsSQL(): string;
35
45
  loadViews(schema: DatabaseSchema, connection: AbstractSqlConnection, schemaName?: string, ctx?: Transaction): Promise<void>;
36
46
  /** Returns SQL to rename a column in a table. */
@@ -41,6 +51,46 @@ export declare abstract class SchemaHelper {
41
51
  * Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
42
52
  */
43
53
  protected getCreateIndexSuffix(_index: IndexDef): string;
54
+ /**
55
+ * Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
56
+ * return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
57
+ * with its own `getCreateIndexSQL` that never calls this, and MariaDB refuses the feature
58
+ * entirely via an override on `getIndexColumns`.
59
+ */
60
+ protected getIndexWhereClause(index: IndexDef): string;
61
+ /**
62
+ * Wraps each indexed column in `(CASE WHEN <predicate> THEN <col> END)` for dialects that
63
+ * emulate partial indexes via functional indexes (MySQL/MariaDB/Oracle). Combined with NULL
64
+ * being treated as distinct in unique indexes, this enforces uniqueness only where the
65
+ * predicate holds. Throws if combined with the advanced `columns` option.
66
+ */
67
+ protected emulatePartialIndexColumns(index: IndexDef): string;
68
+ /**
69
+ * Strips `<col> IS NOT NULL` clauses (with the dialect's identifier quoting) from an
70
+ * introspected partial-index predicate when the column matches one of the index's own
71
+ * columns. MikroORM auto-emits this guard for unique indexes on nullable columns
72
+ * (MSSQL, Oracle) — it's an internal artifact, not user intent.
73
+ *
74
+ * Strips at most one guard per column (the tail-most occurrence), matching how MikroORM
75
+ * appends a single guard per index column. This preserves user intent when they redundantly
76
+ * include the same `<col> IS NOT NULL` in their predicate — the guard we added is removed,
77
+ * their copy survives.
78
+ */
79
+ protected stripAutoNotNullFilter(filterDef: string, columnNames: string[], identifierPattern: RegExp): string;
80
+ /**
81
+ * Whether `[…]` is a quoted identifier (MSSQL convention). Other dialects either reuse
82
+ * `[` for array literals/constructors or never produce it in introspected predicates,
83
+ * so the default is `false` and the MSSQL helper opts in.
84
+ */
85
+ protected get bracketQuotedIdentifiers(): boolean;
86
+ /**
87
+ * Splits on top-level ` AND ` (case-insensitive), ignoring matches that sit inside string
88
+ * literals, quoted identifiers, or parenthesized groups — so a predicate like
89
+ * `'foo AND bar' = col` or `(a AND b) OR c` is not mis-split.
90
+ */
91
+ protected splitTopLevelAnd(s: string): string[];
92
+ /** Returns true iff the leading `(` matches the trailing `)` (i.e. they wrap the whole string). */
93
+ protected isBalancedWrap(s: string): boolean;
44
94
  /**
45
95
  * Build the column list for an index, supporting advanced options like sort order, nulls ordering, and collation.
46
96
  * Note: Prefix length is only supported by MySQL/MariaDB which override this method.
@@ -57,6 +107,8 @@ export declare abstract class SchemaHelper {
57
107
  hasNonDefaultPrimaryKeyName(table: DatabaseTable): boolean;
58
108
  castColumn(name: string, type: string): string;
59
109
  alterTableColumn(column: Column, table: DatabaseTable, changedProperties: Set<string>): string[];
110
+ /** Returns the bare `collate <name>` clause for column DDL. Overridden by PostgreSQL to quote the identifier. */
111
+ protected getCollateSQL(collation: string): string;
60
112
  createTableColumn(column: Column, table: DatabaseTable, changedProperties?: Set<string>): string | undefined;
61
113
  getPreAlterTable(tableDiff: TableDifference, safe: boolean): string[];
62
114
  getPostAlterTable(tableDiff: TableDifference, safe: boolean): string[];
@@ -84,6 +136,16 @@ export declare abstract class SchemaHelper {
84
136
  getReferencedTableName(referencedTableName: string, schema?: string): string;
85
137
  createIndex(index: IndexDef, table: DatabaseTable, createPrimary?: boolean): string;
86
138
  createCheck(table: DatabaseTable, check: CheckDef): string;
139
+ /**
140
+ * Generates SQL to create a database trigger on a table.
141
+ * Override in driver-specific helpers for custom DDL (e.g., PostgreSQL function wrapping).
142
+ */
143
+ createTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
144
+ /**
145
+ * Generates SQL to drop a database trigger from a table.
146
+ * Override in driver-specific helpers for custom DDL.
147
+ */
148
+ dropTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
87
149
  /** @internal */
88
150
  getTableName(table: string, schema?: string): string;
89
151
  getTablesGroupedBySchemas(tables: Table[]): Map<string | undefined, Table[]>;