@mikro-orm/sql 7.1.0-dev.9 → 7.1.0

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 (56) hide show
  1. package/AbstractSqlConnection.d.ts +1 -1
  2. package/AbstractSqlConnection.js +27 -6
  3. package/AbstractSqlDriver.d.ts +15 -1
  4. package/AbstractSqlDriver.js +143 -26
  5. package/AbstractSqlPlatform.d.ts +15 -3
  6. package/AbstractSqlPlatform.js +25 -7
  7. package/PivotCollectionPersister.d.ts +2 -2
  8. package/PivotCollectionPersister.js +6 -1
  9. package/README.md +2 -1
  10. package/SqlEntityManager.d.ts +44 -5
  11. package/SqlEntityManager.js +41 -6
  12. package/SqlMikroORM.d.ts +23 -0
  13. package/SqlMikroORM.js +23 -0
  14. package/dialects/mysql/BaseMySqlPlatform.d.ts +3 -5
  15. package/dialects/mysql/BaseMySqlPlatform.js +6 -10
  16. package/dialects/mysql/MySqlSchemaHelper.d.ts +16 -3
  17. package/dialects/mysql/MySqlSchemaHelper.js +197 -49
  18. package/dialects/oracledb/OracleDialect.d.ts +1 -1
  19. package/dialects/oracledb/OracleDialect.js +2 -1
  20. package/dialects/postgresql/BasePostgreSqlEntityManager.d.ts +19 -0
  21. package/dialects/postgresql/BasePostgreSqlEntityManager.js +24 -0
  22. package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +11 -5
  23. package/dialects/postgresql/BasePostgreSqlPlatform.js +75 -17
  24. package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +31 -1
  25. package/dialects/postgresql/PostgreSqlSchemaHelper.js +269 -28
  26. package/dialects/postgresql/index.d.ts +2 -0
  27. package/dialects/postgresql/index.js +2 -0
  28. package/dialects/postgresql/typeOverrides.d.ts +14 -0
  29. package/dialects/postgresql/typeOverrides.js +12 -0
  30. package/dialects/sqlite/SqlitePlatform.d.ts +2 -1
  31. package/dialects/sqlite/SqlitePlatform.js +4 -0
  32. package/dialects/sqlite/SqliteSchemaHelper.d.ts +4 -1
  33. package/dialects/sqlite/SqliteSchemaHelper.js +49 -19
  34. package/index.d.ts +2 -0
  35. package/index.js +2 -0
  36. package/package.json +4 -4
  37. package/plugin/transformer.d.ts +11 -3
  38. package/plugin/transformer.js +138 -29
  39. package/query/CriteriaNode.d.ts +1 -1
  40. package/query/CriteriaNode.js +2 -2
  41. package/query/ObjectCriteriaNode.js +1 -1
  42. package/query/QueryBuilder.d.ts +42 -1
  43. package/query/QueryBuilder.js +78 -7
  44. package/schema/DatabaseSchema.d.ts +29 -2
  45. package/schema/DatabaseSchema.js +131 -4
  46. package/schema/DatabaseTable.d.ts +14 -1
  47. package/schema/DatabaseTable.js +165 -32
  48. package/schema/SchemaComparator.d.ts +18 -0
  49. package/schema/SchemaComparator.js +196 -1
  50. package/schema/SchemaHelper.d.ts +67 -1
  51. package/schema/SchemaHelper.js +255 -25
  52. package/schema/SqlSchemaGenerator.d.ts +2 -2
  53. package/schema/SqlSchemaGenerator.js +40 -10
  54. package/schema/partitioning.d.ts +13 -0
  55. package/schema/partitioning.js +326 -0
  56. package/typings.d.ts +59 -5
@@ -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
  */
@@ -13,6 +14,14 @@ export class DatabaseTable {
13
14
  #platform;
14
15
  nativeEnums = {}; // for postgres
15
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;
16
25
  constructor(platform, name, schema) {
17
26
  this.name = name;
18
27
  this.schema = schema;
@@ -36,6 +45,13 @@ export class DatabaseTable {
36
45
  getChecks() {
37
46
  return this.#checks;
38
47
  }
48
+ getPartitioning() {
49
+ return this.partitioning;
50
+ }
51
+ /** @internal */
52
+ setPartitioning(partitioning) {
53
+ this.partitioning = partitioning;
54
+ }
39
55
  getTriggers() {
40
56
  return this.#triggers;
41
57
  }
@@ -59,6 +75,7 @@ export class DatabaseTable {
59
75
  this.#indexes = indexes;
60
76
  this.#checks = checks;
61
77
  this.#foreignKeys = fks;
78
+ const helper = this.#platform.getSchemaHelper();
62
79
  this.#columns = cols.reduce((o, v) => {
63
80
  const index = indexes.filter(i => i.columnNames[0] === v.name);
64
81
  v.primary = v.primary || pks.includes(v.name);
@@ -67,6 +84,11 @@ export class DatabaseTable {
67
84
  v.mappedType = this.#platform.getMappedType(type);
68
85
  v.default = v.default?.toString().startsWith('nextval(') ? null : v.default;
69
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
+ }
70
92
  o[v.name] = v;
71
93
  return o;
72
94
  }, {});
@@ -76,7 +98,9 @@ export class DatabaseTable {
76
98
  }
77
99
  addColumnFromProperty(prop, meta, config) {
78
100
  prop.fieldNames?.forEach((field, idx) => {
79
- 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];
80
104
  const mappedType = this.#platform.getMappedType(type);
81
105
  if (mappedType instanceof DecimalType) {
82
106
  const match = /\w+\((\d+), ?(\d+)\)/.exec(prop.columnTypes[idx]);
@@ -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);
@@ -646,6 +673,7 @@ export class DatabaseTable {
646
673
  columnOptions.scale = column.scale;
647
674
  columnOptions.extra = column.extra;
648
675
  columnOptions.comment = column.comment;
676
+ columnOptions.collation = column.collation;
649
677
  columnOptions.enum = !!column.enumItems?.length;
650
678
  columnOptions.items = column.enumItems;
651
679
  }
@@ -712,6 +740,7 @@ export class DatabaseTable {
712
740
  scale: column.scale,
713
741
  extra: column.extra,
714
742
  comment: column.comment,
743
+ collation: column.collation,
715
744
  index: index ? index.keyName : undefined,
716
745
  unique: unique ? unique.keyName : undefined,
717
746
  enum: !!column.enumItems?.length,
@@ -876,16 +905,25 @@ export class DatabaseTable {
876
905
  if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
877
906
  throw new Error(`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`);
878
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);
879
915
  this.#indexes.push({
880
916
  keyName: name,
881
917
  columnNames: properties,
882
918
  composite: properties.length > 1,
883
- // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
884
- 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,
885
922
  primary: type === 'primary',
886
923
  unique: type !== 'index',
887
924
  type: index.type,
888
925
  expression: this.processIndexExpression(name, index.expression, meta),
926
+ where,
889
927
  options: index.options,
890
928
  deferMode: index.deferMode,
891
929
  columns,
@@ -896,6 +934,15 @@ export class DatabaseTable {
896
934
  clustered: index.clustered,
897
935
  });
898
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
+ }
899
946
  addCheck(check) {
900
947
  this.#checks.push(check);
901
948
  }
@@ -904,52 +951,138 @@ export class DatabaseTable {
904
951
  }
905
952
  toJSON() {
906
953
  const columns = this.#columns;
907
- 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) => {
908
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);
909
985
  const normalized = {
910
986
  name: c.name,
911
- type: c.type,
912
- unsigned: !!c.unsigned,
987
+ type,
988
+ unsigned: supportsUnsigned && !!c.unsigned,
913
989
  autoincrement: !!c.autoincrement,
914
- primary: !!c.primary,
990
+ primary: primaryColumns.has(c.name) || !!c.primary,
915
991
  nullable: !!c.nullable,
916
- unique: !!c.unique,
917
- length: c.length ?? null,
918
- precision: c.precision ?? null,
919
- 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),
920
996
  default: c.default ?? null,
921
997
  comment: c.comment ?? null,
998
+ collation: c.collation ?? null,
922
999
  enumItems: c.enumItems ?? [],
923
1000
  mappedType: Utils.keys(t).find(k => t[k] === c.mappedType.constructor),
924
1001
  };
925
- if (c.generated) {
926
- normalized.generated = c.generated;
927
- }
928
- if (c.nativeEnumName) {
929
- normalized.nativeEnumName = c.nativeEnumName;
930
- }
931
- if (c.extra) {
932
- normalized.extra = c.extra;
933
- }
934
- if (c.ignoreSchemaChanges) {
935
- normalized.ignoreSchemaChanges = c.ignoreSchemaChanges;
936
- }
937
- if (c.defaultConstraint) {
938
- 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
+ }
939
1012
  }
940
1013
  o[col] = normalized;
941
1014
  return o;
942
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)]));
943
1076
  return {
944
1077
  name: this.name,
945
1078
  schema: this.schema,
946
1079
  columns: columnsMapped,
947
- indexes: this.#indexes,
948
- checks: this.#checks,
949
- triggers: this.#triggers,
950
- foreignKeys: this.#foreignKeys,
951
- nativeEnums: this.nativeEnums,
952
- 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,
953
1086
  };
954
1087
  }
955
1088
  }
@@ -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.
@@ -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.
@@ -200,6 +347,17 @@ export class SchemaComparator {
200
347
  });
201
348
  changes++;
202
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
+ }
203
361
  const fromTableColumns = fromTable.getColumns();
204
362
  const toTableColumns = toTable.getColumns();
205
363
  // See if all the columns in "from" table exist in "to" table
@@ -266,6 +424,19 @@ export class SchemaComparator {
266
424
  if (!this.diffIndex(index, toTableIndex)) {
267
425
  continue;
268
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
+ }
269
440
  tableDifferences.changedIndexes[index.keyName] = toTableIndex;
270
441
  this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
271
442
  fromTableIndex: index,
@@ -546,6 +717,11 @@ export class SchemaComparator {
546
717
  log(`'comment' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
547
718
  changedProperties.add('comment');
548
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
+ }
549
725
  const isNonNativeEnumArray = !(fromColumn.nativeEnumName || toColumn.nativeEnumName) &&
550
726
  (fromColumn.mappedType instanceof ArrayType || toColumn.mappedType instanceof ArrayType);
551
727
  if (!isNonNativeEnumArray && this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)) {
@@ -567,12 +743,26 @@ export class SchemaComparator {
567
743
  // eslint-disable-next-line eqeqeq
568
744
  return comment1 != comment2 && !(comment1 == null && comment2 === '') && !(comment2 == null && comment1 === '');
569
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
+ }
570
759
  /**
571
760
  * Finds the difference between the indexes index1 and index2.
572
761
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
573
762
  */
574
763
  diffIndex(index1, index2) {
575
- // 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.
576
766
  if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
577
767
  return index1.keyName !== index2.keyName;
578
768
  }
@@ -623,6 +813,11 @@ export class SchemaComparator {
623
813
  if (!!index1.clustered !== !!index2.clustered) {
624
814
  return false;
625
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
+ }
626
821
  if (!index1.unique && !index1.primary) {
627
822
  // this is a special case: If the current key is neither primary or unique, any unique or
628
823
  // primary key will always have the same effect for the index and there cannot be any constraint