@mikro-orm/core 7.1.0-dev.5 → 7.1.0-dev.7

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.
@@ -7,7 +7,7 @@ import { EntityLoader, type EntityLoaderOptions } from './entity/EntityLoader.js
7
7
  import { Reference } from './entity/Reference.js';
8
8
  import { UnitOfWork } from './unit-of-work/UnitOfWork.js';
9
9
  import type { CountOptions, DeleteOptions, FilterOptions, FindAllOptions, FindByCursorOptions, FindOneOptions, FindOneOrFailOptions, FindOptions, GetReferenceOptions, IDatabaseDriver, LockOptions, NativeInsertUpdateOptions, StreamOptions, UpdateOptions, UpsertManyOptions, UpsertOptions } from './drivers/IDatabaseDriver.js';
10
- import type { AnyString, ArrayElement, AutoPath, ConnectionType, Dictionary, EntityClass, EntityData, EntityDictionary, EntityDTO, EntityMetadata, EntityName, FilterDef, FilterQuery, FromEntityType, GetRepository, IHydrator, IsSubset, Loaded, MergeLoaded, MergeSelected, ObjectQuery, PopulateOptions, Primary, Ref, RequiredEntityData, UnboxArray } from './typings.js';
10
+ import type { AnyString, ArrayElement, AutoPath, ConnectionType, Dictionary, EntityClass, EntityData, EntityDictionary, EntityDTO, EntityMetadata, EntityName, FilterDef, FilterQuery, FromEntityType, GetRepository, IHydrator, IsSubset, Loaded, MergeLoaded, MergeSelected, ObjectQuery, PopulateOptions, Primary, Ref, RequiredEntityData, UnboxArray, IndexFilterQuery, WithUsingOptions } from './typings.js';
11
11
  import { FlushMode, LockMode, PopulatePath, type TransactionOptions } from './enums.js';
12
12
  import type { MetadataStorage } from './metadata/MetadataStorage.js';
13
13
  import type { Transaction } from './connections/Connection.js';
@@ -61,7 +61,9 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
61
61
  /**
62
62
  * Finds all entities matching your `where` query. You can pass additional options via the `options` parameter.
63
63
  */
64
- find<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never>(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options?: FindOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
64
+ find<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(entityName: EntityName<Entity>, where: [Using] extends [never] ? FilterQuery<NoInfer<Entity>> : IndexFilterQuery<NoInfer<Entity>, Using>, options?: FindOptions<Entity, Hint, Fields, Excludes> & {
65
+ using?: Using | Using[];
66
+ }): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
65
67
  /**
66
68
  * Finds all entities and returns an async iterable (async generator) that yields results one by one.
67
69
  * The results are merged and mapped to entity instances, without adding them to the identity map.
@@ -79,11 +81,11 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
79
81
  * }
80
82
  * ```
81
83
  */
82
- stream<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never>(entityName: EntityName<Entity>, options?: StreamOptions<NoInfer<Entity>, Hint, Fields, Excludes>): AsyncIterableIterator<Loaded<Entity, Hint, Fields, Excludes>>;
84
+ stream<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(entityName: EntityName<Entity>, options?: WithUsingOptions<StreamOptions<NoInfer<Entity>, Hint, Fields, Excludes>, NoInfer<Entity>, Using>): AsyncIterableIterator<Loaded<Entity, Hint, Fields, Excludes>>;
83
85
  /**
84
86
  * Finds all entities of given type, optionally matching the `where` condition provided in the `options` parameter.
85
87
  */
86
- findAll<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never>(entityName: EntityName<Entity>, options?: FindAllOptions<NoInfer<Entity>, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
88
+ findAll<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(entityName: EntityName<Entity>, options?: WithUsingOptions<FindAllOptions<NoInfer<Entity>, Hint, Fields, Excludes>, NoInfer<Entity>, Using>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
87
89
  private getPopulateWhere;
88
90
  /**
89
91
  * Registers global filter to this entity manager. Global filters are enabled by default (unless disabled via last parameter).
@@ -130,7 +132,9 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
130
132
  * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as tuple
131
133
  * where the first element is the array of entities, and the second is the count.
132
134
  */
133
- findAndCount<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never>(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options?: FindOptions<Entity, Hint, Fields, Excludes>): Promise<[Loaded<Entity, Hint, Fields, Excludes>[], number]>;
135
+ findAndCount<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(entityName: EntityName<Entity>, where: [Using] extends [never] ? FilterQuery<NoInfer<Entity>> : IndexFilterQuery<NoInfer<Entity>, Using>, options?: FindOptions<Entity, Hint, Fields, Excludes> & {
136
+ using?: Using | Using[];
137
+ }): Promise<[Loaded<Entity, Hint, Fields, Excludes>[], number]>;
134
138
  /**
135
139
  * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as {@apilink Cursor} object.
136
140
  * Supports `before`, `after`, `first` and `last` options while disallowing `limit` and `offset`. Explicit `orderBy` option
@@ -187,7 +191,7 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
187
191
  * }
188
192
  * ```
189
193
  */
190
- findByCursor<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, IncludeCount extends boolean = true>(entityName: EntityName<Entity>, options: FindByCursorOptions<Entity, Hint, Fields, Excludes, IncludeCount>): Promise<Cursor<Entity, Hint, Fields, Excludes, IncludeCount>>;
194
+ findByCursor<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, IncludeCount extends boolean = true, Using extends string = never>(entityName: EntityName<Entity>, options: WithUsingOptions<FindByCursorOptions<Entity, Hint, Fields, Excludes, IncludeCount>, Entity, Using>): Promise<Cursor<Entity, Hint, Fields, Excludes, IncludeCount>>;
191
195
  /**
192
196
  * Refreshes the persistent state of an entity from the database, overriding any local changes that have not yet been
193
197
  * persisted. Returns the same entity instance (same object reference), but re-hydrated. If the entity is no longer
@@ -203,14 +207,18 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
203
207
  /**
204
208
  * Finds first entity matching your `where` query.
205
209
  */
206
- findOne<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never>(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options?: FindOneOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes> | null>;
210
+ findOne<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(entityName: EntityName<Entity>, where: [Using] extends [never] ? FilterQuery<NoInfer<Entity>> : IndexFilterQuery<NoInfer<Entity>, Using>, options?: FindOneOptions<Entity, Hint, Fields, Excludes> & {
211
+ using?: Using | Using[];
212
+ }): Promise<Loaded<Entity, Hint, Fields, Excludes> | null>;
207
213
  /**
208
214
  * Finds first entity matching your `where` query. If nothing found, it will throw an error.
209
215
  * If the `strict` option is specified and nothing is found or more than one matching entity is found, it will throw an error.
210
216
  * You can override the factory for creating this method via `options.failHandler` locally
211
217
  * or via `Configuration.findOneOrFailHandler` (`findExactlyOneOrFailHandler` when specifying `strict`) globally.
212
218
  */
213
- findOneOrFail<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never>(entityName: EntityName<Entity>, where: FilterQuery<NoInfer<Entity>>, options?: FindOneOrFailOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>>;
219
+ findOneOrFail<Entity extends object, Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(entityName: EntityName<Entity>, where: [Using] extends [never] ? FilterQuery<NoInfer<Entity>> : IndexFilterQuery<NoInfer<Entity>, Using>, options?: FindOneOrFailOptions<Entity, Hint, Fields, Excludes> & {
220
+ using?: Using | Using[];
221
+ }): Promise<Loaded<Entity, Hint, Fields, Excludes>>;
214
222
  /**
215
223
  * Creates or updates the entity, based on whether it is already present in the database.
216
224
  * This method performs an `insert on conflict merge` query ensuring the database is in sync, returning a managed
@@ -524,6 +532,8 @@ export declare class EntityManager<Driver extends IDatabaseDriver = IDatabaseDri
524
532
  */
525
533
  getComparator(): EntityComparator;
526
534
  private checkLockRequirements;
535
+ private validateIndexUsage;
536
+ private validateWhereKeysForIndex;
527
537
  private lockAndPopulate;
528
538
  private buildFields;
529
539
  /** @internal */
package/EntityManager.js CHANGED
@@ -103,9 +103,6 @@ export class EntityManager {
103
103
  repo(entityName) {
104
104
  return this.getRepository(entityName);
105
105
  }
106
- /**
107
- * Finds all entities matching your `where` query. You can pass additional options via the `options` parameter.
108
- */
109
106
  async find(entityName, where, options = {}) {
110
107
  if (options.disableIdentityMap ?? this.config.get('disableIdentityMap')) {
111
108
  const em = this.getContext(false);
@@ -116,10 +113,11 @@ export class EntityManager {
116
113
  }
117
114
  const em = this.getContext();
118
115
  em.prepareOptions(options);
116
+ const meta = this.metadata.get(entityName);
117
+ em.validateIndexUsage(meta, where, options);
119
118
  await em.tryFlush(entityName, options);
120
119
  where = await em.processWhere(entityName, where, options, 'read');
121
120
  validateParams(where);
122
- const meta = this.metadata.get(entityName);
123
121
  if (meta.orderBy) {
124
122
  options.orderBy = QueryHelper.mergeOrderBy(options.orderBy, meta.orderBy);
125
123
  }
@@ -203,6 +201,7 @@ export class EntityManager {
203
201
  options.orderBy = options.orderBy || {};
204
202
  options.populate = (await em.preparePopulate(entityName, options));
205
203
  const meta = this.metadata.get(entityName);
204
+ em.validateIndexUsage(meta, options.where ?? {}, options);
206
205
  options = { ...options };
207
206
  // save the original hint value so we know it was infer/all
208
207
  options._populateWhere = options.populateWhere ?? this.config.get('populateWhere');
@@ -494,10 +493,6 @@ export class EntityManager {
494
493
  const conds = [...ret, where].filter(c => Utils.hasObjectKeys(c) || Raw.hasObjectFragments(c));
495
494
  return conds.length > 1 ? { $and: conds } : conds[0];
496
495
  }
497
- /**
498
- * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as tuple
499
- * where the first element is the array of entities, and the second is the count.
500
- */
501
496
  async findAndCount(entityName, where, options = {}) {
502
497
  const em = this.getContext(false);
503
498
  await em.tryFlush(entityName, options);
@@ -631,9 +626,6 @@ export class EntityManager {
631
626
  }
632
627
  return entity;
633
628
  }
634
- /**
635
- * Finds first entity matching your `where` query.
636
- */
637
629
  async findOne(entityName, where, options = {}) {
638
630
  if (options.disableIdentityMap ?? this.config.get('disableIdentityMap')) {
639
631
  const em = this.getContext(false);
@@ -653,6 +645,7 @@ export class EntityManager {
653
645
  }
654
646
  await em.tryFlush(entityName, options);
655
647
  const meta = em.metadata.get(entityName);
648
+ em.validateIndexUsage(meta, where, options);
656
649
  where = await em.processWhere(entityName, where, options, 'read');
657
650
  validateEmptyWhere(where);
658
651
  em.checkLockRequirements(options.lockMode, meta);
@@ -701,12 +694,6 @@ export class EntityManager {
701
694
  await em.storeCache(options.cache, cached, () => helper(entity).toPOJO());
702
695
  return entity;
703
696
  }
704
- /**
705
- * Finds first entity matching your `where` query. If nothing found, it will throw an error.
706
- * If the `strict` option is specified and nothing is found or more than one matching entity is found, it will throw an error.
707
- * You can override the factory for creating this method via `options.failHandler` locally
708
- * or via `Configuration.findOneOrFailHandler` (`findExactlyOneOrFailHandler` when specifying `strict`) globally.
709
- */
710
697
  async findOneOrFail(entityName, where, options = {}) {
711
698
  let entity;
712
699
  let isStrictViolation = false;
@@ -1727,6 +1714,79 @@ export class EntityManager {
1727
1714
  throw ValidationError.transactionRequired();
1728
1715
  }
1729
1716
  }
1717
+ validateIndexUsage(meta, where, options) {
1718
+ if (!options.using) {
1719
+ return;
1720
+ }
1721
+ const indexNames = Utils.asArray(options.using);
1722
+ const allIndexes = [...meta.indexes, ...meta.uniques];
1723
+ const indexMap = new Map();
1724
+ for (const idx of allIndexes) {
1725
+ if (idx.name) {
1726
+ indexMap.set(idx.name, Utils.asArray(idx.properties ?? []));
1727
+ }
1728
+ }
1729
+ for (const prop of meta.props) {
1730
+ if (typeof prop.index === 'string') {
1731
+ indexMap.set(prop.index, [prop.name]);
1732
+ }
1733
+ if (typeof prop.unique === 'string') {
1734
+ indexMap.set(prop.unique, [prop.name]);
1735
+ }
1736
+ }
1737
+ const allowedProps = new Set();
1738
+ for (const name of indexNames) {
1739
+ const props = indexMap.get(name);
1740
+ if (!props) {
1741
+ const available = [...indexMap.keys()];
1742
+ throw new Error(`Index '${name}' not found on entity '${meta.className}'. ` +
1743
+ (available.length > 0 ? `Available indexes: ${available.join(', ')}` : 'No named indexes defined.'));
1744
+ }
1745
+ for (const prop of props) {
1746
+ allowedProps.add(prop);
1747
+ }
1748
+ }
1749
+ this.validateWhereKeysForIndex(where, allowedProps, indexNames);
1750
+ if (options.orderBy) {
1751
+ const orderMaps = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy];
1752
+ for (const orderMap of orderMaps) {
1753
+ for (const key of Object.keys(orderMap)) {
1754
+ if (!allowedProps.has(key)) {
1755
+ throw new Error(`Property '${key}' in orderBy is not covered by index '${indexNames.join("', '")}'. ` +
1756
+ `Allowed properties: ${[...allowedProps].join(', ')}`);
1757
+ }
1758
+ }
1759
+ }
1760
+ }
1761
+ }
1762
+ validateWhereKeysForIndex(where, allowedProps, indexNames) {
1763
+ if (typeof where !== 'object' || where === null) {
1764
+ return;
1765
+ }
1766
+ if (Array.isArray(where)) {
1767
+ for (const item of where) {
1768
+ this.validateWhereKeysForIndex(item, allowedProps, indexNames);
1769
+ }
1770
+ return;
1771
+ }
1772
+ for (const key of Object.keys(where)) {
1773
+ if (key === '$and' || key === '$or') {
1774
+ for (const item of Utils.asArray(where[key])) {
1775
+ this.validateWhereKeysForIndex(item, allowedProps, indexNames);
1776
+ }
1777
+ }
1778
+ else if (key === '$not') {
1779
+ this.validateWhereKeysForIndex(where[key], allowedProps, indexNames);
1780
+ }
1781
+ else if (key.startsWith('$')) {
1782
+ continue;
1783
+ }
1784
+ else if (!allowedProps.has(key)) {
1785
+ throw new Error(`Property '${key}' in where clause is not covered by index '${indexNames.join("', '")}'. ` +
1786
+ `Allowed properties: ${[...allowedProps].join(', ')}`);
1787
+ }
1788
+ }
1789
+ }
1730
1790
  async lockAndPopulate(meta, entity, where, options) {
1731
1791
  if (!meta.virtual && options.lockMode === LockMode.OPTIMISTIC) {
1732
1792
  await this.lock(entity, options.lockMode, {
@@ -1,4 +1,4 @@
1
- import type { ConnectionType, Constructor, EntityData, EntityMetadata, EntityProperty, FilterQuery, Primary, Dictionary, IPrimaryKey, PopulateOptions, EntityDictionary, AutoPath, ObjectQuery, FilterObject, Populate, EntityName, PopulateHintOptions, Prefixes } from '../typings.js';
1
+ import type { ConnectionType, Constructor, EntityData, EntityMetadata, EntityProperty, FilterQuery, Primary, Dictionary, IPrimaryKey, PopulateOptions, EntityDictionary, AutoPath, ObjectQuery, FilterObject, Populate, EntityName, PopulateHintOptions, Prefixes, IndexName } from '../typings.js';
2
2
  import type { Connection, QueryResult, Transaction } from '../connections/Connection.js';
3
3
  import type { FlushMode, LockMode, QueryOrderMap, QueryFlag, LoadStrategy, PopulateHint, PopulatePath } from '../enums.js';
4
4
  import type { Platform } from '../platforms/Platform.js';
@@ -216,6 +216,20 @@ export interface FindOptions<Entity, Hint extends string = never, Fields extends
216
216
  connectionType?: ConnectionType;
217
217
  /** SQL: appended to FROM clause (e.g. `'force index(my_index)'`); MongoDB: index name or spec passed as `hint`. */
218
218
  indexHint?: string | Dictionary;
219
+ /**
220
+ * Named index(es) for this query. When provided:
221
+ * - Validates that `where` and `orderBy` only reference columns covered by the specified index(es).
222
+ * - Emits SQL index hints where supported (MySQL/MariaDB: `USE INDEX`, MSSQL: `WITH (INDEX(...))`).
223
+ * - If `indexHint` is also set, `indexHint` takes precedence for SQL generation.
224
+ *
225
+ * Accepts a single index name or an array. For `defineEntity` entities with named indexes
226
+ * and decorator entities with `[IndexHints]`, index names are autocompleted.
227
+ *
228
+ * @example
229
+ * await em.find(Book, { title: 'foo' }, { using: 'idx_book_title' });
230
+ * await em.find(Book, { title: 'foo', author: 1 }, { using: ['idx_book_title', 'idx_book_author'] });
231
+ */
232
+ using?: IndexName<Entity> | IndexName<Entity>[];
219
233
  /** sql only */
220
234
  comments?: string | string[];
221
235
  /** sql only */
@@ -48,14 +48,14 @@ export declare class Collection<T extends object, O extends object = object> {
48
48
  /** Serializes the collection items to plain JSON objects. Returns an empty array if not initialized. */
49
49
  toJSON<TT extends T>(): EntityDTO<TT>[];
50
50
  /** Adds one or more items to the collection, propagating the change to the inverse side. Returns the number of items added. */
51
- add<TT extends T>(entity: TT | Reference<TT> | Iterable<TT | Reference<TT>>, ...entities: (TT | Reference<TT>)[]): number;
51
+ add<TT extends T>(entity: TT | Reference<TT> | Iterable<TT | Reference<TT>>, ...entities: (T | Reference<T>)[]): number;
52
52
  /**
53
53
  * Remove specified item(s) from the collection. Note that removing item from collection does not necessarily imply deleting the target entity,
54
54
  * it means we are disconnecting the relation - removing items from collection, not removing entities from database - `Collection.remove()`
55
55
  * is not the same as `em.remove()`. If we want to delete the entity by removing it from collection, we need to enable `orphanRemoval: true`,
56
56
  * which tells the ORM we don't want orphaned entities to exist, so we know those should be removed.
57
57
  */
58
- remove<TT extends T>(entity: TT | Reference<TT> | Iterable<TT | Reference<TT>> | ((item: TT) => boolean), ...entities: (TT | Reference<TT>)[]): number;
58
+ remove<TT extends T>(entity: TT | Reference<TT> | Iterable<TT | Reference<TT>> | ((item: T) => boolean), ...entities: (T | Reference<T>)[]): number;
59
59
  /** Checks whether the collection contains the given item. */
60
60
  contains<TT extends T>(item: TT | Reference<TT>, check?: boolean): boolean;
61
61
  /** Returns the number of items in the collection. Throws if the collection is not initialized. */
@@ -587,6 +587,7 @@ export class EntityLoader {
587
587
  }
588
588
  const map = await this.#driver.loadFromPivotTable(prop, ids, where, orderBy, this.#em.getTransactionContext(), options2, pivotJoin);
589
589
  const children = [];
590
+ const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop);
590
591
  for (let i = 0; i < filtered.length; i++) {
591
592
  const entity = filtered[i];
592
593
  const items = map[Utils.getPrimaryKeyHash(ids[i])].map(item => {
@@ -596,7 +597,11 @@ export class EntityLoader {
596
597
  schema: options.schema ?? this.#em.config.get('schema'),
597
598
  });
598
599
  }
599
- const entity = this.#em.getEntityFactory().create(prop.targetMeta.class, item, {
600
+ // Union-target items carry their concrete class via `constructor` — dispatch to the right factory call.
601
+ const targetClass = isUnionTargetMN && item.constructor !== Object
602
+ ? item.constructor
603
+ : prop.targetMeta.class;
604
+ const entity = this.#em.getEntityFactory().create(targetClass, item, {
600
605
  refresh,
601
606
  merge: true,
602
607
  convertCustomTypes: true,
@@ -1,7 +1,7 @@
1
1
  import type { PopulatePath } from '../enums.js';
2
2
  import type { CreateOptions, EntityManager, MergeOptions } from '../EntityManager.js';
3
3
  import type { AssignOptions } from './EntityAssigner.js';
4
- import type { EntityData, EntityName, Primary, Loaded, FilterQuery, EntityDictionary, AutoPath, RequiredEntityData, Ref, EntityType, EntityDTO, MergeSelected, FromEntityType, IsSubset, MergeLoaded, ArrayElement } from '../typings.js';
4
+ import type { EntityData, EntityName, Primary, Loaded, FilterQuery, EntityDictionary, AutoPath, RequiredEntityData, Ref, EntityType, EntityDTO, MergeSelected, FromEntityType, IsSubset, MergeLoaded, ArrayElement, IndexFilterQuery, WithUsingOptions } from '../typings.js';
5
5
  import type { CountOptions, DeleteOptions, FindAllOptions, FindByCursorOptions, FindOneOptions, FindOneOrFailOptions, FindOptions, GetReferenceOptions, NativeInsertUpdateOptions, StreamOptions, UpdateOptions, UpsertManyOptions, UpsertOptions } from '../drivers/IDatabaseDriver.js';
6
6
  import type { EntityLoaderOptions } from './EntityLoader.js';
7
7
  import type { Cursor } from '../utils/Cursor.js';
@@ -13,13 +13,17 @@ export declare class EntityRepository<Entity extends object> {
13
13
  /**
14
14
  * Finds first entity matching your `where` query.
15
15
  */
16
- findOne<Hint extends string = never, Fields extends string = never, Excludes extends string = never>(where: FilterQuery<Entity>, options?: FindOneOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes> | null>;
16
+ findOne<Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(where: [Using] extends [never] ? FilterQuery<Entity> : IndexFilterQuery<Entity, Using>, options?: FindOneOptions<Entity, Hint, Fields, Excludes> & {
17
+ using?: Using | Using[];
18
+ }): Promise<Loaded<Entity, Hint, Fields, Excludes> | null>;
17
19
  /**
18
20
  * Finds first entity matching your `where` query. If nothing is found, it will throw an error.
19
21
  * You can override the factory for creating this method via `options.failHandler` locally
20
22
  * or via `Configuration.findOneOrFailHandler` globally.
21
23
  */
22
- findOneOrFail<Hint extends string = never, Fields extends string = never, Excludes extends string = never>(where: FilterQuery<Entity>, options?: FindOneOrFailOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>>;
24
+ findOneOrFail<Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(where: [Using] extends [never] ? FilterQuery<Entity> : IndexFilterQuery<Entity, Using>, options?: FindOneOrFailOptions<Entity, Hint, Fields, Excludes> & {
25
+ using?: Using | Using[];
26
+ }): Promise<Loaded<Entity, Hint, Fields, Excludes>>;
23
27
  /**
24
28
  * Creates or updates the entity, based on whether it is already present in the database.
25
29
  * This method performs an `insert on conflict merge` query ensuring the database is in sync, returning a managed
@@ -72,24 +76,28 @@ export declare class EntityRepository<Entity extends object> {
72
76
  /**
73
77
  * Finds all entities matching your `where` query. You can pass additional options via the `options` parameter.
74
78
  */
75
- find<Hint extends string = never, Fields extends string = never, Excludes extends string = never>(where: FilterQuery<Entity>, options?: FindOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
79
+ find<Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(where: [Using] extends [never] ? FilterQuery<Entity> : IndexFilterQuery<Entity, Using>, options?: FindOptions<Entity, Hint, Fields, Excludes> & {
80
+ using?: Using | Using[];
81
+ }): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
76
82
  /**
77
83
  * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as tuple
78
84
  * where first element is the array of entities, and the second is the count.
79
85
  */
80
- findAndCount<Hint extends string = never, Fields extends string = never, Excludes extends string = never>(where: FilterQuery<Entity>, options?: FindOptions<Entity, Hint, Fields, Excludes>): Promise<[Loaded<Entity, Hint, Fields, Excludes>[], number]>;
86
+ findAndCount<Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(where: [Using] extends [never] ? FilterQuery<Entity> : IndexFilterQuery<Entity, Using>, options?: FindOptions<Entity, Hint, Fields, Excludes> & {
87
+ using?: Using | Using[];
88
+ }): Promise<[Loaded<Entity, Hint, Fields, Excludes>[], number]>;
81
89
  /**
82
90
  * @inheritDoc EntityManager.findByCursor
83
91
  */
84
- findByCursor<Hint extends string = never, Fields extends string = never, Excludes extends string = never, IncludeCount extends boolean = true>(options: FindByCursorOptions<Entity, Hint, Fields, Excludes, IncludeCount>): Promise<Cursor<Entity, Hint, Fields, Excludes, IncludeCount>>;
92
+ findByCursor<Hint extends string = never, Fields extends string = never, Excludes extends string = never, IncludeCount extends boolean = true, Using extends string = never>(options: WithUsingOptions<FindByCursorOptions<Entity, Hint, Fields, Excludes, IncludeCount>, Entity, Using>): Promise<Cursor<Entity, Hint, Fields, Excludes, IncludeCount>>;
85
93
  /**
86
94
  * Finds all entities of given type. You can pass additional options via the `options` parameter.
87
95
  */
88
- findAll<Hint extends string = never, Fields extends string = never, Excludes extends string = never>(options?: FindAllOptions<Entity, Hint, Fields, Excludes>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
96
+ findAll<Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(options?: WithUsingOptions<FindAllOptions<Entity, Hint, Fields, Excludes>, Entity, Using>): Promise<Loaded<Entity, Hint, Fields, Excludes>[]>;
89
97
  /**
90
98
  * @inheritDoc EntityManager.stream
91
99
  */
92
- stream<Hint extends string = never, Fields extends string = never, Excludes extends string = never>(options?: StreamOptions<Entity, Hint, Fields, Excludes>): AsyncIterableIterator<Loaded<Entity, Hint, Fields, Excludes>>;
100
+ stream<Hint extends string = never, Fields extends string = never, Excludes extends string = never, Using extends string = never>(options?: WithUsingOptions<StreamOptions<Entity, Hint, Fields, Excludes>, Entity, Using>): AsyncIterableIterator<Loaded<Entity, Hint, Fields, Excludes>>;
93
101
  /**
94
102
  * @inheritDoc EntityManager.insert
95
103
  */
@@ -1,6 +1,6 @@
1
1
  import type { EntityManager } from '../EntityManager.js';
2
2
  import type { ColumnType, PropertyOptions, ReferenceOptions, EnumOptions, EmbeddedOptions, ManyToOneOptions, OneToManyOptions, OneToOneOptions, ManyToManyOptions, IndexColumnOptions } from '../metadata/types.js';
3
- import type { AnyString, GeneratedColumnCallback, Constructor, CheckCallback, FilterQuery, EntityName, Dictionary, EntityMetadata, PrimaryKeyProp, EntityRepositoryType, Hidden, Opt, Primary, EntityClass, EntitySchemaWithMeta, InferEntity, MaybeReturnType, Ref, IndexCallback, FormulaCallback, EntityCtor, IsNever, IWrappedEntity, DefineConfig, Config, MaybePromise } from '../typings.js';
3
+ import type { AnyString, GeneratedColumnCallback, Constructor, CheckCallback, FilterQuery, EntityName, Dictionary, EntityMetadata, PrimaryKeyProp, EntityRepositoryType, Hidden, Opt, Primary, EntityClass, EntitySchemaWithMeta, InferEntity, MaybeReturnType, Ref, IndexCallback, FormulaCallback, EntityCtor, IsNever, IWrappedEntity, DefineConfig, Config, MaybePromise, IndexHints } from '../typings.js';
4
4
  import type { Raw } from '../utils/RawQueryFragment.js';
5
5
  import type { ScalarReference } from './Reference.js';
6
6
  import type { SerializeOptions } from '../serialization/EntitySerializer.js';
@@ -104,8 +104,16 @@ export interface PropertyChain<Value, Options> {
104
104
  customOrder(...customOrder: string[] | number[] | boolean[]): PropertyChain<Value, Options>;
105
105
  extra(extra: string): PropertyChain<Value, Options>;
106
106
  ignoreSchemaChanges(...ignoreSchemaChanges: ('type' | 'extra' | 'default')[]): PropertyChain<Value, Options>;
107
- index(index?: boolean | string): PropertyChain<Value, Options>;
108
- unique(unique?: boolean | string): PropertyChain<Value, Options>;
107
+ /** Explicitly specify index on a property. When a string name is passed, it enables type-safe `using` in `FindOptions`. */
108
+ index<N extends string>(name: N): PropertyChain<Value, Omit<Options, 'index'> & {
109
+ index: N;
110
+ }>;
111
+ index(index?: boolean): PropertyChain<Value, Options>;
112
+ /** Set column as unique. When a string name is passed, it enables type-safe `using` in `FindOptions`. (SQL only) */
113
+ unique<N extends string>(name: N): PropertyChain<Value, Omit<Options, 'unique'> & {
114
+ unique: N;
115
+ }>;
116
+ unique(unique?: boolean): PropertyChain<Value, Options>;
109
117
  comment(comment: string): PropertyChain<Value, Options>;
110
118
  accessor(accessor?: string | boolean): PropertyChain<Value, Options>;
111
119
  eager(eager?: boolean): HasKind<Options, 'm:1' | '1:m' | '1:1' | 'm:n'> extends true ? PropertyChain<Value, Options> : never;
@@ -338,12 +346,20 @@ export declare class UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys
338
346
  concurrencyCheck(concurrencyCheck?: boolean): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
339
347
  /**
340
348
  * Explicitly specify index on a property.
349
+ * When a string name is passed, it is captured as a literal type for use with the `using` option in `FindOptions`.
341
350
  */
342
- index(index?: boolean | string): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
351
+ index<N extends string>(name: N): Pick<UniversalPropertyOptionsBuilder<Value, Omit<Options, 'index'> & {
352
+ index: N;
353
+ }, IncludeKeys>, IncludeKeys>;
354
+ index(index?: boolean): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
343
355
  /**
344
356
  * Set column as unique for {@link https://mikro-orm.io/docs/schema-generator Schema Generator}. (SQL only)
357
+ * When a string name is passed, it is captured as a literal type for use with the `using` option in `FindOptions`.
345
358
  */
346
- unique(unique?: boolean | string): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
359
+ unique<N extends string>(name: N): Pick<UniversalPropertyOptionsBuilder<Value, Omit<Options, 'unique'> & {
360
+ unique: N;
361
+ }, IncludeKeys>, IncludeKeys>;
362
+ unique(unique?: boolean): Pick<UniversalPropertyOptionsBuilder<Value, Options, IncludeKeys>, IncludeKeys>;
347
363
  /**
348
364
  * Specify column with check constraints. (Postgres driver only)
349
365
  *
@@ -669,7 +685,9 @@ export type InferEntityFromProperties<Properties extends Record<string, any>, PK
669
685
  [Config]?: DefineConfig<{
670
686
  forceObject: true;
671
687
  }>;
672
- } : {});
688
+ } : {}) & {
689
+ [IndexHints]?: [Properties];
690
+ };
673
691
  type InferCombinedPrimaryKey<Properties extends Record<string, any>, PK, Base> = PK extends undefined ? CombinePrimaryKeys<InferPrimaryKey<Properties>, ExtractBasePrimaryKey<Base>> : PK;
674
692
  type ExtractBasePrimaryKey<Base> = Base extends {
675
693
  [PrimaryKeyProp]?: infer BasePK;
@@ -191,15 +191,9 @@ export class UniversalPropertyOptionsBuilder {
191
191
  concurrencyCheck(concurrencyCheck = true) {
192
192
  return this.assignOptions({ concurrencyCheck });
193
193
  }
194
- /**
195
- * Explicitly specify index on a property.
196
- */
197
194
  index(index = true) {
198
195
  return this.assignOptions({ index });
199
196
  }
200
- /**
201
- * Set column as unique for {@link https://mikro-orm.io/docs/schema-generator Schema Generator}. (SQL only)
202
- */
203
197
  unique(unique = true) {
204
198
  return this.assignOptions({ unique });
205
199
  }
package/index.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * @packageDocumentation
3
3
  * @module core
4
4
  */
5
- export { EntityMetadata, PrimaryKeyProp, EntityRepositoryType, OptionalProps, EagerProps, HiddenProps, Config, EntityName, } from './typings.js';
6
- export type { CompiledFunctions, Constructor, ConnectionType, Dictionary, Primary, IPrimaryKey, ObjectQuery, FilterQuery, IWrappedEntity, InferEntityName, EntityData, Highlighter, MaybePromise, AnyEntity, EntityClass, EntityProperty, PopulateOptions, Populate, Loaded, New, LoadedReference, LoadedCollection, IMigrator, IMigrationGenerator, MigratorEvent, GetRepository, MigrationObject, DeepPartial, PrimaryProperty, Cast, IsUnknown, EntityDictionary, EntityDTO, EntityDTOFlat, EntityDTOProp, SerializeDTO, MigrationDiff, GenerateOptions, FilterObject, IMigrationRunner, IEntityGenerator, ISeedManager, SeederObject, IMigratorStorage, RequiredEntityData, CheckCallback, IndexCallback, FormulaCallback, FormulaTable, SchemaTable, SchemaColumns, SimpleColumnMeta, Rel, Ref, ScalarRef, EntityRef, ISchemaGenerator, MigrationInfo, MigrateOptions, MigrationResult, MigrationRow, EntityKey, EntityValue, EntityDataValue, FilterKey, EntityType, FromEntityType, Selected, IsSubset, EntityProps, ExpandProperty, ExpandScalar, FilterItemValue, ExpandQuery, Scalar, ExpandHint, FilterValue, MergeLoaded, MergeSelected, TypeConfig, AnyString, ClearDatabaseOptions, CreateSchemaOptions, EnsureDatabaseOptions, UpdateSchemaOptions, DropSchemaOptions, RefreshDatabaseOptions, AutoPath, UnboxArray, MetadataProcessor, ImportsResolver, RequiredNullable, DefineConfig, Opt, Hidden, EntitySchemaWithMeta, InferEntity, CheckConstraint, GeneratedColumnCallback, FilterDef, EntityCtor, Subquery, PopulateHintOptions, Prefixes, } from './typings.js';
5
+ export { EntityMetadata, PrimaryKeyProp, EntityRepositoryType, OptionalProps, EagerProps, HiddenProps, Config, EntityName, IndexHints, } from './typings.js';
6
+ export type { CompiledFunctions, Constructor, ConnectionType, Dictionary, Primary, IPrimaryKey, ObjectQuery, FilterQuery, IWrappedEntity, InferEntityName, EntityData, Highlighter, MaybePromise, AnyEntity, EntityClass, EntityProperty, PopulateOptions, Populate, Loaded, New, LoadedReference, LoadedCollection, IMigrator, IMigrationGenerator, MigratorEvent, GetRepository, MigrationObject, DeepPartial, PrimaryProperty, Cast, IsUnknown, EntityDictionary, EntityDTO, EntityDTOFlat, EntityDTOProp, SerializeDTO, MigrationDiff, GenerateOptions, FilterObject, IndexFilterQuery, ExtractIndexHints, IndexName, IndexColumns, WithUsingOptions, IMigrationRunner, IEntityGenerator, ISeedManager, SeederObject, IMigratorStorage, RequiredEntityData, CheckCallback, IndexCallback, FormulaCallback, FormulaTable, SchemaTable, SchemaColumns, SimpleColumnMeta, Rel, Ref, ScalarRef, EntityRef, ISchemaGenerator, MigrationInfo, MigrateOptions, MigrationResult, MigrationRow, EntityKey, EntityValue, EntityDataValue, FilterKey, EntityType, FromEntityType, Selected, IsSubset, EntityProps, ExpandProperty, ExpandScalar, FilterItemValue, ExpandQuery, Scalar, ExpandHint, FilterValue, MergeLoaded, MergeSelected, TypeConfig, AnyString, ClearDatabaseOptions, CreateSchemaOptions, EnsureDatabaseOptions, UpdateSchemaOptions, DropSchemaOptions, RefreshDatabaseOptions, AutoPath, UnboxArray, MetadataProcessor, ImportsResolver, RequiredNullable, DefineConfig, Opt, Hidden, EntitySchemaWithMeta, InferEntity, CheckConstraint, GeneratedColumnCallback, FilterDef, EntityCtor, Subquery, PopulateHintOptions, Prefixes, } from './typings.js';
7
7
  export * from './enums.js';
8
8
  export * from './errors.js';
9
9
  export * from './exceptions.js';
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @packageDocumentation
3
3
  * @module core
4
4
  */
5
- export { EntityMetadata, PrimaryKeyProp, EntityRepositoryType, OptionalProps, EagerProps, HiddenProps, Config, EntityName, } from './typings.js';
5
+ export { EntityMetadata, PrimaryKeyProp, EntityRepositoryType, OptionalProps, EagerProps, HiddenProps, Config, EntityName, IndexHints, } from './typings.js';
6
6
  export * from './enums.js';
7
7
  export * from './errors.js';
8
8
  export * from './exceptions.js';
@@ -54,6 +54,16 @@ export declare class MetadataDiscovery {
54
54
  * Define properties for a polymorphic pivot table.
55
55
  */
56
56
  private definePolymorphicPivotProperties;
57
+ /**
58
+ * Mirror of definePolymorphicPivotProperties for union-target M:N
59
+ * (e.g. Post.attachments -> Image | Video via shared pivot with a target-side discriminator).
60
+ *
61
+ * Pivot shape:
62
+ * (owner_fk..., discriminator_column, target_fk...)
63
+ * - owner side is a normal M:1 to the single owner entity
64
+ * - target side is a discriminator column + per-target-type virtual M:1 relations
65
+ */
66
+ private defineUnionTargetPolymorphicPivotProperties;
57
67
  /**
58
68
  * Create a virtual M:1 relation from pivot to a polymorphic owner entity.
59
69
  * This enables single-query join loading for inverse-side polymorphic M:N.
@@ -479,17 +479,28 @@ export class MetadataDiscovery {
479
479
  prop.polymorphic = prop2.polymorphic;
480
480
  prop.discriminator = prop2.discriminator;
481
481
  prop.discriminatorColumn = prop2.discriminatorColumn;
482
- prop.discriminatorValue = prop2.discriminatorValue;
482
+ // For a union-target pivot each inverse side sits on one specific target class, so its
483
+ // discriminator value is that class's tableName. For Rails-style, prop2 has a single fixed value.
484
+ prop.discriminatorValue = QueryHelper.isUnionTargetPolymorphic(prop2) ? meta.tableName : prop2.discriminatorValue;
483
485
  }
484
486
  prop.referencedColumnNames ??= Utils.flatten(meta.primaryKeys.map(primaryKey => meta.properties[primaryKey].fieldNames));
485
- // For polymorphic M:N, use discriminator base name for FK column (e.g., taggable_id instead of post_id)
486
- if (prop.polymorphic && prop.discriminator) {
487
+ // Union-target polymorphic M:N: owner side is fixed (real FK), target side uses discriminator-derived names.
488
+ const isUnionTargetMN = QueryHelper.isUnionTargetPolymorphic(prop);
489
+ if (prop.polymorphic && prop.discriminator && !isUnionTargetMN) {
490
+ // Rails-style: owner side is polymorphic, uses discriminator base name (e.g. taggable_id instead of post_id)
487
491
  prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, referencedColumnName, prop.referencedColumnNames.length > 1));
488
492
  }
489
493
  else {
490
494
  prop.joinColumns ??= prop.referencedColumnNames.map(referencedColumnName => this.#namingStrategy.joinKeyColumnName(meta.root.className, referencedColumnName, meta.compositePK));
491
495
  }
492
- prop.inverseJoinColumns ??= this.initManyToOneFieldName(prop, meta2.root.className);
496
+ if (isUnionTargetMN) {
497
+ // Target side uses discriminator base name (e.g. attachable_id — shared across Image/Video)
498
+ const targetPkCols = Utils.flatten(meta2.primaryKeys.map(pk => meta2.properties[pk].fieldNames));
499
+ prop.inverseJoinColumns ??= targetPkCols.map(fieldName => this.#namingStrategy.joinKeyColumnName(prop.discriminator, fieldName, targetPkCols.length > 1));
500
+ }
501
+ else {
502
+ prop.inverseJoinColumns ??= this.initManyToOneFieldName(prop, meta2.root.className);
503
+ }
493
504
  }
494
505
  isExplicitTableName(meta) {
495
506
  return meta.tableName !== this.#namingStrategy.classToTableName(meta.className);
@@ -583,6 +594,24 @@ export class MetadataDiscovery {
583
594
  if (prop.inversedBy) {
584
595
  prop.targetMeta.properties[prop.inversedBy].pivotEntity = pivotMeta.class;
585
596
  }
597
+ // Propagate pivotEntity to ALL inverse collections using mappedBy pointing at this
598
+ // owner prop. Covers three cases:
599
+ // - regular inverse (Tag.posts mappedBy Post.tags) — handled by inversedBy above
600
+ // - union-target inverse (Image.posts mappedBy Post.attachments) — on each polymorph target
601
+ // - merged inverse (Tag.owners mappedBy [Post,Video].tags) — union collection on the target
602
+ const inverseCandidates = QueryHelper.isUnionTargetPolymorphic(prop)
603
+ ? prop.polymorphTargets
604
+ : [prop.targetMeta];
605
+ for (const targetMeta of inverseCandidates) {
606
+ for (const inverseProp of Object.values(targetMeta.properties)) {
607
+ if (inverseProp.kind === ReferenceKind.MANY_TO_MANY &&
608
+ inverseProp.mappedBy === prop.name &&
609
+ !inverseProp.pivotEntity) {
610
+ inverseProp.pivotEntity = pivotMeta.class;
611
+ inverseProp.pivotTable = pivotMeta.tableName;
612
+ }
613
+ }
614
+ }
586
615
  return pivotMeta;
587
616
  });
588
617
  }
@@ -721,8 +750,12 @@ export class MetadataDiscovery {
721
750
  }
722
751
  }
723
752
  }
724
- // For polymorphic M:N, create discriminator column and polymorphic FK
725
- if (prop.polymorphic && prop.discriminatorColumn) {
753
+ // Union-target polymorphic M:N: discriminator + target FK share the pivot across multiple target types
754
+ if (prop.discriminatorColumn && QueryHelper.isUnionTargetPolymorphic(prop)) {
755
+ this.defineUnionTargetPolymorphicPivotProperties(pivotMeta2, meta, prop);
756
+ }
757
+ else if (prop.polymorphic && prop.discriminatorColumn) {
758
+ // Rails-style polymorphic M:N: multiple owners share the pivot, single target type
726
759
  this.definePolymorphicPivotProperties(pivotMeta2, meta, prop, targetMeta);
727
760
  }
728
761
  else {
@@ -809,6 +842,33 @@ export class MetadataDiscovery {
809
842
  pivotMeta.polymorphicDiscriminatorMap ??= {};
810
843
  pivotMeta.polymorphicDiscriminatorMap[prop.discriminatorValue] = meta.class;
811
844
  }
845
+ /**
846
+ * Mirror of definePolymorphicPivotProperties for union-target M:N
847
+ * (e.g. Post.attachments -> Image | Video via shared pivot with a target-side discriminator).
848
+ *
849
+ * Pivot shape:
850
+ * (owner_fk..., discriminator_column, target_fk...)
851
+ * - owner side is a normal M:1 to the single owner entity
852
+ * - target side is a discriminator column + per-target-type virtual M:1 relations
853
+ */
854
+ defineUnionTargetPolymorphicPivotProperties(pivotMeta, meta, prop) {
855
+ const discriminatorColumn = prop.discriminatorColumn;
856
+ const targets = prop.polymorphTargets;
857
+ pivotMeta.properties[meta.name + '_owner'] = this.definePivotProperty(prop, meta.name + '_owner', meta.class, prop.discriminator, true, false);
858
+ const discriminatorProp = this.createPivotScalarProperty(discriminatorColumn, [this.#platform.getVarcharTypeDeclarationSQL(prop)], [discriminatorColumn], { type: 'string', primary: true, nullable: false });
859
+ this.initFieldName(discriminatorProp);
860
+ pivotMeta.properties[discriminatorColumn] = discriminatorProp;
861
+ const firstTargetColumnTypes = this.getPrimaryKeyColumnTypes(targets[0]);
862
+ pivotMeta.properties[prop.discriminator] = this.createPivotScalarProperty(prop.discriminator, firstTargetColumnTypes, [...prop.inverseJoinColumns], { type: targets[0].className, primary: true, nullable: false });
863
+ pivotMeta.polymorphicDiscriminatorMap ??= {};
864
+ for (const targetMeta of targets) {
865
+ const relationName = `${prop.discriminator}_${targetMeta.tableName}`;
866
+ const relation = this.definePolymorphicOwnerRelation(prop, relationName, targetMeta);
867
+ relation.joinColumns = relation.fieldNames = relation.ownColumns = [...prop.inverseJoinColumns];
868
+ pivotMeta.properties[relationName] = relation;
869
+ pivotMeta.polymorphicDiscriminatorMap[targetMeta.tableName] = targetMeta.class;
870
+ }
871
+ }
812
872
  /**
813
873
  * Create a virtual M:1 relation from pivot to a polymorphic owner entity.
814
874
  * This enables single-query join loading for inverse-side polymorphic M:N.
@@ -1045,11 +1105,15 @@ export class MetadataDiscovery {
1045
1105
  prop.discriminatorColumn ??= this.#namingStrategy.discriminatorColumnName(prop.discriminator);
1046
1106
  prop.createForeignKeyConstraint = false;
1047
1107
  const isToOne = [ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(prop.kind);
1048
- if (isToOne) {
1108
+ const isUnionTargetMN = prop.kind === ReferenceKind.MANY_TO_MANY && Array.isArray(prop.target);
1109
+ if (isToOne || isUnionTargetMN) {
1049
1110
  const types = prop.type.split(/ ?\| ?/);
1050
1111
  prop.polymorphTargets = discovered.filter(m => types.includes(m.className) && !m.embeddable);
1051
1112
  prop.targetMeta = prop.polymorphTargets[0];
1052
1113
  prop.referencedPKs = prop.targetMeta?.primaryKeys;
1114
+ if (isUnionTargetMN && prop.polymorphTargets.length < 2) {
1115
+ throw new MetadataError(`${meta.className}.${prop.name} union-target polymorphic M:N requires at least two target entity types; use a regular M:N relation for a single target.`);
1116
+ }
1053
1117
  }
1054
1118
  if (prop.discriminatorMap) {
1055
1119
  const normalizedMap = {};
@@ -1065,7 +1129,7 @@ export class MetadataDiscovery {
1065
1129
  }
1066
1130
  prop.discriminatorMap = normalizedMap;
1067
1131
  }
1068
- else if (isToOne) {
1132
+ else if (isToOne || isUnionTargetMN) {
1069
1133
  prop.discriminatorMap = {};
1070
1134
  const tableNameToTarget = new Map();
1071
1135
  for (const target of prop.polymorphTargets) {
@@ -171,6 +171,15 @@ export class MetadataValidator {
171
171
  }
172
172
  validatePolymorphicTargets(meta, prop) {
173
173
  const targets = prop.polymorphTargets;
174
+ // Union-target M:N stores one scalar target FK per pivot row, so composite-PK targets
175
+ // can't round-trip through this schema.
176
+ if (prop.kind === ReferenceKind.MANY_TO_MANY && targets.length > 1) {
177
+ for (const target of targets) {
178
+ if (target.compositePK) {
179
+ throw MetadataError.incompatiblePolymorphicTargets(meta, prop, targets[0], target, `${target.className} has a composite primary key; union-target polymorphic M:N does not support composite-PK targets.`);
180
+ }
181
+ }
182
+ }
174
183
  // Validate targetKey exists and is compatible across all targets
175
184
  if (prop.targetKey) {
176
185
  for (const target of targets) {
@@ -340,7 +340,7 @@ export interface PropertyOptions<Owner> {
340
340
  }
341
341
  export interface ReferenceOptions<Owner, Target> extends PropertyOptions<Owner> {
342
342
  /** Set target entity type. For polymorphic relations, pass an array of entity types. */
343
- entity?: () => EntityName<Target> | EntityName<Target>[];
343
+ entity?: () => EntityName<Target> | EntityName[];
344
344
  /** Set what actions on owning entity should be cascaded to the relationship. Defaults to [Cascade.PERSIST, Cascade.MERGE] (see {@doclink cascading}). */
345
345
  cascade?: Cascade[];
346
346
  /** Always load the relationship. Discouraged for use with to-many relations for performance reasons. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.1.0-dev.5",
3
+ "version": "7.1.0-dev.7",
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",
@@ -211,6 +211,11 @@ export declare abstract class Platform {
211
211
  getFullTextWhereClause(prop: EntityProperty): string;
212
212
  supportsCreatingFullTextIndex(): boolean;
213
213
  getFullTextIndexExpression(indexName: string, schemaName: string | undefined, tableName: string, columns: SimpleColumnMeta[]): string;
214
+ /**
215
+ * Generates the SQL index hint clause for the given index names.
216
+ * Returns `undefined` when the platform does not support index hints (e.g. PostgreSQL, SQLite).
217
+ */
218
+ formatIndexHint(indexNames: string[]): string | undefined;
214
219
  /** Whether the driver automatically parses JSON columns into JS objects. */
215
220
  convertsJsonAutomatically(): boolean;
216
221
  /** Converts a JS value to its JSON database representation (typically JSON.stringify). */
@@ -415,6 +415,13 @@ export class Platform {
415
415
  getFullTextIndexExpression(indexName, schemaName, tableName, columns) {
416
416
  throw new Error('Full text searching is not supported by this driver.');
417
417
  }
418
+ /**
419
+ * Generates the SQL index hint clause for the given index names.
420
+ * Returns `undefined` when the platform does not support index hints (e.g. PostgreSQL, SQLite).
421
+ */
422
+ formatIndexHint(indexNames) {
423
+ return undefined;
424
+ }
418
425
  /** Whether the driver automatically parses JSON columns into JS objects. */
419
426
  convertsJsonAutomatically() {
420
427
  return true;
package/typings.d.ts CHANGED
@@ -47,7 +47,7 @@ export type AsyncFunction<R = any, T = Dictionary> = (args: T) => Promise<T>;
47
47
  export type Compute<T> = {
48
48
  [K in keyof T]: T[K];
49
49
  } & {};
50
- type InternalKeys = 'EntityRepositoryType' | 'PrimaryKeyProp' | 'OptionalProps' | 'EagerProps' | 'HiddenProps' | '__selectedType' | '__loadedType';
50
+ type InternalKeys = 'EntityRepositoryType' | 'PrimaryKeyProp' | 'OptionalProps' | 'EagerProps' | 'HiddenProps' | 'IndexHints' | '__selectedType' | '__loadedType';
51
51
  /** Filters out function, symbol, and internal keys from an entity type. When `B = true`, also excludes scalar keys. */
52
52
  export type CleanKeys<T, K extends keyof T, B extends boolean = false> = T[K] & {} extends Function ? never : K extends symbol | InternalKeys ? never : B extends true ? T[K] & {} extends Scalar ? never : K : K;
53
53
  /** Extracts keys of `T` whose values are functions. */
@@ -150,6 +150,44 @@ export declare const EntityName: unique symbol;
150
150
  export type InferEntityName<T> = T extends {
151
151
  [EntityName]?: infer Name;
152
152
  } ? (Name extends string ? Name : never) : never;
153
+ /**
154
+ * Symbol used to declare index-to-column mappings on an entity type.
155
+ * For decorator entities, declare as a phantom property:
156
+ * ```typescript
157
+ * [IndexHints]?: { idx_email: 'email'; idx_name_age: 'name' | 'age' };
158
+ * ```
159
+ * For `defineEntity` entities, index hints are inferred automatically from
160
+ * named indexes (property-level `.index('name')` and entity-level `indexes`/`uniques`).
161
+ */
162
+ export declare const IndexHints: unique symbol;
163
+ /**
164
+ * Extracts the index hints map from an entity type. Returns `never` when no hints are declared.
165
+ * For decorator entities, `[IndexHints]` contains a pre-computed `{ idxName: 'prop' }` map.
166
+ * For `defineEntity` entities, `[IndexHints]` contains `[Properties]` (a tuple wrapping the
167
+ * raw property builders), which is lazily converted to the index map when first accessed.
168
+ */
169
+ export type ExtractIndexHints<T> = T extends {
170
+ [IndexHints]?: infer H;
171
+ } ? H extends [infer P extends Record<string, any>] ? InferPropertyIndexMap<P> : H : never;
172
+ /**
173
+ * Extracts `{ indexName: propertyKey }` from property builder options.
174
+ * Checks `.index('name')` and `.unique('name')` on each property in a single pass.
175
+ */
176
+ export type InferPropertyIndexMap<Properties extends Record<string, any>> = {
177
+ [K in keyof Properties as MaybeReturnType<Properties[K]> extends {
178
+ '~options': {
179
+ index: infer N extends string;
180
+ };
181
+ } ? N : MaybeReturnType<Properties[K]> extends {
182
+ '~options': {
183
+ unique: infer N extends string;
184
+ };
185
+ } ? N : never]: K & string;
186
+ };
187
+ /** Union of declared index names on an entity. Falls back to `string` when no `[IndexHints]` are declared. */
188
+ export type IndexName<T> = [ExtractIndexHints<T>] extends [never] ? string : (keyof ExtractIndexHints<T> & string) | (string & {});
189
+ /** Properties covered by the named index on entity T. Falls back to all entity keys when the index is unknown. */
190
+ export type IndexColumns<T, Name extends string> = ExtractIndexHints<T> extends Record<Name, infer Cols> ? Cols & string : EntityKey<T>;
153
191
  /**
154
192
  * Branded type that marks a property as optional in `em.create()`.
155
193
  * Use as a property type wrapper: `createdAt: Opt<Date>` instead of listing in `[OptionalProps]`.
@@ -328,6 +366,18 @@ export type ObjectQuery<T> = OperatorMap<T> & FilterObject<T>;
328
366
  * Accepts an object query, a primary key value, entity props with operators, or an array of filters.
329
367
  */
330
368
  export type FilterQuery<T> = ObjectQuery<T> | NonNullable<ExpandScalar<Primary<T>>> | NonNullable<EntityProps<T> & OperatorMap<T>> | FilterQuery<T>[];
369
+ /**
370
+ * `FilterQuery` restricted to only properties covered by the specified index(es).
371
+ * Used when `using` option is set in `FindOptions` to enforce type-safe index usage.
372
+ */
373
+ export type IndexFilterQuery<T, Using extends string> = [Using] extends [never] ? FilterQuery<T> : (OperatorMap<T> & {
374
+ -readonly [K in Extract<EntityKey<T>, IndexColumns<T, Using>>]?: ExpandQuery<ExpandProperty<FilterObjectProp<T, K>>> | ExpandQueryMerged<ExpandProperty<FilterObjectProp<T, K>>> | FilterValue<ExpandProperty<FilterObjectProp<T, K>>> | ElemMatchFilter<FilterObjectProp<T, K>> | null;
375
+ }) | NonNullable<ExpandScalar<Primary<T>>> | IndexFilterQuery<T, Using>[];
376
+ /** Replaces `where` and `using` on an options type with index-aware variants when `Using` is specified. */
377
+ export type WithUsingOptions<Opts, Entity, Using extends string> = Omit<Opts, 'where' | 'using'> & {
378
+ using?: Using | Using[];
379
+ where?: [Using] extends [never] ? FilterQuery<Entity> : IndexFilterQuery<Entity, Using>;
380
+ };
331
381
  /** Public interface for the entity wrapper, accessible via `wrap(entity)`. Provides helper methods for entity state management. */
332
382
  export interface IWrappedEntity<Entity extends object> {
333
383
  isInitialized(): boolean;
@@ -1060,6 +1110,11 @@ export interface IMigrator {
1060
1110
  * Executes down migrations to the given point. Without parameter it will migrate one version down.
1061
1111
  */
1062
1112
  down(options?: string | string[] | Omit<MigrateOptions, 'from'>): Promise<MigrationInfo[]>;
1113
+ /**
1114
+ * Combines multiple executed migrations into a single migration file.
1115
+ * Concatenates source code without touching the database schema.
1116
+ */
1117
+ rollup(migrations?: string[]): Promise<MigrationResult>;
1063
1118
  /**
1064
1119
  * Registers event handler.
1065
1120
  */
package/typings.js CHANGED
@@ -21,6 +21,16 @@ export const Config = Symbol('Config');
21
21
  /** Symbol used to declare the entity name as a string literal type (used by `defineEntity`). */
22
22
  // eslint-disable-next-line @typescript-eslint/no-redeclare
23
23
  export const EntityName = Symbol('EntityName');
24
+ /**
25
+ * Symbol used to declare index-to-column mappings on an entity type.
26
+ * For decorator entities, declare as a phantom property:
27
+ * ```typescript
28
+ * [IndexHints]?: { idx_email: 'email'; idx_name_age: 'name' | 'age' };
29
+ * ```
30
+ * For `defineEntity` entities, index hints are inferred automatically from
31
+ * named indexes (property-level `.index('name')` and entity-level `indexes`/`uniques`).
32
+ */
33
+ export const IndexHints = Symbol('IndexHints');
24
34
  /**
25
35
  * Runtime metadata for an entity, holding its properties, relations, indexes, hooks, and more.
26
36
  * Created during metadata discovery and used throughout the ORM lifecycle.
@@ -1,4 +1,4 @@
1
- import type { Constructor, IMigrationGenerator, IMigrationRunner, IMigrator, IMigratorStorage, MaybePromise, Migration, MigrationInfo, MigrationRow, MigratorEvent } from '../typings.js';
1
+ import type { Constructor, IMigrationGenerator, IMigrationRunner, IMigrator, IMigratorStorage, MaybePromise, Migration, MigrationInfo, MigrationResult, MigrationRow, MigratorEvent } from '../typings.js';
2
2
  import type { Transaction } from '../connections/Connection.js';
3
3
  import type { Configuration, MigrationsOptions } from './Configuration.js';
4
4
  import type { EntityManagerType, IDatabaseDriver } from '../drivers/IDatabaseDriver.js';
@@ -70,6 +70,16 @@ export declare abstract class AbstractMigrator<D extends IDatabaseDriver> implem
70
70
  * @inheritDoc
71
71
  */
72
72
  down(options?: string | string[] | Omit<MigrateOptions, 'from'>): Promise<MigrationInfo[]>;
73
+ /**
74
+ * @inheritDoc
75
+ */
76
+ rollup(migrations?: string[]): Promise<MigrationResult>;
77
+ /**
78
+ * Extracts the body of a method from migration source code using brace counting.
79
+ * Returns the raw lines between the opening and closing braces, or empty string if not found.
80
+ * @internal
81
+ */
82
+ private extractMethodBody;
73
83
  abstract getStorage(): IMigratorStorage;
74
84
  /**
75
85
  * @inheritDoc
@@ -63,6 +63,209 @@ export class AbstractMigrator {
63
63
  async down(options) {
64
64
  return this.runMigrations('down', options);
65
65
  }
66
+ /**
67
+ * @inheritDoc
68
+ */
69
+ async rollup(migrations) {
70
+ await this.init();
71
+ const { fs } = await import('@mikro-orm/core/fs-utils');
72
+ const all = await this.discoverMigrations();
73
+ const executedSet = new Set(await this.storage.executed());
74
+ let toRollup;
75
+ if (migrations && migrations.length > 0) {
76
+ const requested = new Set(migrations.map(m => this.getMigrationFilename(m)));
77
+ toRollup = all.filter(m => requested.has(m.name));
78
+ const found = new Set(toRollup.map(m => m.name));
79
+ const notFound = [...requested].filter(name => !found.has(name));
80
+ if (notFound.length > 0) {
81
+ throw new Error(`Migrations not found: ${notFound.join(', ')}`);
82
+ }
83
+ const notExecuted = toRollup.filter(m => !executedSet.has(m.name));
84
+ if (notExecuted.length > 0) {
85
+ throw new Error(`Cannot roll up migrations that have not been executed: ${notExecuted.map(m => m.name).join(', ')}`);
86
+ }
87
+ }
88
+ else {
89
+ toRollup = all.filter(m => executedSet.has(m.name));
90
+ }
91
+ if (toRollup.length < 2) {
92
+ throw new Error('At least 2 executed migrations are required for rollup');
93
+ }
94
+ const withoutPath = toRollup.filter(m => !m.path);
95
+ if (withoutPath.length > 0) {
96
+ throw new Error(`Cannot roll up migrations without file paths (class-based migrations): ${withoutPath.map(m => m.name).join(', ')}`);
97
+ }
98
+ const upBodies = [];
99
+ const downBodies = [];
100
+ const placeholder = `__mikro_orm_rollup_${Date.now()}__`;
101
+ for (const migration of toRollup) {
102
+ const source = await fs.readFile(migration.path);
103
+ const upBody = this.extractMethodBody(source, 'up');
104
+ const downBody = this.extractMethodBody(source, 'down');
105
+ if (upBody) {
106
+ upBodies.push(` // --- merged from ${migration.name} ---\n${upBody}`);
107
+ }
108
+ if (downBody) {
109
+ downBodies.unshift(` // --- merged from ${migration.name} ---\n${downBody}`);
110
+ }
111
+ }
112
+ const diff = {
113
+ up: [placeholder],
114
+ down: downBodies.length > 0 ? [placeholder] : [],
115
+ };
116
+ const [templateCode, fileName] = await this.generator.generate(diff);
117
+ const placeholderRe = new RegExp(`^.*${placeholder}.*$`, 'm');
118
+ let code = templateCode.replace(placeholderRe, upBodies.join('\n'));
119
+ if (downBodies.length > 0) {
120
+ code = code.replace(placeholderRe, downBodies.join('\n'));
121
+ }
122
+ await fs.writeFile(fs.normalizePath(this.absolutePath, fileName), code, { flush: true });
123
+ const updateStorage = async () => {
124
+ for (const migration of toRollup) {
125
+ await this.storage.unlogMigration({ name: migration.name });
126
+ }
127
+ await this.storage.logMigration({ name: fileName.replace(/\.[jt]s$/, '') });
128
+ };
129
+ if (this.options.transactional) {
130
+ await this.driver.getConnection().transactional(async (trx) => {
131
+ this.storage.setMasterMigration(trx);
132
+ try {
133
+ await updateStorage();
134
+ }
135
+ finally {
136
+ this.storage.unsetMasterMigration();
137
+ }
138
+ });
139
+ }
140
+ else {
141
+ await updateStorage();
142
+ }
143
+ await Promise.all(toRollup.map(migration => fs.unlink(migration.path)));
144
+ return { fileName, code, diff: { up: [], down: [] } };
145
+ }
146
+ /**
147
+ * Extracts the body of a method from migration source code using brace counting.
148
+ * Returns the raw lines between the opening and closing braces, or empty string if not found.
149
+ * @internal
150
+ */
151
+ extractMethodBody(source, methodName) {
152
+ const lines = source.split('\n');
153
+ // match method declarations, not occurrences in comments/strings — require preceding whitespace or keyword
154
+ const methodPattern = new RegExp(`^\\s+(?:override\\s+|async\\s+)*${methodName}\\s*\\(`);
155
+ let methodLine = -1;
156
+ for (let i = 0; i < lines.length; i++) {
157
+ if (methodPattern.test(lines[i])) {
158
+ methodLine = i;
159
+ break;
160
+ }
161
+ }
162
+ if (methodLine === -1) {
163
+ return '';
164
+ }
165
+ let braceCount = 0;
166
+ let bodyStart = -1;
167
+ let bodyEnd = -1;
168
+ let bodyStartCol = -1;
169
+ let bodyEndCol = -1;
170
+ let inBacktick = false;
171
+ let inBlockComment = false;
172
+ // stack tracks brace depth at which each template expression `${...}` was entered
173
+ const templateExprStack = [];
174
+ for (let i = methodLine; i < lines.length; i++) {
175
+ const line = lines[i];
176
+ for (let j = 0; j < line.length; j++) {
177
+ // handle multi-line block comments
178
+ if (inBlockComment) {
179
+ if (line[j] === '*' && j + 1 < line.length && line[j + 1] === '/') {
180
+ inBlockComment = false;
181
+ j++;
182
+ }
183
+ continue;
184
+ }
185
+ // handle multi-line template literals
186
+ if (inBacktick) {
187
+ if (line[j] === '\\') {
188
+ j++;
189
+ }
190
+ else if (line[j] === '`') {
191
+ inBacktick = false;
192
+ }
193
+ else if (line[j] === '$' && j + 1 < line.length && line[j + 1] === '{') {
194
+ // entering template expression — resume brace counting
195
+ templateExprStack.push(braceCount);
196
+ inBacktick = false;
197
+ j++; // skip the {
198
+ braceCount++;
199
+ }
200
+ continue;
201
+ }
202
+ const ch = line[j];
203
+ // single/double quoted strings (single-line only)
204
+ if (ch === "'" || ch === '"') {
205
+ const quote = ch;
206
+ j++;
207
+ while (j < line.length) {
208
+ if (line[j] === '\\') {
209
+ j++;
210
+ }
211
+ else if (line[j] === quote) {
212
+ break;
213
+ }
214
+ j++;
215
+ }
216
+ continue;
217
+ }
218
+ // template literal start
219
+ if (ch === '`') {
220
+ inBacktick = true;
221
+ continue;
222
+ }
223
+ // single-line comment
224
+ if (ch === '/' && j + 1 < line.length && line[j + 1] === '/') {
225
+ break;
226
+ }
227
+ // block comment start
228
+ if (ch === '/' && j + 1 < line.length && line[j + 1] === '*') {
229
+ inBlockComment = true;
230
+ j++;
231
+ continue;
232
+ }
233
+ if (ch === '{') {
234
+ if (braceCount === 0) {
235
+ bodyStart = i;
236
+ bodyStartCol = j + 1;
237
+ }
238
+ braceCount++;
239
+ }
240
+ else if (ch === '}') {
241
+ braceCount--;
242
+ // closing a template expression — re-enter backtick mode
243
+ if (templateExprStack.length > 0 && braceCount === templateExprStack[templateExprStack.length - 1]) {
244
+ templateExprStack.pop();
245
+ inBacktick = true;
246
+ continue;
247
+ }
248
+ if (braceCount === 0) {
249
+ bodyEnd = i;
250
+ bodyEndCol = j;
251
+ break;
252
+ }
253
+ }
254
+ }
255
+ if (bodyEnd !== -1) {
256
+ break;
257
+ }
258
+ }
259
+ if (bodyStart === -1 || bodyEnd === -1) {
260
+ return '';
261
+ }
262
+ // single-line method body: extract content between braces on the same line
263
+ if (bodyStart === bodyEnd) {
264
+ const content = lines[bodyStart].slice(bodyStartCol, bodyEndCol).trim();
265
+ return content ? ` ${content}` : '';
266
+ }
267
+ return lines.slice(bodyStart + 1, bodyEnd).join('\n');
268
+ }
66
269
  /**
67
270
  * @inheritDoc
68
271
  */
@@ -6,6 +6,22 @@ import type { FilterOptions } from '../drivers/IDatabaseDriver.js';
6
6
  /** @internal */
7
7
  export declare class QueryHelper {
8
8
  static readonly SUPPORTED_OPERATORS: string[];
9
+ /**
10
+ * True when the property has multiple polymorph target types. Covers two structurally-equivalent
11
+ * shapes routed through the same loading path:
12
+ * 1. Union-target owner side — `Post.attachments: Collection<Image | Video>` (one owner, many
13
+ * target types, shared pivot with target-side discriminator).
14
+ * 2. Merged inverse of Rails-style polymorphic M:N — `Tag.owners: Collection<Post | Video>`
15
+ * (many owner types pointing at one target, viewed from the target where "owners" looks
16
+ * like a union of multiple types).
17
+ *
18
+ * Both cases are loaded via `loadFromUnionTargetPolymorphicPivotTable`, which buckets pivot rows
19
+ * by discriminator and hydrates each target class separately.
20
+ */
21
+ static isUnionTargetPolymorphic(prop: {
22
+ polymorphic?: boolean;
23
+ polymorphTargets?: readonly unknown[];
24
+ }): boolean;
9
25
  /**
10
26
  * Finds the discriminator value (key) for a given entity class in a discriminator map.
11
27
  * Walks up the prototype chain so TPT subclasses resolve to their root's key.
@@ -7,6 +7,21 @@ import { isRaw, Raw } from './RawQueryFragment.js';
7
7
  /** @internal */
8
8
  export class QueryHelper {
9
9
  static SUPPORTED_OPERATORS = ['>', '<', '<=', '>=', '!', '!='];
10
+ /**
11
+ * True when the property has multiple polymorph target types. Covers two structurally-equivalent
12
+ * shapes routed through the same loading path:
13
+ * 1. Union-target owner side — `Post.attachments: Collection<Image | Video>` (one owner, many
14
+ * target types, shared pivot with target-side discriminator).
15
+ * 2. Merged inverse of Rails-style polymorphic M:N — `Tag.owners: Collection<Post | Video>`
16
+ * (many owner types pointing at one target, viewed from the target where "owners" looks
17
+ * like a union of multiple types).
18
+ *
19
+ * Both cases are loaded via `loadFromUnionTargetPolymorphicPivotTable`, which buckets pivot rows
20
+ * by discriminator and hydrates each target class separately.
21
+ */
22
+ static isUnionTargetPolymorphic(prop) {
23
+ return !!prop.polymorphic && (prop.polymorphTargets?.length ?? 0) > 1;
24
+ }
10
25
  /**
11
26
  * Finds the discriminator value (key) for a given entity class in a discriminator map.
12
27
  * Walks up the prototype chain so TPT subclasses resolve to their root's key.
package/utils/Utils.js CHANGED
@@ -141,7 +141,7 @@ export function parseJsonSafe(value) {
141
141
  /** Collection of general-purpose utility methods used throughout the ORM. */
142
142
  export class Utils {
143
143
  static PK_SEPARATOR = '~~~';
144
- static #ORM_VERSION = '7.1.0-dev.5';
144
+ static #ORM_VERSION = '7.1.0-dev.7';
145
145
  /**
146
146
  * Checks if the argument is instance of `Object`. Returns false for arrays.
147
147
  */
@@ -13,7 +13,9 @@ export interface FsUtils {
13
13
  normalizePath(...parts: string[]): string;
14
14
  relativePath(path: string, relativeTo: string): string;
15
15
  absolutePath(path: string, baseDir?: string): string;
16
+ readFile(path: string): Promise<string>;
16
17
  writeFile(path: string, data: string, options?: Record<string, any>): Promise<void>;
18
+ unlink(path: string): Promise<void>;
17
19
  dynamicImport<T = any>(id: string): Promise<T>;
18
20
  }
19
21
  export declare const fs: FsUtils;
package/utils/fs-utils.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, globSync as nodeGlobSync, mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
- import { writeFile as nodeWriteFile } from 'node:fs/promises';
2
+ import { readFile as nodeReadFile, unlink as nodeUnlink, writeFile as nodeWriteFile } from 'node:fs/promises';
3
3
  import { isAbsolute, join, normalize, relative } from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { Utils } from './Utils.js';
@@ -181,9 +181,15 @@ export const fs = {
181
181
  }
182
182
  return this.normalizePath(path);
183
183
  },
184
+ async readFile(path) {
185
+ return nodeReadFile(path, 'utf-8');
186
+ },
184
187
  async writeFile(path, data, options) {
185
188
  await nodeWriteFile(path, data, options);
186
189
  },
190
+ async unlink(path) {
191
+ await nodeUnlink(path);
192
+ },
187
193
  async dynamicImport(id) {
188
194
  /* v8 ignore next */
189
195
  const specifier = id.startsWith('file://') ? id : pathToFileURL(id).href;