From 3c1086348b58c88ac3534c5e7127eb030abb0609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 00:26:14 +0100 Subject: [PATCH 1/9] feat(core): add type-safe `using` option for index hints in FindOptions Adds a new `using` option to `FindOptions` that provides type-safe index awareness for queries. When specified, it validates that `where` and `orderBy` only reference columns covered by the named index(es), and emits driver-specific SQL index hints (MySQL `USE INDEX`, MSSQL `WITH (INDEX(...))`). For `defineEntity` entities, index names are automatically inferred from property-level `.index('name')`/`.unique('name')` calls and entity-level `indexes`/`uniques` definitions. For decorator entities, users declare the `[IndexHints]` symbol (similar to `[PrimaryKeyProp]`). Closes #7175 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/EntityManager.ts | 185 ++++++++++++-- packages/core/src/drivers/IDatabaseDriver.ts | 15 ++ packages/core/src/entity/EntityRepository.ts | 93 ++++--- packages/core/src/entity/defineEntity.ts | 80 +++++- packages/core/src/index.ts | 5 + packages/core/src/platforms/Platform.ts | 8 + packages/core/src/typings.ts | 42 ++++ packages/mongodb/src/MongoDriver.ts | 3 + packages/mongodb/src/MongoEntityManager.ts | 10 +- packages/mssql/src/MsSqlPlatform.ts | 4 + packages/sql/src/AbstractSqlDriver.ts | 18 ++ .../src/dialects/mysql/BaseMySqlPlatform.ts | 4 + tests/bench/types/api-methods.ts | 6 +- tests/bench/types/defineEntity-extends.ts | 16 +- tests/bench/types/defineEntity.ts | 32 ++- tests/features/using-index-hints.test.ts | 238 ++++++++++++++++++ 16 files changed, 679 insertions(+), 80 deletions(-) create mode 100644 tests/features/using-index-hints.test.ts diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index 145d12c08d38..a524cdf2a682 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -64,6 +64,7 @@ import type { Ref, RequiredEntityData, UnboxArray, + IndexFilterQuery, } from './typings.js'; import { EventType, @@ -189,6 +190,18 @@ export class EntityManager { /** * Finds all entities matching your `where` query. You can pass additional options via the `options` parameter. */ + async find< + Entity extends object, + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + entityName: EntityName, + where: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>, + options?: FindOptions & { using?: Using | Using[] }, + ): Promise[]>; + async find< Entity extends object, Hint extends string = never, @@ -202,7 +215,7 @@ export class EntityManager { if (options.disableIdentityMap ?? this.config.get('disableIdentityMap')) { const em = this.getContext(false); const fork = em.fork({ keepTransactionContext: true }); - const ret = await fork.find(entityName, where, { ...options, disableIdentityMap: false }); + const ret = await fork.find(entityName, where as any, { ...options, disableIdentityMap: false }); fork.clear(); return ret; @@ -214,6 +227,7 @@ export class EntityManager { where = await em.processWhere(entityName, where, options, 'read'); validateParams(where); const meta = this.metadata.get(entityName); + em.validateIndexUsage(meta, where, options); if (meta.orderBy) { options.orderBy = QueryHelper.mergeOrderBy(options.orderBy, meta.orderBy); } else { @@ -309,9 +323,13 @@ export class EntityManager { Hint extends string = never, Fields extends string = never, Excludes extends string = never, + Using extends string = never, >( entityName: EntityName, - options: StreamOptions, Hint, Fields, Excludes> = {}, + options: Omit, Hint, Fields, Excludes>, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; + } = {} as any, ): AsyncIterableIterator> { const em = this.getContext(); em.prepareOptions(options); @@ -322,6 +340,7 @@ export class EntityManager { options.orderBy = options.orderBy || {}; options.populate = (await em.preparePopulate(entityName, options)) as any; const meta = this.metadata.get(entityName); + em.validateIndexUsage(meta, options.where ?? {}, options); options = { ...options }; // save the original hint value so we know it was infer/all (options as Dictionary)._populateWhere = options.populateWhere ?? this.config.get('populateWhere'); @@ -359,11 +378,15 @@ export class EntityManager { Hint extends string = never, Fields extends string = never, Excludes extends string = never, + Using extends string = never, >( entityName: EntityName, - options?: FindAllOptions, Hint, Fields, Excludes>, + options?: Omit, Hint, Fields, Excludes>, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; + }, ): Promise[]> { - return this.find(entityName, options?.where ?? {}, options); + return this.find(entityName, options?.where ?? ({} as any), options as any); } private getPopulateWhere( @@ -750,6 +773,18 @@ export class EntityManager { * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as tuple * where the first element is the array of entities, and the second is the count. */ + async findAndCount< + Entity extends object, + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + entityName: EntityName, + where: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>, + options?: FindOptions & { using?: Using | Using[] }, + ): Promise<[Loaded[], number]>; + async findAndCount< Entity extends object, Hint extends string = never, @@ -765,7 +800,7 @@ export class EntityManager { options.flushMode = 'commit'; // do not try to auto flush again return Promise.all([ - em.find(entityName, where, options), + em.find(entityName, where as any, options), em.count(entityName, where, options as CountOptions), ]); } @@ -832,9 +867,13 @@ export class EntityManager { Fields extends string = never, Excludes extends string = never, IncludeCount extends boolean = true, + Using extends string = never, >( entityName: EntityName, - options: FindByCursorOptions, + options: Omit, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; + }, ): Promise> { const em = this.getContext(false); options.overfetch ??= true; @@ -846,14 +885,14 @@ export class EntityManager { const [entities, count] = options.includeCount !== false - ? await em.findAndCount(entityName, options.where, options) - : [await em.find(entityName, options.where, options)]; + ? await em.findAndCount(entityName, options.where as any, options as any) + : [await em.find(entityName, options.where as any, options as any)]; return new Cursor( - entities, + entities as any, count as IncludeCount extends true ? number : undefined, - options, + options as any, this.metadata.get(entityName), - ); + ) as Cursor; } /** @@ -943,6 +982,18 @@ export class EntityManager { /** * Finds first entity matching your `where` query. */ + async findOne< + Entity extends object, + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + entityName: EntityName, + where: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>, + options?: FindOneOptions & { using?: Using | Using[] }, + ): Promise | null>; + async findOne< Entity extends object, Hint extends string = never, @@ -956,7 +1007,7 @@ export class EntityManager { if (options.disableIdentityMap ?? this.config.get('disableIdentityMap')) { const em = this.getContext(false); const fork = em.fork({ keepTransactionContext: true }); - const ret = await fork.findOne(entityName, where, { ...options, disableIdentityMap: false }); + const ret = await fork.findOne(entityName, where as any, { ...options, disableIdentityMap: false }); fork.clear(); return ret; @@ -975,6 +1026,7 @@ export class EntityManager { await em.tryFlush(entityName, options); const meta = em.metadata.get(entityName); + em.validateIndexUsage(meta, where, options); where = await em.processWhere(entityName, where, options, 'read'); validateEmptyWhere(where); em.checkLockRequirements(options.lockMode, meta); @@ -1051,6 +1103,18 @@ export class EntityManager { * You can override the factory for creating this method via `options.failHandler` locally * or via `Configuration.findOneOrFailHandler` (`findExactlyOneOrFailHandler` when specifying `strict`) globally. */ + async findOneOrFail< + Entity extends object, + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + entityName: EntityName, + where: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>, + options?: FindOneOrFailOptions & { using?: Using | Using[] }, + ): Promise>; + async findOneOrFail< Entity extends object, Hint extends string = never, @@ -1065,16 +1129,15 @@ export class EntityManager { let isStrictViolation = false; if (options.strict) { - const ret = await this.find(entityName, where, { ...options, limit: 2 } as FindOptions< - Entity, - Hint, - Fields, - Excludes - >); + const ret = await this.find( + entityName, + where as any, + { ...options, limit: 2 } as FindOptions, + ); isStrictViolation = ret.length !== 1; entity = ret[0]; } else { - entity = await this.findOne(entityName, where, options); + entity = await this.findOne(entityName, where as any, options); } if (!entity || isStrictViolation) { @@ -2531,6 +2594,90 @@ export class EntityManager { } } + private validateIndexUsage( + meta: EntityMetadata, + where: FilterQuery | Dictionary, + options: { using?: string | string[]; orderBy?: any }, + ): void { + if (!options.using) { + return; + } + + const indexNames = Utils.asArray(options.using); + const allIndexes = [...meta.indexes, ...meta.uniques]; + + // Build a map of all named indexes: { name -> property names } + // Includes both entity-level indexes/uniques and property-level index/unique names + const indexMap = new Map(); + + for (const idx of allIndexes) { + if (idx.name) { + indexMap.set(idx.name, Utils.asArray(idx.properties ?? []) as string[]); + } + } + + // Property-level indexes (prop.index = 'indexName', prop.unique = 'uniqueName') + for (const prop of meta.props) { + if (typeof prop.index === 'string') { + indexMap.set(prop.index, [prop.name]); + } + + if (typeof prop.unique === 'string') { + indexMap.set(prop.unique, [prop.name]); + } + } + + const allowedProps = new Set(); + + for (const name of indexNames) { + const props = indexMap.get(name); + + if (!props) { + const available = [...indexMap.keys()]; + throw new Error( + `Index '${name}' not found on entity '${meta.className}'. ` + + (available.length > 0 ? `Available indexes: ${available.join(', ')}` : 'No named indexes defined.'), + ); + } + + for (const prop of props) { + allowedProps.add(prop); + } + } + + // Validate where keys + if (typeof where === 'object' && where !== null && !Array.isArray(where)) { + for (const key of Object.keys(where)) { + if (key.startsWith('$')) { + continue; // skip operators like $and, $or + } + + if (!allowedProps.has(key)) { + throw new Error( + `Property '${key}' in where clause is not covered by index '${indexNames.join("', '")}'. ` + + `Allowed properties: ${[...allowedProps].join(', ')}`, + ); + } + } + } + + // Validate orderBy keys + if (options.orderBy) { + const orderMaps = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]; + + for (const orderMap of orderMaps) { + for (const key of Object.keys(orderMap)) { + if (!allowedProps.has(key)) { + throw new Error( + `Property '${key}' in orderBy is not covered by index '${indexNames.join("', '")}'. ` + + `Allowed properties: ${[...allowedProps].join(', ')}`, + ); + } + } + } + } + } + private async lockAndPopulate< T extends object, P extends string = never, diff --git a/packages/core/src/drivers/IDatabaseDriver.ts b/packages/core/src/drivers/IDatabaseDriver.ts index 8dcb8a2ddf97..4b3c21aa3814 100644 --- a/packages/core/src/drivers/IDatabaseDriver.ts +++ b/packages/core/src/drivers/IDatabaseDriver.ts @@ -17,6 +17,7 @@ import type { EntityName, PopulateHintOptions, Prefixes, + IndexName, } from '../typings.js'; import type { Connection, QueryResult, Transaction } from '../connections/Connection.js'; import type { @@ -369,6 +370,20 @@ export interface FindOptions< connectionType?: ConnectionType; /** SQL: appended to FROM clause (e.g. `'force index(my_index)'`); MongoDB: index name or spec passed as `hint`. */ indexHint?: string | Dictionary; + /** + * Named index(es) for this query. When provided: + * - Validates that `where` and `orderBy` only reference columns covered by the specified index(es). + * - Emits SQL index hints where supported (MySQL/MariaDB: `USE INDEX`, MSSQL: `WITH (INDEX(...))`). + * - If `indexHint` is also set, `indexHint` takes precedence for SQL generation. + * + * Accepts a single index name or an array. For `defineEntity` entities with named indexes + * and decorator entities with `[IndexHints]`, index names are autocompleted. + * + * @example + * await em.find(Book, { title: 'foo' }, { using: 'idx_book_title' }); + * await em.find(Book, { title: 'foo', author: 1 }, { using: ['idx_book_title', 'idx_book_author'] }); + */ + using?: IndexName | IndexName[]; /** sql only */ comments?: string | string[]; /** sql only */ diff --git a/packages/core/src/entity/EntityRepository.ts b/packages/core/src/entity/EntityRepository.ts index ccc371772b10..545f50654497 100644 --- a/packages/core/src/entity/EntityRepository.ts +++ b/packages/core/src/entity/EntityRepository.ts @@ -18,6 +18,7 @@ import type { IsSubset, MergeLoaded, ArrayElement, + IndexFilterQuery, } from '../typings.js'; import type { CountOptions, @@ -50,11 +51,16 @@ export class EntityRepository { /** * Finds first entity matching your `where` query. */ - async findOne( - where: FilterQuery, - options?: FindOneOptions, + async findOne< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + where: [Using] extends [never] ? FilterQuery : IndexFilterQuery, + options?: FindOneOptions & { using?: Using | Using[] }, ): Promise | null> { - return this.getEntityManager().findOne(this.entityName, where as any, options); + return this.getEntityManager().findOne(this.entityName, where as any, options as any); } /** @@ -62,15 +68,16 @@ export class EntityRepository { * You can override the factory for creating this method via `options.failHandler` locally * or via `Configuration.findOneOrFailHandler` globally. */ - async findOneOrFail( - where: FilterQuery, - options?: FindOneOrFailOptions, + async findOneOrFail< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + where: [Using] extends [never] ? FilterQuery : IndexFilterQuery, + options?: FindOneOrFailOptions & { using?: Using | Using[] }, ): Promise> { - return this.getEntityManager().findOneOrFail( - this.entityName, - where as any, - options, - ); + return this.getEntityManager().findOneOrFail(this.entityName, where as any, options as any); } /** @@ -137,22 +144,32 @@ export class EntityRepository { /** * Finds all entities matching your `where` query. You can pass additional options via the `options` parameter. */ - async find( - where: FilterQuery, - options?: FindOptions, + async find< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + where: [Using] extends [never] ? FilterQuery : IndexFilterQuery, + options?: FindOptions & { using?: Using | Using[] }, ): Promise[]> { - return this.getEntityManager().find(this.entityName, where as any, options); + return this.getEntityManager().find(this.entityName, where as any, options as any); } /** * Calls `em.find()` and `em.count()` with the same arguments (where applicable) and returns the results as tuple * where first element is the array of entities, and the second is the count. */ - async findAndCount( - where: FilterQuery, - options?: FindOptions, + async findAndCount< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + where: [Using] extends [never] ? FilterQuery : IndexFilterQuery, + options?: FindOptions & { using?: Using | Using[] }, ): Promise<[Loaded[], number]> { - return this.getEntityManager().findAndCount(this.entityName, where as any, options); + return this.getEntityManager().findAndCount(this.entityName, where as any, options as any); } /** @@ -163,28 +180,48 @@ export class EntityRepository { Fields extends string = never, Excludes extends string = never, IncludeCount extends boolean = true, + Using extends string = never, >( - options: FindByCursorOptions, + options: Omit, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; + }, ): Promise> { - return this.getEntityManager().findByCursor(this.entityName, options); + return this.getEntityManager().findByCursor(this.entityName, options as any); } /** * Finds all entities of given type. You can pass additional options via the `options` parameter. */ - async findAll( - options?: FindAllOptions, + async findAll< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + options?: Omit, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; + }, ): Promise[]> { - return this.getEntityManager().findAll(this.entityName, options); + return this.getEntityManager().findAll(this.entityName, options as any); } /** * @inheritDoc EntityManager.stream */ - async *stream( - options?: StreamOptions, + async *stream< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + options?: Omit, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; + }, ): AsyncIterableIterator> { - yield* this.getEntityManager().stream(this.entityName, options); + yield* this.getEntityManager().stream(this.entityName, options as any); } /** diff --git a/packages/core/src/entity/defineEntity.ts b/packages/core/src/entity/defineEntity.ts index 415f9881647f..3d9d21df8e3b 100644 --- a/packages/core/src/entity/defineEntity.ts +++ b/packages/core/src/entity/defineEntity.ts @@ -38,6 +38,7 @@ import type { DefineConfig, Config, MaybePromise, + IndexHints, } from '../typings.js'; import type { Raw } from '../utils/RawQueryFragment.js'; import type { ScalarReference } from './Reference.js'; @@ -148,8 +149,10 @@ export interface PropertyChain { customOrder(...customOrder: string[] | number[] | boolean[]): PropertyChain; extra(extra: string): PropertyChain; ignoreSchemaChanges(...ignoreSchemaChanges: ('type' | 'extra' | 'default')[]): PropertyChain; - index(index?: boolean | string): PropertyChain; - unique(unique?: boolean | string): PropertyChain; + index(name: N): PropertyChain & { index: N }>; + index(index?: boolean): PropertyChain; + unique(name: N): PropertyChain & { unique: N }>; + unique(unique?: boolean): PropertyChain; comment(comment: string): PropertyChain; accessor(accessor?: string | boolean): PropertyChain; @@ -605,19 +608,25 @@ export class UniversalPropertyOptionsBuilder, IncludeKeys> { + index( + name: N, + ): Pick & { index: N }, IncludeKeys>, IncludeKeys>; + index(index?: boolean): Pick, IncludeKeys>; + index(index: boolean | string = true): any { return this.assignOptions({ index }); } /** * Set column as unique for {@link https://mikro-orm.io/docs/schema-generator Schema Generator}. (SQL only) + * When a string name is passed, it is captured as a literal type for use with the `using` option in `FindOptions`. */ - unique( - unique: boolean | string = true, - ): Pick, IncludeKeys> { + unique( + name: N, + ): Pick & { unique: N }, IncludeKeys>, IncludeKeys>; + unique(unique?: boolean): Pick, IncludeKeys>; + unique(unique: boolean | string = true): any { return this.assignOptions({ unique }); } @@ -1299,12 +1308,22 @@ export function defineEntity< const TBase = never, const TRepository = never, const TForceObject extends boolean = false, + const TIndexDefs extends readonly { name?: string; properties?: any }[] = readonly [], + const TUniqueDefs extends readonly { name?: string; properties?: any }[] = readonly [], >( - meta: EntityMetadataWithProperties, + meta: Omit< + EntityMetadataWithProperties, + 'indexes' | 'uniques' + > & { + indexes?: TIndexDefs & + EntityMetadataWithProperties['indexes']; + uniques?: TUniqueDefs & + EntityMetadataWithProperties['uniques']; + }, ): EntitySchemaWithMeta< TName, TTableName, - InferEntityFromProperties, + InferEntityFromProperties, TBase, TProperties >; @@ -1483,6 +1502,8 @@ export type InferEntityFromProperties< Base = never, Repository = never, ForceObject extends boolean = false, + IndexDefs extends readonly any[] = readonly [], + UniqueDefs extends readonly any[] = readonly [], > = (IsNever extends true ? {} : Base extends { toObject(...args: any[]): any } @@ -1507,7 +1528,9 @@ export type InferEntityFromProperties< ? {} : { [EntityRepositoryType]?: Repository extends Constructor ? R : Repository }) & (IsNever extends true ? {} : Omit) & - (ForceObject extends true ? { [Config]?: DefineConfig<{ forceObject: true }> } : {}); + (ForceObject extends true ? { [Config]?: DefineConfig<{ forceObject: true }> } : {}) & { + [IndexHints]?: InferIndexMap; + }; // Combines primary keys from child properties and base entity type InferCombinedPrimaryKey, PK, Base> = PK extends undefined @@ -1625,3 +1648,38 @@ type ValueOf = T[keyof T] extends infer V : never; type IsUnion = T extends U ? ([U] extends [T] ? false : true) : false; + +/** Extracts `{ indexName: propertyKey }` from property-level `.index('name')` calls. */ +type InferPropertyIndexes> = { + [K in keyof Properties as MaybeReturnType extends { '~options': { index: infer N extends string } } + ? N + : never]: K & string; +}; + +/** Extracts `{ uniqueName: propertyKey }` from property-level `.unique('name')` calls. */ +type InferPropertyUniques> = { + [K in keyof Properties as MaybeReturnType extends { '~options': { unique: infer N extends string } } + ? N + : never]: K & string; +}; + +/** Extracts `{ name: prop1 | prop2 }` from entity-level `indexes`/`uniques` array. */ +type ExtractIndexDefs = { + [I in Defs[number] as I extends { name: infer N extends string } ? N : never]: I extends { properties: infer P } + ? P extends readonly string[] + ? P[number] + : P extends string + ? P + : never + : never; +}; + +/** Merges property-level and entity-level index maps into a single `[IndexHints]` type. */ +type InferIndexMap< + Properties extends Record, + IndexDefs extends readonly any[], + UniqueDefs extends readonly any[], +> = InferPropertyIndexes & + InferPropertyUniques & + ExtractIndexDefs & + ExtractIndexDefs; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e735a05ca06..a2b910f1b1e0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export { HiddenProps, Config, EntityName, + IndexHints, } from './typings.js'; export type { CompiledFunctions, @@ -52,6 +53,10 @@ export type { MigrationDiff, GenerateOptions, FilterObject, + IndexFilterQuery, + ExtractIndexHints, + IndexName, + IndexColumns, IMigrationRunner, IEntityGenerator, ISeedManager, diff --git a/packages/core/src/platforms/Platform.ts b/packages/core/src/platforms/Platform.ts index b49ae366dba0..89ec7e25d9b4 100644 --- a/packages/core/src/platforms/Platform.ts +++ b/packages/core/src/platforms/Platform.ts @@ -565,6 +565,14 @@ export abstract class Platform { throw new Error('Full text searching is not supported by this driver.'); } + /** + * Generates the SQL index hint clause for the given index names. + * Returns `undefined` when the platform does not support index hints (e.g. PostgreSQL, SQLite). + */ + formatIndexHint(indexNames: string[]): string | undefined { + return undefined; + } + /** Whether the driver automatically parses JSON columns into JS objects. */ convertsJsonAutomatically(): boolean { return true; diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index e106d283cab9..127bfe2a8c27 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -72,6 +72,7 @@ type InternalKeys = | 'OptionalProps' | 'EagerProps' | 'HiddenProps' + | 'IndexHints' | '__selectedType' | '__loadedType'; /** Filters out function, symbol, and internal keys from an entity type. When `B = true`, also excludes scalar keys. */ @@ -209,6 +210,29 @@ export const EntityName = Symbol('EntityName'); /** Extracts the entity name string literal from an entity type that declares `[EntityName]`. */ export type InferEntityName = T extends { [EntityName]?: infer Name } ? (Name extends string ? Name : never) : never; +/** + * Symbol used to declare index-to-column mappings on an entity type. + * For decorator entities, declare as a phantom property: + * ```typescript + * [IndexHints]?: { idx_email: 'email'; idx_name_age: 'name' | 'age' }; + * ``` + * For `defineEntity` entities, index hints are inferred automatically from + * named indexes (property-level `.index('name')` and entity-level `indexes`/`uniques`). + */ +export const IndexHints = Symbol('IndexHints'); + +/** Extracts the index hints map from an entity type. Returns `never` when no hints are declared. */ +export type ExtractIndexHints = T extends { [IndexHints]?: infer H } ? H : never; + +/** Union of declared index names on an entity. Falls back to `string` when no `[IndexHints]` are declared. */ +export type IndexName = [ExtractIndexHints] extends [never] + ? string + : (keyof ExtractIndexHints & string) | (string & {}); + +/** Properties covered by the named index on entity T. Falls back to all entity keys when the index is unknown. */ +export type IndexColumns = + ExtractIndexHints extends Record ? Cols & string : EntityKey; + /** * Branded type that marks a property as optional in `em.create()`. * Use as a property type wrapper: `createdAt: Opt` instead of listing in `[OptionalProps]`. @@ -469,6 +493,24 @@ export type FilterQuery = | NonNullable & OperatorMap> | FilterQuery[]; +/** + * `FilterQuery` restricted to only properties covered by the specified index(es). + * Used when `using` option is set in `FindOptions` to enforce type-safe index usage. + */ +export type IndexFilterQuery = [Using] extends [never] + ? FilterQuery + : + | (OperatorMap & { + -readonly [K in Extract, IndexColumns>]?: + | ExpandQuery>> + | ExpandQueryMerged>> + | FilterValue>> + | ElemMatchFilter> + | null; + }) + | NonNullable>> + | IndexFilterQuery[]; + /** Public interface for the entity wrapper, accessible via `wrap(entity)`. Provides helper methods for entity state management. */ export interface IWrappedEntity { isInitialized(): boolean; diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index d1a458d0c54c..73129361c4a1 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -469,6 +469,9 @@ export class MongoDriver extends DatabaseDriver { if (options.indexHint != null) { ret.indexHint = options.indexHint; + } else if ((options as any).using != null) { + const names = Utils.asArray((options as any).using); + ret.indexHint = names.length === 1 ? names[0] : names; } if (options.maxTimeMS != null) { diff --git a/packages/mongodb/src/MongoEntityManager.ts b/packages/mongodb/src/MongoEntityManager.ts index 5f84862346be..88edd06e63fc 100644 --- a/packages/mongodb/src/MongoEntityManager.ts +++ b/packages/mongodb/src/MongoEntityManager.ts @@ -3,7 +3,9 @@ import { Utils, type EntityName, type EntityRepository, + type FilterQuery, type GetRepository, + type IndexFilterQuery, type TransactionOptions, type StreamOptions, type Loaded, @@ -38,15 +40,19 @@ export class MongoEntityManager extend Hint extends string = never, Fields extends string = never, Excludes extends string = never, + Using extends string = never, >( entityName: EntityName, - options: StreamOptions, Hint, Fields, Excludes> = {}, + options: Omit, Hint, Fields, Excludes>, 'where' | 'using'> & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; + } = {} as any, ): AsyncIterableIterator> { if (!Utils.isEmpty(options.populate)) { throw new Error('Populate option is not supported when streaming results in MongoDB'); } - yield* super.stream(entityName, options); + yield* super.stream(entityName, options as any); } getCollection(entityOrCollectionName: EntityName | string): Collection { diff --git a/packages/mssql/src/MsSqlPlatform.ts b/packages/mssql/src/MsSqlPlatform.ts index baa0705ebcfb..4f2cd5813049 100644 --- a/packages/mssql/src/MsSqlPlatform.ts +++ b/packages/mssql/src/MsSqlPlatform.ts @@ -36,6 +36,10 @@ export class MsSqlPlatform extends AbstractSqlPlatform { boolean: 'bit', }; + override formatIndexHint(indexNames: string[]): string { + return `with (index(${indexNames.join(', ')}))`; + } + /** @inheritDoc */ override lookupExtensions(orm: MikroORM): void { MsSqlSchemaGenerator.register(orm); diff --git a/packages/sql/src/AbstractSqlDriver.ts b/packages/sql/src/AbstractSqlDriver.ts index 5b7161a611ca..7600d2ee2b26 100644 --- a/packages/sql/src/AbstractSqlDriver.ts +++ b/packages/sql/src/AbstractSqlDriver.ts @@ -164,6 +164,15 @@ export abstract class AbstractSqlDriver< where = { [Utils.getPrimaryKeyHash(meta.primaryKeys)]: where } as ObjectQuery; } + if (options.using && !options.indexHint) { + const names = Utils.asArray(options.using); + const hint = this.platform.formatIndexHint(names); + + if (hint) { + options.indexHint = hint; + } + } + this.validateSqlOptions(options); const { first, last, before, after } = options as FindByCursorOptions; @@ -863,6 +872,15 @@ export abstract class AbstractSqlDriver< this.buildFields(meta, populate, joinedProps, qb, qb.alias, options as FindOptions, schema); } + if ('using' in options && (options as any).using && !options.indexHint) { + const names = Utils.asArray((options as any).using); + const hint = this.platform.formatIndexHint(names); + + if (hint) { + options.indexHint = hint; + } + } + this.validateSqlOptions(options); qb.state.resolvedPopulateWhere = (options as Dictionary)._populateWhere; diff --git a/packages/sql/src/dialects/mysql/BaseMySqlPlatform.ts b/packages/sql/src/dialects/mysql/BaseMySqlPlatform.ts index edcea569f29d..466bccda1cb2 100644 --- a/packages/sql/src/dialects/mysql/BaseMySqlPlatform.ts +++ b/packages/sql/src/dialects/mysql/BaseMySqlPlatform.ts @@ -41,6 +41,10 @@ export class BaseMySqlPlatform extends AbstractSqlPlatform { return new MySqlNativeQueryBuilder(this); } + override formatIndexHint(indexNames: string[]): string { + return `use index(${indexNames.join(', ')})`; + } + override getDefaultCharset(): string { return 'utf8mb4'; } diff --git a/tests/bench/types/api-methods.ts b/tests/bench/types/api-methods.ts index 1d76370c50dc..a26bae4ebd15 100644 --- a/tests/bench/types/api-methods.ts +++ b/tests/bench/types/api-methods.ts @@ -70,19 +70,19 @@ bench('em.find() return type - no options', () => { type R = ReturnType>; const x = {} as R; void x; -}).types([717, 'instantiations']); +}).types([745, 'instantiations']); bench('em.find() return type - with populate', () => { type R = ReturnType>; const x = {} as R; void x; -}).types([717, 'instantiations']); +}).types([745, 'instantiations']); bench('em.find() return type - with populate and fields', () => { type R = ReturnType>; const x = {} as R; void x; -}).types([711, 'instantiations']); +}).types([739, 'instantiations']); // ============================================ // em.create() - input and return type computation diff --git a/tests/bench/types/defineEntity-extends.ts b/tests/bench/types/defineEntity-extends.ts index 09f6d04cab74..682175b757d5 100644 --- a/tests/bench/types/defineEntity-extends.ts +++ b/tests/bench/types/defineEntity-extends.ts @@ -19,7 +19,7 @@ bench('entity extends entity', () => { extra: p.string(), }, }); -}).types([1459, 'instantiations']); +}).types([1780, 'instantiations']); bench('entity extends entity with discriminator', () => { const base = defineEntity({ @@ -40,7 +40,7 @@ bench('entity extends entity with discriminator', () => { extra: p.string(), }, }); -}).types([1480, 'instantiations']); +}).types([1817, 'instantiations']); bench('embeddable extends embeddable', () => { const base = defineEntity({ @@ -60,7 +60,7 @@ bench('embeddable extends embeddable', () => { bar: p.string(), }, }); -}).types([913, 'instantiations']); +}).types([1246, 'instantiations']); bench('embeddable extends embeddable with discriminator', () => { const base = defineEntity({ @@ -83,7 +83,7 @@ bench('embeddable extends embeddable with discriminator', () => { bar: p.string(), }, }); -}).types([934, 'instantiations']); +}).types([1283, 'instantiations']); bench('polymorphic embeddables', () => { const base = defineEntity({ @@ -198,7 +198,7 @@ bench('polymorphic embeddables', () => { data: () => p.embedded([documentDataAwEdCard, documentDataCoCheckMig, documentDataMvDiCard]).object(), }, }); -}).types([3252, 'instantiations']); +}).types([4250, 'instantiations']); bench('realistic entity (~20 props, relations, extends)', () => { const base = defineEntity({ @@ -270,7 +270,7 @@ bench('realistic entity (~20 props, relations, extends)', () => { tags: () => p.manyToMany(Tag), }, }); -}).types([7310, 'instantiations']); +}).types([8345, 'instantiations']); bench('realistic entity - InferEntity usage', () => { const base = defineEntity({ @@ -350,7 +350,7 @@ bench('realistic entity - InferEntity usage', () => { // Force evaluation of all entity types const _check: [IAuthor, IBook, ITag, IPublisher] = {} as any; -}).types([7402, 'instantiations']); +}).types([8453, 'instantiations']); bench('realistic entity - setClass pattern', () => { const BaseSchema = defineEntity({ @@ -436,4 +436,4 @@ bench('realistic entity - setClass pattern', () => { PublisherSchema.setClass(Publisher); AuthorSchema.setClass(Author); BookSchema.setClass(Book); -}).types([7425, 'instantiations']); +}).types([9496, 'instantiations']); diff --git a/tests/bench/types/defineEntity.ts b/tests/bench/types/defineEntity.ts index c7e9e6902321..dbf789251bf9 100644 --- a/tests/bench/types/defineEntity.ts +++ b/tests/bench/types/defineEntity.ts @@ -19,7 +19,7 @@ bench('defineEntity with relations', () => { strictNullRef: () => p.oneToOne(Foo).strictNullable(), }, }); -}).types([7117, 'instantiations']); +}).types([7621, 'instantiations']); bench('defineEntity with ref and nullable', () => { const Foo = defineEntity({ @@ -36,7 +36,7 @@ bench('defineEntity with ref and nullable', () => { scalarRefNullable: p.string().ref().nullable(), }, }); -}).types([6607, 'instantiations']); +}).types([7111, 'instantiations']); bench('defineEntity only with ref and nullable', () => { const Foo = defineEntity({ @@ -46,7 +46,7 @@ bench('defineEntity only with ref and nullable', () => { toOneRefNullable: () => p.oneToOne(Foo).ref().nullable(), }, }); -}).types([1836, 'instantiations']); +}).types([2120, 'instantiations']); bench('defineEntity only with nullable and ref', () => { const Foo = defineEntity({ @@ -56,7 +56,7 @@ bench('defineEntity only with nullable and ref', () => { toOneRefNullable: () => p.oneToOne(Foo).nullable().ref(), }, }); -}).types([1836, 'instantiations']); +}).types([2120, 'instantiations']); bench('defineEntity with relations using class', () => { class Foo { @@ -88,7 +88,7 @@ bench('defineEntity with relations using class', () => { scalarRefNullable: p.string().ref().nullable(), }, }); -}).types([7105, 'instantiations']); +}).types([7613, 'instantiations']); bench('defineEntity with ref and nullable using class', () => { class Foo { @@ -120,7 +120,7 @@ bench('defineEntity with ref and nullable using class', () => { scalarRefNullable: p.string().ref().nullable(), }, }); -}).types([7105, 'instantiations']); +}).types([7613, 'instantiations']); bench('defineEntity only with ref and nullable using class', () => { class Foo { @@ -138,7 +138,7 @@ bench('defineEntity only with ref and nullable using class', () => { toOneRefNullable: () => p.oneToOne(Foo).ref().nullable(), }, }); -}).types([2359, 'instantiations']); +}).types([2647, 'instantiations']); bench('defineEntity only with nullable and ref using class', () => { class Foo { @@ -156,7 +156,7 @@ bench('defineEntity only with nullable and ref using class', () => { toOneRefNullable: () => p.oneToOne(Foo).nullable().ref(), }, }); -}).types([2359, 'instantiations']); +}).types([2647, 'instantiations']); bench('EntitySchema', () => { interface IFoo { @@ -241,4 +241,18 @@ bench('defineEntity with setClass pattern (circular relations)', () => { AuthorSchema.setClass(Author); BookSchema.setClass(Book); -}).types([5571, 'instantiations']); +}).types([6088, 'instantiations']); + +bench('defineEntity with indexes', () => { + const Bar = defineEntity({ + name: 'Bar', + properties: { + id: p.number().primary(), + title: p.string().index('idx_title'), + slug: p.string().unique('uniq_slug'), + status: p.string(), + views: p.number(), + }, + indexes: [{ name: 'idx_status_views', properties: ['status', 'views'] }], + }); +}).types([3414, 'instantiations']); diff --git a/tests/features/using-index-hints.test.ts b/tests/features/using-index-hints.test.ts new file mode 100644 index 000000000000..8cc72005936b --- /dev/null +++ b/tests/features/using-index-hints.test.ts @@ -0,0 +1,238 @@ +import { IndexHints, MikroORM, PrimaryKeyProp, type EntityManager, type IndexFilterQuery } from '@mikro-orm/core'; +import { Entity, Index, PrimaryKey, Property, ReflectMetadataProvider, Unique } from '@mikro-orm/decorators/legacy'; +import { defineEntity, p, SqliteDriver } from '@mikro-orm/sqlite'; +import { mockLogger } from '../helpers.js'; + +// ========================================== +// Test Entities +// ========================================== + +@Entity() +@Index({ name: 'idx_user_name', properties: ['name'] }) +@Index({ name: 'idx_user_name_email', properties: ['name', 'email'] }) +@Unique({ name: 'uniq_user_email', properties: ['email'] }) +class User { + [PrimaryKeyProp]?: 'id'; + [IndexHints]?: { + idx_user_name: 'name'; + idx_user_name_email: 'name' | 'email'; + uniq_user_email: 'email'; + }; + + @PrimaryKey() + id!: number; + + @Property() + name!: string; + + @Property() + email!: string; + + @Property({ nullable: true }) + age?: number; +} + +const Article = defineEntity({ + name: 'Article', + properties: { + id: p.integer().primary(), + title: p.string().index('idx_article_title'), + slug: p.string().unique('uniq_article_slug'), + status: p.string(), + views: p.integer(), + }, + indexes: [{ name: 'idx_article_status_views', properties: ['status', 'views'] }], +}); + +@Entity() +class Tag { + @PrimaryKey() + id!: number; + + @Property() + label!: string; +} + +// ========================================== +// Type Tests (compile-time only) +// ========================================== + +// Type tests verified at compile time by `yarn tsc-check-tests`. +// At runtime, skip the block entirely. +describe.skipIf(true)('using option - type safety', () => { + const em = null as unknown as EntityManager; + + test('decorator entity with [IndexHints] narrows where', () => { + // Should compile — 'name' is in idx_user_name + em.find(User, { name: 'foo' }, { using: 'idx_user_name' }) as any; + // Should compile — 'name' and 'email' are in idx_user_name_email + em.find(User, { name: 'foo', email: 'bar' }, { using: 'idx_user_name_email' }) as any; + // Should compile — PK is always allowed + em.find(User, 1, { using: 'idx_user_name' }) as any; + // Should compile — no using, full FilterQuery + em.find(User, { age: 30 }) as any; + }); + + test('decorator entity errors on non-index props in where', () => { + // @ts-expect-error 'age' is not in idx_user_name + em.find(User, { age: 30 }, { using: 'idx_user_name' }) as any; + // @ts-expect-error 'age' is not in idx_user_name_email + em.find(User, { name: 'foo', age: 30 }, { using: 'idx_user_name_email' }) as any; + }); + + test('defineEntity entity infers index hints and narrows where', () => { + em.find(Article, { title: 'foo' }, { using: 'idx_article_title' }) as any; + em.find(Article, { slug: 'foo' }, { using: 'uniq_article_slug' }) as any; + em.find(Article, { status: 'draft', views: 100 }, { using: 'idx_article_status_views' }) as any; + }); + + test('defineEntity entity errors on non-index props', () => { + // @ts-expect-error 'views' is not in idx_article_title + em.find(Article, { views: 100 }, { using: 'idx_article_title' }) as any; + // @ts-expect-error 'title' is not in idx_article_status_views + em.find(Article, { title: 'foo' }, { using: 'idx_article_status_views' }) as any; + }); + + test('entity without [IndexHints] allows any string for using', () => { + em.find(Tag, { label: 'foo' }, { using: 'some_idx' }) as any; + }); + + test('using works with findOne', () => { + em.findOne(User, { name: 'foo' }, { using: 'idx_user_name' }) as any; + // @ts-expect-error 'age' is not in idx_user_name + em.findOne(User, { age: 30 }, { using: 'idx_user_name' }) as any; + }); + + test('using works with findOneOrFail', () => { + em.findOneOrFail(User, { email: 'foo' }, { using: 'uniq_user_email' }) as any; + // @ts-expect-error 'name' is not in uniq_user_email + em.findOneOrFail(User, { name: 'foo' }, { using: 'uniq_user_email' }) as any; + }); + + test('using works with findAndCount', () => { + em.findAndCount(User, { name: 'foo' }, { using: 'idx_user_name' }) as any; + // @ts-expect-error 'age' is not in idx_user_name + em.findAndCount(User, { age: 30 }, { using: 'idx_user_name' }) as any; + }); + + test('using works with findAll', () => { + em.findAll(User, { where: { name: 'foo' }, using: 'idx_user_name' }) as any; + // @ts-expect-error 'age' is not in idx_user_name + em.findAll(User, { where: { age: 30 }, using: 'idx_user_name' }) as any; + }); + + test('using accepts array for multiple indexes', () => { + em.find(User, { name: 'foo' }, { using: ['idx_user_name', 'uniq_user_email'] }) as any; + }); +}); + +// ========================================== +// Runtime Tests - SQLite (validation only, no SQL hints) +// ========================================== + +describe('using option - runtime validation (SQLite)', () => { + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + metadataProvider: ReflectMetadataProvider, + entities: [User, Article, Tag], + dbName: ':memory:', + driver: SqliteDriver, + }); + await orm.schema.refresh(); + }); + + afterAll(async () => { + await orm.close(true); + }); + + test('using with valid where passes validation', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).toMatch(/select/i); + }); + + test('using does not add SQL hint for SQLite', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).not.toMatch(/use index/i); + }); + + test('using throws on unknown index name', async () => { + await expect(orm.em.find(User, { name: 'foo' } as any, { using: 'nonexistent_idx' })).rejects.toThrow( + /Index 'nonexistent_idx' not found on entity 'User'/, + ); + }); + + test('using throws when where property not covered by index', async () => { + await expect(orm.em.find(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age' in where clause is not covered by index 'idx_user_name'/, + ); + }); + + test('using throws when orderBy property not covered by index', async () => { + await expect( + orm.em.find( + User, + { name: 'foo' } as any, + { + using: 'idx_user_name', + orderBy: { age: 'asc' }, + } as any, + ), + ).rejects.toThrow(/Property 'age' in orderBy is not covered by index 'idx_user_name'/); + }); + + test('using with multiple indexes allows union of properties', async () => { + // idx_user_name covers 'name', uniq_user_email covers 'email' + await orm.em.find(User, { name: 'foo', email: 'bar' } as any, { + using: ['idx_user_name', 'uniq_user_email'], + }); + }); + + test('using does not override explicit indexHint', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo' } as any, { + using: 'idx_user_name', + indexHint: 'indexed by idx_user_name', + }); + expect(mock.mock.calls[0][0]).toMatch(/indexed by idx_user_name/); + }); + + test('using validates with findOne', async () => { + await expect(orm.em.findOne(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age' in where clause is not covered by index 'idx_user_name'/, + ); + }); + + test('using validates with findAndCount', async () => { + await expect(orm.em.findAndCount(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age' in where clause is not covered by index 'idx_user_name'/, + ); + }); + + test('using skips $-prefixed operators in where validation', async () => { + await orm.em.find(User, { $and: [{ name: 'foo' }] } as any, { using: 'idx_user_name' }); + }); + + test('using validation lists available indexes in error message', async () => { + await expect(orm.em.find(User, { name: 'foo' } as any, { using: 'bad_idx' })).rejects.toThrow(/Available indexes/); + }); + + test('using works with defineEntity entities at runtime', async () => { + const mock = mockLogger(orm); + await orm.em.find(Article, { title: 'foo' } as any, { using: 'idx_article_title' }); + expect(mock.mock.calls[0][0]).toMatch(/select/i); + }); + + test('using validates defineEntity entities at runtime', async () => { + await expect(orm.em.find(Article, { views: 100 } as any, { using: 'idx_article_title' })).rejects.toThrow( + /Property 'views' in where clause is not covered by index 'idx_article_title'/, + ); + }); + + test('using with empty where passes validation', async () => { + await orm.em.find(User, {} as any, { using: 'idx_user_name' }); + }); +}); From 0a0916d98fa46ff2e0e430ad0120c5cd064f7f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 10:43:43 +0100 Subject: [PATCH 2/9] refactor(core): simplify using option implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `WithUsingOptions` utility type to reduce inline `Omit` duplication - Move `using` → `indexHint` conversion into `validateSqlOptions` - Remove redundant comments in `validateIndexUsage` - Fix type assertions in defineEntity.test.ts for `[IndexHints]` phantom property - Fix benchmark to use `p.integer()` instead of `p.number()` Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/EntityManager.ts | 29 ++--- packages/core/src/entity/EntityRepository.ts | 16 +-- packages/core/src/index.ts | 1 + packages/core/src/typings.ts | 6 + packages/mongodb/src/MongoEntityManager.ts | 12 +- packages/sql/src/AbstractSqlDriver.ts | 29 ++--- tests/bench/types/defineEntity.ts | 4 +- tests/defineEntity.test.ts | 110 ++++++++++++++---- .../legacy/decorator.transactional.test.ts | 4 +- .../SchemaGenerator.mssql.test.ts.snap | 86 ++++++++++++++ tests/repositories/AuthorRepository.ts | 2 +- 11 files changed, 214 insertions(+), 85 deletions(-) diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index a524cdf2a682..6484e3b6f665 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -65,6 +65,7 @@ import type { RequiredEntityData, UnboxArray, IndexFilterQuery, + WithUsingOptions, } from './typings.js'; import { EventType, @@ -326,10 +327,11 @@ export class EntityManager { Using extends string = never, >( entityName: EntityName, - options: Omit, Hint, Fields, Excludes>, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; - } = {} as any, + options: WithUsingOptions< + StreamOptions, Hint, Fields, Excludes>, + NoInfer, + Using + > = {} as any, ): AsyncIterableIterator> { const em = this.getContext(); em.prepareOptions(options); @@ -381,10 +383,7 @@ export class EntityManager { Using extends string = never, >( entityName: EntityName, - options?: Omit, Hint, Fields, Excludes>, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; - }, + options?: WithUsingOptions, Hint, Fields, Excludes>, NoInfer, Using>, ): Promise[]> { return this.find(entityName, options?.where ?? ({} as any), options as any); } @@ -870,14 +869,11 @@ export class EntityManager { Using extends string = never, >( entityName: EntityName, - options: Omit, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; - }, + options: WithUsingOptions, Entity, Using>, ): Promise> { const em = this.getContext(false); options.overfetch ??= true; - options.where ??= {}; + (options as any).where ??= {}; if (Utils.isEmpty(options.orderBy) && !Raw.hasObjectFragments(options.orderBy)) { throw new Error('Explicit `orderBy` option required'); @@ -2606,8 +2602,6 @@ export class EntityManager { const indexNames = Utils.asArray(options.using); const allIndexes = [...meta.indexes, ...meta.uniques]; - // Build a map of all named indexes: { name -> property names } - // Includes both entity-level indexes/uniques and property-level index/unique names const indexMap = new Map(); for (const idx of allIndexes) { @@ -2616,7 +2610,6 @@ export class EntityManager { } } - // Property-level indexes (prop.index = 'indexName', prop.unique = 'uniqueName') for (const prop of meta.props) { if (typeof prop.index === 'string') { indexMap.set(prop.index, [prop.name]); @@ -2645,11 +2638,10 @@ export class EntityManager { } } - // Validate where keys if (typeof where === 'object' && where !== null && !Array.isArray(where)) { for (const key of Object.keys(where)) { if (key.startsWith('$')) { - continue; // skip operators like $and, $or + continue; } if (!allowedProps.has(key)) { @@ -2661,7 +2653,6 @@ export class EntityManager { } } - // Validate orderBy keys if (options.orderBy) { const orderMaps = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]; diff --git a/packages/core/src/entity/EntityRepository.ts b/packages/core/src/entity/EntityRepository.ts index 545f50654497..9a61f855e1d0 100644 --- a/packages/core/src/entity/EntityRepository.ts +++ b/packages/core/src/entity/EntityRepository.ts @@ -19,6 +19,7 @@ import type { MergeLoaded, ArrayElement, IndexFilterQuery, + WithUsingOptions, } from '../typings.js'; import type { CountOptions, @@ -182,10 +183,7 @@ export class EntityRepository { IncludeCount extends boolean = true, Using extends string = never, >( - options: Omit, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; - }, + options: WithUsingOptions, Entity, Using>, ): Promise> { return this.getEntityManager().findByCursor(this.entityName, options as any); } @@ -199,10 +197,7 @@ export class EntityRepository { Excludes extends string = never, Using extends string = never, >( - options?: Omit, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; - }, + options?: WithUsingOptions, Entity, Using>, ): Promise[]> { return this.getEntityManager().findAll(this.entityName, options as any); } @@ -216,10 +211,7 @@ export class EntityRepository { Excludes extends string = never, Using extends string = never, >( - options?: Omit, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; - }, + options?: WithUsingOptions, Entity, Using>, ): AsyncIterableIterator> { yield* this.getEntityManager().stream(this.entityName, options as any); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a2b910f1b1e0..94261db0ec8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,7 @@ export type { ExtractIndexHints, IndexName, IndexColumns, + WithUsingOptions, IMigrationRunner, IEntityGenerator, ISeedManager, diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 127bfe2a8c27..749e5a787692 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -511,6 +511,12 @@ export type IndexFilterQuery = [Using] extends [never] | NonNullable>> | IndexFilterQuery[]; +/** Replaces `where` and `using` on an options type with index-aware variants when `Using` is specified. */ +export type WithUsingOptions = Omit & { + using?: Using | Using[]; + where?: [Using] extends [never] ? FilterQuery : IndexFilterQuery; +}; + /** Public interface for the entity wrapper, accessible via `wrap(entity)`. Provides helper methods for entity state management. */ export interface IWrappedEntity { isInitialized(): boolean; diff --git a/packages/mongodb/src/MongoEntityManager.ts b/packages/mongodb/src/MongoEntityManager.ts index 88edd06e63fc..693e9e6468ff 100644 --- a/packages/mongodb/src/MongoEntityManager.ts +++ b/packages/mongodb/src/MongoEntityManager.ts @@ -3,12 +3,11 @@ import { Utils, type EntityName, type EntityRepository, - type FilterQuery, type GetRepository, - type IndexFilterQuery, type TransactionOptions, type StreamOptions, type Loaded, + type WithUsingOptions, } from '@mikro-orm/core'; import type { Collection, Document, TransactionOptions as MongoTransactionOptions } from 'mongodb'; import type { MongoDriver } from './MongoDriver.js'; @@ -43,10 +42,11 @@ export class MongoEntityManager extend Using extends string = never, >( entityName: EntityName, - options: Omit, Hint, Fields, Excludes>, 'where' | 'using'> & { - using?: Using | Using[]; - where?: [Using] extends [never] ? FilterQuery> : IndexFilterQuery, Using>; - } = {} as any, + options: WithUsingOptions< + StreamOptions, Hint, Fields, Excludes>, + NoInfer, + Using + > = {} as any, ): AsyncIterableIterator> { if (!Utils.isEmpty(options.populate)) { throw new Error('Populate option is not supported when streaming results in MongoDB'); diff --git a/packages/sql/src/AbstractSqlDriver.ts b/packages/sql/src/AbstractSqlDriver.ts index 7600d2ee2b26..b771c13dc73f 100644 --- a/packages/sql/src/AbstractSqlDriver.ts +++ b/packages/sql/src/AbstractSqlDriver.ts @@ -114,7 +114,16 @@ export abstract class AbstractSqlDriver< return { alias, name: meta.tableName, schema: effectiveSchema, qualifiedName, toString: () => alias }; } - private validateSqlOptions(options: { collation?: any; indexHint?: any }): void { + private validateSqlOptions(options: { collation?: any; indexHint?: any; using?: any }): void { + if (options.using && !options.indexHint) { + const names = Utils.asArray(options.using); + const hint = this.platform.formatIndexHint(names); + + if (hint) { + options.indexHint = hint; + } + } + if (options.collation != null && typeof options.collation !== 'string') { throw new Error( 'Collation option for SQL drivers must be a string (collation name). Use a CollationOptions object only with MongoDB.', @@ -164,15 +173,6 @@ export abstract class AbstractSqlDriver< where = { [Utils.getPrimaryKeyHash(meta.primaryKeys)]: where } as ObjectQuery; } - if (options.using && !options.indexHint) { - const names = Utils.asArray(options.using); - const hint = this.platform.formatIndexHint(names); - - if (hint) { - options.indexHint = hint; - } - } - this.validateSqlOptions(options); const { first, last, before, after } = options as FindByCursorOptions; @@ -872,15 +872,6 @@ export abstract class AbstractSqlDriver< this.buildFields(meta, populate, joinedProps, qb, qb.alias, options as FindOptions, schema); } - if ('using' in options && (options as any).using && !options.indexHint) { - const names = Utils.asArray((options as any).using); - const hint = this.platform.formatIndexHint(names); - - if (hint) { - options.indexHint = hint; - } - } - this.validateSqlOptions(options); qb.state.resolvedPopulateWhere = (options as Dictionary)._populateWhere; diff --git a/tests/bench/types/defineEntity.ts b/tests/bench/types/defineEntity.ts index dbf789251bf9..561e8a63c3de 100644 --- a/tests/bench/types/defineEntity.ts +++ b/tests/bench/types/defineEntity.ts @@ -247,11 +247,11 @@ bench('defineEntity with indexes', () => { const Bar = defineEntity({ name: 'Bar', properties: { - id: p.number().primary(), + id: p.integer().primary(), title: p.string().index('idx_title'), slug: p.string().unique('uniq_slug'), status: p.string(), - views: p.number(), + views: p.integer(), }, indexes: [{ name: 'idx_status_views', properties: ['status', 'views'] }], }); diff --git a/tests/defineEntity.test.ts b/tests/defineEntity.test.ts index 27ab879baa5f..30e5ebcdce1a 100644 --- a/tests/defineEntity.test.ts +++ b/tests/defineEntity.test.ts @@ -11,6 +11,7 @@ import { EntityRepositoryType, EntitySchema, Hidden, + IndexHints, InferEntity, InferEntityFromProperties, IType, @@ -77,7 +78,7 @@ describe('defineEntity', () => { }); type IFoo = InferEntity; - assert; name: string; [PrimaryKeyProp]?: 'id' }>>(true); + assert; name: string; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }>>(true); const FooSchema = new EntitySchema({ name: 'Foo', @@ -102,9 +103,12 @@ describe('defineEntity', () => { }); type IBook = InferEntity; - assert>( - true, - ); + assert< + IsExact< + IBook, + { _id: ObjectId; id: string; title: string; tags: string[]; [PrimaryKeyProp]?: '_id'; [IndexHints]?: {} } + > + >(true); }); it('should define entity with class', () => { @@ -212,7 +216,7 @@ describe('defineEntity', () => { }); expect(Foo.init().meta.primaryKeys).toEqual(['name']); type IFoo = InferEntity; - assert>(true); + assert>(true); const Car = defineEntity({ name: 'Car', @@ -223,7 +227,9 @@ describe('defineEntity', () => { }); expect(Car.init().meta.primaryKeys).toEqual(['name', 'year']); type ICar = InferEntity; - assert>(true); + assert>( + true, + ); // @ts-expect-error const Car2 = defineEntity({ @@ -256,6 +262,7 @@ describe('defineEntity', () => { lastName: string; age: number; [PrimaryKeyProp]?: ['firstName', 'lastName']; + [IndexHints]?: {}; } > >(true); @@ -276,8 +283,15 @@ describe('defineEntity', () => { }); type IMyEntity = InferEntity; - assert, Primary<{ myClass: IType }>>>(true); - assert; [PrimaryKeyProp]?: undefined }>>(true); + assert< + IsExact< + Primary, + Primary<{ myClass: IType; [PrimaryKeyProp]?: undefined; [IndexHints]?: {} }> + > + >(true); + assert; [PrimaryKeyProp]?: undefined; [IndexHints]?: {} }>>( + true, + ); function create(type: EntityName, data: EntityData | RequiredEntityData) { // @@ -324,7 +338,17 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< - IsExact; updatedAt: Opt; [PrimaryKeyProp]?: 'id' }> + IsExact< + IFoo, + { + id: number; + name: string; + createdAt: Opt; + updatedAt: Opt; + [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; + } + > >(true); }); @@ -433,9 +457,12 @@ describe('defineEntity', () => { }); type IFoo = InferEntity; - assert; name: string; settings: { theme: string }; [PrimaryKeyProp]?: 'id' }>>( - true, - ); + assert< + IsExact< + IFoo, + { id: Opt; name: string; settings: { theme: string }; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + > + >(true); const FooSchema = new EntitySchema({ name: 'Foo', @@ -458,7 +485,7 @@ describe('defineEntity', () => { }); type IBox = InferEntity; - assert>(true); + assert>(true); }); it('should define entity with nullable property', () => { @@ -480,6 +507,7 @@ describe('defineEntity', () => { name: string | null | undefined; settings: { theme: string } | null | undefined; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -515,6 +543,7 @@ describe('defineEntity', () => { name: string | null; settings: { theme: string } | null; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -582,6 +611,7 @@ describe('defineEntity', () => { profileLazy: ScalarRef; profileNullable: ScalarRef; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -611,7 +641,7 @@ describe('defineEntity', () => { type IFoo = InferEntity; type ToObject = EntityDTO; - assert; name: Hidden; [PrimaryKeyProp]?: 'id' }>>(true); + assert; name: Hidden; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }>>(true); assert }>>(true); const FooSchema = new EntitySchema({ @@ -644,7 +674,9 @@ describe('defineEntity', () => { }); type IFoo = InferEntity; - assert; bar: 'foo' | 'bar' | 1; baz: BaZ; [PrimaryKeyProp]?: 'id' }>>(true); + assert< + IsExact; bar: 'foo' | 'bar' | 1; baz: BaZ; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }> + >(true); const FooSchema = new EntitySchema({ name: 'Foo', @@ -697,7 +729,9 @@ describe('defineEntity', () => { type IFoo = InferEntity; type IAddress = InferEntity; - assert; name: string; address: IAddress; [PrimaryKeyProp]?: 'id' }>>(true); + assert< + IsExact; name: string; address: IAddress; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }> + >(true); const AddressSchema = new EntitySchema({ name: 'Address', @@ -745,6 +779,7 @@ describe('defineEntity', () => { friend: Ref; friendNullable: Ref | null | undefined; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -824,10 +859,18 @@ describe('defineEntity', () => { type IFolder = InferEntity; type IFile = InferEntity; - assert; name: string; files: Collection; [PrimaryKeyProp]?: 'id' }>>( - true, - ); - assert; name: string; folder: Ref; [PrimaryKeyProp]?: 'id' }>>(true); + assert< + IsExact< + IFolder, + { id: Opt; name: string; files: Collection; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + > + >(true); + assert< + IsExact< + IFile, + { id: Opt; name: string; folder: Ref; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + > + >(true); const FolderSchema = new EntitySchema({ name: 'Folder', @@ -874,7 +917,12 @@ describe('defineEntity', () => { }); type IFoo = InferEntity; - assert; name: string; friends: Collection; [PrimaryKeyProp]?: 'id' }>>(true); + assert< + IsExact< + IFoo, + { id: Opt; name: string; friends: Collection; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + > + >(true); const Student = defineEntity({ name: 'Student', @@ -984,8 +1032,12 @@ describe('defineEntity', () => { type IFoo = InferEntity; type IProfile = InferEntity; - assert; name: string; profile: IProfile; [PrimaryKeyProp]?: 'id' }>>(true); - assert; bio: string; foo: IFoo; [PrimaryKeyProp]?: 'id' }>>(true); + assert< + IsExact; name: string; profile: IProfile; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }> + >(true); + assert; bio: string; foo: IFoo; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }>>( + true, + ); const FooSchema = new EntitySchema({ name: 'Foo', @@ -1032,6 +1084,7 @@ describe('defineEntity', () => { bigintAsNumber: number; bigintAsString: string; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1076,6 +1129,7 @@ describe('defineEntity', () => { numbers: number[]; customArray: { id: number; name: string }[]; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1132,6 +1186,7 @@ describe('defineEntity', () => { numbers: number[]; tags: string[]; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1200,6 +1255,7 @@ describe('defineEntity', () => { amount: number; balance: string; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1225,7 +1281,7 @@ describe('defineEntity', () => { indexes: [ { name: 'unique_name_children', - expression: (columns, table) => + expression: (columns: Record, table: { name: string }) => `create unique index ${table.name}_${columns.name}_${columns.children} on ${table.name} (${columns.name}, ${columns.children})`, }, ], @@ -1522,6 +1578,7 @@ describe('PropertyOptionsBuilder', () => { hydrateFalse: string; returning: string; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1583,6 +1640,7 @@ describe('PropertyOptionsBuilder', () => { status: Opt<('active' | 'inactive')[]>; type: number; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1879,6 +1937,7 @@ describe('OneToOneRelationOptionsBuilder', () => { bio: string; user: IUser; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -1901,6 +1960,7 @@ describe('OneToOneRelationOptionsBuilder', () => { name: string; profile: number; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); @@ -2008,6 +2068,7 @@ describe('ManyToOneRelationOptionsBuilder', () => { name: Opt; users: Collection; [PrimaryKeyProp]?: 'name'; + [IndexHints]?: {}; } > >(true); @@ -2030,6 +2091,7 @@ describe('ManyToOneRelationOptionsBuilder', () => { name: string; group: string; [PrimaryKeyProp]?: 'id'; + [IndexHints]?: {}; } > >(true); diff --git a/tests/features/decorators/legacy/decorator.transactional.test.ts b/tests/features/decorators/legacy/decorator.transactional.test.ts index 8c7c6c04a8d5..cd476b6a532e 100644 --- a/tests/features/decorators/legacy/decorator.transactional.test.ts +++ b/tests/features/decorators/legacy/decorator.transactional.test.ts @@ -78,7 +78,7 @@ class TransactionalManager { Fields extends string = '*', Excludes extends string = never, >(entityName: EntityName, options?: FindAllOptions, Hint, Fields, Excludes>) { - return this.getEntityManager()!.findAll(entityName, options); + return this.getEntityManager()!.findAll(entityName, options as any); } @Transactional() @@ -92,7 +92,7 @@ class TransactionalManager { where: FilterQuery>, options?: FindOneOptions, ) { - return this.getEntityManager()!.findOne(entityName, where, options); + return this.getEntityManager()!.findOne(entityName, where as any, options); } @Transactional({ ignoreNestedTransactions: true }) diff --git a/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap b/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap index ec8d8289d7ad..b9ab7ba5edf2 100644 --- a/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap +++ b/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap @@ -183,6 +183,37 @@ if object_id('[base_user2]', 'U') is not null drop table [base_user2]; exports[`SchemaGenerator > read-only schema tests > generate schema from metadata [mssql] > mssql-update-schema-dump 1`] = `""`; +exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-add-unique 1`] = ` +"create unique index [book2_title_unique] on [book2] ([title]) where [title] is not null; +" +`; + +exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-alter-column 1`] = ` +"drop index [author2_born_index] on [author2]; +alter table [author2] alter column [born] nvarchar(max); +alter table [author2] drop constraint [author2_favourite_author_id_foreign]; + +declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'age') if @constraint0 is not null exec('alter table [author2] drop constraint ' + @constraint0); +alter table [author2] add constraint [author2_age_default] default 42 for [age]; +declare @constraint1 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'born') if @constraint1 is not null exec('alter table [author2] drop constraint ' + @constraint1); +alter table [author2] alter column [born] int not null; +alter table [author2] add constraint [author2_born_default] default 42 for [born]; +alter table [author2] add constraint [author2_favourite_author_id_foreign] foreign key ([favourite_author_id]) references [foo_bar2] ([id]) on delete set null; + +create index [author2_born_index] on [author2] ([born]); +" +`; + +exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-drop-index 1`] = ` +"drop index [new_title_idx] on [book2]; +" +`; + +exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-drop-unique 1`] = ` +"drop index [book2_title_unique] on [book2]; +" +`; + exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-rename-column 1`] = ` "alter table [author2] drop constraint [author2_favourite_author_id_foreign]; @@ -292,6 +323,61 @@ exports[`SchemaGenerator > update schema [mssql] > mssql-update-schema-drop-uniq " `; +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-add-column 1`] = ` +"alter table [new_table] add [id] int identity(1,1) not null primary key, [updated_at] datetime2(3) not null constraint [new_table_updated_at_default] default current_timestamp; + +alter table [author2] add [favourite_book_uuid_pk] uniqueidentifier null; +alter table [author2] add constraint [author2_favourite_book_uuid_pk_foreign] foreign key ([favourite_book_uuid_pk]) references [book2] ([uuid_pk]) on update no action on delete cascade; +" +`; + +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-1:1 1`] = ` +"alter table [foo_bar2] drop constraint [foo_bar2_baz_id_foreign]; + +drop index [foo_bar2_baz_id_unique] on [foo_bar2]; +declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'foo_bar2' and all_columns.name = 'baz_id') if @constraint0 is not null exec('alter table [foo_bar2] drop constraint ' + @constraint0); +alter table [foo_bar2] drop column [baz_id]; +" +`; + +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-column 1`] = ` +"alter table [author2] drop constraint [author2_favourite_book_uuid_pk_foreign]; + +alter table [new_table] drop constraint [PK__new_tabl__123]; +declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'new_table' and all_columns.name = 'id') if @constraint0 is not null exec('alter table [new_table] drop constraint ' + @constraint0); +alter table [new_table] drop constraint [new_table_updated_at_default]; +alter table [new_table] drop column [id], [updated_at]; + +declare @constraint1 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'favourite_book_uuid_pk') if @constraint1 is not null exec('alter table [author2] drop constraint ' + @constraint1); +alter table [author2] drop column [favourite_book_uuid_pk]; +" +`; + +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-table 1`] = ` +"alter table [address2] drop constraint [address2_author_id_foreign]; +alter table [author_to_friend] drop constraint [author_to_friend_author2_1_id_foreign]; +alter table [author_to_friend] drop constraint [author_to_friend_author2_2_id_foreign]; +alter table [author2_following] drop constraint [author2_following_author2_1_id_foreign]; +alter table [author2_following] drop constraint [author2_following_author2_2_id_foreign]; +alter table [book2] drop constraint [book2_author_id_foreign]; + +if object_id('[author2]', 'U') is not null drop table [author2]; +if object_id('[new_table]', 'U') is not null drop table [new_table]; +" +`; + +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-table-disabled 1`] = ` +"alter table [address2] drop constraint [address2_author_id_foreign]; +alter table [author_to_friend] drop constraint [author_to_friend_author2_1_id_foreign]; +alter table [author_to_friend] drop constraint [author_to_friend_author2_2_id_foreign]; +alter table [author2_following] drop constraint [author2_following_author2_1_id_foreign]; +alter table [author2_following] drop constraint [author2_following_author2_2_id_foreign]; +alter table [book2] drop constraint [book2_author_id_foreign]; +" +`; + +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-table-safe 1`] = `""`; + exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-enums-1 1`] = ` "create table [new_table] ([id] int identity(1,1) not null primary key, [enum_test] nvarchar(100) not null); " diff --git a/tests/repositories/AuthorRepository.ts b/tests/repositories/AuthorRepository.ts index 750128d130c0..76d8b3892b5c 100644 --- a/tests/repositories/AuthorRepository.ts +++ b/tests/repositories/AuthorRepository.ts @@ -11,6 +11,6 @@ export class AuthorRepository extends EntityRepository { where: FilterQuery = {}, options?: FindOptions, ): Promise[]> { - return super.find(where, options); + return super.find(where as any, options); } } From 73b90403da799a27e90659bf7aaa861d9cc28e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 11:00:42 +0100 Subject: [PATCH 3/9] fix(core): address review findings in using option - Move validateIndexUsage before processWhere in find() to avoid false-positive errors from filter-injected properties - Fix MongoDB: use first index name only (MongoDB only supports a single hint per query), remove invalid array hint - Add 'using' to MongoDB buildQueryOptions Pick type, remove as-any cast Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/EntityManager.ts | 4 ++-- packages/mongodb/src/MongoDriver.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index 6484e3b6f665..ae9c04bd2190 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -224,11 +224,11 @@ export class EntityManager { const em = this.getContext(); em.prepareOptions(options); + const meta = this.metadata.get(entityName); + em.validateIndexUsage(meta, where, options); await em.tryFlush(entityName, options); where = await em.processWhere(entityName, where, options, 'read'); validateParams(where); - const meta = this.metadata.get(entityName); - em.validateIndexUsage(meta, where, options); if (meta.orderBy) { options.orderBy = QueryHelper.mergeOrderBy(options.orderBy, meta.orderBy); } else { diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index 73129361c4a1..cf20ead90604 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -453,7 +453,7 @@ export class MongoDriver extends DatabaseDriver { } private buildQueryOptions( - options: Pick, 'collation' | 'indexHint' | 'maxTimeMS' | 'allowDiskUse'>, + options: Pick, 'collation' | 'indexHint' | 'using' | 'maxTimeMS' | 'allowDiskUse'>, ): MongoQueryOptions { if (options.collation != null && typeof options.collation === 'string') { throw new Error( @@ -469,9 +469,10 @@ export class MongoDriver extends DatabaseDriver { if (options.indexHint != null) { ret.indexHint = options.indexHint; - } else if ((options as any).using != null) { - const names = Utils.asArray((options as any).using); - ret.indexHint = names.length === 1 ? names[0] : names; + } else if (options.using != null) { + const names = Utils.asArray(options.using); + // MongoDB only supports a single index hint per query + ret.indexHint = names[0]; } if (options.maxTimeMS != null) { From 9ed92e4a7ed45228baa789866dcadd0506f214ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 11:48:20 +0100 Subject: [PATCH 4/9] fix(core): address staff review findings for using option - Recurse into $and/$or/$not when validating where keys against index - Remove TIndexDefs/TUniqueDefs const generics from defineEntity (major type perf win: setClass pattern 9496 -> 8897, embeddable 1246 -> 926) - Property-level .index('name')/.unique('name') still auto-infer; entity-level composite indexes need manual [IndexHints] for type narrowing - Fixes expression callback inference regression (no explicit types needed) - MongoDB: throw error when using has multiple indexes (only one supported) - Revert unrelated MSSQL snapshot changes Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/EntityManager.ts | 46 ++++++++++----- packages/core/src/entity/defineEntity.ts | 58 ++++--------------- packages/mongodb/src/MongoDriver.ts | 8 ++- tests/bench/types/defineEntity-extends.ts | 16 ++--- tests/bench/types/defineEntity.ts | 20 +++---- tests/defineEntity.test.ts | 2 +- .../SchemaGenerator.mssql.test.ts.snap | 33 ++++------- tests/features/using-index-hints.test.ts | 22 +++++-- 8 files changed, 99 insertions(+), 106 deletions(-) diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index ae9c04bd2190..18dd7ff7db71 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -2638,20 +2638,7 @@ export class EntityManager { } } - if (typeof where === 'object' && where !== null && !Array.isArray(where)) { - for (const key of Object.keys(where)) { - if (key.startsWith('$')) { - continue; - } - - if (!allowedProps.has(key)) { - throw new Error( - `Property '${key}' in where clause is not covered by index '${indexNames.join("', '")}'. ` + - `Allowed properties: ${[...allowedProps].join(', ')}`, - ); - } - } - } + this.validateWhereKeysForIndex(where, allowedProps, indexNames); if (options.orderBy) { const orderMaps = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]; @@ -2669,6 +2656,37 @@ export class EntityManager { } } + private validateWhereKeysForIndex(where: any, allowedProps: Set, indexNames: string[]): void { + if (typeof where !== 'object' || where === null) { + return; + } + + if (Array.isArray(where)) { + for (const item of where) { + this.validateWhereKeysForIndex(item, allowedProps, indexNames); + } + + return; + } + + for (const key of Object.keys(where)) { + if (key === '$and' || key === '$or') { + for (const item of Utils.asArray(where[key])) { + this.validateWhereKeysForIndex(item, allowedProps, indexNames); + } + } else if (key === '$not') { + this.validateWhereKeysForIndex(where[key], allowedProps, indexNames); + } else if (key.startsWith('$')) { + continue; + } else if (!allowedProps.has(key)) { + throw new Error( + `Property '${key}' in where clause is not covered by index '${indexNames.join("', '")}'. ` + + `Allowed properties: ${[...allowedProps].join(', ')}`, + ); + } + } + } + private async lockAndPopulate< T extends object, P extends string = never, diff --git a/packages/core/src/entity/defineEntity.ts b/packages/core/src/entity/defineEntity.ts index 3d9d21df8e3b..841ee7ddd94c 100644 --- a/packages/core/src/entity/defineEntity.ts +++ b/packages/core/src/entity/defineEntity.ts @@ -1308,22 +1308,12 @@ export function defineEntity< const TBase = never, const TRepository = never, const TForceObject extends boolean = false, - const TIndexDefs extends readonly { name?: string; properties?: any }[] = readonly [], - const TUniqueDefs extends readonly { name?: string; properties?: any }[] = readonly [], >( - meta: Omit< - EntityMetadataWithProperties, - 'indexes' | 'uniques' - > & { - indexes?: TIndexDefs & - EntityMetadataWithProperties['indexes']; - uniques?: TUniqueDefs & - EntityMetadataWithProperties['uniques']; - }, + meta: EntityMetadataWithProperties, ): EntitySchemaWithMeta< TName, TTableName, - InferEntityFromProperties, + InferEntityFromProperties, TBase, TProperties >; @@ -1502,8 +1492,6 @@ export type InferEntityFromProperties< Base = never, Repository = never, ForceObject extends boolean = false, - IndexDefs extends readonly any[] = readonly [], - UniqueDefs extends readonly any[] = readonly [], > = (IsNever extends true ? {} : Base extends { toObject(...args: any[]): any } @@ -1529,7 +1517,7 @@ export type InferEntityFromProperties< : { [EntityRepositoryType]?: Repository extends Constructor ? R : Repository }) & (IsNever extends true ? {} : Omit) & (ForceObject extends true ? { [Config]?: DefineConfig<{ forceObject: true }> } : {}) & { - [IndexHints]?: InferIndexMap; + [IndexHints]?: InferPropertyIndexMap; }; // Combines primary keys from child properties and base entity @@ -1649,37 +1637,15 @@ type ValueOf = T[keyof T] extends infer V type IsUnion = T extends U ? ([U] extends [T] ? false : true) : false; -/** Extracts `{ indexName: propertyKey }` from property-level `.index('name')` calls. */ -type InferPropertyIndexes> = { +/** + * Single-pass extraction of `{ indexName: propertyKey }` from property-level + * `.index('name')` and `.unique('name')` calls. Checks both in one mapped type + * to minimize type instantiation cost. + */ +type InferPropertyIndexMap> = { [K in keyof Properties as MaybeReturnType extends { '~options': { index: infer N extends string } } ? N - : never]: K & string; -}; - -/** Extracts `{ uniqueName: propertyKey }` from property-level `.unique('name')` calls. */ -type InferPropertyUniques> = { - [K in keyof Properties as MaybeReturnType extends { '~options': { unique: infer N extends string } } - ? N - : never]: K & string; -}; - -/** Extracts `{ name: prop1 | prop2 }` from entity-level `indexes`/`uniques` array. */ -type ExtractIndexDefs = { - [I in Defs[number] as I extends { name: infer N extends string } ? N : never]: I extends { properties: infer P } - ? P extends readonly string[] - ? P[number] - : P extends string - ? P - : never - : never; + : MaybeReturnType extends { '~options': { unique: infer N extends string } } + ? N + : never]: K & string; }; - -/** Merges property-level and entity-level index maps into a single `[IndexHints]` type. */ -type InferIndexMap< - Properties extends Record, - IndexDefs extends readonly any[], - UniqueDefs extends readonly any[], -> = InferPropertyIndexes & - InferPropertyUniques & - ExtractIndexDefs & - ExtractIndexDefs; diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index cf20ead90604..3e6dcc7955a2 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -471,7 +471,13 @@ export class MongoDriver extends DatabaseDriver { ret.indexHint = options.indexHint; } else if (options.using != null) { const names = Utils.asArray(options.using); - // MongoDB only supports a single index hint per query + + if (names.length > 1) { + throw new Error( + 'MongoDB only supports a single index hint per query. Provide one index name instead of an array.', + ); + } + ret.indexHint = names[0]; } diff --git a/tests/bench/types/defineEntity-extends.ts b/tests/bench/types/defineEntity-extends.ts index 682175b757d5..93e8cf2c0bba 100644 --- a/tests/bench/types/defineEntity-extends.ts +++ b/tests/bench/types/defineEntity-extends.ts @@ -19,7 +19,7 @@ bench('entity extends entity', () => { extra: p.string(), }, }); -}).types([1780, 'instantiations']); +}).types([1472, 'instantiations']); bench('entity extends entity with discriminator', () => { const base = defineEntity({ @@ -40,7 +40,7 @@ bench('entity extends entity with discriminator', () => { extra: p.string(), }, }); -}).types([1817, 'instantiations']); +}).types([1493, 'instantiations']); bench('embeddable extends embeddable', () => { const base = defineEntity({ @@ -60,7 +60,7 @@ bench('embeddable extends embeddable', () => { bar: p.string(), }, }); -}).types([1246, 'instantiations']); +}).types([926, 'instantiations']); bench('embeddable extends embeddable with discriminator', () => { const base = defineEntity({ @@ -83,7 +83,7 @@ bench('embeddable extends embeddable with discriminator', () => { bar: p.string(), }, }); -}).types([1283, 'instantiations']); +}).types([947, 'instantiations']); bench('polymorphic embeddables', () => { const base = defineEntity({ @@ -198,7 +198,7 @@ bench('polymorphic embeddables', () => { data: () => p.embedded([documentDataAwEdCard, documentDataCoCheckMig, documentDataMvDiCard]).object(), }, }); -}).types([4250, 'instantiations']); +}).types([3650, 'instantiations']); bench('realistic entity (~20 props, relations, extends)', () => { const base = defineEntity({ @@ -270,7 +270,7 @@ bench('realistic entity (~20 props, relations, extends)', () => { tags: () => p.manyToMany(Tag), }, }); -}).types([8345, 'instantiations']); +}).types([7902, 'instantiations']); bench('realistic entity - InferEntity usage', () => { const base = defineEntity({ @@ -350,7 +350,7 @@ bench('realistic entity - InferEntity usage', () => { // Force evaluation of all entity types const _check: [IAuthor, IBook, ITag, IPublisher] = {} as any; -}).types([8453, 'instantiations']); +}).types([8010, 'instantiations']); bench('realistic entity - setClass pattern', () => { const BaseSchema = defineEntity({ @@ -436,4 +436,4 @@ bench('realistic entity - setClass pattern', () => { PublisherSchema.setClass(Publisher); AuthorSchema.setClass(Author); BookSchema.setClass(Book); -}).types([9496, 'instantiations']); +}).types([8897, 'instantiations']); diff --git a/tests/bench/types/defineEntity.ts b/tests/bench/types/defineEntity.ts index 561e8a63c3de..18fdfe84d616 100644 --- a/tests/bench/types/defineEntity.ts +++ b/tests/bench/types/defineEntity.ts @@ -19,7 +19,7 @@ bench('defineEntity with relations', () => { strictNullRef: () => p.oneToOne(Foo).strictNullable(), }, }); -}).types([7621, 'instantiations']); +}).types([7349, 'instantiations']); bench('defineEntity with ref and nullable', () => { const Foo = defineEntity({ @@ -36,7 +36,7 @@ bench('defineEntity with ref and nullable', () => { scalarRefNullable: p.string().ref().nullable(), }, }); -}).types([7111, 'instantiations']); +}).types([6839, 'instantiations']); bench('defineEntity only with ref and nullable', () => { const Foo = defineEntity({ @@ -46,7 +46,7 @@ bench('defineEntity only with ref and nullable', () => { toOneRefNullable: () => p.oneToOne(Foo).ref().nullable(), }, }); -}).types([2120, 'instantiations']); +}).types([1848, 'instantiations']); bench('defineEntity only with nullable and ref', () => { const Foo = defineEntity({ @@ -56,7 +56,7 @@ bench('defineEntity only with nullable and ref', () => { toOneRefNullable: () => p.oneToOne(Foo).nullable().ref(), }, }); -}).types([2120, 'instantiations']); +}).types([1848, 'instantiations']); bench('defineEntity with relations using class', () => { class Foo { @@ -88,7 +88,7 @@ bench('defineEntity with relations using class', () => { scalarRefNullable: p.string().ref().nullable(), }, }); -}).types([7613, 'instantiations']); +}).types([7329, 'instantiations']); bench('defineEntity with ref and nullable using class', () => { class Foo { @@ -120,7 +120,7 @@ bench('defineEntity with ref and nullable using class', () => { scalarRefNullable: p.string().ref().nullable(), }, }); -}).types([7613, 'instantiations']); +}).types([7329, 'instantiations']); bench('defineEntity only with ref and nullable using class', () => { class Foo { @@ -138,7 +138,7 @@ bench('defineEntity only with ref and nullable using class', () => { toOneRefNullable: () => p.oneToOne(Foo).ref().nullable(), }, }); -}).types([2647, 'instantiations']); +}).types([2363, 'instantiations']); bench('defineEntity only with nullable and ref using class', () => { class Foo { @@ -156,7 +156,7 @@ bench('defineEntity only with nullable and ref using class', () => { toOneRefNullable: () => p.oneToOne(Foo).nullable().ref(), }, }); -}).types([2647, 'instantiations']); +}).types([2363, 'instantiations']); bench('EntitySchema', () => { interface IFoo { @@ -241,7 +241,7 @@ bench('defineEntity with setClass pattern (circular relations)', () => { AuthorSchema.setClass(Author); BookSchema.setClass(Book); -}).types([6088, 'instantiations']); +}).types([5796, 'instantiations']); bench('defineEntity with indexes', () => { const Bar = defineEntity({ @@ -255,4 +255,4 @@ bench('defineEntity with indexes', () => { }, indexes: [{ name: 'idx_status_views', properties: ['status', 'views'] }], }); -}).types([3414, 'instantiations']); +}).types([1931, 'instantiations']); diff --git a/tests/defineEntity.test.ts b/tests/defineEntity.test.ts index 30e5ebcdce1a..b1e88dd3771d 100644 --- a/tests/defineEntity.test.ts +++ b/tests/defineEntity.test.ts @@ -1281,7 +1281,7 @@ describe('defineEntity', () => { indexes: [ { name: 'unique_name_children', - expression: (columns: Record, table: { name: string }) => + expression: (columns, table) => `create unique index ${table.name}_${columns.name}_${columns.children} on ${table.name} (${columns.name}, ${columns.children})`, }, ], diff --git a/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap b/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap index b9ab7ba5edf2..7a77cfb46c81 100644 --- a/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap +++ b/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap @@ -188,22 +188,6 @@ exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-add-uniqu " `; -exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-alter-column 1`] = ` -"drop index [author2_born_index] on [author2]; -alter table [author2] alter column [born] nvarchar(max); -alter table [author2] drop constraint [author2_favourite_author_id_foreign]; - -declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'age') if @constraint0 is not null exec('alter table [author2] drop constraint ' + @constraint0); -alter table [author2] add constraint [author2_age_default] default 42 for [age]; -declare @constraint1 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'born') if @constraint1 is not null exec('alter table [author2] drop constraint ' + @constraint1); -alter table [author2] alter column [born] int not null; -alter table [author2] add constraint [author2_born_default] default 42 for [born]; -alter table [author2] add constraint [author2_favourite_author_id_foreign] foreign key ([favourite_author_id]) references [foo_bar2] ([id]) on delete set null; - -create index [author2_born_index] on [author2] ([born]); -" -`; - exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-drop-index 1`] = ` "drop index [new_title_idx] on [book2]; " @@ -331,12 +315,19 @@ alter table [author2] add constraint [author2_favourite_book_uuid_pk_foreign] fo " `; -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-1:1 1`] = ` -"alter table [foo_bar2] drop constraint [foo_bar2_baz_id_foreign]; +exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-alter-column 1`] = ` +"drop index [author2_born_index] on [author2]; +alter table [author2] alter column [born] nvarchar(max); +alter table [author2] drop constraint [author2_favourite_author_id_foreign]; -drop index [foo_bar2_baz_id_unique] on [foo_bar2]; -declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'foo_bar2' and all_columns.name = 'baz_id') if @constraint0 is not null exec('alter table [foo_bar2] drop constraint ' + @constraint0); -alter table [foo_bar2] drop column [baz_id]; +declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'age') if @constraint0 is not null exec('alter table [author2] drop constraint ' + @constraint0); +alter table [author2] add constraint [author2_age_default] default 42 for [age]; +declare @constraint1 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'born') if @constraint1 is not null exec('alter table [author2] drop constraint ' + @constraint1); +alter table [author2] alter column [born] int not null; +alter table [author2] add constraint [author2_born_default] default 42 for [born]; +alter table [author2] add constraint [author2_favourite_author_id_foreign] foreign key ([favourite_author_id]) references [foo_bar2] ([id]) on delete set null; + +create index [author2_born_index] on [author2] ([born]); " `; diff --git a/tests/features/using-index-hints.test.ts b/tests/features/using-index-hints.test.ts index 8cc72005936b..04519e43bcb0 100644 --- a/tests/features/using-index-hints.test.ts +++ b/tests/features/using-index-hints.test.ts @@ -80,17 +80,16 @@ describe.skipIf(true)('using option - type safety', () => { em.find(User, { name: 'foo', age: 30 }, { using: 'idx_user_name_email' }) as any; }); - test('defineEntity entity infers index hints and narrows where', () => { + test('defineEntity entity infers index hints from property-level .index()/.unique()', () => { em.find(Article, { title: 'foo' }, { using: 'idx_article_title' }) as any; em.find(Article, { slug: 'foo' }, { using: 'uniq_article_slug' }) as any; - em.find(Article, { status: 'draft', views: 100 }, { using: 'idx_article_status_views' }) as any; + // Entity-level composite indexes (idx_article_status_views) require manual [IndexHints] + // for type narrowing; runtime validation still works for all indexes }); test('defineEntity entity errors on non-index props', () => { // @ts-expect-error 'views' is not in idx_article_title em.find(Article, { views: 100 }, { using: 'idx_article_title' }) as any; - // @ts-expect-error 'title' is not in idx_article_status_views - em.find(Article, { title: 'foo' }, { using: 'idx_article_status_views' }) as any; }); test('entity without [IndexHints] allows any string for using', () => { @@ -212,8 +211,21 @@ describe('using option - runtime validation (SQLite)', () => { ); }); - test('using skips $-prefixed operators in where validation', async () => { + test('using validates properties inside $and/$or/$not', async () => { + // Valid: property inside $and is covered by index await orm.em.find(User, { $and: [{ name: 'foo' }] } as any, { using: 'idx_user_name' }); + // Invalid: property inside $and is NOT covered by index + await expect(orm.em.find(User, { $and: [{ age: 30 }] } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age' in where clause is not covered by index 'idx_user_name'/, + ); + // Invalid: property inside $or is NOT covered + await expect( + orm.em.find(User, { $or: [{ name: 'foo' }, { age: 30 }] } as any, { using: 'idx_user_name' }), + ).rejects.toThrow(/Property 'age'/); + // Invalid: property inside $not is NOT covered + await expect(orm.em.find(User, { $not: { age: 30 } } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age'/, + ); }); test('using validation lists available indexes in error message', async () => { From a9728de1ae07aa2a1e72cf08a062fc7b6639d1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 12:13:08 +0100 Subject: [PATCH 5/9] perf(core): defer InferPropertyIndexMap to access time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store raw [Properties] tuple in [IndexHints] instead of computed map. ExtractIndexHints lazily computes InferPropertyIndexMap only when the using option is actually accessed (via IndexName or IndexColumns). This avoids evaluating the property iteration during defineEntity(), especially in expression callback types where InferEntityFromProperties is referenced multiple times. Impact vs original pre-PR baselines: - realistic entity - setClass: 7419 → 8049 (+8.5%, down from +20%) - embeddable extends: 914 → 926 (+1.3%) - entity extends: 1460 → 1472 (+0.8%) - defineEntity with relations: 7118 → 7349 (+3.2%) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/entity/defineEntity.ts | 16 +-- packages/core/src/typings.ts | 25 ++++- tests/bench/types/defineEntity-extends.ts | 6 +- tests/defineEntity.test.ts | 115 +++++++++++----------- 4 files changed, 83 insertions(+), 79 deletions(-) diff --git a/packages/core/src/entity/defineEntity.ts b/packages/core/src/entity/defineEntity.ts index 841ee7ddd94c..aecb3d71612e 100644 --- a/packages/core/src/entity/defineEntity.ts +++ b/packages/core/src/entity/defineEntity.ts @@ -39,6 +39,7 @@ import type { Config, MaybePromise, IndexHints, + InferPropertyIndexMap, } from '../typings.js'; import type { Raw } from '../utils/RawQueryFragment.js'; import type { ScalarReference } from './Reference.js'; @@ -1517,7 +1518,7 @@ export type InferEntityFromProperties< : { [EntityRepositoryType]?: Repository extends Constructor ? R : Repository }) & (IsNever extends true ? {} : Omit) & (ForceObject extends true ? { [Config]?: DefineConfig<{ forceObject: true }> } : {}) & { - [IndexHints]?: InferPropertyIndexMap; + [IndexHints]?: [Properties]; }; // Combines primary keys from child properties and base entity @@ -1636,16 +1637,3 @@ type ValueOf = T[keyof T] extends infer V : never; type IsUnion = T extends U ? ([U] extends [T] ? false : true) : false; - -/** - * Single-pass extraction of `{ indexName: propertyKey }` from property-level - * `.index('name')` and `.unique('name')` calls. Checks both in one mapped type - * to minimize type instantiation cost. - */ -type InferPropertyIndexMap> = { - [K in keyof Properties as MaybeReturnType extends { '~options': { index: infer N extends string } } - ? N - : MaybeReturnType extends { '~options': { unique: infer N extends string } } - ? N - : never]: K & string; -}; diff --git a/packages/core/src/typings.ts b/packages/core/src/typings.ts index 749e5a787692..aa1f7dcd6669 100644 --- a/packages/core/src/typings.ts +++ b/packages/core/src/typings.ts @@ -221,8 +221,29 @@ export type InferEntityName = T extends { [EntityName]?: infer Name } ? (Name */ export const IndexHints = Symbol('IndexHints'); -/** Extracts the index hints map from an entity type. Returns `never` when no hints are declared. */ -export type ExtractIndexHints = T extends { [IndexHints]?: infer H } ? H : never; +/** + * Extracts the index hints map from an entity type. Returns `never` when no hints are declared. + * For decorator entities, `[IndexHints]` contains a pre-computed `{ idxName: 'prop' }` map. + * For `defineEntity` entities, `[IndexHints]` contains `[Properties]` (a tuple wrapping the + * raw property builders), which is lazily converted to the index map when first accessed. + */ +export type ExtractIndexHints = T extends { [IndexHints]?: infer H } + ? H extends [infer P extends Record] + ? InferPropertyIndexMap

+ : H + : never; + +/** + * Extracts `{ indexName: propertyKey }` from property builder options. + * Checks `.index('name')` and `.unique('name')` on each property in a single pass. + */ +export type InferPropertyIndexMap> = { + [K in keyof Properties as MaybeReturnType extends { '~options': { index: infer N extends string } } + ? N + : MaybeReturnType extends { '~options': { unique: infer N extends string } } + ? N + : never]: K & string; +}; /** Union of declared index names on an entity. Falls back to `string` when no `[IndexHints]` are declared. */ export type IndexName = [ExtractIndexHints] extends [never] diff --git a/tests/bench/types/defineEntity-extends.ts b/tests/bench/types/defineEntity-extends.ts index 93e8cf2c0bba..3e4b3430aca2 100644 --- a/tests/bench/types/defineEntity-extends.ts +++ b/tests/bench/types/defineEntity-extends.ts @@ -270,7 +270,7 @@ bench('realistic entity (~20 props, relations, extends)', () => { tags: () => p.manyToMany(Tag), }, }); -}).types([7902, 'instantiations']); +}).types([7890, 'instantiations']); bench('realistic entity - InferEntity usage', () => { const base = defineEntity({ @@ -350,7 +350,7 @@ bench('realistic entity - InferEntity usage', () => { // Force evaluation of all entity types const _check: [IAuthor, IBook, ITag, IPublisher] = {} as any; -}).types([8010, 'instantiations']); +}).types([7998, 'instantiations']); bench('realistic entity - setClass pattern', () => { const BaseSchema = defineEntity({ @@ -436,4 +436,4 @@ bench('realistic entity - setClass pattern', () => { PublisherSchema.setClass(Publisher); AuthorSchema.setClass(Author); BookSchema.setClass(Book); -}).types([8897, 'instantiations']); +}).types([8049, 'instantiations']); diff --git a/tests/defineEntity.test.ts b/tests/defineEntity.test.ts index b1e88dd3771d..bc758455c100 100644 --- a/tests/defineEntity.test.ts +++ b/tests/defineEntity.test.ts @@ -78,7 +78,7 @@ describe('defineEntity', () => { }); type IFoo = InferEntity; - assert; name: string; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }>>(true); + assert, { id: Opt; name: string; [PrimaryKeyProp]?: 'id' }>>(true); const FooSchema = new EntitySchema({ name: 'Foo', @@ -105,8 +105,8 @@ describe('defineEntity', () => { type IBook = InferEntity; assert< IsExact< - IBook, - { _id: ObjectId; id: string; title: string; tags: string[]; [PrimaryKeyProp]?: '_id'; [IndexHints]?: {} } + Omit, + { _id: ObjectId; id: string; title: string; tags: string[]; [PrimaryKeyProp]?: '_id' } > >(true); }); @@ -216,7 +216,7 @@ describe('defineEntity', () => { }); expect(Foo.init().meta.primaryKeys).toEqual(['name']); type IFoo = InferEntity; - assert>(true); + assert, { name: string; [PrimaryKeyProp]?: 'name' }>>(true); const Car = defineEntity({ name: 'Car', @@ -227,9 +227,9 @@ describe('defineEntity', () => { }); expect(Car.init().meta.primaryKeys).toEqual(['name', 'year']); type ICar = InferEntity; - assert>( - true, - ); + assert< + IsExact, { name: string; year: number; [PrimaryKeyProp]?: ('name' | 'year')[] }> + >(true); // @ts-expect-error const Car2 = defineEntity({ @@ -256,13 +256,12 @@ describe('defineEntity', () => { type IBar = InferEntity; assert< IsExact< - IBar, + Omit, { firstName: string; lastName: string; age: number; [PrimaryKeyProp]?: ['firstName', 'lastName']; - [IndexHints]?: {}; } > >(true); @@ -285,13 +284,13 @@ describe('defineEntity', () => { type IMyEntity = InferEntity; assert< IsExact< - Primary, - Primary<{ myClass: IType; [PrimaryKeyProp]?: undefined; [IndexHints]?: {} }> + Primary>, + Primary<{ myClass: IType; [PrimaryKeyProp]?: undefined }> > >(true); - assert; [PrimaryKeyProp]?: undefined; [IndexHints]?: {} }>>( - true, - ); + assert< + IsExact, { myClass: IType; [PrimaryKeyProp]?: undefined }> + >(true); function create(type: EntityName, data: EntityData | RequiredEntityData) { // @@ -339,14 +338,13 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: number; name: string; createdAt: Opt; updatedAt: Opt; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -459,8 +457,8 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, - { id: Opt; name: string; settings: { theme: string }; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + Omit, + { id: Opt; name: string; settings: { theme: string }; [PrimaryKeyProp]?: 'id' } > >(true); @@ -485,7 +483,7 @@ describe('defineEntity', () => { }); type IBox = InferEntity; - assert>(true); + assert, { objectVolume: number; [PrimaryKeyProp]?: undefined }>>(true); }); it('should define entity with nullable property', () => { @@ -501,13 +499,12 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string | null | undefined; settings: { theme: string } | null | undefined; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -537,13 +534,12 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string | null; settings: { theme: string } | null; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -603,7 +599,7 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: number; name: Ref; @@ -611,7 +607,6 @@ describe('defineEntity', () => { profileLazy: ScalarRef; profileNullable: ScalarRef; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -641,7 +636,9 @@ describe('defineEntity', () => { type IFoo = InferEntity; type ToObject = EntityDTO; - assert; name: Hidden; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }>>(true); + assert, { id: Opt; name: Hidden; [PrimaryKeyProp]?: 'id' }>>( + true, + ); assert }>>(true); const FooSchema = new EntitySchema({ @@ -675,7 +672,10 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< - IsExact; bar: 'foo' | 'bar' | 1; baz: BaZ; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }> + IsExact< + Omit, + { id: Opt; bar: 'foo' | 'bar' | 1; baz: BaZ; [PrimaryKeyProp]?: 'id' } + > >(true); const FooSchema = new EntitySchema({ @@ -730,7 +730,10 @@ describe('defineEntity', () => { type IFoo = InferEntity; type IAddress = InferEntity; assert< - IsExact; name: string; address: IAddress; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }> + IsExact< + Omit, + { id: Opt; name: string; address: IAddress; [PrimaryKeyProp]?: 'id' } + > >(true); const AddressSchema = new EntitySchema({ @@ -772,14 +775,13 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string; friend: Ref; friendNullable: Ref | null | undefined; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -861,14 +863,14 @@ describe('defineEntity', () => { type IFile = InferEntity; assert< IsExact< - IFolder, - { id: Opt; name: string; files: Collection; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + Omit, + { id: Opt; name: string; files: Collection; [PrimaryKeyProp]?: 'id' } > >(true); assert< IsExact< - IFile, - { id: Opt; name: string; folder: Ref; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + Omit, + { id: Opt; name: string; folder: Ref; [PrimaryKeyProp]?: 'id' } > >(true); @@ -919,8 +921,8 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, - { id: Opt; name: string; friends: Collection; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} } + Omit, + { id: Opt; name: string; friends: Collection; [PrimaryKeyProp]?: 'id' } > >(true); @@ -1033,11 +1035,14 @@ describe('defineEntity', () => { type IFoo = InferEntity; type IProfile = InferEntity; assert< - IsExact; name: string; profile: IProfile; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }> + IsExact< + Omit, + { id: Opt; name: string; profile: IProfile; [PrimaryKeyProp]?: 'id' } + > + >(true); + assert< + IsExact, { id: Opt; bio: string; foo: IFoo; [PrimaryKeyProp]?: 'id' }> >(true); - assert; bio: string; foo: IFoo; [PrimaryKeyProp]?: 'id'; [IndexHints]?: {} }>>( - true, - ); const FooSchema = new EntitySchema({ name: 'Foo', @@ -1076,7 +1081,7 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string; @@ -1084,7 +1089,6 @@ describe('defineEntity', () => { bigintAsNumber: number; bigintAsString: string; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1121,7 +1125,7 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string; @@ -1129,7 +1133,6 @@ describe('defineEntity', () => { numbers: number[]; customArray: { id: number; name: string }[]; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1178,7 +1181,7 @@ describe('defineEntity', () => { // Verify InferEntity gives correct types assert< IsExact< - IBar, + Omit, { id: Opt; barcodes: string[]; @@ -1186,7 +1189,6 @@ describe('defineEntity', () => { numbers: number[]; tags: string[]; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1247,7 +1249,7 @@ describe('defineEntity', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string; @@ -1255,7 +1257,6 @@ describe('defineEntity', () => { amount: number; balance: string; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1560,7 +1561,7 @@ describe('PropertyOptionsBuilder', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string; @@ -1578,7 +1579,6 @@ describe('PropertyOptionsBuilder', () => { hydrateFalse: string; returning: string; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1627,7 +1627,7 @@ describe('PropertyOptionsBuilder', () => { type IFoo = InferEntity; assert< IsExact< - IFoo, + Omit, { id: Opt; name: string; @@ -1640,7 +1640,6 @@ describe('PropertyOptionsBuilder', () => { status: Opt<('active' | 'inactive')[]>; type: number; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1931,13 +1930,12 @@ describe('OneToOneRelationOptionsBuilder', () => { type IProfile = InferEntity; assert< IsExact< - IProfile, + Omit, { id: Opt; bio: string; user: IUser; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -1954,13 +1952,12 @@ describe('OneToOneRelationOptionsBuilder', () => { type IUser = InferEntity; assert< IsExact< - IUser, + Omit, { id: Opt; name: string; profile: number; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); @@ -2063,12 +2060,11 @@ describe('ManyToOneRelationOptionsBuilder', () => { type IGroup = InferEntity; assert< IsExact< - IGroup, + Omit, { name: Opt; users: Collection; [PrimaryKeyProp]?: 'name'; - [IndexHints]?: {}; } > >(true); @@ -2085,13 +2081,12 @@ describe('ManyToOneRelationOptionsBuilder', () => { type IUser = InferEntity; assert< IsExact< - IUser, + Omit, { id: Opt; name: string; group: string; [PrimaryKeyProp]?: 'id'; - [IndexHints]?: {}; } > >(true); From 8bf8d508a0c5c35366c3804d40c54f709de7af8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 12:35:53 +0100 Subject: [PATCH 6/9] fix: remove obsolete MSSQL snapshot entries Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SchemaGenerator.mssql.test.ts.snap | 77 ------------------- 1 file changed, 77 deletions(-) diff --git a/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap b/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap index 7a77cfb46c81..ec8d8289d7ad 100644 --- a/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap +++ b/tests/features/schema-generator/__snapshots__/SchemaGenerator.mssql.test.ts.snap @@ -183,21 +183,6 @@ if object_id('[base_user2]', 'U') is not null drop table [base_user2]; exports[`SchemaGenerator > read-only schema tests > generate schema from metadata [mssql] > mssql-update-schema-dump 1`] = `""`; -exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-add-unique 1`] = ` -"create unique index [book2_title_unique] on [book2] ([title]) where [title] is not null; -" -`; - -exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-drop-index 1`] = ` -"drop index [new_title_idx] on [book2]; -" -`; - -exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-drop-unique 1`] = ` -"drop index [book2_title_unique] on [book2]; -" -`; - exports[`SchemaGenerator > rename column [mssql] > mssql-update-schema-rename-column 1`] = ` "alter table [author2] drop constraint [author2_favourite_author_id_foreign]; @@ -307,68 +292,6 @@ exports[`SchemaGenerator > update schema [mssql] > mssql-update-schema-drop-uniq " `; -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-add-column 1`] = ` -"alter table [new_table] add [id] int identity(1,1) not null primary key, [updated_at] datetime2(3) not null constraint [new_table_updated_at_default] default current_timestamp; - -alter table [author2] add [favourite_book_uuid_pk] uniqueidentifier null; -alter table [author2] add constraint [author2_favourite_book_uuid_pk_foreign] foreign key ([favourite_book_uuid_pk]) references [book2] ([uuid_pk]) on update no action on delete cascade; -" -`; - -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-alter-column 1`] = ` -"drop index [author2_born_index] on [author2]; -alter table [author2] alter column [born] nvarchar(max); -alter table [author2] drop constraint [author2_favourite_author_id_foreign]; - -declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'age') if @constraint0 is not null exec('alter table [author2] drop constraint ' + @constraint0); -alter table [author2] add constraint [author2_age_default] default 42 for [age]; -declare @constraint1 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'born') if @constraint1 is not null exec('alter table [author2] drop constraint ' + @constraint1); -alter table [author2] alter column [born] int not null; -alter table [author2] add constraint [author2_born_default] default 42 for [born]; -alter table [author2] add constraint [author2_favourite_author_id_foreign] foreign key ([favourite_author_id]) references [foo_bar2] ([id]) on delete set null; - -create index [author2_born_index] on [author2] ([born]); -" -`; - -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-column 1`] = ` -"alter table [author2] drop constraint [author2_favourite_book_uuid_pk_foreign]; - -alter table [new_table] drop constraint [PK__new_tabl__123]; -declare @constraint0 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'new_table' and all_columns.name = 'id') if @constraint0 is not null exec('alter table [new_table] drop constraint ' + @constraint0); -alter table [new_table] drop constraint [new_table_updated_at_default]; -alter table [new_table] drop column [id], [updated_at]; - -declare @constraint1 varchar(100) = (select default_constraints.name from sys.all_columns join sys.tables on all_columns.object_id = tables.object_id join sys.schemas on tables.schema_id = schemas.schema_id join sys.default_constraints on all_columns.default_object_id = default_constraints.object_id where schemas.name = 'dbo' and tables.name = 'author2' and all_columns.name = 'favourite_book_uuid_pk') if @constraint1 is not null exec('alter table [author2] drop constraint ' + @constraint1); -alter table [author2] drop column [favourite_book_uuid_pk]; -" -`; - -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-table 1`] = ` -"alter table [address2] drop constraint [address2_author_id_foreign]; -alter table [author_to_friend] drop constraint [author_to_friend_author2_1_id_foreign]; -alter table [author_to_friend] drop constraint [author_to_friend_author2_2_id_foreign]; -alter table [author2_following] drop constraint [author2_following_author2_1_id_foreign]; -alter table [author2_following] drop constraint [author2_following_author2_2_id_foreign]; -alter table [book2] drop constraint [book2_author_id_foreign]; - -if object_id('[author2]', 'U') is not null drop table [author2]; -if object_id('[new_table]', 'U') is not null drop table [new_table]; -" -`; - -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-table-disabled 1`] = ` -"alter table [address2] drop constraint [address2_author_id_foreign]; -alter table [author_to_friend] drop constraint [author_to_friend_author2_1_id_foreign]; -alter table [author_to_friend] drop constraint [author_to_friend_author2_2_id_foreign]; -alter table [author2_following] drop constraint [author2_following_author2_1_id_foreign]; -alter table [author2_following] drop constraint [author2_following_author2_2_id_foreign]; -alter table [book2] drop constraint [book2_author_id_foreign]; -" -`; - -exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-drop-table-safe 1`] = `""`; - exports[`SchemaGenerator > update schema enums [mssql] > mssql-update-schema-enums-1 1`] = ` "create table [new_table] ([id] int identity(1,1) not null primary key, [enum_test] nvarchar(100) not null); " From 898b134a18d002e87f3ea115cc04e224193f58c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 13:00:01 +0100 Subject: [PATCH 7/9] docs: add JSDoc to PropertyChain index/unique overloads Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/entity/defineEntity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/entity/defineEntity.ts b/packages/core/src/entity/defineEntity.ts index aecb3d71612e..874888d8372f 100644 --- a/packages/core/src/entity/defineEntity.ts +++ b/packages/core/src/entity/defineEntity.ts @@ -150,8 +150,10 @@ export interface PropertyChain { customOrder(...customOrder: string[] | number[] | boolean[]): PropertyChain; extra(extra: string): PropertyChain; ignoreSchemaChanges(...ignoreSchemaChanges: ('type' | 'extra' | 'default')[]): PropertyChain; + /** Explicitly specify index on a property. When a string name is passed, it enables type-safe `using` in `FindOptions`. */ index(name: N): PropertyChain & { index: N }>; index(index?: boolean): PropertyChain; + /** Set column as unique. When a string name is passed, it enables type-safe `using` in `FindOptions`. (SQL only) */ unique(name: N): PropertyChain & { unique: N }>; unique(unique?: boolean): PropertyChain; comment(comment: string): PropertyChain; From 1299479c719ca3f088cf155ef63d31ac143b188a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sun, 22 Mar 2026 13:22:31 +0100 Subject: [PATCH 8/9] test: comprehensive coverage for using option across all drivers - Add MySQL tests: USE INDEX hint generation, multiple indexes, findOne, findAndCount - Add MSSQL tests: WITH (INDEX(...)) hint generation, multiple indexes - Add MongoDB tests: hint passthrough, multiple-index error, indexHint precedence - Add SQLite tests: no-named-indexes error, orderBy array, valid orderBy, PK where, findAll, findByCursor, stream, findOneOrFail, $eq operator - Add Plain entity (no named indexes) for 'No named indexes defined' branch - Add Doc entity (MongoDB-compatible) with _id PK Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/features/using-index-hints.test.ts | 319 +++++++++++++++++++++-- 1 file changed, 292 insertions(+), 27 deletions(-) diff --git a/tests/features/using-index-hints.test.ts b/tests/features/using-index-hints.test.ts index 04519e43bcb0..c8551dc683c0 100644 --- a/tests/features/using-index-hints.test.ts +++ b/tests/features/using-index-hints.test.ts @@ -1,6 +1,9 @@ import { IndexHints, MikroORM, PrimaryKeyProp, type EntityManager, type IndexFilterQuery } from '@mikro-orm/core'; import { Entity, Index, PrimaryKey, Property, ReflectMetadataProvider, Unique } from '@mikro-orm/decorators/legacy'; import { defineEntity, p, SqliteDriver } from '@mikro-orm/sqlite'; +import { MySqlDriver } from '@mikro-orm/mysql'; +import { MsSqlDriver } from '@mikro-orm/mssql'; +import { MongoDriver } from '@mikro-orm/mongodb'; import { mockLogger } from '../helpers.js'; // ========================================== @@ -44,6 +47,32 @@ const Article = defineEntity({ indexes: [{ name: 'idx_article_status_views', properties: ['status', 'views'] }], }); +// Entity with NO named indexes at all (only unnamed) +@Entity() +class Plain { + @PrimaryKey() + id!: number; + + @Property() + value!: string; +} + +// MongoDB-compatible entity (uses ObjectId PK) +@Entity() +@Index({ name: 'idx_doc_title', properties: ['title'] }) +class Doc { + [IndexHints]?: { idx_doc_title: 'title' }; + + @PrimaryKey() + _id!: string; + + @Property() + title!: string; + + @Property({ nullable: true }) + body?: string; +} + @Entity() class Tag { @PrimaryKey() @@ -63,13 +92,9 @@ describe.skipIf(true)('using option - type safety', () => { const em = null as unknown as EntityManager; test('decorator entity with [IndexHints] narrows where', () => { - // Should compile — 'name' is in idx_user_name em.find(User, { name: 'foo' }, { using: 'idx_user_name' }) as any; - // Should compile — 'name' and 'email' are in idx_user_name_email em.find(User, { name: 'foo', email: 'bar' }, { using: 'idx_user_name_email' }) as any; - // Should compile — PK is always allowed em.find(User, 1, { using: 'idx_user_name' }) as any; - // Should compile — no using, full FilterQuery em.find(User, { age: 30 }) as any; }); @@ -83,8 +108,6 @@ describe.skipIf(true)('using option - type safety', () => { test('defineEntity entity infers index hints from property-level .index()/.unique()', () => { em.find(Article, { title: 'foo' }, { using: 'idx_article_title' }) as any; em.find(Article, { slug: 'foo' }, { using: 'uniq_article_slug' }) as any; - // Entity-level composite indexes (idx_article_status_views) require manual [IndexHints] - // for type narrowing; runtime validation still works for all indexes }); test('defineEntity entity errors on non-index props', () => { @@ -126,7 +149,7 @@ describe.skipIf(true)('using option - type safety', () => { }); // ========================================== -// Runtime Tests - SQLite (validation only, no SQL hints) +// Runtime Tests - SQLite (validation + all code paths) // ========================================== describe('using option - runtime validation (SQLite)', () => { @@ -135,7 +158,7 @@ describe('using option - runtime validation (SQLite)', () => { beforeAll(async () => { orm = await MikroORM.init({ metadataProvider: ReflectMetadataProvider, - entities: [User, Article, Tag], + entities: [User, Article, Tag, Plain], dbName: ':memory:', driver: SqliteDriver, }); @@ -146,6 +169,8 @@ describe('using option - runtime validation (SQLite)', () => { await orm.close(true); }); + // --- Basic validation --- + test('using with valid where passes validation', async () => { const mock = mockLogger(orm); await orm.em.find(User, { name: 'foo' } as any, { using: 'idx_user_name' }); @@ -164,32 +189,87 @@ describe('using option - runtime validation (SQLite)', () => { ); }); + test('using throws on unknown index for entity with no named indexes', async () => { + await expect(orm.em.find(Plain, { value: 'x' } as any, { using: 'bad_idx' })).rejects.toThrow( + /No named indexes defined/, + ); + }); + + // --- where validation --- + test('using throws when where property not covered by index', async () => { await expect(orm.em.find(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( /Property 'age' in where clause is not covered by index 'idx_user_name'/, ); }); + test('using validates properties inside $and/$or/$not', async () => { + await orm.em.find(User, { $and: [{ name: 'foo' }] } as any, { using: 'idx_user_name' }); + await expect(orm.em.find(User, { $and: [{ age: 30 }] } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age'/, + ); + await expect( + orm.em.find(User, { $or: [{ name: 'foo' }, { age: 30 }] } as any, { using: 'idx_user_name' }), + ).rejects.toThrow(/Property 'age'/); + await expect(orm.em.find(User, { $not: { age: 30 } } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age'/, + ); + }); + + test('using skips non-condition $ operators like $eq', async () => { + await orm.em.find(User, { name: { $eq: 'foo' } } as any, { using: 'idx_user_name' }); + }); + + test('using with empty where passes validation', async () => { + await orm.em.find(User, {} as any, { using: 'idx_user_name' }); + }); + + test('using with null/PK where skips where validation', async () => { + await orm.em.find(User, 1 as any, { using: 'idx_user_name' }); + }); + + // --- orderBy validation --- + test('using throws when orderBy property not covered by index', async () => { + await expect( + orm.em.find(User, { name: 'foo' } as any, { using: 'idx_user_name', orderBy: { age: 'asc' } } as any), + ).rejects.toThrow(/Property 'age' in orderBy is not covered by index 'idx_user_name'/); + }); + + test('using validates orderBy array', async () => { await expect( orm.em.find( User, { name: 'foo' } as any, { using: 'idx_user_name', - orderBy: { age: 'asc' }, + orderBy: [{ name: 'asc' }, { age: 'desc' }], } as any, ), - ).rejects.toThrow(/Property 'age' in orderBy is not covered by index 'idx_user_name'/); + ).rejects.toThrow(/Property 'age' in orderBy/); + }); + + test('using allows valid orderBy', async () => { + await orm.em.find( + User, + { name: 'foo' } as any, + { + using: 'idx_user_name', + orderBy: { name: 'asc' }, + } as any, + ); }); + // --- Multiple indexes --- + test('using with multiple indexes allows union of properties', async () => { - // idx_user_name covers 'name', uniq_user_email covers 'email' await orm.em.find(User, { name: 'foo', email: 'bar' } as any, { using: ['idx_user_name', 'uniq_user_email'], }); }); + // --- indexHint precedence --- + test('using does not override explicit indexHint', async () => { const mock = mockLogger(orm); await orm.em.find(User, { name: 'foo' } as any, { @@ -199,39 +279,63 @@ describe('using option - runtime validation (SQLite)', () => { expect(mock.mock.calls[0][0]).toMatch(/indexed by idx_user_name/); }); + // --- All EM methods --- + test('using validates with findOne', async () => { await expect(orm.em.findOne(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( - /Property 'age' in where clause is not covered by index 'idx_user_name'/, + /Property 'age'/, + ); + const mock = mockLogger(orm); + await orm.em.findOne(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).toMatch(/select/i); + }); + + test('using validates with findOneOrFail', async () => { + await expect(orm.em.findOneOrFail(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age'/, ); }); test('using validates with findAndCount', async () => { await expect(orm.em.findAndCount(User, { age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow( - /Property 'age' in where clause is not covered by index 'idx_user_name'/, + /Property 'age'/, ); + const mock = mockLogger(orm); + await orm.em.findAndCount(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).toMatch(/select/i); }); - test('using validates properties inside $and/$or/$not', async () => { - // Valid: property inside $and is covered by index - await orm.em.find(User, { $and: [{ name: 'foo' }] } as any, { using: 'idx_user_name' }); - // Invalid: property inside $and is NOT covered by index - await expect(orm.em.find(User, { $and: [{ age: 30 }] } as any, { using: 'idx_user_name' })).rejects.toThrow( - /Property 'age' in where clause is not covered by index 'idx_user_name'/, + test('using validates with findAll', async () => { + await expect(orm.em.findAll(User, { where: { age: 30 }, using: 'idx_user_name' } as any)).rejects.toThrow( + /Property 'age'/, ); - // Invalid: property inside $or is NOT covered + const mock = mockLogger(orm); + await orm.em.findAll(User, { where: { name: 'foo' }, using: 'idx_user_name' } as any); + expect(mock.mock.calls[0][0]).toMatch(/select/i); + }); + + test('using validates with findByCursor', async () => { await expect( - orm.em.find(User, { $or: [{ name: 'foo' }, { age: 30 }] } as any, { using: 'idx_user_name' }), + orm.em.findByCursor(User, { where: { age: 30 }, using: 'idx_user_name', orderBy: { name: 'asc' } } as any), ).rejects.toThrow(/Property 'age'/); - // Invalid: property inside $not is NOT covered - await expect(orm.em.find(User, { $not: { age: 30 } } as any, { using: 'idx_user_name' })).rejects.toThrow( - /Property 'age'/, - ); }); + test('using validates with stream', async () => { + await expect(async () => { + for await (const _ of orm.em.stream(User, { where: { age: 30 }, using: 'idx_user_name' } as any)) { + // should not reach here + } + }).rejects.toThrow(/Property 'age'/); + }); + + // --- Available indexes in error message --- + test('using validation lists available indexes in error message', async () => { await expect(orm.em.find(User, { name: 'foo' } as any, { using: 'bad_idx' })).rejects.toThrow(/Available indexes/); }); + // --- defineEntity entities --- + test('using works with defineEntity entities at runtime', async () => { const mock = mockLogger(orm); await orm.em.find(Article, { title: 'foo' } as any, { using: 'idx_article_title' }); @@ -244,7 +348,168 @@ describe('using option - runtime validation (SQLite)', () => { ); }); - test('using with empty where passes validation', async () => { - await orm.em.find(User, {} as any, { using: 'idx_user_name' }); + test('using works with defineEntity entity-level indexes at runtime', async () => { + await orm.em.find(Article, { status: 'draft', views: 100 } as any, { using: 'idx_article_status_views' }); + }); + + test('using works with defineEntity unique constraint at runtime', async () => { + await orm.em.find(Article, { slug: 'foo' } as any, { using: 'uniq_article_slug' }); + }); +}); + +// ========================================== +// Runtime Tests - MySQL (SQL hint generation) +// ========================================== + +describe('using option - MySQL runtime', () => { + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + metadataProvider: ReflectMetadataProvider, + entities: [User, Tag, Plain], + dbName: 'using_test', + driver: MySqlDriver, + port: 3308, + }); + await orm.schema.refresh(); + }); + + afterAll(async () => { + await orm.schema.drop(); + await orm.close(true); + }); + + test('using generates USE INDEX hint for MySQL', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).toMatch(/use index\(idx_user_name\)/); + }); + + test('using with multiple indexes generates combined hint', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo', email: 'bar' } as any, { + using: ['idx_user_name', 'idx_user_name_email'], + }); + expect(mock.mock.calls[0][0]).toMatch(/use index\(idx_user_name, idx_user_name_email\)/); + }); + + test('using does not override explicit indexHint', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo' } as any, { + using: 'idx_user_name', + indexHint: 'force index(idx_user_name_email)', + }); + expect(mock.mock.calls[0][0]).toMatch(/force index\(idx_user_name_email\)/); + expect(mock.mock.calls[0][0]).not.toMatch(/use index/); + }); + + test('using generates hint for findOne', async () => { + const mock = mockLogger(orm); + await orm.em.findOne(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).toMatch(/use index\(idx_user_name\)/); + }); + + test('using generates hint for count (via findAndCount)', async () => { + const mock = mockLogger(orm); + await orm.em.findAndCount(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + // Both the find and count queries should have the hint + const queries = mock.mock.calls.map((c: any) => c[0]).filter((q: string) => q.includes('idx_user_name')); + expect(queries.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ========================================== +// Runtime Tests - MSSQL (SQL hint generation) +// ========================================== + +describe('using option - MSSQL runtime', () => { + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + metadataProvider: ReflectMetadataProvider, + entities: [User, Tag, Plain], + dbName: 'using_test', + driver: MsSqlDriver, + password: 'Root.Root', + }); + await orm.schema.refresh(); + }); + + afterAll(async () => { + await orm.schema.drop(); + await orm.close(true); + }); + + test('using generates WITH (INDEX(...)) hint for MSSQL', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo' } as any, { using: 'idx_user_name' }); + expect(mock.mock.calls[0][0]).toMatch(/with \(index\(idx_user_name\)\)/); + }); + + test('using with multiple indexes generates combined MSSQL hint', async () => { + const mock = mockLogger(orm); + await orm.em.find(User, { name: 'foo', email: 'bar' } as any, { + using: ['idx_user_name', 'idx_user_name_email'], + }); + expect(mock.mock.calls[0][0]).toMatch(/with \(index\(idx_user_name, idx_user_name_email\)\)/); + }); +}); + +// ========================================== +// Runtime Tests - MongoDB +// ========================================== + +describe('using option - MongoDB runtime', () => { + let orm: MikroORM; + + beforeAll(async () => { + orm = await MikroORM.init({ + metadataProvider: ReflectMetadataProvider, + entities: [Doc], + dbName: 'using_test', + driver: MongoDriver, + clientUrl: 'mongodb://localhost:27017', + }); + await orm.schema.refresh(); + }); + + afterAll(async () => { + await orm.schema.drop(); + await orm.close(true); + }); + + test('using passes index name as hint to MongoDB', async () => { + const mock = mockLogger(orm); + await orm.em.find(Doc, { title: 'foo' } as any, { using: 'idx_doc_title' }); + expect(mock.mock.calls[0][0]).toMatch(/idx_doc_title/); + }); + + test('using throws when multiple indexes provided for MongoDB', async () => { + await expect( + orm.em.find(Doc, { title: 'foo' } as any, { using: ['idx_doc_title', 'idx_doc_title'] }), + ).rejects.toThrow(/MongoDB only supports a single index hint per query/); + }); + + test('using does not override explicit indexHint in MongoDB', async () => { + const mock = mockLogger(orm); + await orm.em.find(Doc, { title: 'foo' } as any, { + using: 'idx_doc_title', + indexHint: '_id_', + }); + expect(mock.mock.calls[0][0]).toMatch(/_id_/); + }); + + test('using throws on unknown index (MongoDB)', async () => { + await expect(orm.em.find(Doc, { title: 'foo' } as any, { using: 'nonexistent_idx' })).rejects.toThrow( + /Index 'nonexistent_idx' not found on entity 'Doc'/, + ); + }); + + test('using throws when where property not covered (MongoDB)', async () => { + await expect(orm.em.find(Doc, { body: 'x' } as any, { using: 'idx_doc_title' })).rejects.toThrow( + /Property 'body' in where clause is not covered by index 'idx_doc_title'/, + ); }); }); From 913c4e4a1fe3ae43061cd0b0d4c75187609df605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Sat, 18 Apr 2026 17:05:55 +0200 Subject: [PATCH 9/9] test(core): cover using index hints across dialects Adds patch coverage for the `using` index-hint option across EntityRepository delegation, MongoEntityManager.stream, findByCursor with includeCount=false, array/$-operator where clauses, expression-only indexes, and the stream/findAll default-where paths. --- tests/features/using-index-hints.test.ts | 124 ++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/tests/features/using-index-hints.test.ts b/tests/features/using-index-hints.test.ts index c8551dc683c0..0d2ddac0a7ed 100644 --- a/tests/features/using-index-hints.test.ts +++ b/tests/features/using-index-hints.test.ts @@ -82,6 +82,20 @@ class Tag { label!: string; } +// Entity with an expression-based index that has a name but no `properties`. +@Entity() +@Index({ + name: 'expr_widget', + expression: 'create index `expr_widget` on `widget` (`code`)', +}) +class Widget { + @PrimaryKey() + id!: number; + + @Property() + code!: string; +} + // ========================================== // Type Tests (compile-time only) // ========================================== @@ -158,7 +172,7 @@ describe('using option - runtime validation (SQLite)', () => { beforeAll(async () => { orm = await MikroORM.init({ metadataProvider: ReflectMetadataProvider, - entities: [User, Article, Tag, Plain], + entities: [User, Article, Tag, Plain, Widget], dbName: ':memory:', driver: SqliteDriver, }); @@ -355,6 +369,100 @@ describe('using option - runtime validation (SQLite)', () => { test('using works with defineEntity unique constraint at runtime', async () => { await orm.em.find(Article, { slug: 'foo' } as any, { using: 'uniq_article_slug' }); }); + + // --- EntityRepository delegation --- + + test('using works through EntityRepository.find', async () => { + const repo = orm.em.getRepository(User); + await repo.find({ name: 'foo' } as any, { using: 'idx_user_name' }); + await expect(repo.find({ age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow(/Property 'age'/); + }); + + test('using works through EntityRepository.findOne', async () => { + const repo = orm.em.getRepository(User); + await repo.findOne({ name: 'foo' } as any, { using: 'idx_user_name' }); + await expect(repo.findOne({ age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow(/Property 'age'/); + }); + + test('using works through EntityRepository.findOneOrFail', async () => { + const repo = orm.em.getRepository(User); + await expect(repo.findOneOrFail({ name: 'foo' } as any, { using: 'idx_user_name' })).rejects.toThrow(/not found/); + await expect(repo.findOneOrFail({ age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow(/Property 'age'/); + }); + + test('using works through EntityRepository.findAndCount', async () => { + const repo = orm.em.getRepository(User); + await repo.findAndCount({ name: 'foo' } as any, { using: 'idx_user_name' }); + await expect(repo.findAndCount({ age: 30 } as any, { using: 'idx_user_name' })).rejects.toThrow(/Property 'age'/); + }); + + test('using works through EntityRepository.findAll', async () => { + const repo = orm.em.getRepository(User); + await repo.findAll({ where: { name: 'foo' }, using: 'idx_user_name' } as any); + }); + + test('using works through EntityRepository.findByCursor', async () => { + const repo = orm.em.getRepository(User); + await repo.findByCursor({ where: { name: 'foo' }, using: 'idx_user_name', orderBy: { name: 'asc' } } as any); + }); + + test('using works through EntityRepository.stream', async () => { + const repo = orm.em.getRepository(User); + for await (const _ of repo.stream({ where: { name: 'foo' }, using: 'idx_user_name' } as any)) { + // no rows, but iterator runs + } + }); + + // --- findByCursor with includeCount: false --- + + test('using with findByCursor includeCount=false', async () => { + const cursor = await orm.em.findByCursor(User, { + where: { name: 'foo' }, + using: 'idx_user_name', + orderBy: { name: 'asc' }, + includeCount: false, + } as any); + expect(cursor.items).toEqual([]); + }); + + // --- stream without any using option also hits validateIndexUsage early return --- + + test('stream without using option passes through', async () => { + for await (const _ of orm.em.stream(User, {} as any)) { + // no rows + } + }); + + // --- findAll without options triggers default where --- + + test('findAll without any options works (covers default where branch)', async () => { + await orm.em.findAll(User); + }); + + // --- array where (OR of partial conditions) --- + + test('using validates array where clauses', async () => { + await orm.em.find(User, [{ name: 'foo' }, { name: 'bar' }] as any, { using: 'idx_user_name' }); + await expect(orm.em.find(User, [{ name: 'foo' }, { age: 30 }] as any, { using: 'idx_user_name' })).rejects.toThrow( + /Property 'age'/, + ); + }); + + // --- top-level $ operator (not $and/$or/$not) is skipped --- + + test('using skips unknown top-level $ operators', async () => { + await orm.em.find(User, { $fulltext: 'x', name: 'foo' } as any, { using: 'idx_user_name' }); + }); + + // --- expression-based index without explicit `properties` (covers `?? []` fallback) --- + + test('using with expression-based index (no properties) allows empty where', async () => { + await orm.em.find(Widget, {} as any, { using: 'expr_widget' }); + // any where key is rejected because allowedProps is empty + await expect(orm.em.find(Widget, { code: 'x' } as any, { using: 'expr_widget' })).rejects.toThrow( + /Property 'code' in where clause is not covered by index 'expr_widget'/, + ); + }); }); // ========================================== @@ -512,4 +620,18 @@ describe('using option - MongoDB runtime', () => { /Property 'body' in where clause is not covered by index 'idx_doc_title'/, ); }); + + test('MongoEntityManager.stream delegates to parent', async () => { + for await (const _ of orm.em.stream(Doc, { where: { title: 'foo' } } as any)) { + // no rows + } + }); + + test('MongoEntityManager.stream throws when populate is used', async () => { + await expect(async () => { + for await (const _ of orm.em.stream(Doc, { populate: ['body'] } as any)) { + // should not reach here + } + }).rejects.toThrow(/Populate option is not supported when streaming/); + }); });