@mikro-orm/sql 7.1.0-dev.13 → 7.1.0-dev.15

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.
@@ -147,6 +147,13 @@ export declare abstract class AbstractSqlDriver<Connection extends AbstractSqlCo
147
147
  mapPropToFieldNames<T extends object>(qb: AnyQueryBuilder<T>, prop: EntityProperty<T>, tableAlias: string, meta: EntityMetadata<T>, schema?: string, explicitFields?: readonly InternalField<T>[]): InternalField<T>[];
148
148
  /** @internal */
149
149
  createQueryBuilder<T extends object>(entityName: EntityName<T> | AnyQueryBuilder<T>, ctx?: Transaction, preferredConnectionType?: ConnectionType, convertCustomTypes?: boolean, loggerContext?: LoggingOptions, alias?: string, em?: SqlEntityManager): AnyQueryBuilder<T>;
150
+ /**
151
+ * Renders a `FilterQuery` predicate into a SQL fragment (without the `WHERE` keyword and
152
+ * without table-alias prefixes) suitable for inlining into a partial-index DDL statement.
153
+ * Used by `DatabaseTable.addIndex` when the user passes an object `where` on `@Index` /
154
+ * `@Unique`. Strings are returned unchanged.
155
+ */
156
+ renderPartialIndexWhere<T extends object>(entityName: EntityName<T>, where: string | FilterQuery<T>): string;
150
157
  protected resolveConnectionType(args: {
151
158
  ctx?: Transaction;
152
159
  connectionType?: ConnectionType;
@@ -1944,6 +1944,57 @@ export class AbstractSqlDriver extends DatabaseDriver {
1944
1944
  }
1945
1945
  return qb;
1946
1946
  }
1947
+ /**
1948
+ * Renders a `FilterQuery` predicate into a SQL fragment (without the `WHERE` keyword and
1949
+ * without table-alias prefixes) suitable for inlining into a partial-index DDL statement.
1950
+ * Used by `DatabaseTable.addIndex` when the user passes an object `where` on `@Index` /
1951
+ * `@Unique`. Strings are returned unchanged.
1952
+ */
1953
+ renderPartialIndexWhere(entityName, where) {
1954
+ if (typeof where === 'string') {
1955
+ return where;
1956
+ }
1957
+ const name = Utils.className(entityName);
1958
+ if (where == null || (Utils.isPlainObject(where) && Object.keys(where).length === 0)) {
1959
+ throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` is empty.`);
1960
+ }
1961
+ const alias = '__p';
1962
+ const qb = this.createQueryBuilder(entityName, undefined, undefined, undefined, undefined, alias);
1963
+ qb.where(where);
1964
+ const sql = qb.getFormattedQuery();
1965
+ // Relation traversal produces join clauses whose aliased identifiers can't be inlined
1966
+ // into a CREATE INDEX ... WHERE clause — reject with a clear error rather than emitting broken DDL.
1967
+ if (/\bjoin\b/i.test(sql.split(/\bwhere\b/i)[0])) {
1968
+ throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` may not traverse relations.`);
1969
+ }
1970
+ // Anchor at end-of-string only — the synthetic QB has no top-level order by / limit /
1971
+ // group by / having / offset, so any such keyword inside the captured predicate is
1972
+ // inside a subquery and must not terminate the match.
1973
+ const match = /\bwhere\s+([\s\S]+)$/i.exec(sql);
1974
+ if (!match) {
1975
+ throw new Error(`Failed to render partial-index predicate for entity '${name}': ${sql}`);
1976
+ }
1977
+ const quote = (s) => this.platform.quoteIdentifier(s);
1978
+ const aliasPrefix = new RegExp(`${quote(alias).replace(/[[\]]/g, '\\$&')}\\.`, 'g');
1979
+ const stripped = match[1].replace(aliasPrefix, '').trim();
1980
+ // Any qualified column reference remaining after the alias strip points at another table or
1981
+ // subquery and can't be inlined into a CREATE INDEX ... WHERE predicate. Covers both
1982
+ // QB-generated sub-aliases (quoted, e.g. `"e0"."col"`) and raw fragments with bare refs
1983
+ // (e.g. `raw('other_table.col = 1')`). String literals are erased first so dots inside
1984
+ // them (e.g. JSON path operands like `'$.path'`) don't trip the guard.
1985
+ // Both patterns use a `(?!\s*\()` lookahead so schema-qualified function calls
1986
+ // (`pg_catalog.lower(name)`, `"public".my_func(col)`) are accepted — only `<id>.<id>` not
1987
+ // followed by `(` is treated as a cross-table column reference.
1988
+ const withoutStrings = stripped.replace(/'(?:[^']|'')*'/g, "''");
1989
+ const quotedIdent = String.raw `(?:"(?:[^"]|"")+"|\`(?:[^\`]|\`\`)+\`|\[(?:[^\]]|\]\])+\])`;
1990
+ const anyIdent = `(?:${quotedIdent}|[A-Za-z_]\\w*)`;
1991
+ const quotedCrossRef = new RegExp(`${quotedIdent}\\s*\\.\\s*${anyIdent}(?!\\s*\\()`);
1992
+ const bareCrossRef = /\b[A-Za-z_]\w*\s*\.\s*[A-Za-z_]\w*\b(?!\s*\()/;
1993
+ if (quotedCrossRef.test(withoutStrings) || bareCrossRef.test(withoutStrings)) {
1994
+ throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` references another table or subquery which cannot be inlined into a CREATE INDEX ... WHERE clause.`);
1995
+ }
1996
+ return stripped;
1997
+ }
1947
1998
  resolveConnectionType(args) {
1948
1999
  if (args.ctx) {
1949
2000
  return 'write';
@@ -11,6 +11,7 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
11
11
  'current_timestamp(?)': string[];
12
12
  '0': string[];
13
13
  };
14
+ private static readonly PARTIAL_INDEX_RE;
14
15
  getSchemaBeginning(charset: string, disableForeignKeys?: boolean): string;
15
16
  disableForeignKeysSQL(): string;
16
17
  enableForeignKeysSQL(): string;
@@ -22,8 +23,10 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
22
23
  getAllIndexes(connection: AbstractSqlConnection, tables: Table[], ctx?: Transaction): Promise<Dictionary<IndexDef[]>>;
23
24
  getCreateIndexSQL(tableName: string, index: IndexDef, partialExpression?: boolean): string;
24
25
  /**
25
- * Build the column list for a MySQL index, with MySQL-specific handling for collation.
26
- * MySQL requires collation to be specified as an expression: (column_name COLLATE collation_name)
26
+ * Build the column list for a MySQL index. MySQL requires collation via an expression:
27
+ * `(column COLLATE collation_name)`. Partial indexes (`where`) are emulated via functional
28
+ * indexes — requires MySQL 8.0.13+. MariaDB does not support inline functional indexes
29
+ * and overrides to throw at a higher level.
27
30
  */
28
31
  protected getIndexColumns(index: IndexDef): string;
29
32
  /**
@@ -7,6 +7,10 @@ export class MySqlSchemaHelper extends SchemaHelper {
7
7
  'current_timestamp(?)': ['current_timestamp(?)'],
8
8
  '0': ['0', 'false'],
9
9
  };
10
+ // Greedy `(.+)` so nested CASE expressions inside the predicate don't trip the match on
11
+ // an inner `then <col> end` — the trailing `$` anchor forces the regex engine to extend
12
+ // the capture to the outermost case-end boundary.
13
+ static PARTIAL_INDEX_RE = /^\s*\(\s*case\s+when\s+(.+)\s+then\s+`([^`]+)`\s+end\s*\)\s*$/is;
10
14
  getSchemaBeginning(charset, disableForeignKeys) {
11
15
  if (disableForeignKeys) {
12
16
  return `set names ${charset};\n${this.disableForeignKeysSQL()}\n\n`;
@@ -84,8 +88,11 @@ export class MySqlSchemaHelper extends SchemaHelper {
84
88
  const ret = {};
85
89
  for (const index of allIndexes) {
86
90
  const key = this.getTableKey(index);
91
+ const partialMatch = !index.column_name && typeof index.expression === 'string'
92
+ ? MySqlSchemaHelper.PARTIAL_INDEX_RE.exec(index.expression)
93
+ : null;
87
94
  const indexDef = {
88
- columnNames: [index.column_name],
95
+ columnNames: [partialMatch ? partialMatch[2] : index.column_name],
89
96
  keyName: index.index_name,
90
97
  unique: !index.non_unique,
91
98
  primary: index.index_name === 'PRIMARY',
@@ -113,7 +120,10 @@ export class MySqlSchemaHelper extends SchemaHelper {
113
120
  if (index.is_visible === 'NO') {
114
121
  indexDef.invisible = true;
115
122
  }
116
- if (!index.column_name || index.expression?.match(/ where /i)) {
123
+ if (partialMatch) {
124
+ indexDef.where = partialMatch[1].trim();
125
+ }
126
+ else if (!index.column_name || index.expression?.match(/ where /i)) {
117
127
  indexDef.expression = index.expression; // required for the `getCreateIndexSQL()` call
118
128
  indexDef.expression = this.getCreateIndexSQL(index.table_name, indexDef, !!index.expression);
119
129
  }
@@ -150,10 +160,15 @@ export class MySqlSchemaHelper extends SchemaHelper {
150
160
  return this.appendMySqlIndexSuffix(sql, index);
151
161
  }
152
162
  /**
153
- * Build the column list for a MySQL index, with MySQL-specific handling for collation.
154
- * MySQL requires collation to be specified as an expression: (column_name COLLATE collation_name)
163
+ * Build the column list for a MySQL index. MySQL requires collation via an expression:
164
+ * `(column COLLATE collation_name)`. Partial indexes (`where`) are emulated via functional
165
+ * indexes — requires MySQL 8.0.13+. MariaDB does not support inline functional indexes
166
+ * and overrides to throw at a higher level.
155
167
  */
156
168
  getIndexColumns(index) {
169
+ if (index.where) {
170
+ return this.emulatePartialIndexColumns(index);
171
+ }
157
172
  if (index.columns?.length) {
158
173
  return index.columns
159
174
  .map(col => {
@@ -14,6 +14,8 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
14
14
  'null::timestamp with time zone': string[];
15
15
  'null::timestamp without time zone': string[];
16
16
  };
17
+ private static readonly PARTIAL_WHERE_RE;
18
+ private static readonly FUNCTIONAL_COL_RE;
17
19
  getSchemaBeginning(charset: string, disableForeignKeys?: boolean): string;
18
20
  getCreateDatabaseSQL(name: string): string;
19
21
  getListTablesSQL(): string;
@@ -12,6 +12,8 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
12
12
  'null::timestamp with time zone': ['null'],
13
13
  'null::timestamp without time zone': ['null'],
14
14
  };
15
+ static PARTIAL_WHERE_RE = /\swhere\s+(.+)$/is;
16
+ static FUNCTIONAL_COL_RE = /[(): ,"'`]/;
15
17
  getSchemaBeginning(charset, disableForeignKeys) {
16
18
  if (disableForeignKeys) {
17
19
  return `set names '${charset}';\n${this.disableForeignKeysSQL()}\n\n`;
@@ -165,9 +167,22 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
165
167
  if (index.condeferrable) {
166
168
  indexDef.deferMode = index.condeferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
167
169
  }
168
- if (index.index_def.some((col) => /[(): ,"'`]/.exec(col)) || index.expression?.match(/ where /i)) {
170
+ const hasFunctionalColumns = index.index_def.some((col) => PostgreSqlSchemaHelper.FUNCTIONAL_COL_RE.exec(col));
171
+ const whereMatch = hasFunctionalColumns
172
+ ? null
173
+ : PostgreSqlSchemaHelper.PARTIAL_WHERE_RE.exec(index.expression ?? '');
174
+ if (hasFunctionalColumns) {
175
+ // Functional-column expression can't be diffed structurally — keep the whole CREATE
176
+ // statement (WHERE included) on `expression`; don't try to split the predicate.
169
177
  indexDef.expression = index.expression;
170
178
  }
179
+ else if (whereMatch) {
180
+ let where = whereMatch[1].trim();
181
+ if (where.startsWith('(') && where.endsWith(')') && this.isBalancedWrap(where)) {
182
+ where = where.slice(1, -1).trim();
183
+ }
184
+ indexDef.where = where;
185
+ }
171
186
  if (index.deferrable) {
172
187
  indexDef.deferMode = index.initially_deferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
173
188
  }
@@ -5,6 +5,7 @@ import type { Column, IndexDef, Table, TableDifference, SqlTriggerDef } from '..
5
5
  import type { DatabaseTable } from '../../schema/DatabaseTable.js';
6
6
  import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
7
7
  export declare class SqliteSchemaHelper extends SchemaHelper {
8
+ private static readonly PARTIAL_WHERE_RE;
8
9
  disableForeignKeysSQL(): string;
9
10
  enableForeignKeysSQL(): string;
10
11
  supportsSchemaConstraints(): boolean;
@@ -15,6 +15,7 @@ const SPATIALITE_VIEWS = [
15
15
  'ElementaryGeometries',
16
16
  ];
17
17
  export class SqliteSchemaHelper extends SchemaHelper {
18
+ static PARTIAL_WHERE_RE = /\swhere\s+(.+?)\s*$/is;
18
19
  disableForeignKeysSQL() {
19
20
  return 'pragma foreign_keys = off;';
20
21
  }
@@ -216,10 +217,10 @@ export class SqliteSchemaHelper extends SchemaHelper {
216
217
  if (index.columnNames.some(column => column.includes('.'))) {
217
218
  // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
218
219
  const columns = this.platform.getJsonIndexDefinition(index);
219
- return `${sqlPrefix} (${columns.join(', ')})`;
220
+ return `${sqlPrefix} (${columns.join(', ')})${this.getIndexWhereClause(index)}`;
220
221
  }
221
222
  // Use getIndexColumns to support advanced options like sort order and collation
222
- return `${sqlPrefix} (${this.getIndexColumns(index)})`;
223
+ return `${sqlPrefix} (${this.getIndexColumns(index)})${this.getIndexWhereClause(index)}`;
223
224
  }
224
225
  parseTableDefinition(sql, cols) {
225
226
  const columns = {};
@@ -355,6 +356,16 @@ export class SqliteSchemaHelper extends SchemaHelper {
355
356
  const sql = `pragma ${prefix}table_info(\`${tableName}\`)`;
356
357
  const cols = await connection.execute(sql, [], 'all', ctx);
357
358
  const indexes = await connection.execute(`pragma ${prefix}index_list(\`${tableName}\`)`, [], 'all', ctx);
359
+ // sqlite_master.sql holds the original CREATE INDEX statement — the only place a partial
360
+ // index's WHERE predicate is preserved (PRAGMA index_* don't expose it).
361
+ const indexSqls = await connection.execute(`select name, sql from ${prefix}sqlite_master where type = 'index' and tbl_name = ?`, [tableName], 'all', ctx);
362
+ const wherePredicates = new Map();
363
+ for (const row of indexSqls) {
364
+ const match = row.sql && SqliteSchemaHelper.PARTIAL_WHERE_RE.exec(row.sql);
365
+ if (match) {
366
+ wherePredicates.set(row.name, match[1].trim());
367
+ }
368
+ }
358
369
  const ret = [];
359
370
  for (const col of cols.filter(c => c.pk)) {
360
371
  ret.push({
@@ -367,12 +378,14 @@ export class SqliteSchemaHelper extends SchemaHelper {
367
378
  }
368
379
  for (const index of indexes.filter(index => !this.isImplicitIndex(index.name))) {
369
380
  const res = await connection.execute(`pragma ${prefix}index_info(\`${index.name}\`)`, [], 'all', ctx);
381
+ const where = wherePredicates.get(index.name);
370
382
  ret.push(...res.map(row => ({
371
383
  columnNames: [row.name],
372
384
  keyName: index.name,
373
385
  unique: !!index.unique,
374
386
  constraint: !!index.unique,
375
387
  primary: false,
388
+ ...(where ? { where } : {}),
376
389
  })));
377
390
  }
378
391
  return this.mapIndexes(ret);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.1.0-dev.13",
3
+ "version": "7.1.0-dev.15",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
@@ -53,7 +53,7 @@
53
53
  "@mikro-orm/core": "^7.0.11"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.1.0-dev.13"
56
+ "@mikro-orm/core": "7.1.0-dev.15"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -21,7 +21,7 @@ export declare class CriteriaNode<T extends object> implements ICriteriaNode<T>
21
21
  shouldInline(payload: any): boolean;
22
22
  willAutoJoin(qb: IQueryBuilder<T>, alias?: string, options?: ICriteriaNodeProcessOptions): boolean;
23
23
  shouldRename(payload: any): boolean;
24
- renameFieldToPK<T>(qb: IQueryBuilder<T>, ownerAlias?: string): string;
24
+ renameFieldToPK<T>(qb: IQueryBuilder<T>, ownerAlias?: string, options?: ICriteriaNodeProcessOptions): string;
25
25
  getPath(opts?: {
26
26
  addIndex?: boolean;
27
27
  parentPath?: string;
@@ -83,8 +83,8 @@ export class CriteriaNode {
83
83
  return false;
84
84
  }
85
85
  }
86
- renameFieldToPK(qb, ownerAlias) {
87
- const joinAlias = qb.getAliasForJoinPath(this.getPath(), { matchPopulateJoins: true });
86
+ renameFieldToPK(qb, ownerAlias, options) {
87
+ const joinAlias = qb.getAliasForJoinPath(this.getPath(), { ...options, matchPopulateJoins: true });
88
88
  if (!joinAlias &&
89
89
  this.parent &&
90
90
  [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(this.prop.kind) &&
@@ -105,7 +105,7 @@ export class ObjectCriteriaNode extends CriteriaNode {
105
105
  this.inlineChildPayload(o, payload, field, a, childAlias);
106
106
  }
107
107
  else if (childNode.shouldRename(payload)) {
108
- this.inlineCondition(childNode.renameFieldToPK(qb, alias), o, payload);
108
+ this.inlineCondition(childNode.renameFieldToPK(qb, alias, options), o, payload);
109
109
  }
110
110
  else if (isRawField) {
111
111
  const rawField = RawQueryFragment.getKnownFragment(field);
@@ -67,6 +67,7 @@ export declare class DatabaseTable {
67
67
  name?: string;
68
68
  type?: string;
69
69
  expression?: string | IndexCallback<any>;
70
+ where?: string | Dictionary;
70
71
  deferMode?: DeferMode | `${DeferMode}`;
71
72
  options?: Dictionary;
72
73
  columns?: {
@@ -82,6 +83,7 @@ export declare class DatabaseTable {
82
83
  disabled?: boolean;
83
84
  clustered?: boolean;
84
85
  }, type: 'index' | 'unique' | 'primary'): void;
86
+ private processIndexWhere;
85
87
  addCheck(check: CheckDef): void;
86
88
  addTrigger(trigger: SqlTriggerDef): void;
87
89
  toJSON(): Dictionary;
@@ -204,6 +204,7 @@ export class DatabaseTable {
204
204
  name: index.keyName,
205
205
  deferMode: index.deferMode,
206
206
  expression: index.expression,
207
+ where: index.where,
207
208
  // Advanced index options - convert column names to property names
208
209
  columns: index.columns?.map(col => ({
209
210
  ...col,
@@ -234,7 +235,7 @@ export class DatabaseTable {
234
235
  index.invisible ||
235
236
  index.disabled ||
236
237
  index.clustered;
237
- const isTrivial = !index.deferMode && !index.expression && !hasAdvancedOptions;
238
+ const isTrivial = !index.deferMode && !index.expression && !index.where && !hasAdvancedOptions;
238
239
  if (isTrivial) {
239
240
  // Index is for FK. Map to the FK prop and move on.
240
241
  const fkForIndex = fkIndexes.get(index);
@@ -876,16 +877,25 @@ export class DatabaseTable {
876
877
  if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
877
878
  throw new Error(`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`);
878
879
  }
880
+ // The `expression` escape hatch takes the full index definition as raw SQL; combining it
881
+ // with `where` is dialect-dependent (PG/Oracle/MySQL drop `where`, MSSQL appends it) so we
882
+ // reject the combination up-front and ask users to inline the predicate into `expression`.
883
+ if (index.expression && index.where != null) {
884
+ 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\`.`);
885
+ }
886
+ const where = this.processIndexWhere(index.where, meta);
879
887
  this.#indexes.push({
880
888
  keyName: name,
881
889
  columnNames: properties,
882
890
  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('.')),
891
+ // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them.
892
+ // Partial indexes (`where`) must use CREATE [UNIQUE] INDEX form — constraints can't carry predicates.
893
+ constraint: type !== 'index' && !properties.some((d) => d.includes('.')) && !where,
885
894
  primary: type === 'primary',
886
895
  unique: type !== 'index',
887
896
  type: index.type,
888
897
  expression: this.processIndexExpression(name, index.expression, meta),
898
+ where,
889
899
  options: index.options,
890
900
  deferMode: index.deferMode,
891
901
  columns,
@@ -896,6 +906,15 @@ export class DatabaseTable {
896
906
  clustered: index.clustered,
897
907
  });
898
908
  }
909
+ processIndexWhere(where, meta) {
910
+ if (where == null) {
911
+ return undefined;
912
+ }
913
+ // The driver is always an `AbstractSqlDriver` here — `DatabaseTable` is only instantiated
914
+ // by SQL-side schema code, so the platform's config-bound driver is guaranteed to be one.
915
+ const driver = this.#platform.getConfig().getDriver();
916
+ return driver.renderPartialIndexWhere(meta.class, where);
917
+ }
899
918
  addCheck(check) {
900
919
  this.#checks.push(check);
901
920
  }
@@ -266,6 +266,19 @@ export class SchemaComparator {
266
266
  if (!this.diffIndex(index, toTableIndex)) {
267
267
  continue;
268
268
  }
269
+ // Constraint-vs-index form mismatch needs drop+create with the OLD form's drop SQL,
270
+ // which `changedIndexes` (uses new form only) can't do. Primary keys stay on the
271
+ // changed path which emits `add primary key`.
272
+ if (!index.primary && !!index.constraint !== !!toTableIndex.constraint) {
273
+ tableDifferences.removedIndexes[index.keyName] = index;
274
+ tableDifferences.addedIndexes[index.keyName] = toTableIndex;
275
+ this.log(`index ${index.keyName} changed form in table ${tableDifferences.name}`, {
276
+ fromTableIndex: index,
277
+ toTableIndex,
278
+ });
279
+ changes += 2;
280
+ continue;
281
+ }
269
282
  tableDifferences.changedIndexes[index.keyName] = toTableIndex;
270
283
  this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
271
284
  fromTableIndex: index,
@@ -572,7 +585,8 @@ export class SchemaComparator {
572
585
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
573
586
  */
574
587
  diffIndex(index1, index2) {
575
- // if one of them is a custom expression or full text index, compare only by name
588
+ // Opaque raw expressions (`expression` escape hatch) and full-text indexes can't be
589
+ // compared structurally — fall back to name-only matching.
576
590
  if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
577
591
  return index1.keyName !== index2.keyName;
578
592
  }
@@ -623,6 +637,11 @@ export class SchemaComparator {
623
637
  if (!!index1.clustered !== !!index2.clustered) {
624
638
  return false;
625
639
  }
640
+ // Compare WHERE predicate of partial indexes structurally (whitespace/quoting/casing
641
+ // are normalized via the same helper used for check constraints).
642
+ if (this.diffExpression(index1.where ?? '', index2.where ?? '')) {
643
+ return false;
644
+ }
626
645
  if (!index1.unique && !index1.primary) {
627
646
  // this is a special case: If the current key is neither primary or unique, any unique or
628
647
  // primary key will always have the same effect for the index and there cannot be any constraint
@@ -41,6 +41,46 @@ export declare abstract class SchemaHelper {
41
41
  * Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
42
42
  */
43
43
  protected getCreateIndexSuffix(_index: IndexDef): string;
44
+ /**
45
+ * Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
46
+ * return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
47
+ * with its own `getCreateIndexSQL` that never calls this, and MariaDB refuses the feature
48
+ * entirely via an override on `getIndexColumns`.
49
+ */
50
+ protected getIndexWhereClause(index: IndexDef): string;
51
+ /**
52
+ * Wraps each indexed column in `(CASE WHEN <predicate> THEN <col> END)` for dialects that
53
+ * emulate partial indexes via functional indexes (MySQL/MariaDB/Oracle). Combined with NULL
54
+ * being treated as distinct in unique indexes, this enforces uniqueness only where the
55
+ * predicate holds. Throws if combined with the advanced `columns` option.
56
+ */
57
+ protected emulatePartialIndexColumns(index: IndexDef): string;
58
+ /**
59
+ * Strips `<col> IS NOT NULL` clauses (with the dialect's identifier quoting) from an
60
+ * introspected partial-index predicate when the column matches one of the index's own
61
+ * columns. MikroORM auto-emits this guard for unique indexes on nullable columns
62
+ * (MSSQL, Oracle) — it's an internal artifact, not user intent.
63
+ *
64
+ * Strips at most one guard per column (the tail-most occurrence), matching how MikroORM
65
+ * appends a single guard per index column. This preserves user intent when they redundantly
66
+ * include the same `<col> IS NOT NULL` in their predicate — the guard we added is removed,
67
+ * their copy survives.
68
+ */
69
+ protected stripAutoNotNullFilter(filterDef: string, columnNames: string[], identifierPattern: RegExp): string;
70
+ /**
71
+ * Whether `[…]` is a quoted identifier (MSSQL convention). Other dialects either reuse
72
+ * `[` for array literals/constructors or never produce it in introspected predicates,
73
+ * so the default is `false` and the MSSQL helper opts in.
74
+ */
75
+ protected get bracketQuotedIdentifiers(): boolean;
76
+ /**
77
+ * Splits on top-level ` AND ` (case-insensitive), ignoring matches that sit inside string
78
+ * literals, quoted identifiers, or parenthesized groups — so a predicate like
79
+ * `'foo AND bar' = col` or `(a AND b) OR c` is not mis-split.
80
+ */
81
+ protected splitTopLevelAnd(s: string): string[];
82
+ /** Returns true iff the leading `(` matches the trailing `)` (i.e. they wrap the whole string). */
83
+ protected isBalancedWrap(s: string): boolean;
44
84
  /**
45
85
  * Build the column list for an index, supporting advanced options like sort order, nulls ordering, and collation.
46
86
  * Note: Prefix length is only supported by MySQL/MariaDB which override this method.
@@ -110,7 +110,7 @@ export class SchemaHelper {
110
110
  // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
111
111
  sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}`;
112
112
  const columns = this.platform.getJsonIndexDefinition(index);
113
- return `${sql} (${columns.join(', ')})${this.getCreateIndexSuffix(index)}${defer}`;
113
+ return `${sql} (${columns.join(', ')})${this.getCreateIndexSuffix(index)}${this.getIndexWhereClause(index)}${defer}`;
114
114
  }
115
115
  // Build column list with advanced options
116
116
  const columns = this.getIndexColumns(index);
@@ -119,7 +119,7 @@ export class SchemaHelper {
119
119
  if (index.include?.length) {
120
120
  sql += ` include (${index.include.map(c => this.quote(c)).join(', ')})`;
121
121
  }
122
- return sql + this.getCreateIndexSuffix(index) + defer;
122
+ return sql + this.getCreateIndexSuffix(index) + this.getIndexWhereClause(index) + defer;
123
123
  }
124
124
  /**
125
125
  * Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
@@ -127,6 +127,153 @@ export class SchemaHelper {
127
127
  getCreateIndexSuffix(_index) {
128
128
  return '';
129
129
  }
130
+ /**
131
+ * Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
132
+ * return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
133
+ * with its own `getCreateIndexSQL` that never calls this, and MariaDB refuses the feature
134
+ * entirely via an override on `getIndexColumns`.
135
+ */
136
+ getIndexWhereClause(index) {
137
+ return index.where ? ` where ${index.where}` : '';
138
+ }
139
+ /**
140
+ * Wraps each indexed column in `(CASE WHEN <predicate> THEN <col> END)` for dialects that
141
+ * emulate partial indexes via functional indexes (MySQL/MariaDB/Oracle). Combined with NULL
142
+ * being treated as distinct in unique indexes, this enforces uniqueness only where the
143
+ * predicate holds. Throws if combined with the advanced `columns` option.
144
+ */
145
+ emulatePartialIndexColumns(index) {
146
+ if (index.columns?.length) {
147
+ throw new Error(`Index '${index.keyName}': combining \`where\` with advanced \`columns\` options is not supported when emulating a partial index via functional expressions; use plain \`properties\` (or \`columnNames\`).`);
148
+ }
149
+ const predicate = index.where;
150
+ return index.columnNames.map(c => `(case when ${predicate} then ${this.quote(c)} end)`).join(', ');
151
+ }
152
+ /**
153
+ * Strips `<col> IS NOT NULL` clauses (with the dialect's identifier quoting) from an
154
+ * introspected partial-index predicate when the column matches one of the index's own
155
+ * columns. MikroORM auto-emits this guard for unique indexes on nullable columns
156
+ * (MSSQL, Oracle) — it's an internal artifact, not user intent.
157
+ *
158
+ * Strips at most one guard per column (the tail-most occurrence), matching how MikroORM
159
+ * appends a single guard per index column. This preserves user intent when they redundantly
160
+ * include the same `<col> IS NOT NULL` in their predicate — the guard we added is removed,
161
+ * their copy survives.
162
+ */
163
+ stripAutoNotNullFilter(filterDef, columnNames, identifierPattern) {
164
+ // Peel off any number of balanced wrapping paren layers. Introspection sources differ
165
+ // (MSSQL `filter_definition` wraps once, Oracle `INDEX_EXPRESSIONS` typically not at all),
166
+ // and a user `where` round-tripped through a dialect that double-wraps would otherwise slip
167
+ // past the auto-NOT-NULL recognizer below.
168
+ let inner = filterDef.trim();
169
+ while (inner.startsWith('(') && inner.endsWith(')') && this.isBalancedWrap(inner)) {
170
+ inner = inner.slice(1, -1).trim();
171
+ }
172
+ const clauses = this.splitTopLevelAnd(inner);
173
+ const autoCol = (clause) => {
174
+ let trimmed = clause.trim();
175
+ while (trimmed.startsWith('(') && trimmed.endsWith(')') && this.isBalancedWrap(trimmed)) {
176
+ trimmed = trimmed.slice(1, -1).trim();
177
+ }
178
+ const match = identifierPattern.exec(trimmed);
179
+ return match && columnNames.includes(match[1]) ? match[1] : null;
180
+ };
181
+ const seen = new Set();
182
+ const kept = [];
183
+ for (let i = clauses.length - 1; i >= 0; i--) {
184
+ const col = autoCol(clauses[i]);
185
+ if (col && !seen.has(col)) {
186
+ seen.add(col);
187
+ continue;
188
+ }
189
+ kept.unshift(clauses[i]);
190
+ }
191
+ return kept.join(' and ').trim();
192
+ }
193
+ /**
194
+ * Whether `[…]` is a quoted identifier (MSSQL convention). Other dialects either reuse
195
+ * `[` for array literals/constructors or never produce it in introspected predicates,
196
+ * so the default is `false` and the MSSQL helper opts in.
197
+ */
198
+ get bracketQuotedIdentifiers() {
199
+ return false;
200
+ }
201
+ /**
202
+ * Splits on top-level ` AND ` (case-insensitive), ignoring matches that sit inside string
203
+ * literals, quoted identifiers, or parenthesized groups — so a predicate like
204
+ * `'foo AND bar' = col` or `(a AND b) OR c` is not mis-split.
205
+ */
206
+ splitTopLevelAnd(s) {
207
+ const parts = [];
208
+ let depth = 0;
209
+ let quote = null;
210
+ let start = 0;
211
+ let i = 0;
212
+ while (i < s.length) {
213
+ const c = s[i];
214
+ if (quote) {
215
+ // Handle SQL's doubled-delimiter escape inside quoted strings/identifiers:
216
+ // `'` → `''`, `"` → `""`, `` ` `` → ```` `` ````, MSSQL `]` → `]]`.
217
+ if (c === quote && s[i + 1] === quote) {
218
+ i += 2;
219
+ continue;
220
+ }
221
+ if (c === quote) {
222
+ quote = null;
223
+ }
224
+ i++;
225
+ continue;
226
+ }
227
+ if (c === "'" || c === '"' || c === '`') {
228
+ quote = c;
229
+ i++;
230
+ continue;
231
+ }
232
+ if (c === '[' && this.bracketQuotedIdentifiers) {
233
+ quote = ']';
234
+ i++;
235
+ continue;
236
+ }
237
+ if (c === '(') {
238
+ depth++;
239
+ i++;
240
+ continue;
241
+ }
242
+ if (c === ')') {
243
+ depth--;
244
+ i++;
245
+ continue;
246
+ }
247
+ if (depth === 0 && /\s/.test(c)) {
248
+ const m = /^\s+and\s+/i.exec(s.slice(i));
249
+ if (m) {
250
+ parts.push(s.slice(start, i).trim());
251
+ i += m[0].length;
252
+ start = i;
253
+ continue;
254
+ }
255
+ }
256
+ i++;
257
+ }
258
+ parts.push(s.slice(start).trim());
259
+ return parts.filter(p => p.length > 0);
260
+ }
261
+ /** Returns true iff the leading `(` matches the trailing `)` (i.e. they wrap the whole string). */
262
+ isBalancedWrap(s) {
263
+ let depth = 0;
264
+ for (let i = 0; i < s.length; i++) {
265
+ if (s[i] === '(') {
266
+ depth++;
267
+ }
268
+ else if (s[i] === ')') {
269
+ depth--;
270
+ if (depth === 0 && i < s.length - 1) {
271
+ return false;
272
+ }
273
+ }
274
+ }
275
+ return depth === 0;
276
+ }
130
277
  /**
131
278
  * Build the column list for an index, supporting advanced options like sort order, nulls ordering, and collation.
132
279
  * Note: Prefix length is only supported by MySQL/MariaDB which override this method.
package/typings.d.ts CHANGED
@@ -71,6 +71,11 @@ export interface IndexDef {
71
71
  primary: boolean;
72
72
  composite?: boolean;
73
73
  expression?: string;
74
+ /**
75
+ * WHERE predicate for partial indexes, normalized to a SQL fragment after metadata
76
+ * resolution and introspection. Mutually exclusive with `expression`.
77
+ */
78
+ where?: string;
74
79
  options?: Dictionary;
75
80
  type?: string | Readonly<{
76
81
  indexType?: string;
@@ -246,7 +251,7 @@ export interface ICriteriaNode<T extends object> {
246
251
  shouldInline(payload: any): boolean;
247
252
  willAutoJoin(qb: IQueryBuilder<T>, alias?: string, options?: ICriteriaNodeProcessOptions): boolean;
248
253
  shouldRename(payload: any): boolean;
249
- renameFieldToPK<T>(qb: IQueryBuilder<T>, ownerAlias?: string): string;
254
+ renameFieldToPK<T>(qb: IQueryBuilder<T>, ownerAlias?: string, options?: ICriteriaNodeProcessOptions): string;
250
255
  getPath(opts?: {
251
256
  addIndex?: boolean;
252
257
  }): string;