@mikro-orm/sql 7.0.0-dev.230 → 7.0.0-dev.232

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.
@@ -21,6 +21,15 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
21
21
  loadInformationSchema(schema: DatabaseSchema, connection: AbstractSqlConnection, tables: Table[]): Promise<void>;
22
22
  getAllIndexes(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<IndexDef[]>>;
23
23
  getCreateIndexSQL(tableName: string, index: IndexDef, partialExpression?: boolean): string;
24
+ /**
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)
27
+ */
28
+ protected getIndexColumns(index: IndexDef): string;
29
+ /**
30
+ * Append MySQL-specific index suffixes like INVISIBLE.
31
+ */
32
+ protected appendMySqlIndexSuffix(sql: string, index: IndexDef): string;
24
33
  getAllColumns(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<Column[]>>;
25
34
  getAllChecks(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<CheckDef[]>>;
26
35
  getAllForeignKeys(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<Dictionary<ForeignKey>>>;
@@ -72,7 +72,7 @@ export class MySqlSchemaHelper extends SchemaHelper {
72
72
  }
73
73
  }
74
74
  async getAllIndexes(connection, tables) {
75
- const sql = `select table_name as table_name, nullif(table_schema, schema()) as schema_name, index_name as index_name, non_unique as non_unique, column_name as column_name /*!80013 , expression as expression */
75
+ const sql = `select table_name as table_name, nullif(table_schema, schema()) as schema_name, index_name as index_name, non_unique as non_unique, column_name as column_name, index_type as index_type, sub_part as sub_part, collation as sort_order /*!80013 , expression as expression, is_visible as is_visible */
76
76
  from information_schema.statistics where table_schema = database()
77
77
  and table_name in (${tables.map(t => this.platform.quoteValue(t.table_name)).join(', ')})
78
78
  order by schema_name, table_name, index_name, seq_in_index`;
@@ -87,6 +87,26 @@ export class MySqlSchemaHelper extends SchemaHelper {
87
87
  primary: index.index_name === 'PRIMARY',
88
88
  constraint: !index.non_unique,
89
89
  };
90
+ // Capture column options (prefix length, sort order)
91
+ if (index.sub_part != null || index.sort_order === 'D') {
92
+ indexDef.columns = [{
93
+ name: index.column_name,
94
+ ...(index.sub_part != null && { length: index.sub_part }),
95
+ ...(index.sort_order === 'D' && { sort: 'DESC' }),
96
+ }];
97
+ }
98
+ // Capture index type for fulltext and spatial indexes
99
+ if (index.index_type === 'FULLTEXT') {
100
+ indexDef.type = 'fulltext';
101
+ }
102
+ else if (index.index_type === 'SPATIAL') {
103
+ /* v8 ignore next */
104
+ indexDef.type = 'spatial';
105
+ }
106
+ // Capture invisible flag (MySQL 8.0.13+)
107
+ if (index.is_visible === 'NO') {
108
+ indexDef.invisible = true;
109
+ }
90
110
  if (!index.column_name || index.expression?.match(/ where /i)) {
91
111
  indexDef.expression = index.expression; // required for the `getCreateIndexSQL()` call
92
112
  indexDef.expression = this.getCreateIndexSQL(index.table_name, indexDef, !!index.expression);
@@ -106,17 +126,66 @@ export class MySqlSchemaHelper extends SchemaHelper {
106
126
  }
107
127
  tableName = this.quote(tableName);
108
128
  const keyName = this.quote(index.keyName);
109
- const sql = `alter table ${tableName} add ${index.unique ? 'unique' : 'index'} ${keyName} `;
129
+ let sql = `alter table ${tableName} add ${index.unique ? 'unique' : 'index'} ${keyName} `;
110
130
  if (index.expression && partialExpression) {
111
- return `${sql}(${index.expression})`;
131
+ sql += `(${index.expression})`;
132
+ return this.appendMySqlIndexSuffix(sql, index);
112
133
  }
113
134
  // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
114
135
  if (index.columnNames.some(column => column.includes('.'))) {
115
136
  const columns = this.platform.getJsonIndexDefinition(index);
116
- const sql = `alter table ${tableName} add ${index.unique ? 'unique ' : ''}index ${keyName} `;
117
- return `${sql}(${columns.join(', ')})`;
137
+ sql = `alter table ${tableName} add ${index.unique ? 'unique ' : ''}index ${keyName} `;
138
+ sql += `(${columns.join(', ')})`;
139
+ return this.appendMySqlIndexSuffix(sql, index);
140
+ }
141
+ // Build column list with advanced options
142
+ const columns = this.getIndexColumns(index);
143
+ sql += `(${columns})`;
144
+ return this.appendMySqlIndexSuffix(sql, index);
145
+ }
146
+ /**
147
+ * Build the column list for a MySQL index, with MySQL-specific handling for collation.
148
+ * MySQL requires collation to be specified as an expression: (column_name COLLATE collation_name)
149
+ */
150
+ getIndexColumns(index) {
151
+ if (index.columns?.length) {
152
+ return index.columns.map(col => {
153
+ const quotedName = this.quote(col.name);
154
+ // MySQL supports collation via expression: (column_name COLLATE collation_name)
155
+ // When collation is specified, wrap in parentheses as an expression
156
+ if (col.collation) {
157
+ let expr = col.length ? `${quotedName}(${col.length})` : quotedName;
158
+ expr = `(${expr} collate ${col.collation})`;
159
+ // Sort order comes after the expression
160
+ if (col.sort) {
161
+ expr += ` ${col.sort}`;
162
+ }
163
+ return expr;
164
+ }
165
+ // Standard column definition without collation
166
+ let colDef = quotedName;
167
+ // MySQL supports prefix length
168
+ if (col.length) {
169
+ colDef += `(${col.length})`;
170
+ }
171
+ // MySQL supports sort order
172
+ if (col.sort) {
173
+ colDef += ` ${col.sort}`;
174
+ }
175
+ return colDef;
176
+ }).join(', ');
118
177
  }
119
- return `${sql}(${index.columnNames.map(c => this.quote(c)).join(', ')})`;
178
+ return index.columnNames.map(c => this.quote(c)).join(', ');
179
+ }
180
+ /**
181
+ * Append MySQL-specific index suffixes like INVISIBLE.
182
+ */
183
+ appendMySqlIndexSuffix(sql, index) {
184
+ // MySQL 8.0+ supports INVISIBLE indexes
185
+ if (index.invisible) {
186
+ sql += ' invisible';
187
+ }
188
+ return sql;
120
189
  }
121
190
  async getAllColumns(connection, tables) {
122
191
  const sql = `select table_name as table_name,
@@ -29,6 +29,20 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
29
29
  private getIgnoredNamespacesConditionSQL;
30
30
  loadInformationSchema(schema: DatabaseSchema, connection: AbstractSqlConnection, tables: Table[], schemas?: string[]): Promise<void>;
31
31
  getAllIndexes(connection: AbstractSqlConnection, tables: Table[]): Promise<Dictionary<IndexDef[]>>;
32
+ /**
33
+ * Parses column definitions from the full CREATE INDEX expression.
34
+ * Since pg_get_indexdef(oid, col_num, true) doesn't include sort modifiers,
35
+ * we extract them from the full expression instead.
36
+ *
37
+ * We use columnDefs (from individual pg_get_indexdef calls) as the source
38
+ * of column names, and find their modifiers in the expression.
39
+ */
40
+ private parseIndexColumnsFromExpression;
41
+ /**
42
+ * Extracts the content inside parentheses starting at the given position.
43
+ * Handles nested parentheses correctly.
44
+ */
45
+ private extractParenthesizedContent;
32
46
  getAllColumns(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, nativeEnums?: Dictionary<{
33
47
  name: string;
34
48
  schema?: string;
@@ -62,6 +76,14 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
62
76
  enableForeignKeysSQL(): string;
63
77
  getRenameIndexSQL(tableName: string, index: IndexDef, oldIndexName: string): string[];
64
78
  dropIndex(table: string, index: IndexDef, oldIndexName?: string): string;
79
+ /**
80
+ * Build the column list for a PostgreSQL index.
81
+ */
82
+ protected getIndexColumns(index: IndexDef): string;
83
+ /**
84
+ * PostgreSQL-specific index options like fill factor.
85
+ */
86
+ protected getCreateIndexSuffix(index: IndexDef): string;
65
87
  private getIndexesSQL;
66
88
  private getChecksSQL;
67
89
  inferLengthFromColumnType(type: string): number | undefined;
@@ -128,15 +128,31 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
128
128
  const ret = {};
129
129
  for (const index of allIndexes) {
130
130
  const key = this.getTableKey(index);
131
+ // Extract INCLUDE columns from expression first, to filter them from key columns
132
+ const includeMatch = index.expression?.match(/include\s*\(([^)]+)\)/i);
133
+ const includeColumns = includeMatch
134
+ ? includeMatch[1].split(',').map((col) => unquote(col.trim()))
135
+ : [];
136
+ // Filter out INCLUDE columns from the column definitions to get only key columns
137
+ const keyColumnDefs = index.index_def.filter((col) => !includeColumns.includes(unquote(col)));
138
+ // Parse sort order and NULLS ordering from the full expression
139
+ // pg_get_indexdef individual columns don't include sort modifiers, so we parse from full expression
140
+ const columns = this.parseIndexColumnsFromExpression(index.expression, keyColumnDefs, unquote);
141
+ const columnNames = columns.map(col => col.name);
142
+ const hasAdvancedColumnOptions = columns.some(col => col.sort || col.nulls || col.collation);
131
143
  const indexDef = {
132
- columnNames: index.index_def.map((name) => unquote(name)),
133
- composite: index.index_def.length > 1,
144
+ columnNames,
145
+ composite: columnNames.length > 1,
134
146
  // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
135
147
  constraint: index.contype === 'u',
136
148
  keyName: index.constraint_name,
137
149
  unique: index.unique,
138
150
  primary: index.primary,
139
151
  };
152
+ // Add columns array if there are advanced options
153
+ if (hasAdvancedColumnOptions) {
154
+ indexDef.columns = columns;
155
+ }
140
156
  if (index.condeferrable) {
141
157
  indexDef.deferMode = index.condeferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
142
158
  }
@@ -146,11 +162,91 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
146
162
  if (index.deferrable) {
147
163
  indexDef.deferMode = index.initially_deferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
148
164
  }
165
+ // Extract fillFactor from reloptions
166
+ if (index.reloptions) {
167
+ const fillFactorMatch = index.reloptions.find((opt) => opt.startsWith('fillfactor='));
168
+ if (fillFactorMatch) {
169
+ indexDef.fillFactor = parseInt(fillFactorMatch.split('=')[1], 10);
170
+ }
171
+ }
172
+ // Add INCLUDE columns (already extracted above)
173
+ if (includeColumns.length > 0) {
174
+ indexDef.include = includeColumns;
175
+ }
176
+ // Add index type if not btree (the default)
177
+ if (index.index_type && index.index_type !== 'btree') {
178
+ indexDef.type = index.index_type;
179
+ }
149
180
  ret[key] ??= [];
150
181
  ret[key].push(indexDef);
151
182
  }
152
183
  return ret;
153
184
  }
185
+ /**
186
+ * Parses column definitions from the full CREATE INDEX expression.
187
+ * Since pg_get_indexdef(oid, col_num, true) doesn't include sort modifiers,
188
+ * we extract them from the full expression instead.
189
+ *
190
+ * We use columnDefs (from individual pg_get_indexdef calls) as the source
191
+ * of column names, and find their modifiers in the expression.
192
+ */
193
+ parseIndexColumnsFromExpression(expression, columnDefs, unquote) {
194
+ // Extract just the column list from the expression (between first parens after USING)
195
+ // Pattern: ... USING method (...columns...) [INCLUDE (...)] [WHERE ...]
196
+ // Note: pg_get_indexdef always returns a valid expression with USING clause
197
+ const usingMatch = expression.match(/using\s+\w+\s*\(/i);
198
+ const startIdx = usingMatch.index + usingMatch[0].length - 1; // Position of opening (
199
+ const columnsStr = this.extractParenthesizedContent(expression, startIdx);
200
+ // Use the column names from columnDefs and find their modifiers in the expression
201
+ return columnDefs.map(colDef => {
202
+ const name = unquote(colDef);
203
+ const result = { name };
204
+ // Find this column in the expression and extract modifiers
205
+ // Create a pattern that matches the column name (quoted or unquoted) followed by modifiers
206
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
207
+ const colPattern = new RegExp(`"?${escapedName}"?\\s*([^,)]*?)(?:,|$)`, 'i');
208
+ const colMatch = columnsStr.match(colPattern);
209
+ if (colMatch) {
210
+ const modifiers = colMatch[1];
211
+ // Extract sort order (PostgreSQL omits ASC in output as it's the default)
212
+ if (/\bdesc\b/i.test(modifiers)) {
213
+ result.sort = 'DESC';
214
+ }
215
+ // Extract NULLS ordering
216
+ const nullsMatch = modifiers.match(/nulls\s+(first|last)/i);
217
+ if (nullsMatch) {
218
+ result.nulls = nullsMatch[1].toUpperCase();
219
+ }
220
+ // Extract collation
221
+ const collateMatch = modifiers.match(/collate\s+"?([^"\s,)]+)"?/i);
222
+ if (collateMatch) {
223
+ result.collation = collateMatch[1];
224
+ }
225
+ }
226
+ return result;
227
+ });
228
+ }
229
+ /**
230
+ * Extracts the content inside parentheses starting at the given position.
231
+ * Handles nested parentheses correctly.
232
+ */
233
+ extractParenthesizedContent(str, startIdx) {
234
+ let depth = 0;
235
+ const start = startIdx + 1;
236
+ for (let i = startIdx; i < str.length; i++) {
237
+ if (str[i] === '(') {
238
+ depth++;
239
+ }
240
+ else if (str[i] === ')') {
241
+ depth--;
242
+ if (depth === 0) {
243
+ return str.slice(start, i);
244
+ }
245
+ }
246
+ }
247
+ /* v8 ignore next - pg_get_indexdef always returns balanced parentheses */
248
+ return '';
249
+ }
154
250
  async getAllColumns(connection, tablesBySchemas, nativeEnums) {
155
251
  const sql = `select table_schema as schema_name, table_name, column_name,
156
252
  column_default,
@@ -486,7 +582,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
486
582
  getChangeColumnCommentSQL(tableName, to, schemaName) {
487
583
  const name = this.quote((schemaName && schemaName !== this.platform.getDefaultSchemaName() ? schemaName + '.' : '') + tableName);
488
584
  const value = to.comment ? this.platform.quoteValue(to.comment) : 'null';
489
- return `comment on column ${name}."${to.name}" is ${value}`;
585
+ return `comment on column ${name}.${this.quote(to.name)} is ${value}`;
490
586
  }
491
587
  alterTableComment(table, comment) {
492
588
  return `comment on table ${table.getQuotedName()} is ${this.platform.quoteValue(comment ?? '')}`;
@@ -522,7 +618,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
522
618
  return `select 1 from pg_database where datname = '${name}'`;
523
619
  }
524
620
  getDatabaseNotExistsError(dbName) {
525
- return `database "${dbName}" does not exist`;
621
+ return `database ${this.quote(dbName)} does not exist`;
526
622
  }
527
623
  getManagementDbName() {
528
624
  return this.platform.getConfig().get('schemaGenerator', {}).managementDbName ?? 'postgres';
@@ -544,6 +640,43 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
544
640
  }
545
641
  return `drop index ${this.quote(oldIndexName)}`;
546
642
  }
643
+ /**
644
+ * Build the column list for a PostgreSQL index.
645
+ */
646
+ getIndexColumns(index) {
647
+ if (index.columns?.length) {
648
+ return index.columns.map(col => {
649
+ let colDef = this.quote(col.name);
650
+ // PostgreSQL supports collation with double quotes
651
+ if (col.collation) {
652
+ colDef += ` collate ${this.quote(col.collation)}`;
653
+ }
654
+ // PostgreSQL supports sort order
655
+ if (col.sort) {
656
+ colDef += ` ${col.sort}`;
657
+ }
658
+ // PostgreSQL supports NULLS FIRST/LAST
659
+ if (col.nulls) {
660
+ colDef += ` nulls ${col.nulls}`;
661
+ }
662
+ return colDef;
663
+ }).join(', ');
664
+ }
665
+ return index.columnNames.map(c => this.quote(c)).join(', ');
666
+ }
667
+ /**
668
+ * PostgreSQL-specific index options like fill factor.
669
+ */
670
+ getCreateIndexSuffix(index) {
671
+ const withOptions = [];
672
+ if (index.fillFactor != null) {
673
+ withOptions.push(`fillfactor = ${index.fillFactor}`);
674
+ }
675
+ if (withOptions.length > 0) {
676
+ return ` with (${withOptions.join(', ')})`;
677
+ }
678
+ return super.getCreateIndexSuffix(index);
679
+ }
547
680
  getIndexesSQL(tables) {
548
681
  return `select indrelid::regclass as table_name, ns.nspname as schema_name, relname as constraint_name, idx.indisunique as unique, idx.indisprimary as primary, contype, condeferrable, condeferred,
549
682
  array(
@@ -553,10 +686,13 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
553
686
  ) as index_def,
554
687
  pg_get_indexdef(idx.indexrelid) as expression,
555
688
  c.condeferrable as deferrable,
556
- c.condeferred as initially_deferred
689
+ c.condeferred as initially_deferred,
690
+ i.reloptions,
691
+ am.amname as index_type
557
692
  from pg_index idx
558
693
  join pg_class as i on i.oid = idx.indexrelid
559
694
  join pg_namespace as ns on i.relnamespace = ns.oid
695
+ join pg_am as am on am.oid = i.relam
560
696
  left join pg_constraint as c on c.conname = i.relname
561
697
  where indrelid in (${tables.map(t => `${this.platform.quoteValue(`${this.quote(t.schema_name)}.${this.quote(t.table_name)}`)}::regclass`).join(', ')})
562
698
  order by relname`;
@@ -211,7 +211,8 @@ export class SqliteSchemaHelper extends SchemaHelper {
211
211
  const columns = this.platform.getJsonIndexDefinition(index);
212
212
  return `${sqlPrefix} (${columns.join(', ')})`;
213
213
  }
214
- return `${sqlPrefix} (${index.columnNames.map(c => this.quote(c)).join(', ')})`;
214
+ // Use getIndexColumns to support advanced options like sort order and collation
215
+ return `${sqlPrefix} (${this.getIndexColumns(index)})`;
215
216
  }
216
217
  parseTableDefinition(sql, cols) {
217
218
  const columns = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.0.0-dev.230",
3
+ "version": "7.0.0-dev.232",
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
  "type": "module",
6
6
  "exports": {
@@ -56,6 +56,6 @@
56
56
  "@mikro-orm/core": "^6.6.4"
57
57
  },
58
58
  "peerDependencies": {
59
- "@mikro-orm/core": "7.0.0-dev.230"
59
+ "@mikro-orm/core": "7.0.0-dev.232"
60
60
  }
61
61
  }
@@ -62,6 +62,18 @@ export declare class DatabaseTable {
62
62
  expression?: string | IndexCallback<any>;
63
63
  deferMode?: DeferMode | `${DeferMode}`;
64
64
  options?: Dictionary;
65
+ columns?: {
66
+ name: string;
67
+ sort?: 'ASC' | 'DESC' | 'asc' | 'desc';
68
+ nulls?: 'FIRST' | 'LAST' | 'first' | 'last';
69
+ length?: number;
70
+ collation?: string;
71
+ }[];
72
+ include?: string | string[];
73
+ fillFactor?: number;
74
+ invisible?: boolean;
75
+ disabled?: boolean;
76
+ clustered?: boolean;
65
77
  }, type: 'index' | 'unique' | 'primary'): void;
66
78
  addCheck(check: CheckDef): void;
67
79
  toJSON(): Dictionary;
@@ -167,13 +167,40 @@ export class DatabaseTable {
167
167
  )
168
168
  // ignore indexes that don't have all column names (this can happen in sqlite where there is no way to infer this for expressions)
169
169
  && !(index.columnNames.some(col => !col) && !index.expression));
170
+ // Helper to map column name to property name
171
+ const columnToPropertyName = (colName) => this.getPropertyName(namingStrategy, colName);
170
172
  for (const index of potentiallyUnmappedIndexes) {
173
+ // Build the index/unique options object with advanced options
171
174
  const ret = {
172
175
  name: index.keyName,
173
176
  deferMode: index.deferMode,
174
177
  expression: index.expression,
178
+ // Advanced index options - convert column names to property names
179
+ columns: index.columns?.map(col => ({
180
+ ...col,
181
+ name: columnToPropertyName(col.name),
182
+ })),
183
+ include: index.include?.map(colName => columnToPropertyName(colName)),
184
+ fillFactor: index.fillFactor,
185
+ disabled: index.disabled,
175
186
  };
176
- const isTrivial = !index.deferMode && !index.expression;
187
+ // Index-only options (not valid for Unique)
188
+ if (!index.unique) {
189
+ if (index.type) {
190
+ // Convert index type - IndexDef.type can be string or object, IndexOptions.type is just string
191
+ ret.type = typeof index.type === 'string' ? index.type : index.type.indexType;
192
+ }
193
+ if (index.invisible) {
194
+ ret.invisible = index.invisible;
195
+ }
196
+ if (index.clustered) {
197
+ ret.clustered = index.clustered;
198
+ }
199
+ }
200
+ // An index is trivial if it has no special options that require entity-level declaration
201
+ const hasAdvancedOptions = index.columns?.length || index.include?.length || index.fillFactor ||
202
+ index.type || index.invisible || index.disabled || index.clustered;
203
+ const isTrivial = !index.deferMode && !index.expression && !hasAdvancedOptions;
177
204
  if (isTrivial) {
178
205
  // Index is for FK. Map to the FK prop and move on.
179
206
  const fkForIndex = fkIndexes.get(index);
@@ -724,6 +751,10 @@ export class DatabaseTable {
724
751
  return expression;
725
752
  }
726
753
  addIndex(meta, index, type) {
754
+ // If columns are specified but properties are not, derive properties from column names
755
+ if (index.columns?.length && !index.expression && (!index.properties || Utils.asArray(index.properties).length === 0)) {
756
+ index = { ...index, properties: index.columns.map(c => c.name) };
757
+ }
727
758
  const properties = Utils.unique(Utils.flatten(Utils.asArray(index.properties).map(prop => {
728
759
  const parts = prop.split('.');
729
760
  const root = parts[0];
@@ -755,6 +786,37 @@ export class DatabaseTable {
755
786
  return;
756
787
  }
757
788
  const name = this.getIndexName(index.name, properties, type);
789
+ // Process include columns (map property names to field names)
790
+ const includeColumns = index.include ? Utils.unique(Utils.flatten(Utils.asArray(index.include).map(prop => {
791
+ if (meta.properties[prop]) {
792
+ return meta.properties[prop].fieldNames;
793
+ }
794
+ /* v8 ignore next */
795
+ return [prop];
796
+ }))) : undefined;
797
+ // Process columns with advanced options (map property names to field names)
798
+ const columns = index.columns?.map(col => {
799
+ const fieldName = meta.properties[col.name]?.fieldNames[0] ?? col.name;
800
+ return {
801
+ name: fieldName,
802
+ sort: col.sort?.toUpperCase(),
803
+ nulls: col.nulls?.toUpperCase(),
804
+ length: col.length,
805
+ collation: col.collation,
806
+ };
807
+ });
808
+ // Validate that column options reference fields in the index properties
809
+ if (columns?.length && properties.length > 0) {
810
+ for (const col of columns) {
811
+ if (!properties.includes(col.name)) {
812
+ throw new Error(`Index '${name}' on entity '${meta.className}': column option references field '${col.name}' which is not in the index properties`);
813
+ }
814
+ }
815
+ }
816
+ // Validate fillFactor range
817
+ if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
818
+ throw new Error(`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`);
819
+ }
758
820
  this.indexes.push({
759
821
  keyName: name,
760
822
  columnNames: properties,
@@ -767,6 +829,12 @@ export class DatabaseTable {
767
829
  expression: this.processIndexExpression(name, index.expression, meta),
768
830
  options: index.options,
769
831
  deferMode: index.deferMode,
832
+ columns,
833
+ include: includeColumns,
834
+ fillFactor: index.fillFactor,
835
+ invisible: index.invisible,
836
+ disabled: index.disabled,
837
+ clustered: index.clustered,
770
838
  });
771
839
  }
772
840
  addCheck(check) {
@@ -50,6 +50,14 @@ export declare class SchemaComparator {
50
50
  * Checks if the other index already fulfills all the indexing and constraint needs of the current one.
51
51
  */
52
52
  isIndexFulfilledBy(index1: IndexDef, index2: IndexDef): boolean;
53
+ /**
54
+ * Compare advanced column options between two indexes.
55
+ */
56
+ private compareIndexColumns;
57
+ /**
58
+ * Compare two arrays for equality (order matters).
59
+ */
60
+ private compareArrays;
53
61
  diffExpression(expr1: string, expr2: string): boolean;
54
62
  parseJsonDefault(defaultValue?: string | null): Dictionary | string | null;
55
63
  hasSameDefaultValue(from: Column, to: Column): boolean;
@@ -517,6 +517,30 @@ export class SchemaComparator {
517
517
  if (!spansColumns()) {
518
518
  return false;
519
519
  }
520
+ // Compare advanced column options (sort order, nulls, length, collation)
521
+ if (!this.compareIndexColumns(index1, index2)) {
522
+ return false;
523
+ }
524
+ // Compare INCLUDE columns for covering indexes
525
+ if (!this.compareArrays(index1.include, index2.include)) {
526
+ return false;
527
+ }
528
+ // Compare fill factor
529
+ if (index1.fillFactor !== index2.fillFactor) {
530
+ return false;
531
+ }
532
+ // Compare invisible flag
533
+ if (!!index1.invisible !== !!index2.invisible) {
534
+ return false;
535
+ }
536
+ // Compare disabled flag
537
+ if (!!index1.disabled !== !!index2.disabled) {
538
+ return false;
539
+ }
540
+ // Compare clustered flag
541
+ if (!!index1.clustered !== !!index2.clustered) {
542
+ return false;
543
+ }
520
544
  if (!index1.unique && !index1.primary) {
521
545
  // this is a special case: If the current key is neither primary or unique, any unique or
522
546
  // primary key will always have the same effect for the index and there cannot be any constraint
@@ -529,6 +553,58 @@ export class SchemaComparator {
529
553
  }
530
554
  return index1.primary === index2.primary && index1.unique === index2.unique;
531
555
  }
556
+ /**
557
+ * Compare advanced column options between two indexes.
558
+ */
559
+ compareIndexColumns(index1, index2) {
560
+ const cols1 = index1.columns ?? [];
561
+ const cols2 = index2.columns ?? [];
562
+ // If neither has column options, they match
563
+ if (cols1.length === 0 && cols2.length === 0) {
564
+ return true;
565
+ }
566
+ // If only one has column options, they don't match
567
+ if (cols1.length !== cols2.length) {
568
+ return false;
569
+ }
570
+ // Compare each column's options
571
+ // Note: We don't check c1.name !== c2.name because the indexes already have matching columnNames
572
+ // and the columns array is derived from those same column names
573
+ for (let i = 0; i < cols1.length; i++) {
574
+ const c1 = cols1[i];
575
+ const c2 = cols2[i];
576
+ const sort1 = c1.sort?.toUpperCase() ?? 'ASC';
577
+ const sort2 = c2.sort?.toUpperCase() ?? 'ASC';
578
+ if (sort1 !== sort2) {
579
+ return false;
580
+ }
581
+ const defaultNulls = (s) => s === 'DESC' ? 'FIRST' : 'LAST';
582
+ const nulls1 = c1.nulls?.toUpperCase() ?? defaultNulls(sort1);
583
+ const nulls2 = c2.nulls?.toUpperCase() ?? defaultNulls(sort2);
584
+ if (nulls1 !== nulls2) {
585
+ return false;
586
+ }
587
+ if (c1.length !== c2.length) {
588
+ return false;
589
+ }
590
+ if (c1.collation !== c2.collation) {
591
+ return false;
592
+ }
593
+ }
594
+ return true;
595
+ }
596
+ /**
597
+ * Compare two arrays for equality (order matters).
598
+ */
599
+ compareArrays(arr1, arr2) {
600
+ if (!arr1 && !arr2) {
601
+ return true;
602
+ }
603
+ if (!arr1 || !arr2 || arr1.length !== arr2.length) {
604
+ return false;
605
+ }
606
+ return arr1.every((val, i) => val === arr2[i]);
607
+ }
532
608
  diffExpression(expr1, expr2) {
533
609
  // expressions like check constraints might be normalized by the driver,
534
610
  // e.g. quotes might be added (https://github.com/mikro-orm/mikro-orm/issues/3827)
@@ -27,6 +27,15 @@ export declare abstract class SchemaHelper {
27
27
  loadViews(schema: DatabaseSchema, connection: AbstractSqlConnection, schemaName?: string): Promise<void>;
28
28
  getRenameColumnSQL(tableName: string, oldColumnName: string, to: Column, schemaName?: string): string;
29
29
  getCreateIndexSQL(tableName: string, index: IndexDef): string;
30
+ /**
31
+ * Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
32
+ */
33
+ protected getCreateIndexSuffix(_index: IndexDef): string;
34
+ /**
35
+ * Build the column list for an index, supporting advanced options like sort order, nulls ordering, and collation.
36
+ * Note: Prefix length is only supported by MySQL/MariaDB which override this method.
37
+ */
38
+ protected getIndexColumns(index: IndexDef): string;
30
39
  getDropIndexSQL(tableName: string, index: IndexDef): string;
31
40
  getRenameIndexSQL(tableName: string, index: IndexDef, oldIndexName: string): string[];
32
41
  alterTable(diff: TableDifference, safe?: boolean): string[];