@mikro-orm/sql 7.0.17 → 7.0.18-dev.1

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 (50) hide show
  1. package/AbstractSqlConnection.d.ts +1 -1
  2. package/AbstractSqlConnection.js +27 -6
  3. package/AbstractSqlDriver.d.ts +25 -1
  4. package/AbstractSqlDriver.js +356 -20
  5. package/AbstractSqlPlatform.d.ts +13 -2
  6. package/AbstractSqlPlatform.js +16 -3
  7. package/PivotCollectionPersister.d.ts +2 -2
  8. package/PivotCollectionPersister.js +19 -3
  9. package/README.md +2 -1
  10. package/SqlEntityManager.d.ts +46 -3
  11. package/SqlEntityManager.js +77 -7
  12. package/SqlMikroORM.d.ts +4 -4
  13. package/dialects/mssql/MsSqlNativeQueryBuilder.js +4 -0
  14. package/dialects/mysql/BaseMySqlPlatform.d.ts +1 -0
  15. package/dialects/mysql/BaseMySqlPlatform.js +3 -0
  16. package/dialects/mysql/MySqlNativeQueryBuilder.js +11 -0
  17. package/dialects/mysql/MySqlSchemaHelper.d.ts +19 -3
  18. package/dialects/mysql/MySqlSchemaHelper.js +254 -21
  19. package/dialects/oracledb/OracleDialect.d.ts +1 -1
  20. package/dialects/oracledb/OracleDialect.js +2 -1
  21. package/dialects/postgresql/BasePostgreSqlEntityManager.d.ts +19 -0
  22. package/dialects/postgresql/BasePostgreSqlEntityManager.js +24 -0
  23. package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +8 -0
  24. package/dialects/postgresql/BasePostgreSqlPlatform.js +50 -0
  25. package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +38 -1
  26. package/dialects/postgresql/PostgreSqlSchemaHelper.js +341 -6
  27. package/dialects/postgresql/index.d.ts +2 -0
  28. package/dialects/postgresql/index.js +2 -0
  29. package/dialects/postgresql/typeOverrides.d.ts +14 -0
  30. package/dialects/postgresql/typeOverrides.js +12 -0
  31. package/dialects/sqlite/SqliteSchemaHelper.d.ts +7 -1
  32. package/dialects/sqlite/SqliteSchemaHelper.js +131 -2
  33. package/package.json +2 -2
  34. package/query/NativeQueryBuilder.d.ts +6 -0
  35. package/query/NativeQueryBuilder.js +16 -1
  36. package/query/QueryBuilder.d.ts +83 -1
  37. package/query/QueryBuilder.js +181 -8
  38. package/schema/DatabaseSchema.d.ts +29 -2
  39. package/schema/DatabaseSchema.js +137 -0
  40. package/schema/DatabaseTable.d.ts +20 -1
  41. package/schema/DatabaseTable.js +62 -3
  42. package/schema/SchemaComparator.d.ts +19 -0
  43. package/schema/SchemaComparator.js +250 -1
  44. package/schema/SchemaHelper.d.ts +77 -1
  45. package/schema/SchemaHelper.js +279 -5
  46. package/schema/SqlSchemaGenerator.d.ts +2 -2
  47. package/schema/SqlSchemaGenerator.js +47 -10
  48. package/schema/partitioning.d.ts +13 -0
  49. package/schema/partitioning.js +326 -0
  50. package/typings.d.ts +69 -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
  }
@@ -113,6 +137,7 @@ export class DatabaseTable {
113
137
  default: prop.defaultRaw,
114
138
  enumItems: prop.nativeEnumName || prop.items?.every(i => typeof i === 'string') ? prop.items : undefined,
115
139
  comment: prop.comment,
140
+ collation: prop.collation,
116
141
  extra: prop.extra,
117
142
  ignoreSchemaChanges: prop.ignoreSchemaChanges,
118
143
  };
@@ -185,6 +210,7 @@ export class DatabaseTable {
185
210
  const { fksOnColumnProps, fksOnStandaloneProps, columnFks, fkIndexes, nullableForeignKeys, skippedColumnNames } = this.foreignKeysToProps(namingStrategy, scalarPropertiesForRelations);
186
211
  const name = namingStrategy.getEntityName(this.name, this.schema);
187
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);
188
214
  const compositeFkIndexes = {};
189
215
  const compositeFkUniques = {};
190
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.
@@ -204,6 +230,7 @@ export class DatabaseTable {
204
230
  name: index.keyName,
205
231
  deferMode: index.deferMode,
206
232
  expression: index.expression,
233
+ where: index.where,
207
234
  // Advanced index options - convert column names to property names
208
235
  columns: index.columns?.map(col => ({
209
236
  ...col,
@@ -234,7 +261,7 @@ export class DatabaseTable {
234
261
  index.invisible ||
235
262
  index.disabled ||
236
263
  index.clustered;
237
- const isTrivial = !index.deferMode && !index.expression && !hasAdvancedOptions;
264
+ const isTrivial = !index.deferMode && !index.expression && !index.where && !hasAdvancedOptions;
238
265
  if (isTrivial) {
239
266
  // Index is for FK. Map to the FK prop and move on.
240
267
  const fkForIndex = fkIndexes.get(index);
@@ -606,6 +633,12 @@ export class DatabaseTable {
606
633
  hasCheck(checkName) {
607
634
  return !!this.getCheck(checkName);
608
635
  }
636
+ getTrigger(triggerName) {
637
+ return this.#triggers.find(t => t.name === triggerName);
638
+ }
639
+ hasTrigger(triggerName) {
640
+ return !!this.getTrigger(triggerName);
641
+ }
609
642
  getPrimaryKey() {
610
643
  return this.#indexes.find(i => i.primary);
611
644
  }
@@ -640,6 +673,7 @@ export class DatabaseTable {
640
673
  columnOptions.scale = column.scale;
641
674
  columnOptions.extra = column.extra;
642
675
  columnOptions.comment = column.comment;
676
+ columnOptions.collation = column.collation;
643
677
  columnOptions.enum = !!column.enumItems?.length;
644
678
  columnOptions.items = column.enumItems;
645
679
  }
@@ -706,6 +740,7 @@ export class DatabaseTable {
706
740
  scale: column.scale,
707
741
  extra: column.extra,
708
742
  comment: column.comment,
743
+ collation: column.collation,
709
744
  index: index ? index.keyName : undefined,
710
745
  unique: unique ? unique.keyName : undefined,
711
746
  enum: !!column.enumItems?.length,
@@ -870,16 +905,25 @@ export class DatabaseTable {
870
905
  if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
871
906
  throw new Error(`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`);
872
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);
873
915
  this.#indexes.push({
874
916
  keyName: name,
875
917
  columnNames: properties,
876
918
  composite: properties.length > 1,
877
- // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
878
- 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,
879
922
  primary: type === 'primary',
880
923
  unique: type !== 'index',
881
924
  type: index.type,
882
925
  expression: this.processIndexExpression(name, index.expression, meta),
926
+ where,
883
927
  options: index.options,
884
928
  deferMode: index.deferMode,
885
929
  columns,
@@ -890,9 +934,21 @@ export class DatabaseTable {
890
934
  clustered: index.clustered,
891
935
  });
892
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
+ }
893
946
  addCheck(check) {
894
947
  this.#checks.push(check);
895
948
  }
949
+ addTrigger(trigger) {
950
+ this.#triggers.push(trigger);
951
+ }
896
952
  toJSON() {
897
953
  const columns = this.#columns;
898
954
  // locale-independent comparison so the snapshot is stable across machines
@@ -939,6 +995,7 @@ export class DatabaseTable {
939
995
  scale: fixedPrecision ? null : (c.scale ?? null),
940
996
  default: c.default ?? null,
941
997
  comment: c.comment ?? null,
998
+ collation: c.collation ?? null,
942
999
  enumItems: c.enumItems ?? [],
943
1000
  mappedType: Utils.keys(t).find(k => t[k] === c.mappedType.constructor),
944
1001
  };
@@ -1012,6 +1069,7 @@ export class DatabaseTable {
1012
1069
  };
1013
1070
  const sortedIndexes = [...this.#indexes].sort((a, b) => byString(a.keyName, b.keyName)).map(normalizeIndex);
1014
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));
1015
1073
  const sortedForeignKeys = Object.fromEntries(Object.entries(this.#foreignKeys)
1016
1074
  .sort(([a], [b]) => byString(a, b))
1017
1075
  .map(([k, v]) => [k, normalizeFk(v)]));
@@ -1021,6 +1079,7 @@ export class DatabaseTable {
1021
1079
  columns: columnsMapped,
1022
1080
  indexes: sortedIndexes,
1023
1081
  checks: sortedChecks,
1082
+ triggers: sortedTriggers,
1024
1083
  foreignKeys: sortedForeignKeys,
1025
1084
  // emit `comment` even when unset so introspection (which always reads it) matches metadata
1026
1085
  comment: this.comment ?? null,
@@ -17,6 +17,15 @@ export declare class SchemaComparator {
17
17
  * stored in toSchema.
18
18
  */
19
19
  compare(fromSchema: DatabaseSchema, toSchema: DatabaseSchema, inverseDiff?: SchemaDifference): SchemaDifference;
20
+ private compareRoutines;
21
+ private diffRoutine;
22
+ /** Strips outer `BEGIN ... END` and trailing semicolons so the comparator doesn't churn on cosmetic round-trip differences. */
23
+ private normaliseBody;
24
+ private diffRoutineParams;
25
+ /** Engines drop length/precision modifiers and use different aliases for the same logical type — canonicalise both sides. */
26
+ private normaliseParamType;
27
+ private static readonly PARAM_TYPE_ALIASES;
28
+ private diffRoutineReturns;
20
29
  /**
21
30
  * Returns the difference between the tables fromTable and toTable.
22
31
  * If there are no differences this method returns the boolean false.
@@ -39,6 +48,15 @@ export declare class SchemaComparator {
39
48
  diffColumn(fromColumn: Column, toColumn: Column, fromTable: DatabaseTable, logging?: boolean): Set<string>;
40
49
  diffEnumItems(items1?: string[], items2?: string[]): boolean;
41
50
  diffComment(comment1?: string, comment2?: string): boolean;
51
+ /**
52
+ * `from` is the introspected DB state, `to` is the target metadata. A column-level `COLLATE`
53
+ * clause naming the table/database default is just verbose syntax for inheriting that default,
54
+ * so both sides are normalized — anything matching `tableDefault` collapses to `undefined` and
55
+ * compares equal to "no explicit collation". Comparison is case-insensitive on dialects that
56
+ * treat collation identifiers as case-insensitive (MySQL/MSSQL/SQLite); PostgreSQL's
57
+ * `pg_collation.collname` is case-sensitive and is compared verbatim.
58
+ */
59
+ diffCollation(fromCollation?: string, toCollation?: string, tableDefault?: string): boolean;
42
60
  /**
43
61
  * Finds the difference between the indexes index1 and index2.
44
62
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
@@ -69,6 +87,7 @@ export declare class SchemaComparator {
69
87
  * @see https://github.com/mikro-orm/mikro-orm/issues/7308
70
88
  */
71
89
  private diffViewExpression;
90
+ private diffTrigger;
72
91
  parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
73
92
  hasSameDefaultValue(from: Column, to: Column): boolean;
74
93
  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
  */
@@ -27,6 +28,9 @@ export class SchemaComparator {
27
28
  newViews: {},
28
29
  changedViews: {},
29
30
  removedViews: {},
31
+ newRoutines: {},
32
+ changedRoutines: {},
33
+ removedRoutines: {},
30
34
  orphanedForeignKeys: [],
31
35
  newNativeEnums: [],
32
36
  removedNativeEnums: [],
@@ -162,8 +166,151 @@ export class SchemaComparator {
162
166
  diff.changedTables[viewName] = tableDiff;
163
167
  }
164
168
  }
169
+ this.compareRoutines(fromSchema, toSchema, diff);
165
170
  return diff;
166
171
  }
172
+ compareRoutines(fromSchema, toSchema, diff) {
173
+ // Case-fold so user-written `'sql_hash'` matches Oracle's introspected `'SQL_HASH'`.
174
+ const routineKey = (r) => ((r.schema ? `${r.schema}.` : '') + r.name).toLowerCase();
175
+ const fromByKey = new Map(fromSchema.getRoutines().map(r => [routineKey(r), r]));
176
+ const toByKey = new Map(toSchema.getRoutines().map(r => [routineKey(r), r]));
177
+ for (const [key, toRoutine] of toByKey) {
178
+ const fromRoutine = fromByKey.get(key);
179
+ if (!fromRoutine) {
180
+ diff.newRoutines[key] = toRoutine;
181
+ this.log(`routine ${key} added`);
182
+ continue;
183
+ }
184
+ if (this.diffRoutine(fromRoutine, toRoutine)) {
185
+ diff.changedRoutines[key] = { from: fromRoutine, to: toRoutine };
186
+ this.log(`routine ${key} changed`, { fromRoutine, toRoutine });
187
+ }
188
+ }
189
+ for (const [key, fromRoutine] of fromByKey) {
190
+ if (!toByKey.has(key)) {
191
+ diff.removedRoutines[key] = fromRoutine;
192
+ this.log(`routine ${key} removed`);
193
+ }
194
+ }
195
+ }
196
+ diffRoutine(from, to) {
197
+ const ignore = new Set(to.ignoreSchemaChanges ?? []);
198
+ if (from.type !== to.type) {
199
+ this.log(`routine ${from.name}: type ${from.type} -> ${to.type}`);
200
+ return true;
201
+ }
202
+ if (!ignore.has('body') && from.expression == null && to.expression == null) {
203
+ const a = this.normaliseBody(from.body);
204
+ const b = this.normaliseBody(to.body);
205
+ if (a !== b) {
206
+ this.log(`routine ${from.name}: body differs`, { from: a, to: b });
207
+ return true;
208
+ }
209
+ }
210
+ if (!ignore.has('comment') && (from.comment ?? '') !== (to.comment ?? '')) {
211
+ return true;
212
+ }
213
+ // For security/deterministic/definer, unset on the metadata side means "don't care": the
214
+ // DB always has some server default, comparing it would force a drop+create on every run.
215
+ if (!ignore.has('security') && to.security != null && from.security !== to.security) {
216
+ return true;
217
+ }
218
+ if (!ignore.has('deterministic') &&
219
+ to.deterministic != null &&
220
+ (from.deterministic ?? false) !== to.deterministic) {
221
+ return true;
222
+ }
223
+ if (!ignore.has('definer') && to.definer != null && from.definer !== to.definer) {
224
+ return true;
225
+ }
226
+ // `language` (PG) / `dataAccess` (MySQL) follow the same to-side-wins policy: only diff when
227
+ // the metadata explicitly declares them, otherwise the create DDL's defaults will line up with
228
+ // whatever the engine introspected.
229
+ if (to.language != null && (from.language ?? '').toLowerCase() !== to.language.toLowerCase()) {
230
+ this.log(`routine ${from.name}: language ${from.language} -> ${to.language}`);
231
+ return true;
232
+ }
233
+ if (to.dataAccess != null && from.dataAccess !== to.dataAccess) {
234
+ this.log(`routine ${from.name}: dataAccess ${from.dataAccess} -> ${to.dataAccess}`);
235
+ return true;
236
+ }
237
+ if (this.diffRoutineParams(from.params, to.params)) {
238
+ this.log(`routine ${from.name}: params differ`, { from: from.params, to: to.params });
239
+ return true;
240
+ }
241
+ if (this.diffRoutineReturns(from.returns, to.returns)) {
242
+ this.log(`routine ${from.name}: returns differ`, { from: from.returns, to: to.returns });
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+ /** Strips outer `BEGIN ... END` and trailing semicolons so the comparator doesn't churn on cosmetic round-trip differences. */
248
+ normaliseBody(body) {
249
+ let result = (body ?? '').replace(/\s+/g, ' ').trim();
250
+ const beginEnd = /^begin\s+([\s\S]*?)\s*end\s*;?\s*$/i.exec(result);
251
+ if (beginEnd) {
252
+ result = beginEnd[1].trim();
253
+ }
254
+ return result.replace(/;+\s*$/, '').trim();
255
+ }
256
+ diffRoutineParams(from, to) {
257
+ if (from.length !== to.length) {
258
+ return true;
259
+ }
260
+ for (let i = 0; i < from.length; i++) {
261
+ const a = from[i];
262
+ const b = to[i];
263
+ if (a.name.toLowerCase() !== b.name.toLowerCase() ||
264
+ this.normaliseParamType(a.type) !== this.normaliseParamType(b.type) ||
265
+ a.direction !== b.direction) {
266
+ return true;
267
+ }
268
+ // Asymmetric: drivers don't currently introspect param nullability, so the from side is
269
+ // always undefined; comparing eagerly would churn metadata-declared `nullable: true`.
270
+ if (a.nullable != null && !!a.nullable !== !!b.nullable) {
271
+ return true;
272
+ }
273
+ }
274
+ return false;
275
+ }
276
+ /** Engines drop length/precision modifiers and use different aliases for the same logical type — canonicalise both sides. */
277
+ normaliseParamType(type) {
278
+ const lengthMatch = /^([^()]+)\(([^)]*)\)\s*$/.exec(type);
279
+ const base = (lengthMatch ? lengthMatch[1] : type).trim().toLowerCase();
280
+ const aliased = SchemaComparator.PARAM_TYPE_ALIASES[base] ?? base;
281
+ const length = lengthMatch ? Number.parseInt(lengthMatch[2].split(',')[0].trim(), 10) : NaN;
282
+ const options = Number.isFinite(length) ? { length } : {};
283
+ return this.#platform.normalizeColumnType(aliased, options).toLowerCase();
284
+ }
285
+ static PARAM_TYPE_ALIASES = {
286
+ int: 'integer',
287
+ int4: 'integer',
288
+ int8: 'bigint',
289
+ int2: 'smallint',
290
+ bool: 'boolean',
291
+ 'character varying': 'varchar',
292
+ bpchar: 'char',
293
+ float8: 'double precision',
294
+ float4: 'real',
295
+ // Oracle's USER_ARGUMENTS reports `REF CURSOR`; users declare `sys_refcursor`.
296
+ 'ref cursor': 'sys_refcursor',
297
+ };
298
+ diffRoutineReturns(from, to) {
299
+ if (from == null && to == null) {
300
+ return false;
301
+ }
302
+ if (from == null || to == null) {
303
+ return true;
304
+ }
305
+ if (this.normaliseParamType(from.type) !== this.normaliseParamType(to.type)) {
306
+ return true;
307
+ }
308
+ // Engines report function returns as nullable; only diff when metadata explicitly declares it.
309
+ if (to.nullable != null && (from.nullable ?? false) !== to.nullable) {
310
+ return true;
311
+ }
312
+ return false;
313
+ }
167
314
  /**
168
315
  * Returns the difference between the tables fromTable and toTable.
169
316
  * If there are no differences this method returns the boolean false.
@@ -176,14 +323,17 @@ export class SchemaComparator {
176
323
  addedForeignKeys: {},
177
324
  addedIndexes: {},
178
325
  addedChecks: {},
326
+ addedTriggers: {},
179
327
  changedColumns: {},
180
328
  changedForeignKeys: {},
181
329
  changedIndexes: {},
182
330
  changedChecks: {},
331
+ changedTriggers: {},
183
332
  removedColumns: {},
184
333
  removedForeignKeys: {},
185
334
  removedIndexes: {},
186
335
  removedChecks: {},
336
+ removedTriggers: {},
187
337
  renamedColumns: {},
188
338
  renamedIndexes: {},
189
339
  fromTable,
@@ -197,6 +347,17 @@ export class SchemaComparator {
197
347
  });
198
348
  changes++;
199
349
  }
350
+ if (diffPartitioning(fromTable.getPartitioning(), toTable.getPartitioning(), this.#platform.getDefaultSchemaName())) {
351
+ tableDifferences.changedPartitioning = {
352
+ from: fromTable.getPartitioning(),
353
+ to: toTable.getPartitioning(),
354
+ };
355
+ this.log(`table partitioning changed for ${tableDifferences.name}`, {
356
+ fromPartitioning: fromTable.getPartitioning(),
357
+ toPartitioning: toTable.getPartitioning(),
358
+ });
359
+ changes++;
360
+ }
200
361
  const fromTableColumns = fromTable.getColumns();
201
362
  const toTableColumns = toTable.getColumns();
202
363
  // See if all the columns in "from" table exist in "to" table
@@ -263,6 +424,19 @@ export class SchemaComparator {
263
424
  if (!this.diffIndex(index, toTableIndex)) {
264
425
  continue;
265
426
  }
427
+ // Constraint-vs-index form mismatch needs drop+create with the OLD form's drop SQL,
428
+ // which `changedIndexes` (uses new form only) can't do. Primary keys stay on the
429
+ // changed path which emits `add primary key`.
430
+ if (!index.primary && !!index.constraint !== !!toTableIndex.constraint) {
431
+ tableDifferences.removedIndexes[index.keyName] = index;
432
+ tableDifferences.addedIndexes[index.keyName] = toTableIndex;
433
+ this.log(`index ${index.keyName} changed form in table ${tableDifferences.name}`, {
434
+ fromTableIndex: index,
435
+ toTableIndex,
436
+ });
437
+ changes += 2;
438
+ continue;
439
+ }
266
440
  tableDifferences.changedIndexes[index.keyName] = toTableIndex;
267
441
  this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
268
442
  fromTableIndex: index,
@@ -309,6 +483,33 @@ export class SchemaComparator {
309
483
  tableDifferences.changedChecks[check.name] = toTableCheck;
310
484
  changes++;
311
485
  }
486
+ const fromTableTriggers = fromTable.getTriggers();
487
+ const toTableTriggers = toTable.getTriggers();
488
+ for (const trigger of toTableTriggers) {
489
+ if (fromTable.hasTrigger(trigger.name)) {
490
+ continue;
491
+ }
492
+ tableDifferences.addedTriggers[trigger.name] = trigger;
493
+ this.log(`trigger ${trigger.name} added to table ${tableDifferences.name}`, { trigger });
494
+ changes++;
495
+ }
496
+ for (const trigger of fromTableTriggers) {
497
+ if (!toTable.hasTrigger(trigger.name)) {
498
+ tableDifferences.removedTriggers[trigger.name] = trigger;
499
+ this.log(`trigger ${trigger.name} removed from table ${tableDifferences.name}`);
500
+ changes++;
501
+ continue;
502
+ }
503
+ const toTableTrigger = toTable.getTrigger(trigger.name);
504
+ if (this.diffTrigger(trigger, toTableTrigger)) {
505
+ this.log(`trigger ${trigger.name} changed in table ${tableDifferences.name}`, {
506
+ fromTableTrigger: trigger,
507
+ toTableTrigger,
508
+ });
509
+ tableDifferences.changedTriggers[trigger.name] = toTableTrigger;
510
+ changes++;
511
+ }
512
+ }
312
513
  const fromForeignKeys = { ...fromTable.getForeignKeys() };
313
514
  const toForeignKeys = { ...toTable.getForeignKeys() };
314
515
  for (const fromConstraint of Object.values(fromForeignKeys)) {
@@ -516,6 +717,11 @@ export class SchemaComparator {
516
717
  log(`'comment' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
517
718
  changedProperties.add('comment');
518
719
  }
720
+ if (!(fromColumn.ignoreSchemaChanges?.includes('collation') || toColumn.ignoreSchemaChanges?.includes('collation')) &&
721
+ this.diffCollation(fromColumn.collation, toColumn.collation, fromTable.collation)) {
722
+ log(`'collation' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
723
+ changedProperties.add('collation');
724
+ }
519
725
  const isNonNativeEnumArray = !(fromColumn.nativeEnumName || toColumn.nativeEnumName) &&
520
726
  (fromColumn.mappedType instanceof ArrayType || toColumn.mappedType instanceof ArrayType);
521
727
  if (!isNonNativeEnumArray && this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)) {
@@ -537,12 +743,26 @@ export class SchemaComparator {
537
743
  // eslint-disable-next-line eqeqeq
538
744
  return comment1 != comment2 && !(comment1 == null && comment2 === '') && !(comment2 == null && comment1 === '');
539
745
  }
746
+ /**
747
+ * `from` is the introspected DB state, `to` is the target metadata. A column-level `COLLATE`
748
+ * clause naming the table/database default is just verbose syntax for inheriting that default,
749
+ * so both sides are normalized — anything matching `tableDefault` collapses to `undefined` and
750
+ * compares equal to "no explicit collation". Comparison is case-insensitive on dialects that
751
+ * treat collation identifiers as case-insensitive (MySQL/MSSQL/SQLite); PostgreSQL's
752
+ * `pg_collation.collname` is case-sensitive and is compared verbatim.
753
+ */
754
+ diffCollation(fromCollation, toCollation, tableDefault) {
755
+ const fold = this.#platform.caseInsensitiveCollationNames() ? (s) => s.toLowerCase() : (s) => s;
756
+ const norm = (c) => c && tableDefault && fold(c) === fold(tableDefault) ? undefined : c == null ? undefined : fold(c);
757
+ return norm(fromCollation) !== norm(toCollation);
758
+ }
540
759
  /**
541
760
  * Finds the difference between the indexes index1 and index2.
542
761
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
543
762
  */
544
763
  diffIndex(index1, index2) {
545
- // if one of them is a custom expression or full text index, compare only by name
764
+ // Opaque raw expressions (`expression` escape hatch) and full-text indexes can't be
765
+ // compared structurally — fall back to name-only matching.
546
766
  if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
547
767
  return index1.keyName !== index2.keyName;
548
768
  }
@@ -593,6 +813,11 @@ export class SchemaComparator {
593
813
  if (!!index1.clustered !== !!index2.clustered) {
594
814
  return false;
595
815
  }
816
+ // Compare WHERE predicate of partial indexes structurally (whitespace/quoting/casing
817
+ // are normalized via the same helper used for check constraints).
818
+ if (this.diffExpression(index1.where ?? '', index2.where ?? '')) {
819
+ return false;
820
+ }
596
821
  if (!index1.unique && !index1.primary) {
597
822
  // this is a special case: If the current key is neither primary or unique, any unique or
598
823
  // primary key will always have the same effect for the index and there cannot be any constraint
@@ -714,6 +939,30 @@ export class SchemaComparator {
714
939
  }
715
940
  return true;
716
941
  }
942
+ diffTrigger(from, to) {
943
+ // Raw DDL expression cannot be meaningfully compared to introspected
944
+ // trigger metadata, so skip diffing when the metadata side uses it.
945
+ if (to.expression) {
946
+ // Both sides have expression — compare the raw DDL directly
947
+ if (from.expression) {
948
+ return this.diffExpression(from.expression, to.expression);
949
+ }
950
+ // Only metadata side has expression — the raw DDL cannot be compared to
951
+ // introspected metadata. Changes to the expression value won't be detected;
952
+ // drop and recreate the trigger manually to apply expression changes.
953
+ return false;
954
+ }
955
+ if (from.timing !== to.timing || from.forEach !== to.forEach) {
956
+ return true;
957
+ }
958
+ if ([...from.events].sort().join(',') !== [...to.events].sort().join(',')) {
959
+ return true;
960
+ }
961
+ if ((from.when ?? '') !== (to.when ?? '')) {
962
+ return true;
963
+ }
964
+ return this.diffExpression(from.body, to.body);
965
+ }
717
966
  parseJsonDefault(defaultValue) {
718
967
  /* v8 ignore next */
719
968
  if (!defaultValue) {